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:
noah
2026-03-10 10:35:41 +01:00
parent 062af2630f
commit cf489c4f02
6 changed files with 152 additions and 133 deletions
+10
View File
@@ -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"),
),
} }
} }
+48 -2
View File
@@ -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
+47
View File
@@ -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 {
+7 -3
View File
@@ -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
View File
@@ -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&apos;s it. Three steps. That&apos;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 ──── */}