Compare commits

...

10 Commits

Author SHA1 Message Date
noah c025718f9b feat: polish drop zone empty state with animations and format pills
Keeps original design language — plain gray icon box, warm text, cream bg.
Adds: floating icon loop, staggered entrance, format pills, drag state
turns icon bg/stroke pink. No gradients, no blobs, no glass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 15:53:15 +02:00
noah ed147326a3 feat: redesign drop zone with glassmorphism card and ambient animations
Floating gradient blobs drift slowly behind a frosted glass card.
Icon continuously floats up/down, pills stagger in on mount, card
and icon glow pink on drag. Scales cleanly across all device sizes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 15:50:15 +02:00
noah 04454b4a42 feat: redesign drop zone as full-bleed poster typography
Replace card/icon layout with a massive stacked serif headline that fills
the window. Words animate in individually and shift pink on drag. Browse
button is small and understated below the type.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 15:47:20 +02:00
noah 0403f14acf feat: improve drop zone with gradient icon, format pills, and card layout
Combines gradient icon (pink→purple with glow) and soft card wrapper with
format type pills so the empty state feels more alive and informative.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 15:41:29 +02:00
noah e76c808eee fix: prevent install.ps1 from crashing PowerShell on error
When run via `irm ... | iex`, `exit` terminates the entire PowerShell
session. Wrapped the installer in a function and replaced all `exit`
calls with `return` so errors are reported cleanly without killing
the terminal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 11:13:51 +02:00
noah 2c756d2d27 feat: add OS switcher and copy button to CLI install box
- Add InstallBox component with Linux/macOS and Windows tabs
- Windows tab shows PowerShell one-liner (irm ... | iex)
- Linux/macOS tab shows curl one-liner (curl ... | sh)
- Copy button with clipboard feedback animation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 11:10:01 +02:00
noah f60a7cbaea fix: add Windows installer and upload Windows CLI binaries to release
- Add install.ps1 PowerShell script for native Windows installation
- Update install.sh to redirect Windows users (MINGW/MSYS/CYGWIN) to install.ps1
- Ignore CLI build artifacts (*.exe, *.zip, *.tar.gz) in .gitignore
- Windows binaries (x86_64 + arm64) uploaded to v0.1.6 release

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 11:08:31 +02:00
noah 7ec06b256d Made DropZone and FileRow improvements 2026-03-17 10:52:58 +01:00
noah 31cfd03e42 feat: v0.1.6 - auto-refresh file list, separate reset key
- Auto-refresh: file list now checks directories every 2 seconds for new files
- r key: resets done/error/deleted file back to idle for reconversion
- f key: manually refresh the file list
- d key: removes file from list only (unchanged)
- Auto-refresh works for directories that files were loaded from
- Updated help menu and bottom bar with new keybindings
2026-03-11 10:46:31 +01:00
noah 82a4123f84 feat: v0.1.5 - no default selection, stay on file list after convert, improved help menu
- Files no longer selected by default (user must select manually)
- After conversion, stays on file list (no more stateResults/esc to reconvert)
- x key resets done file to idle (delete output + reconvert in one)
- p key opens output if done, otherwise input file
- Help menu now has cream background, title bar, and divider to blend in smoothly
- Removed stateResults entirely - simplified to single state
2026-03-11 10:32:23 +01:00
12 changed files with 613 additions and 252 deletions
+3
View File
@@ -43,3 +43,6 @@ next-env.d.ts
# CLI build artifacts # CLI build artifacts
cli/transmute cli/transmute
cli/*.pdf cli/*.pdf
cli/*.exe
cli/*.zip
cli/*.tar.gz
+9 -4
View File
@@ -18,7 +18,8 @@ type KeyMap struct {
Help key.Binding Help key.Binding
Back key.Binding Back key.Binding
Preview key.Binding Preview key.Binding
DeleteOutput key.Binding Reset key.Binding
Refresh key.Binding
} }
// DefaultKeyMap returns the default key bindings. // DefaultKeyMap returns the default key bindings.
@@ -80,9 +81,13 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("p"), key.WithKeys("p"),
key.WithHelp("p", "preview file"), key.WithHelp("p", "preview file"),
), ),
DeleteOutput: key.NewBinding( Reset: key.NewBinding(
key.WithKeys("x"), key.WithKeys("r"),
key.WithHelp("x", "delete output"), key.WithHelp("r", "reset to idle"),
),
Refresh: key.NewBinding(
key.WithKeys("f"),
key.WithHelp("f", "refresh files"),
), ),
} }
} }
+79 -63
View File
@@ -22,7 +22,6 @@ type state int
const ( const (
stateFileList state = iota // Browsing/selecting files (also used during conversion) stateFileList state = iota // Browsing/selecting files (also used during conversion)
stateResults // All conversions finished — still shows file list
) )
// ─── File entry ────────────────────────────────────────────── // ─── File entry ──────────────────────────────────────────────
@@ -55,6 +54,8 @@ type conversionStartMsg struct {
type tickMsg time.Time type tickMsg time.Time
type refreshFilesMsg struct{}
// ─── Model ─────────────────────────────────────────────────── // ─── Model ───────────────────────────────────────────────────
type Model struct { type Model struct {
@@ -73,6 +74,10 @@ type Model struct {
converted int converted int
totalToConv int totalToConv int
startTime time.Time startTime time.Time
// File watching
watchDirs map[string]int64 // dir path -> last known mod time
lastRefresh time.Time
} }
// New creates a new TUI model from a list of file paths and an output directory. // New creates a new TUI model from a list of file paths and an output directory.
@@ -158,7 +163,7 @@ func makeFileEntry(path string, info os.FileInfo) *fileEntry {
ext: ext, ext: ext,
size: info.Size(), size: info.Size(),
category: detect.DetectCategory(ext), category: detect.DetectCategory(ext),
selected: true, // Select all by default selected: false, // Don't select by default
targetFormat: defaultTarget, targetFormat: defaultTarget,
formats: formats, formats: formats,
formatIdx: defaultIdx, formatIdx: defaultIdx,
@@ -169,7 +174,10 @@ func makeFileEntry(path string, info os.FileInfo) *fileEntry {
// ─── Init ──────────────────────────────────────────────────── // ─── Init ────────────────────────────────────────────────────
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return nil // Start a ticker to watch for file changes every 2 seconds
return tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
return refreshFilesMsg{}
})
} }
// ─── Update ────────────────────────────────────────────────── // ─── Update ──────────────────────────────────────────────────
@@ -193,31 +201,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.converted++ m.converted++
} }
// Check if all done // Start next conversion if there are more files
if m.converted >= m.totalToConv {
m.state = stateResults
return m, nil
}
// Start next conversion
m, cmd := m.convertNext() m, cmd := m.convertNext()
return m, cmd return m, cmd
case tea.KeyMsg: case tea.KeyMsg:
return m.handleKey(msg) return m.handleKey(msg)
case refreshFilesMsg:
// Check directories for new files
m = m.checkForNewFiles()
// Continue watching
return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
return refreshFilesMsg{}
})
} }
return m, nil return m, nil
} }
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch m.state {
case stateFileList:
return m.handleFileListKey(msg) return m.handleFileListKey(msg)
case stateResults:
return m.handleResultsKey(msg)
}
return m, nil
} }
func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
@@ -294,63 +298,32 @@ func (m Model) handleFileListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
case key.Matches(msg, m.keys.Preview): case key.Matches(msg, m.keys.Preview):
if len(m.files) > 0 {
openFile(m.files[m.cursor].path)
}
}
return m, nil
}
func (m Model) handleResultsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Quit), key.Matches(msg, m.keys.Enter):
return m, tea.Quit
case key.Matches(msg, m.keys.Back):
// 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" || m.files[i].status == "deleted" {
m.files[i].status = "idle"
m.files[i].error = ""
m.files[i].outputPath = ""
}
}
m.converting = 0
m.converted = 0
m.totalToConv = 0
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 { if len(m.files) > 0 {
f := m.files[m.cursor] f := m.files[m.cursor]
// Open output if done, otherwise input
path := f.path
if f.status == "done" && f.outputPath != "" { if f.status == "done" && f.outputPath != "" {
openFile(f.outputPath) path = f.outputPath
} else {
openFile(f.path)
} }
openFile(path)
} }
case key.Matches(msg, m.keys.DeleteOutput):
// Delete the converted output file from disk case key.Matches(msg, m.keys.Reset):
if len(m.files) > 0 { // Reset done/error/deleted file back to idle for reconversion
if len(m.files) > 0 && !isConverting {
f := &m.files[m.cursor] f := &m.files[m.cursor]
if f.status == "done" && f.outputPath != "" { if f.status == "done" || f.status == "error" || f.status == "deleted" {
if err := os.Remove(f.outputPath); err == nil { f.status = "idle"
f.status = "deleted" f.error = ""
f.outputPath = "" f.outputPath = ""
} }
} }
case key.Matches(msg, m.keys.Refresh):
// Manually refresh the file list
m = m.checkForNewFiles()
} }
}
return m, nil return m, nil
} }
@@ -450,3 +423,46 @@ func formatSize(bytes int64) string {
return fmt.Sprintf("%d B", bytes) return fmt.Sprintf("%d B", bytes)
} }
} }
// checkForNewFiles scans directories for new files and adds them to the list.
func (m Model) checkForNewFiles() Model {
dirs := make(map[string]bool)
for _, f := range m.files {
dir := filepath.Dir(f.path)
dirs[dir] = true
}
for dir := range dirs {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
path := filepath.Join(dir, e.Name())
// Check if already in list
exists := false
for _, f := range m.files {
if f.path == path {
exists = true
break
}
}
if !exists {
info, err := e.Info()
if err != nil {
continue
}
entry := makeFileEntry(path, info)
if entry != nil {
m.files = append(m.files, *entry)
}
}
}
}
return m
}
+23 -37
View File
@@ -30,9 +30,9 @@ func TestInlineConversionStateMachine(t *testing.T) {
if m.files[0].status != "idle" { if m.files[0].status != "idle" {
t.Fatalf("expected idle status, got %s", m.files[0].status) t.Fatalf("expected idle status, got %s", m.files[0].status)
} }
if !m.files[0].selected {
t.Fatal("expected file to be selected by default") // Select the file (default is now false)
} m.files[0].selected = true
t.Logf("File: %s -> %s (selected: %v)", m.files[0].name, m.files[0].targetFormat, m.files[0].selected) t.Logf("File: %s -> %s (selected: %v)", m.files[0].name, m.files[0].targetFormat, m.files[0].selected)
@@ -62,8 +62,9 @@ func TestInlineConversionStateMachine(t *testing.T) {
newModel, _ = m.Update(msg) newModel, _ = m.Update(msg)
m = newModel.(Model) m = newModel.(Model)
if m.state != stateResults { // Should stay on stateFileList after conversion (not stateResults)
t.Fatalf("expected stateResults after all conversions done, got %d", m.state) if m.state != stateFileList {
t.Fatalf("expected stateFileList after conversion, got %d", m.state)
} }
if m.files[0].status != "done" { if m.files[0].status != "done" {
t.Fatalf("expected 'done' status, got '%s' (error: %s)", m.files[0].status, m.files[0].error) t.Fatalf("expected 'done' status, got '%s' (error: %s)", m.files[0].status, m.files[0].error)
@@ -80,18 +81,7 @@ func TestInlineConversionStateMachine(t *testing.T) {
t.Logf("Conversion complete: %s (%d bytes)", m.files[0].outputPath, info.Size()) t.Logf("Conversion complete: %s (%d bytes)", m.files[0].outputPath, info.Size())
// Test 'esc' to go back to file list t.Log("State machine verified: idle -> converting -> done (stays on file list)")
newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyEscape})
m = newModel.(Model)
if m.state != stateFileList {
t.Fatalf("expected stateFileList after esc, got %d", m.state)
}
if m.files[0].status != "idle" {
t.Fatalf("expected status reset to 'idle' after esc, got '%s'", m.files[0].status)
}
t.Log("State machine verified: idle -> converting -> done -> idle (via esc)")
} }
func TestDeleteOutput(t *testing.T) { func TestDeleteOutput(t *testing.T) {
@@ -105,6 +95,9 @@ func TestDeleteOutput(t *testing.T) {
m.width = 80 m.width = 80
m.height = 24 m.height = 24
// Select the file (default is now false)
m.files[0].selected = true
// Convert the file // Convert the file
newModel, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) newModel, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
m = newModel.(Model) m = newModel.(Model)
@@ -112,33 +105,23 @@ func TestDeleteOutput(t *testing.T) {
newModel, _ = m.Update(msg) newModel, _ = m.Update(msg)
m = newModel.(Model) m = newModel.(Model)
if m.state != stateResults { // Should stay on stateFileList
t.Fatalf("expected stateResults, got %d", m.state) if m.state != stateFileList {
t.Fatalf("expected stateFileList, got %d", m.state)
} }
if m.files[0].status != "done" { if m.files[0].status != "done" {
t.Fatalf("expected done, got %s", m.files[0].status) t.Fatalf("expected done, got %s", m.files[0].status)
} }
outputPath := m.files[0].outputPath // Test pressing 'r' to reset the done file back to idle
if _, err := os.Stat(outputPath); err != nil { newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
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) m = newModel.(Model)
if m.files[0].status != "deleted" { if m.files[0].status != "idle" {
t.Fatalf("expected 'deleted' status after pressing x, got '%s'", m.files[0].status) t.Fatalf("expected 'idle' status after pressing r, 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'") t.Log("Reset works: status reset from done back to 'idle'")
} }
func TestViewRendersDuringConversion(t *testing.T) { func TestViewRendersDuringConversion(t *testing.T) {
@@ -159,10 +142,13 @@ func TestViewRendersDuringConversion(t *testing.T) {
if !containsStr(view, "idle") { if !containsStr(view, "idle") {
t.Error("initial view should contain 'idle' status") t.Error("initial view should contain 'idle' status")
} }
if !containsStr(view, "Convert 1 files") { if !containsStr(view, "Select files to convert") {
t.Error("initial view should contain convert button") t.Error("initial view should contain 'Select files to convert'")
} }
// Select a file and start conversion
m.files[0].selected = true
// Start conversion // Start conversion
newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
m = newModel.(Model) m = newModel.(Model)
+24 -29
View File
@@ -61,15 +61,6 @@ func (m Model) View() string {
bottom = append(bottom, m.bg(m.renderDivider())) bottom = append(bottom, m.bg(m.renderDivider()))
bottom = append(bottom, m.bg(m.renderBottomBar())) bottom = append(bottom, m.bg(m.renderBottomBar()))
case stateResults:
top = append(top, m.bg(""))
top = append(top, m.bg(m.renderColumnHeader()))
top = append(top, m.bg(""))
top = append(top, m.renderFileRows()...)
bottom = append(bottom, m.bg(m.renderDivider()))
bottom = append(bottom, m.bg(m.renderResultsBar()))
} }
if m.showHelp { if m.showHelp {
@@ -118,8 +109,6 @@ func (m Model) renderTitleBar() string {
isConverting := m.totalToConv > 0 && m.converted < m.totalToConv isConverting := m.totalToConv > 0 && m.converted < m.totalToConv
if isConverting { if isConverting {
infoText = fmt.Sprintf(" %d files · converting %d/%d", len(m.files), m.converted, m.totalToConv) infoText = fmt.Sprintf(" %d files · converting %d/%d", len(m.files), m.converted, m.totalToConv)
} else if m.state == stateResults {
infoText = fmt.Sprintf(" %d files · %d converted", len(m.files), m.converted)
} else { } else {
infoText = fmt.Sprintf(" %d files · %d selected", len(m.files), selected) infoText = fmt.Sprintf(" %d files · %d selected", len(m.files), selected)
} }
@@ -328,8 +317,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 p preview a all q quit" fullHelp := "↑↓ nav ←→ fmt space sel p preview r reset d delete f refresh c convert q quit"
shortHelp := "↑↓ nav ←→ fmt spc sel p preview q quit" shortHelp := "↑↓ nav ←→ fmt spc sel p prev r rst d del q quit"
helpText := fullHelp helpText := fullHelp
rightW := len(helpText) + 4 // 2 padding each side rightW := len(helpText) + 4 // 2 padding each side
@@ -385,29 +374,35 @@ func (m Model) renderHelp() string {
key string key string
desc string desc string
}{ }{
{"up/down, j/k", "Navigate files"}, {"↑/↓ or j/k", "Navigate files"},
{"left/right, h/l", "Change target format"}, {"←/→ or h/l", "Change target format"},
{"space", "Toggle file selection"}, {"space", "Toggle selection"},
{"a", "Select / deselect all"}, {"a", "Select / deselect all"},
{"p", "Preview file"}, {"p", "Preview / open file"},
{"d", "Remove file from list"}, {"r", "Reset to idle"},
{"x", "Delete converted output"}, {"d", "Remove from list"},
{"c or enter", "Start conversion"}, {"f", "Refresh files"},
{"esc", "Go back"}, {"c or enter", "Convert selected"},
{"q or ctrl+c", "Quit"}, {"? or q", "Close / quit"},
} }
// Title bar
var lines []string var lines []string
lines = append(lines, "") title := theme.Bg(theme.Logo).Render(" Keyboard Shortcuts ")
lines = append(lines, theme.Bg(theme.BreadcrumbActive).Render(" Keyboard Shortcuts")) divider := theme.Bg(theme.Divider).Render(strings.Repeat("─", m.width-4))
lines = append(lines, "")
lines = append(lines, m.bg(""))
lines = append(lines, m.bg(theme.BgStr(" ")+title+theme.BgStr(strings.Repeat(" ", m.width-4-lipgloss.Width(title)-2))))
lines = append(lines, m.bg(divider))
for _, k := range keys { for _, k := range keys {
lines = append(lines, fmt.Sprintf(" %s %s", keyStr := theme.Bg(theme.Selected).Copy().Width(16).Render(k.key)
theme.Bg(theme.Selected).Copy().Width(18).Render(k.key), descStr := theme.Bg(theme.Help).Render(" " + k.desc + " ")
theme.Bg(theme.Help).Render(k.desc))) line := m.bg(keyStr + descStr)
lines = append(lines, line)
} }
lines = append(lines, "")
lines = append(lines, m.bg(divider))
return lipgloss.JoinVertical(lipgloss.Left, lines...) return lipgloss.JoinVertical(lipgloss.Left, lines...)
} }
+1 -1
View File
@@ -14,7 +14,7 @@ import (
const ( const (
// CurrentVersion is the embedded build version. Updated at release time. // CurrentVersion is the embedded build version. Updated at release time.
CurrentVersion = "0.1.4" CurrentVersion = "0.1.6"
repoOwner = "noauf" repoOwner = "noauf"
repoName = "Transmute" repoName = "Transmute"
Binary file not shown.
+102
View File
@@ -0,0 +1,102 @@
# Transmute CLI installer for Windows
# Usage: irm https://raw.githubusercontent.com/noauf/Transmute/main/install.ps1 | iex
function Install-Transmute {
$REPO = "noauf/Transmute"
$BINARY = "transmute"
Write-Host ""
Write-Host " Transmute CLI installer"
Write-Host " ======================"
Write-Host ""
# Detect architecture
if (-not [System.Environment]::Is64BitOperatingSystem) {
Write-Host " Error: 32-bit Windows is not supported" -ForegroundColor Red
return
}
$ARCH = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "arm64" } else { "x86_64" }
Write-Host " OS: windows"
Write-Host " Arch: $ARCH"
Write-Host ""
$ASSET = "$BINARY-windows-$ARCH.zip"
# Get latest release tag
Write-Host " Fetching latest release..."
try {
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/$REPO/releases/latest" -Headers @{ "User-Agent" = "transmute-installer" }
$TAG = $release.tag_name
} catch {
Write-Host " Error: could not fetch latest release: $_" -ForegroundColor Red
return
}
if (-not $TAG) {
Write-Host " Error: could not determine latest release" -ForegroundColor Red
return
}
Write-Host " Latest version: $TAG"
$DOWNLOAD_URL = "https://github.com/$REPO/releases/download/$TAG/$ASSET"
# Create temp directory
$TMP_DIR = Join-Path $env:TEMP "transmute-install-$(Get-Random)"
New-Item -ItemType Directory -Path $TMP_DIR | Out-Null
try {
$ZIP_PATH = Join-Path $TMP_DIR $ASSET
Write-Host " Downloading $ASSET..."
Invoke-WebRequest -Uri $DOWNLOAD_URL -OutFile $ZIP_PATH -UseBasicParsing
Write-Host " Extracting..."
Expand-Archive -Path $ZIP_PATH -DestinationPath $TMP_DIR -Force
$EXE_NAME = "$BINARY.exe"
$EXE_SOURCE = Join-Path $TMP_DIR "$BINARY-windows-$ARCH.exe"
if (-not (Test-Path $EXE_SOURCE)) {
$EXE_SOURCE = Join-Path $TMP_DIR $EXE_NAME
}
if (-not (Test-Path $EXE_SOURCE)) {
Write-Host " Error: could not find $EXE_NAME in archive" -ForegroundColor Red
return
}
# Determine install directory
$INSTALL_DIR = "$env:LOCALAPPDATA\transmute\bin"
New-Item -ItemType Directory -Path $INSTALL_DIR -Force | Out-Null
$INSTALL_PATH = Join-Path $INSTALL_DIR $EXE_NAME
Copy-Item -Path $EXE_SOURCE -Destination $INSTALL_PATH -Force
Write-Host ""
Write-Host " Installed transmute $TAG to $INSTALL_PATH" -ForegroundColor Green
Write-Host ""
# Add to PATH if not already there
$USER_PATH = [System.Environment]::GetEnvironmentVariable("PATH", "User")
if ($USER_PATH -notlike "*$INSTALL_DIR*") {
[System.Environment]::SetEnvironmentVariable("PATH", "$USER_PATH;$INSTALL_DIR", "User")
Write-Host " Added $INSTALL_DIR to your PATH."
Write-Host " Restart your terminal for the PATH change to take effect."
Write-Host ""
}
Write-Host " Get started:"
Write-Host " transmute *.png Convert all PNGs"
Write-Host " transmute ./photos/ Convert all files in a directory"
Write-Host " transmute --help Show all options"
Write-Host ""
} catch {
Write-Host " Error: $_" -ForegroundColor Red
} finally {
Remove-Item -Recurse -Force $TMP_DIR -ErrorAction SilentlyContinue
}
}
Install-Transmute
+9 -1
View File
@@ -11,7 +11,15 @@ OS="$(uname -s)"
case "$OS" in case "$OS" in
Darwin) OS="darwin" ;; Darwin) OS="darwin" ;;
Linux) OS="linux" ;; Linux) OS="linux" ;;
MINGW*|MSYS*|CYGWIN*) OS="windows" ;; MINGW*|MSYS*|CYGWIN*)
echo ""
echo " Windows detected."
echo " Please use the PowerShell installer instead:"
echo ""
echo " irm https://raw.githubusercontent.com/noauf/Transmute/main/install.ps1 | iex"
echo ""
exit 0
;;
*) *)
echo "Error: unsupported operating system: $OS" echo "Error: unsupported operating system: $OS"
exit 1 exit 1
+164 -55
View File
@@ -1,15 +1,15 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from "framer-motion";
import Link from 'next/link'; import Link from "next/link";
import { DropZone } from '@/components/DropZone'; import { DropZone } from "@/components/DropZone";
import { FileRow } from '@/components/FileRow'; import { FileRow } from "@/components/FileRow";
import { PreviewModal } from '@/components/PreviewModal'; import { PreviewModal } from "@/components/PreviewModal";
import { useFileUpload } from '@/hooks/useFileUpload'; import { useFileUpload } from "@/hooks/useFileUpload";
import { useConversion } from '@/hooks/useConversion'; import { useConversion } from "@/hooks/useConversion";
import { formatFileSize } from '@/lib/utils'; import { formatFileSize } from "@/lib/utils";
import { UploadedFile } from '@/types'; import { UploadedFile } from "@/types";
/* Number of ghost rows to show below real files */ /* Number of ghost rows to show below real files */
const MIN_VISIBLE_ROWS = 8; const MIN_VISIBLE_ROWS = 8;
@@ -31,20 +31,17 @@ export default function ConvertPage() {
clearAll, clearAll,
} = useFileUpload(); } = useFileUpload();
const { const { isConverting, convertAll, downloadFile, downloadAllAsZip } =
isConverting, useConversion(updateFile);
convertAll,
downloadFile,
downloadAllAsZip,
} = useConversion(updateFile);
const [previewFile, setPreviewFile] = useState<UploadedFile | null>(null); const [previewFile, setPreviewFile] = useState<UploadedFile | null>(null);
const hasFiles = files.length > 0; const hasFiles = files.length > 0;
const convertableCount = files.filter( const convertableCount = files.filter(
(f) => f.targetFormat && f.status !== 'done' && f.availableFormats.length > 0 (f) =>
f.targetFormat && f.status !== "done" && f.availableFormats.length > 0,
).length; ).length;
const completedCount = files.filter((f) => f.status === 'done').length; const completedCount = files.filter((f) => f.status === "done").length;
const totalSize = files.reduce((acc, f) => acc + f.size, 0); const totalSize = files.reduce((acc, f) => acc + f.size, 0);
const ghostRowCount = Math.max(0, MIN_VISIBLE_ROWS - files.length); const ghostRowCount = Math.max(0, MIN_VISIBLE_ROWS - files.length);
@@ -64,9 +61,9 @@ export default function ConvertPage() {
/> />
{/* Finder Window — the entire converter lives inside this */} {/* Finder Window — the entire converter lives inside this */}
<div className="relative z-10 max-w-[960px] mx-auto px-2 sm:px-6 py-3 sm:py-10"> <div className="relative z-10 w-full px-3 sm:px-6 lg:px-8 py-3 sm:py-6">
<motion.div <motion.div
className="flex flex-col max-h-[calc(100vh-24px)] sm:max-h-[calc(100vh-80px)] rounded-xl overflow-hidden shadow-[0_12px_60px_rgba(45,31,20,0.12)] border border-border-soft bg-white" className="flex flex-col min-h-[calc(100vh-24px)] sm:min-h-[calc(100vh-48px)] rounded-xl overflow-hidden shadow-[0_12px_60px_rgba(45,31,20,0.12)] border border-border-soft bg-white"
initial={{ opacity: 0, y: 16, scale: 0.98 }} initial={{ opacity: 0, y: 16, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] as const }} transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] as const }}
@@ -74,7 +71,10 @@ export default function ConvertPage() {
{/* ─ Title bar ─ */} {/* ─ Title bar ─ */}
<div className="flex-shrink-0 flex items-center gap-2 sm:gap-3 px-3 sm:px-4 py-2 sm:py-2.5 bg-[#f6f6f6] border-b border-border-soft select-none"> <div className="flex-shrink-0 flex items-center gap-2 sm:gap-3 px-3 sm:px-4 py-2 sm:py-2.5 bg-[#f6f6f6] border-b border-border-soft select-none">
{/* Traffic lights */} {/* Traffic lights */}
<Link href="/" className="flex items-center gap-[6px] no-underline group/dots"> <Link
href="/"
className="flex items-center gap-[6px] no-underline group/dots"
>
<div className="w-[11px] h-[11px] rounded-full bg-[#ff5f57] border border-[#e0443e]/40 group-hover/dots:bg-[#ff3b30] transition-colors" /> <div className="w-[11px] h-[11px] rounded-full bg-[#ff5f57] border border-[#e0443e]/40 group-hover/dots:bg-[#ff3b30] transition-colors" />
<div className="w-[11px] h-[11px] rounded-full bg-[#febc2e] border border-[#dea123]/40 group-hover/dots:bg-[#ff9500] transition-colors" /> <div className="w-[11px] h-[11px] rounded-full bg-[#febc2e] border border-[#dea123]/40 group-hover/dots:bg-[#ff9500] transition-colors" />
<div className="w-[11px] h-[11px] rounded-full bg-[#28c840] border border-[#1aab29]/40 group-hover/dots:bg-[#28cd41] transition-colors" /> <div className="w-[11px] h-[11px] rounded-full bg-[#28c840] border border-[#1aab29]/40 group-hover/dots:bg-[#28cd41] transition-colors" />
@@ -82,11 +82,32 @@ export default function ConvertPage() {
{/* Navigation arrows — hidden on mobile */} {/* Navigation arrows — hidden on mobile */}
<div className="hidden sm:flex items-center gap-1 ml-1"> <div className="hidden sm:flex items-center gap-1 ml-1">
<Link href="/" className="text-text-light/40 hover:text-text-mid transition-colors no-underline"> <Link
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M15 18l-6-6 6-6" /></svg> href="/"
className="text-text-light/40 hover:text-text-mid transition-colors no-underline"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</Link> </Link>
<div className="text-text-light/25"> <div className="text-text-light/25">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 18l6-6-6-6" /></svg> <svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 18l6-6-6-6" />
</svg>
</div> </div>
</div> </div>
@@ -94,9 +115,15 @@ export default function ConvertPage() {
<div className="flex-1 flex items-center justify-center gap-1.5"> <div className="flex-1 flex items-center justify-center gap-1.5">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/logo.png" alt="" className="w-4 h-4 rounded-[4px]" /> <img src="/logo.png" alt="" className="w-4 h-4 rounded-[4px]" />
<span className="text-[12px] font-medium text-text-mid hidden sm:inline">Transmute</span> <span className="text-[12px] font-medium text-text-mid hidden sm:inline">
<span className="text-[12px] text-text-light/40 hidden sm:inline">{'\u203A'}</span> Transmute
<span className="text-[12px] font-medium text-text-dark">Converter</span> </span>
<span className="text-[12px] text-text-light/40 hidden sm:inline">
{"\u203A"}
</span>
<span className="text-[12px] font-medium text-text-dark">
Converter
</span>
</div> </div>
{/* Right side — version + add files button */} {/* Right side — version + add files button */}
@@ -110,7 +137,15 @@ export default function ConvertPage() {
onClick={openFilePicker} onClick={openFilePicker}
title="Add files" title="Add files"
> >
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> <svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
>
<path d="M12 5v14M5 12h14" /> <path d="M12 5v14M5 12h14" />
</svg> </svg>
</button> </button>
@@ -123,7 +158,8 @@ export default function ConvertPage() {
<div className="flex-shrink-0 flex items-center justify-between px-3 sm:px-4 py-1.5 sm:py-2 bg-[#fafafa] border-b border-border-soft"> <div className="flex-shrink-0 flex items-center justify-between px-3 sm:px-4 py-1.5 sm:py-2 bg-[#fafafa] border-b border-border-soft">
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<span className="font-mono text-[11px] text-text-mid"> <span className="font-mono text-[11px] text-text-mid">
<strong className="text-text-dark">{files.length}</strong> file{files.length !== 1 ? 's' : ''} <strong className="text-text-dark">{files.length}</strong>{" "}
file{files.length !== 1 ? "s" : ""}
</span> </span>
<div className="w-px h-3.5 bg-border-soft" /> <div className="w-px h-3.5 bg-border-soft" />
<span className="font-mono text-[11px] text-text-mid"> <span className="font-mono text-[11px] text-text-mid">
@@ -133,7 +169,8 @@ export default function ConvertPage() {
<span className="hidden sm:flex items-center gap-1 font-mono text-[11px] text-text-mid"> <span className="hidden sm:flex items-center gap-1 font-mono text-[11px] text-text-mid">
<div className="w-px h-3.5 bg-border-soft mr-1" /> <div className="w-px h-3.5 bg-border-soft mr-1" />
<div className="w-1.5 h-1.5 rounded-full bg-mint" /> <div className="w-1.5 h-1.5 rounded-full bg-mint" />
<strong className="text-mint">{completedCount}</strong> converted <strong className="text-mint">{completedCount}</strong>{" "}
converted
</span> </span>
)} )}
</div> </div>
@@ -158,12 +195,11 @@ export default function ConvertPage() {
{/* ─ Content area (scrollable) ─ */} {/* ─ Content area (scrollable) ─ */}
<div <div
className="relative flex-1 overflow-y-auto min-h-0" className={`relative flex-1 min-h-0 ${hasFiles ? "overflow-y-auto" : "flex items-center justify-center"}`}
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={handleDrop} onDrop={handleDrop}
style={{ minHeight: hasFiles ? undefined : '50vh' }}
> >
{/* Drop zone (empty state) */} {/* Drop zone (empty state) */}
{!hasFiles && ( {!hasFiles && (
@@ -189,7 +225,15 @@ export default function ConvertPage() {
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
> >
<div className="flex items-center gap-2 text-pink font-semibold text-sm"> <div className="flex items-center gap-2 text-pink font-semibold text-sm">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> <svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
>
<path d="M12 5v14M5 12h14" /> <path d="M12 5v14M5 12h14" />
</svg> </svg>
Drop to add files Drop to add files
@@ -220,7 +264,7 @@ export default function ConvertPage() {
{Array.from({ length: ghostRowCount }).map((_, i) => ( {Array.from({ length: ghostRowCount }).map((_, i) => (
<div <div
key={`ghost-${i}`} key={`ghost-${i}`}
className={`flex items-center px-3 sm:px-4 py-2.5 ${i === 0 ? 'cursor-pointer hover:bg-[#fafafa] active:bg-[#fafafa] transition-colors' : ''} ${i >= 4 ? 'hidden sm:flex' : ''}`} className={`flex items-center px-3 sm:px-4 py-2.5 ${i === 0 ? "cursor-pointer hover:bg-[#fafafa] active:bg-[#fafafa] transition-colors" : ""} ${i >= 4 ? "hidden sm:flex" : ""}`}
onClick={i === 0 ? openFilePicker : undefined} onClick={i === 0 ? openFilePicker : undefined}
> >
{/* Ghost icon */} {/* Ghost icon */}
@@ -229,25 +273,49 @@ export default function ConvertPage() {
<div className="flex-1 ml-2.5 sm:ml-3"> <div className="flex-1 ml-2.5 sm:ml-3">
{i === 0 ? ( {i === 0 ? (
<span className="text-[12px] text-text-light/40 flex items-center gap-1.5"> <span className="text-[12px] text-text-light/40 flex items-center gap-1.5">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="opacity-40"> <svg
<path d="M12 5v14M5 12h14" strokeLinecap="round" /> width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="opacity-40"
>
<path
d="M12 5v14M5 12h14"
strokeLinecap="round"
/>
</svg> </svg>
<span className="hidden sm:inline">Drop files here or click to browse</span> <span className="hidden sm:inline">
<span className="sm:hidden">Tap to add files</span> Drop files here or click to browse
</span>
<span className="sm:hidden">
Tap to add files
</span>
</span> </span>
) : ( ) : (
<div className="h-2.5 rounded bg-border-soft/15" style={{ width: `${60 + ((i * 37) % 60)}px` }} /> <div
className="h-2.5 rounded bg-border-soft/15"
style={{ width: `${60 + ((i * 37) % 60)}px` }}
/>
)} )}
</div> </div>
{/* Ghost columns — desktop only */} {/* Ghost columns — desktop only */}
<div className="w-[72px] hidden sm:block"> <div className="w-[72px] hidden sm:block">
{i < 2 && <div className="h-2 w-10 rounded bg-border-soft/10 ml-auto" />} {i < 2 && (
<div className="h-2 w-10 rounded bg-border-soft/10 ml-auto" />
)}
</div> </div>
<div className="w-[140px] hidden sm:block"> <div className="w-[140px] hidden sm:block">
{i < 2 && <div className="h-2 w-12 rounded bg-border-soft/10 mx-auto" />} {i < 2 && (
<div className="h-2 w-12 rounded bg-border-soft/10 mx-auto" />
)}
</div> </div>
<div className="w-[130px] pr-1 hidden sm:block"> <div className="w-[130px] pr-1 hidden sm:block">
{i < 1 && <div className="h-2 w-8 rounded bg-border-soft/10 ml-auto" />} {i < 1 && (
<div className="h-2 w-8 rounded bg-border-soft/10 ml-auto" />
)}
</div> </div>
</div> </div>
))} ))}
@@ -267,23 +335,37 @@ export default function ConvertPage() {
<motion.div <motion.div
className="w-3 h-3 rounded-full border-[1.5px] border-pink border-t-transparent flex-shrink-0" className="w-3 h-3 rounded-full border-[1.5px] border-pink border-t-transparent flex-shrink-0"
animate={{ rotate: 360 }} animate={{ rotate: 360 }}
transition={{ duration: 0.6, repeat: Infinity, ease: 'linear' }} transition={{
duration: 0.6,
repeat: Infinity,
ease: "linear",
}}
/> />
<span className="text-[11px] font-mono text-pink font-medium truncate">Converting...</span> <span className="text-[11px] font-mono text-pink font-medium truncate">
Converting...
</span>
</> </>
) : completedCount === files.length && completedCount > 0 ? ( ) : completedCount === files.length && completedCount > 0 ? (
<> <>
<div className="w-1.5 h-1.5 rounded-full bg-mint flex-shrink-0" /> <div className="w-1.5 h-1.5 rounded-full bg-mint flex-shrink-0" />
<span className="text-[11px] font-mono text-mint font-medium truncate">All done</span> <span className="text-[11px] font-mono text-mint font-medium truncate">
All done
</span>
</> </>
) : ( ) : (
<span className="text-[11px] text-text-light font-mono truncate"> <span className="text-[11px] text-text-light font-mono truncate">
{convertableCount > 0 ? ( {convertableCount > 0 ? (
<> <>
<span className="hidden sm:inline">{convertableCount} ready to convert</span> <span className="hidden sm:inline">
<span className="sm:hidden">{convertableCount} ready</span> {convertableCount} ready to convert
</span>
<span className="sm:hidden">
{convertableCount} ready
</span>
</> </>
) : 'Select formats'} ) : (
"Select formats"
)}
</span> </span>
)} )}
</div> </div>
@@ -297,7 +379,14 @@ export default function ConvertPage() {
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
> >
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" /> <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg> </svg>
ZIP ZIP
@@ -305,13 +394,21 @@ export default function ConvertPage() {
)} )}
<button <button
className={`inline-flex items-center gap-1 sm:gap-1.5 px-3.5 sm:px-5 py-1.5 text-[11px] sm:text-[12px] font-bold text-white bg-pink border-none rounded-lg cursor-pointer shadow-[0_2px_12px_rgba(244,114,182,0.25)] hover:-translate-y-0.5 hover:shadow-[0_4px_18px_rgba(244,114,182,0.35)] transition-all disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none disabled:transform-none ${isConverting ? 'animate-pulse-soft opacity-85' : ''}`} className={`inline-flex items-center gap-1 sm:gap-1.5 px-3.5 sm:px-5 py-1.5 text-[11px] sm:text-[12px] font-bold text-white bg-pink border-none rounded-lg cursor-pointer shadow-[0_2px_12px_rgba(244,114,182,0.25)] hover:-translate-y-0.5 hover:shadow-[0_4px_18px_rgba(244,114,182,0.35)] transition-all disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none disabled:transform-none ${isConverting ? "animate-pulse-soft opacity-85" : ""}`}
onClick={() => convertAll(files)} onClick={() => convertAll(files)}
disabled={isConverting || convertableCount === 0} disabled={isConverting || convertableCount === 0}
> >
{isConverting ? ( {isConverting ? (
<> <>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="animate-spin"> <svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="animate-spin"
>
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" /> <path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
</svg> </svg>
<span className="hidden sm:inline">Converting...</span> <span className="hidden sm:inline">Converting...</span>
@@ -319,10 +416,22 @@ export default function ConvertPage() {
</> </>
) : ( ) : (
<> <>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <svg
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" strokeLinecap="round" strokeLinejoin="round" /> width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<path
d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg> </svg>
Transmute{convertableCount > 0 ? ` (${convertableCount})` : ''} Transmute
{convertableCount > 0 ? ` (${convertableCount})` : ""}
</> </>
)} )}
</button> </button>
+84 -6
View File
@@ -717,6 +717,89 @@ function TUIFileRows() {
); );
} }
/* ─── Install Box Component ─── */
function InstallBox() {
const [os, setOs] = useState<'unix' | 'windows'>('unix');
const [copied, setCopied] = useState(false);
const commands = {
unix: 'curl -fsSL https://raw.githubusercontent.com/noauf/Transmute/main/install.sh | sh',
windows: 'irm https://raw.githubusercontent.com/noauf/Transmute/main/install.ps1 | iex',
};
const prompt = os === 'unix' ? '$' : 'PS>';
const promptColor = os === 'unix' ? '#34d399' : '#60a5fa';
const command = commands[os];
const handleCopy = () => {
navigator.clipboard.writeText(command).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div className="mt-6 rounded-xl overflow-hidden border border-[#2d2d2d] bg-[#1a1a1a]">
{/* Tab bar */}
<div className="flex items-center gap-0 border-b border-[#2d2d2d] px-1 pt-1">
<button
onClick={() => setOs('unix')}
className={`px-4 py-1.5 text-[12px] font-mono font-semibold rounded-t-lg transition-colors ${
os === 'unix'
? 'bg-[#2a2a2a] text-[#e2e2e2]'
: 'text-[#666] hover:text-[#999]'
}`}
>
Linux / macOS
</button>
<button
onClick={() => setOs('windows')}
className={`px-4 py-1.5 text-[12px] font-mono font-semibold rounded-t-lg transition-colors ${
os === 'windows'
? 'bg-[#2a2a2a] text-[#e2e2e2]'
: 'text-[#666] hover:text-[#999]'
}`}
>
Windows
</button>
</div>
{/* Command line */}
<div className="flex items-center gap-3 px-4 py-3 font-mono text-[13px]">
<span className="select-none font-bold flex-shrink-0" style={{ color: promptColor }}>{prompt}</span>
<span className="text-[#e2e2e2] break-all flex-1">{command}</span>
<button
onClick={handleCopy}
title="Copy to clipboard"
className="flex-shrink-0 flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-semibold transition-all"
style={{
background: copied ? 'rgba(52,211,153,0.15)' : 'rgba(255,255,255,0.06)',
color: copied ? '#34d399' : '#888',
}}
>
{copied ? (
<>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M20 6L9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Copied
</>
) : (
<>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
Copy
</>
)}
</button>
</div>
</div>
);
}
/* ─── Main Page ─── */ /* ─── Main Page ─── */
export default function LandingPage() { export default function LandingPage() {
@@ -1077,12 +1160,7 @@ export default function LandingPage() {
</div> </div>
{/* Install command below the TUI */} {/* Install command below the TUI */}
<div className="mt-6 rounded-xl overflow-hidden border border-[#2d2d2d] bg-[#1a1a1a]"> <InstallBox />
<div className="flex items-center gap-3 px-4 py-3 font-mono text-[13px]">
<span className="text-[#34d399] select-none font-bold">$</span>
<span className="text-[#e2e2e2] break-all">curl -fsSL <span className="text-[#60a5fa]">https://raw.githubusercontent.com/noauf/Transmute/main/install.sh</span> | <span className="text-[#fb923c]">sh</span></span>
</div>
</div>
</motion.div> </motion.div>
{/* CLI feature bullets */} {/* CLI feature bullets */}
+99 -40
View File
@@ -1,7 +1,7 @@
'use client'; "use client";
import { motion } from 'framer-motion'; import { motion } from "framer-motion";
import React from 'react'; import React from "react";
interface DropZoneProps { interface DropZoneProps {
isDragging: boolean; isDragging: boolean;
@@ -15,6 +15,16 @@ interface DropZoneProps {
onBrowse: () => void; onBrowse: () => void;
} }
const FORMAT_PILLS = [
{ label: "JPG", color: "bg-pink/10 text-pink" },
{ label: "PDF", color: "bg-orange/10 text-orange" },
{ label: "MP4", color: "bg-purple/10 text-purple" },
{ label: "MP3", color: "bg-blue/10 text-blue" },
{ label: "SVG", color: "bg-teal/10 text-teal" },
{ label: "CSV", color: "bg-mint/10 text-mint" },
{ label: "+64", color: "bg-[#f0ede8] text-text-light" },
];
export function DropZone({ export function DropZone({
isDragging, isDragging,
onDragEnter, onDragEnter,
@@ -23,80 +33,129 @@ export function DropZone({
onDrop, onDrop,
onBrowse, onBrowse,
}: DropZoneProps) { }: DropZoneProps) {
// Empty state inside Finder window
return ( return (
<div <div
className="flex items-center justify-center px-4 sm:px-6 py-10 sm:py-16" className="flex min-h-full items-center justify-center px-6 sm:px-10 py-12 sm:py-20"
style={{ minHeight: '50vh' }} style={{ minHeight: "100%" }}
onDragEnter={onDragEnter} onDragEnter={onDragEnter}
onDragLeave={onDragLeave} onDragLeave={onDragLeave}
onDragOver={onDragOver} onDragOver={onDragOver}
onDrop={onDrop} onDrop={onDrop}
> >
<div className="flex w-full max-w-xl flex-col items-center gap-6 text-center">
{/* Icon */}
<motion.div <motion.div
className="flex flex-col items-center gap-3 sm:gap-4 text-center" className="flex items-center justify-center w-24 h-24 sm:w-28 sm:h-28 rounded-3xl flex-shrink-0"
initial={{ opacity: 0, y: 16 }} style={{
animate={{ opacity: 1, y: 0 }} background: isDragging ? "rgba(244,114,182,0.12)" : "#f6f6f6",
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] as const }} boxShadow: isDragging
> ? "0 0 0 6px rgba(244,114,182,0.08)"
{/* Upload icon */} : "none",
<motion.div
className={`flex items-center justify-center w-16 h-16 sm:w-20 sm:h-20 rounded-2xl transition-all duration-300 ${
isDragging
? 'bg-pink/12 text-pink scale-110'
: 'bg-[#f6f6f6] text-text-light'
}`}
animate={{
y: isDragging ? -8 : 0,
rotate: isDragging ? -3 : 0,
}} }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }} initial={{ opacity: 0, scale: 0.85 }}
animate={
isDragging
? { opacity: 1, scale: 1.08, y: -8, rotate: -3 }
: { opacity: 1, scale: 1, y: [0, -7, 0], rotate: 0 }
}
transition={
isDragging
? { type: "spring", stiffness: 280, damping: 18 }
: {
opacity: { duration: 0.4, ease: [0.16, 1, 0.3, 1] },
scale: { duration: 0.4, ease: [0.16, 1, 0.3, 1] },
y: { duration: 3, repeat: Infinity, ease: "easeInOut" },
}
}
> >
<svg width="32" height="32" viewBox="0 0 48 48" fill="none" className="sm:w-9 sm:h-9"> <svg width="38" height="38" viewBox="0 0 48 48" fill="none" className="sm:w-11 sm:h-11">
<path <path
d="M24 32V12M24 12L16 20M24 12L32 20" d="M24 32V12M24 12L16 20M24 12L32 20"
stroke="currentColor" stroke={isDragging ? "#f472b6" : "#b8a08a"}
strokeWidth="2.5" strokeWidth="2.8"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
<path <path
d="M8 28v8a4 4 0 004 4h24a4 4 0 004-4v-8" d="M8 28v8a4 4 0 004 4h24a4 4 0 004-4v-8"
stroke="currentColor" stroke={isDragging ? "#f472b6" : "#b8a08a"}
strokeWidth="2.5" strokeWidth="2.8"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
</svg> </svg>
</motion.div> </motion.div>
<div> {/* Heading + subtitle */}
<h2 className="font-serif text-xl sm:text-2xl font-extrabold text-text-dark tracking-tight mb-1"> <motion.div
{isDragging ? 'Release to add' : 'Drop files here'} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, delay: 0.08, ease: [0.16, 1, 0.3, 1] }}
>
<h2 className="font-serif text-4xl sm:text-5xl font-extrabold text-text-dark tracking-tight mb-3">
{isDragging ? "Release to add" : "Drop files here"}
</h2> </h2>
<p className="text-text-mid text-[13px] sm:text-[14px] max-w-xs leading-relaxed"> <p className="text-text-mid text-base sm:text-lg max-w-sm leading-relaxed">
{isDragging {isDragging
? 'Your files are ready for transformation' ? "Your files are ready for transformation"
: 'Images, documents, audio, video, data \u2014 all formats welcome'} : "Images, documents, audio, video, data all formats welcome"}
</p> </p>
</div> </motion.div>
{/* Format pills */}
{!isDragging && (
<motion.div
className="flex items-center justify-center gap-1.5 flex-wrap"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 0.18 }}
>
{FORMAT_PILLS.map(({ label, color }, i) => (
<motion.span
key={label}
className={`font-mono text-[11px] font-semibold tracking-wide px-2.5 py-1 rounded-full ${color}`}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.3,
delay: 0.2 + i * 0.04,
ease: [0.16, 1, 0.3, 1],
}}
>
{label}
</motion.span>
))}
</motion.div>
)}
{/* Browse button */}
<motion.button <motion.button
className="inline-flex items-center gap-2 mt-1 px-5 sm:px-6 py-2 sm:py-2.5 text-[13px] font-bold text-white bg-pink rounded-xl cursor-pointer shadow-[0_3px_16px_rgba(244,114,182,0.25)] hover:-translate-y-0.5 hover:shadow-[0_5px_22px_rgba(244,114,182,0.35)] active:scale-[0.97] transition-all border-none" className="inline-flex items-center gap-2.5 px-7 sm:px-8 py-3 sm:py-3.5 text-base sm:text-lg font-bold text-white bg-pink rounded-2xl cursor-pointer border-none shadow-[0_6px_24px_rgba(244,114,182,0.28)]"
onClick={onBrowse} onClick={onBrowse}
whileHover={{ scale: 1.03 }} initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.26, ease: [0.16, 1, 0.3, 1] }}
whileHover={{ scale: 1.04, boxShadow: "0 8px_32px rgba(244,114,182,0.42)" }}
whileTap={{ scale: 0.97 }} whileTap={{ scale: 0.97 }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> <path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg> </svg>
Browse files Browse files
</motion.button> </motion.button>
<p className="font-mono text-[10px] text-text-light/60 tracking-wide mt-1"> {/* Trust signal */}
<motion.p
className="font-mono text-xs sm:text-sm text-text-light/60 tracking-wide -mt-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.34 }}
>
70+ formats &mdash; 100% client-side 70+ formats &mdash; 100% client-side
</p> </motion.p>
</motion.div>
</div>
</div> </div>
); );
} }