feat: add preview/delete-output to TUI, replace how-it-works with tutorial gif
CLI: - p key opens file with system viewer (input in file list, output in results) - x key deletes converted output file from disk in results state - New 'deleted' status shown in red in the status column - Updated help overlay and bottom bar keybindings Web: - Replace three-step timeline in 'How it works' with Tuturial.gif - GIF shown in browser-style window frame matching site design
This commit is contained in:
@@ -17,6 +17,8 @@ type KeyMap struct {
|
|||||||
Quit key.Binding
|
Quit key.Binding
|
||||||
Help key.Binding
|
Help key.Binding
|
||||||
Back key.Binding
|
Back key.Binding
|
||||||
|
Preview key.Binding
|
||||||
|
DeleteOutput key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultKeyMap returns the default key bindings.
|
// DefaultKeyMap returns the default key bindings.
|
||||||
@@ -74,5 +76,13 @@ func DefaultKeyMap() KeyMap {
|
|||||||
key.WithKeys("esc"),
|
key.WithKeys("esc"),
|
||||||
key.WithHelp("esc", "back"),
|
key.WithHelp("esc", "back"),
|
||||||
),
|
),
|
||||||
|
Preview: key.NewBinding(
|
||||||
|
key.WithKeys("p"),
|
||||||
|
key.WithHelp("p", "preview file"),
|
||||||
|
),
|
||||||
|
DeleteOutput: key.NewBinding(
|
||||||
|
key.WithKeys("x"),
|
||||||
|
key.WithHelp("x", "delete output"),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -35,7 +37,7 @@ type fileEntry struct {
|
|||||||
targetFormat string
|
targetFormat string
|
||||||
formats []string
|
formats []string
|
||||||
formatIdx int
|
formatIdx int
|
||||||
status string // "idle", "converting", "done", "error"
|
status string // "idle", "converting", "done", "error", "deleted"
|
||||||
error string
|
error string
|
||||||
outputPath string
|
outputPath string
|
||||||
}
|
}
|
||||||
@@ -290,6 +292,11 @@ func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
if !isConverting {
|
if !isConverting {
|
||||||
return m.startConversion()
|
return m.startConversion()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Preview):
|
||||||
|
if len(m.files) > 0 {
|
||||||
|
openFile(m.files[m.cursor].path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -303,7 +310,7 @@ func (m Model) handleResultsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
// Go back to file list to convert more
|
// Go back to file list to convert more
|
||||||
m.state = stateFileList
|
m.state = stateFileList
|
||||||
for i := range m.files {
|
for i := range m.files {
|
||||||
if m.files[i].status == "done" || m.files[i].status == "error" {
|
if m.files[i].status == "done" || m.files[i].status == "error" || m.files[i].status == "deleted" {
|
||||||
m.files[i].status = "idle"
|
m.files[i].status = "idle"
|
||||||
m.files[i].error = ""
|
m.files[i].error = ""
|
||||||
m.files[i].outputPath = ""
|
m.files[i].outputPath = ""
|
||||||
@@ -315,10 +322,33 @@ func (m Model) handleResultsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case key.Matches(msg, m.keys.Up):
|
case key.Matches(msg, m.keys.Up):
|
||||||
if m.cursor > 0 {
|
if m.cursor > 0 {
|
||||||
m.cursor--
|
m.cursor--
|
||||||
|
m.ensureVisible()
|
||||||
}
|
}
|
||||||
case key.Matches(msg, m.keys.Down):
|
case key.Matches(msg, m.keys.Down):
|
||||||
if m.cursor < len(m.files)-1 {
|
if m.cursor < len(m.files)-1 {
|
||||||
m.cursor++
|
m.cursor++
|
||||||
|
m.ensureVisible()
|
||||||
|
}
|
||||||
|
case key.Matches(msg, m.keys.Preview):
|
||||||
|
// Preview: open output file if done, otherwise open input file
|
||||||
|
if len(m.files) > 0 {
|
||||||
|
f := m.files[m.cursor]
|
||||||
|
if f.status == "done" && f.outputPath != "" {
|
||||||
|
openFile(f.outputPath)
|
||||||
|
} else {
|
||||||
|
openFile(f.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case key.Matches(msg, m.keys.DeleteOutput):
|
||||||
|
// Delete the converted output file from disk
|
||||||
|
if len(m.files) > 0 {
|
||||||
|
f := &m.files[m.cursor]
|
||||||
|
if f.status == "done" && f.outputPath != "" {
|
||||||
|
if err := os.Remove(f.outputPath); err == nil {
|
||||||
|
f.status = "deleted"
|
||||||
|
f.outputPath = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -387,6 +417,22 @@ func (m Model) maxVisibleFiles() int {
|
|||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// openFile opens a file with the system default viewer.
|
||||||
|
func openFile(path string) {
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
cmd = exec.Command("open", path)
|
||||||
|
case "linux":
|
||||||
|
cmd = exec.Command("xdg-open", path)
|
||||||
|
case "windows":
|
||||||
|
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", path)
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmd.Start() //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
func formatSize(bytes int64) string {
|
func formatSize(bytes int64) string {
|
||||||
const (
|
const (
|
||||||
KB = 1024
|
KB = 1024
|
||||||
|
|||||||
@@ -94,6 +94,53 @@ func TestInlineConversionStateMachine(t *testing.T) {
|
|||||||
t.Log("State machine verified: idle -> converting -> done -> idle (via esc)")
|
t.Log("State machine verified: idle -> converting -> done -> idle (via esc)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeleteOutput(t *testing.T) {
|
||||||
|
testFile := filepath.Join("..", "..", "..", "public", "logo.png")
|
||||||
|
if _, err := os.Stat(testFile); err != nil {
|
||||||
|
t.Skipf("test file not found: %s", testFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
outDir := t.TempDir()
|
||||||
|
m := New([]string{testFile}, outDir)
|
||||||
|
m.width = 80
|
||||||
|
m.height = 24
|
||||||
|
|
||||||
|
// Convert the file
|
||||||
|
newModel, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
|
||||||
|
m = newModel.(Model)
|
||||||
|
msg := cmd()
|
||||||
|
newModel, _ = m.Update(msg)
|
||||||
|
m = newModel.(Model)
|
||||||
|
|
||||||
|
if m.state != stateResults {
|
||||||
|
t.Fatalf("expected stateResults, got %d", m.state)
|
||||||
|
}
|
||||||
|
if m.files[0].status != "done" {
|
||||||
|
t.Fatalf("expected done, got %s", m.files[0].status)
|
||||||
|
}
|
||||||
|
|
||||||
|
outputPath := m.files[0].outputPath
|
||||||
|
if _, err := os.Stat(outputPath); err != nil {
|
||||||
|
t.Fatalf("output file should exist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Press 'x' to delete the output
|
||||||
|
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
|
||||||
|
m = newModel.(Model)
|
||||||
|
|
||||||
|
if m.files[0].status != "deleted" {
|
||||||
|
t.Fatalf("expected 'deleted' status after pressing x, got '%s'", m.files[0].status)
|
||||||
|
}
|
||||||
|
if m.files[0].outputPath != "" {
|
||||||
|
t.Fatal("outputPath should be cleared after delete")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(outputPath); err == nil {
|
||||||
|
t.Fatal("output file should have been deleted from disk")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("Delete output works: file removed from disk, status set to 'deleted'")
|
||||||
|
}
|
||||||
|
|
||||||
func TestViewRendersDuringConversion(t *testing.T) {
|
func TestViewRendersDuringConversion(t *testing.T) {
|
||||||
testFile := filepath.Join("..", "..", "..", "public", "logo.png")
|
testFile := filepath.Join("..", "..", "..", "public", "logo.png")
|
||||||
if _, err := os.Stat(testFile); err != nil {
|
if _, err := os.Stat(testFile); err != nil {
|
||||||
|
|||||||
@@ -256,6 +256,8 @@ func (m Model) renderFileRow(idx int) string {
|
|||||||
statusStr = bgS(theme.StatusDone).Render("done")
|
statusStr = bgS(theme.StatusDone).Render("done")
|
||||||
case "error":
|
case "error":
|
||||||
statusStr = bgS(theme.StatusError).Render("error")
|
statusStr = bgS(theme.StatusError).Render("error")
|
||||||
|
case "deleted":
|
||||||
|
statusStr = bgS(theme.StatusError).Render("deleted")
|
||||||
}
|
}
|
||||||
statusCell := bgS(lipgloss.NewStyle()).Copy().Width(colStatus).Align(lipgloss.Center).Render(statusStr)
|
statusCell := bgS(lipgloss.NewStyle()).Copy().Width(colStatus).Align(lipgloss.Center).Render(statusStr)
|
||||||
|
|
||||||
@@ -326,8 +328,8 @@ func (m Model) renderBottomBar() string {
|
|||||||
|
|
||||||
// Adaptive keybindings: full or compact based on available space
|
// Adaptive keybindings: full or compact based on available space
|
||||||
leftW := lipgloss.Width(left)
|
leftW := lipgloss.Width(left)
|
||||||
fullHelp := "up/down navigate left/right format space select a all q quit"
|
fullHelp := "up/down navigate left/right format space select p preview a all q quit"
|
||||||
shortHelp := "↑↓ nav ←→ fmt space sel a all q quit"
|
shortHelp := "↑↓ nav ←→ fmt spc sel p preview q quit"
|
||||||
|
|
||||||
helpText := fullHelp
|
helpText := fullHelp
|
||||||
rightW := len(helpText) + 4 // 2 padding each side
|
rightW := len(helpText) + 4 // 2 padding each side
|
||||||
@@ -367,7 +369,7 @@ func (m Model) renderResultsBar() string {
|
|||||||
}
|
}
|
||||||
left += theme.Bg(theme.Help).Render(fmt.Sprintf(" %s", elapsed))
|
left += theme.Bg(theme.Help).Render(fmt.Sprintf(" %s", elapsed))
|
||||||
|
|
||||||
right := theme.Bg(theme.Help).Render("enter quit esc convert more") + theme.BgStr(" ")
|
right := theme.Bg(theme.Help).Render("p preview x delete enter quit esc convert more") + theme.BgStr(" ")
|
||||||
|
|
||||||
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
|
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
|
||||||
if gap < 1 {
|
if gap < 1 {
|
||||||
@@ -387,7 +389,9 @@ func (m Model) renderHelp() string {
|
|||||||
{"left/right, h/l", "Change target format"},
|
{"left/right, h/l", "Change target format"},
|
||||||
{"space", "Toggle file selection"},
|
{"space", "Toggle file selection"},
|
||||||
{"a", "Select / deselect all"},
|
{"a", "Select / deselect all"},
|
||||||
|
{"p", "Preview file"},
|
||||||
{"d", "Remove file from list"},
|
{"d", "Remove file from list"},
|
||||||
|
{"x", "Delete converted output"},
|
||||||
{"c or enter", "Start conversion"},
|
{"c or enter", "Start conversion"},
|
||||||
{"esc", "Go back"},
|
{"esc", "Go back"},
|
||||||
{"q or ctrl+c", "Quit"},
|
{"q or ctrl+c", "Quit"},
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 11 MiB |
+22
-110
@@ -884,126 +884,38 @@ export default function LandingPage() {
|
|||||||
<h2 className="font-serif font-extrabold text-[clamp(32px,5vw,48px)] leading-[1.1] tracking-tight text-text-dark">
|
<h2 className="font-serif font-extrabold text-[clamp(32px,5vw,48px)] leading-[1.1] tracking-tight text-text-dark">
|
||||||
Three steps. That's it.
|
Three steps. That's it.
|
||||||
</h2>
|
</h2>
|
||||||
</motion.div>
|
<p className="text-[17px] text-text-mid leading-relaxed max-w-[520px]">
|
||||||
|
Drop files, pick a format, download. See it in action.
|
||||||
{/* Timeline layout */}
|
|
||||||
<div className="relative max-w-[960px] w-full">
|
|
||||||
{/* Connecting line — desktop only */}
|
|
||||||
<div className="absolute top-[52px] left-[calc(8.33%+24px)] right-[calc(8.33%+24px)] h-[2px] bg-border-soft hidden md:block" />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-10 md:gap-0">
|
|
||||||
{/* Step 1 — Drop */}
|
|
||||||
<motion.div
|
|
||||||
className="flex flex-col items-center text-center px-4"
|
|
||||||
initial={{ opacity: 0, y: 28 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true, margin: '-40px' }}
|
|
||||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] as const }}
|
|
||||||
>
|
|
||||||
{/* Visual scene */}
|
|
||||||
<div className="relative w-[104px] h-[104px] mb-5">
|
|
||||||
{/* Background shape */}
|
|
||||||
<div className="absolute inset-0 rounded-[28px] bg-pink/8 rotate-3" />
|
|
||||||
<div className="relative w-full h-full rounded-[28px] bg-white border-2 border-pink/20 shadow-[0_4px_20px_rgba(244,114,182,0.1)] flex items-center justify-center -rotate-1">
|
|
||||||
{/* File stack */}
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute -top-1 -left-1 w-10 h-12 rounded-lg bg-pink/10 border border-pink/15 rotate-[-6deg]" />
|
|
||||||
<div className="absolute -top-0.5 left-0 w-10 h-12 rounded-lg bg-pink/8 border border-pink/12 rotate-[-3deg]" />
|
|
||||||
<div className="relative w-10 h-12 rounded-lg bg-white border-[1.5px] border-pink/25 flex items-center justify-center">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" className="text-pink">
|
|
||||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Number + text */}
|
|
||||||
<div className="w-8 h-8 rounded-full bg-pink flex items-center justify-center font-serif font-extrabold text-sm text-white mb-3 shadow-[0_2px_8px_rgba(244,114,182,0.3)]">
|
|
||||||
1
|
|
||||||
</div>
|
|
||||||
<h3 className="font-serif font-bold text-[17px] text-text-dark mb-1.5">Drop your files</h3>
|
|
||||||
<p className="text-[13px] text-text-mid leading-relaxed max-w-[220px]">
|
|
||||||
Drag and drop anything {'\u2014'} images, docs, audio, video, data. We handle 70+ formats.
|
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Step 2 — Pick */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="flex flex-col items-center text-center px-4"
|
className="w-full max-w-[720px]"
|
||||||
initial={{ opacity: 0, y: 28 }}
|
initial={{ opacity: 0, y: 24 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true, margin: '-40px' }}
|
viewport={{ once: true, margin: '-60px' }}
|
||||||
transition={{ duration: 0.5, delay: 0.1, ease: [0.16, 1, 0.3, 1] as const }}
|
transition={{ duration: 0.6, delay: 0.15, ease: [0.16, 1, 0.3, 1] as const }}
|
||||||
>
|
>
|
||||||
{/* Visual scene */}
|
<div className="rounded-xl overflow-hidden shadow-[0_8px_48px_rgba(45,31,20,0.12)] border border-border-soft bg-white">
|
||||||
<div className="relative w-[104px] h-[104px] mb-5">
|
{/* Browser-style title bar */}
|
||||||
<div className="absolute inset-0 rounded-[28px] bg-purple/8 -rotate-2" />
|
<div className="flex items-center gap-3 px-4 py-2.5 bg-card-bg border-b border-border-soft">
|
||||||
<div className="relative w-full h-full rounded-[28px] bg-white border-2 border-purple/20 shadow-[0_4px_20px_rgba(167,139,250,0.1)] flex items-center justify-center rotate-1">
|
<div className="flex items-center gap-[6px]">
|
||||||
{/* Format picker mini-UI */}
|
<div className="w-[11px] h-[11px] rounded-full bg-[#ff5f57] border border-[#e0443e]/40" />
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="w-[11px] h-[11px] rounded-full bg-[#febc2e] border border-[#dea123]/40" />
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="w-[11px] h-[11px] rounded-full bg-[#28c840] border border-[#1aab29]/40" />
|
||||||
<div className="w-[42px] h-[14px] rounded-md bg-purple/15 border border-purple/20" />
|
</div>
|
||||||
<div className="w-3 h-3 rounded-full bg-purple/30 flex items-center justify-center">
|
<div className="flex-1 text-center">
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-purple" />
|
<span className="text-[12px] font-mono text-text-light">transmute.ing</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<div className="w-[42px] h-[14px] rounded-md bg-purple/8 border border-purple/10" />
|
<img
|
||||||
<div className="w-3 h-3 rounded-full border border-purple/20" />
|
src="/Tuturial.gif"
|
||||||
|
alt="Tutorial showing how to convert files with Transmute"
|
||||||
|
className="w-full block"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<div className="w-[42px] h-[14px] rounded-md bg-purple/8 border border-purple/10" />
|
|
||||||
<div className="w-3 h-3 rounded-full border border-purple/20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Number + text */}
|
|
||||||
<div className="w-8 h-8 rounded-full bg-purple flex items-center justify-center font-serif font-extrabold text-sm text-white mb-3 shadow-[0_2px_8px_rgba(167,139,250,0.3)]">
|
|
||||||
2
|
|
||||||
</div>
|
|
||||||
<h3 className="font-serif font-bold text-[17px] text-text-dark mb-1.5">Pick a format</h3>
|
|
||||||
<p className="text-[13px] text-text-mid leading-relaxed max-w-[220px]">
|
|
||||||
Smart suggestions based on your file type. Or choose any compatible output format.
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Step 3 — Download */}
|
|
||||||
<motion.div
|
|
||||||
className="flex flex-col items-center text-center px-4"
|
|
||||||
initial={{ opacity: 0, y: 28 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true, margin: '-40px' }}
|
|
||||||
transition={{ duration: 0.5, delay: 0.2, ease: [0.16, 1, 0.3, 1] as const }}
|
|
||||||
>
|
|
||||||
{/* Visual scene */}
|
|
||||||
<div className="relative w-[104px] h-[104px] mb-5">
|
|
||||||
<div className="absolute inset-0 rounded-[28px] bg-mint/8 rotate-2" />
|
|
||||||
<div className="relative w-full h-full rounded-[28px] bg-white border-2 border-mint/20 shadow-[0_4px_20px_rgba(52,211,153,0.1)] flex items-center justify-center -rotate-1">
|
|
||||||
{/* Checkmark + download visual */}
|
|
||||||
<div className="relative">
|
|
||||||
<div className="w-12 h-12 rounded-2xl bg-mint/10 border-[1.5px] border-mint/25 flex items-center justify-center">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" className="text-mint">
|
|
||||||
<path d="M20 6L9 17l-5-5" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{/* Tiny sparkles */}
|
|
||||||
<div className="absolute -top-1.5 -right-1.5 w-2.5 h-2.5 rounded-full bg-mint/30" />
|
|
||||||
<div className="absolute -bottom-1 -left-2 w-2 h-2 rounded-full bg-mint/20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Number + text */}
|
|
||||||
<div className="w-8 h-8 rounded-full bg-mint flex items-center justify-center font-serif font-extrabold text-sm text-white mb-3 shadow-[0_2px_8px_rgba(52,211,153,0.3)]">
|
|
||||||
3
|
|
||||||
</div>
|
|
||||||
<h3 className="font-serif font-bold text-[17px] text-text-dark mb-1.5">Download</h3>
|
|
||||||
<p className="text-[13px] text-text-mid leading-relaxed max-w-[220px]">
|
|
||||||
Converted instantly in your browser. Hit download {'\u2014'} done. Files never leave your machine.
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ──── PRIVACY ──── */}
|
{/* ──── PRIVACY ──── */}
|
||||||
|
|||||||
Reference in New Issue
Block a user