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
+23 -13
View File
@@ -4,19 +4,21 @@ import "github.com/charmbracelet/bubbles/key"
// KeyMap defines key bindings for the TUI.
type KeyMap struct {
Up key.Binding
Down key.Binding
Left key.Binding
Right key.Binding
Enter key.Binding
Space key.Binding
Tab key.Binding
Delete key.Binding
SelectAll key.Binding
Convert key.Binding
Quit key.Binding
Help key.Binding
Back key.Binding
Up key.Binding
Down key.Binding
Left key.Binding
Right key.Binding
Enter key.Binding
Space key.Binding
Tab key.Binding
Delete key.Binding
SelectAll key.Binding
Convert key.Binding
Quit key.Binding
Help key.Binding
Back key.Binding
Preview key.Binding
DeleteOutput key.Binding
}
// DefaultKeyMap returns the default key bindings.
@@ -74,5 +76,13 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("esc"),
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 (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
@@ -35,7 +37,7 @@ type fileEntry struct {
targetFormat string
formats []string
formatIdx int
status string // "idle", "converting", "done", "error"
status string // "idle", "converting", "done", "error", "deleted"
error string
outputPath string
}
@@ -290,6 +292,11 @@ func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if !isConverting {
return m.startConversion()
}
case key.Matches(msg, m.keys.Preview):
if len(m.files) > 0 {
openFile(m.files[m.cursor].path)
}
}
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
m.state = stateFileList
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].error = ""
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):
if m.cursor > 0 {
m.cursor--
m.ensureVisible()
}
case key.Matches(msg, m.keys.Down):
if m.cursor < len(m.files)-1 {
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
@@ -387,6 +417,22 @@ func (m Model) maxVisibleFiles() int {
// ─── 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 {
const (
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)")
}
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) {
testFile := filepath.Join("..", "..", "..", "public", "logo.png")
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")
case "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)
@@ -326,8 +328,8 @@ func (m Model) renderBottomBar() string {
// Adaptive keybindings: full or compact based on available space
leftW := lipgloss.Width(left)
fullHelp := "up/down navigate left/right format space select a all q quit"
shortHelp := "↑↓ nav ←→ fmt space sel a all q quit"
fullHelp := "up/down navigate left/right format space select p preview a all q quit"
shortHelp := "↑↓ nav ←→ fmt spc sel p preview q quit"
helpText := fullHelp
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))
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)
if gap < 1 {
@@ -387,7 +389,9 @@ func (m Model) renderHelp() string {
{"left/right, h/l", "Change target format"},
{"space", "Toggle file selection"},
{"a", "Select / deselect all"},
{"p", "Preview file"},
{"d", "Remove file from list"},
{"x", "Delete converted output"},
{"c or enter", "Start conversion"},
{"esc", "Go back"},
{"q or ctrl+c", "Quit"},
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

+27 -115
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">
Three steps. That&apos;s it.
</h2>
<p className="text-[17px] text-text-mid leading-relaxed max-w-[520px]">
Drop files, pick a format, download. See it in action.
</p>
</motion.div>
{/* 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>
<motion.div
className="w-full max-w-[720px]"
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.6, delay: 0.15, ease: [0.16, 1, 0.3, 1] as const }}
>
<div className="rounded-xl overflow-hidden shadow-[0_8px_48px_rgba(45,31,20,0.12)] border border-border-soft bg-white">
{/* Browser-style title bar */}
<div className="flex items-center gap-3 px-4 py-2.5 bg-card-bg border-b border-border-soft">
<div className="flex items-center gap-[6px]">
<div className="w-[11px] h-[11px] rounded-full bg-[#ff5f57] border border-[#e0443e]/40" />
<div className="w-[11px] h-[11px] rounded-full bg-[#febc2e] border border-[#dea123]/40" />
<div className="w-[11px] h-[11px] rounded-full bg-[#28c840] border border-[#1aab29]/40" />
</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 className="flex-1 text-center">
<span className="text-[12px] font-mono text-text-light">transmute.ing</span>
</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>
</motion.div>
{/* Step 2 — Pick */}
<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.1, 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-purple/8 -rotate-2" />
<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">
{/* Format picker mini-UI */}
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-1.5">
<div className="w-[42px] h-[14px] rounded-md bg-purple/15 border border-purple/20" />
<div className="w-3 h-3 rounded-full bg-purple/30 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-purple" />
</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 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>
{/* 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>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/Tuturial.gif"
alt="Tutorial showing how to convert files with Transmute"
className="w-full block"
/>
</div>
</div>
</motion.div>
</section>
{/* ──── PRIVACY ──── */}