Compare commits
10 Commits
5f1743ddce
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c025718f9b | |||
| ed147326a3 | |||
| 04454b4a42 | |||
| 0403f14acf | |||
| e76c808eee | |||
| 2c756d2d27 | |||
| f60a7cbaea | |||
| 7ec06b256d | |||
| 31cfd03e42 | |||
| 82a4123f84 |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 — 100% client-side
|
70+ formats — 100% client-side
|
||||||
</p>
|
</motion.p>
|
||||||
</motion.div>
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user