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
+23 -18
View File
@@ -4,21 +4,22 @@ import "github.com/charmbracelet/bubbles/key"
// KeyMap defines key bindings for the TUI. // KeyMap defines key bindings for the TUI.
type KeyMap struct { type KeyMap struct {
Up key.Binding Up key.Binding
Down key.Binding Down key.Binding
Left key.Binding Left key.Binding
Right key.Binding Right key.Binding
Enter key.Binding Enter key.Binding
Space key.Binding Space key.Binding
Tab key.Binding Tab key.Binding
Delete key.Binding Delete key.Binding
SelectAll key.Binding SelectAll key.Binding
Convert key.Binding Convert key.Binding
Quit key.Binding Quit key.Binding
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"),
), ),
} }
} }
+81 -65
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 { return m.handleFileListKey(msg)
case stateFileList:
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}
> >
<motion.div <div className="flex w-full max-w-xl flex-col items-center gap-6 text-center">
className="flex flex-col items-center gap-3 sm:gap-4 text-center"
initial={{ opacity: 0, y: 16 }} {/* Icon */}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] as const }}
>
{/* Upload icon */}
<motion.div <motion.div
className={`flex items-center justify-center w-16 h-16 sm:w-20 sm:h-20 rounded-2xl transition-all duration-300 ${ className="flex items-center justify-center w-24 h-24 sm:w-28 sm:h-28 rounded-3xl flex-shrink-0"
isDragging style={{
? 'bg-pink/12 text-pink scale-110' background: isDragging ? "rgba(244,114,182,0.12)" : "#f6f6f6",
: 'bg-[#f6f6f6] text-text-light' boxShadow: isDragging
}`} ? "0 0 0 6px rgba(244,114,182,0.08)"
animate={{ : "none",
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>
); );
} }