Redesign: premium drop-to-transcribe main experience (#1396)
* Add redesign design contract and Inter font Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Redesign main experience: premium drop-to-transcribe flow - New design language: paper/ink palette, Inter Variable, aurora gradient accent, pill buttons, hairline borders (light + dark) - New unified main page replacing home + batch: aurora hero dropzone, drop/browse/record/link -> transcription starts immediately, file queue with per-file progress, live streaming transcript, editorial reading view with Copy/Export/Search toolbar - Topbar with wordmark + ghost actions replaces the "..." menu - Setup page restyled; settings modal inherits the new tokens - Premium logo: gradient ink, refined sparkles (SVG + regenerated PNG) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Simplify idle screen, fix settings modal viewport overflow - Whole dropzone card is now the single click/drop target (dashed border, upload glyph); removed eyebrow, big headline and separate Browse button - Secondary actions (Record / From link / Folder) moved below the card - Removed model-name item from the quiet row - Settings modal height now capped to the viewport Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Folder auto-detect in picker/drop, source switcher, cleaner topbar - Dropped or picked folders are detected and expanded to their media files automatically (recursive per advanced options); Select folder pill removed - Idle screen is now a source switcher (File / Record / From link) where the active source replaces the drop area entirely - Topbar: removed back arrow and New button; gear (and update indicator when available) only Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Mock: multi-file dialog selection to exercise batch queue Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Recents sidebar + auto-saved transcripts, settings row system, website redesign - Transcripts auto-save as JSON in Documents/Vibe (opt-out in Privacy); collapsible Recents sidebar: reopen, re-transcribe, reveal, delete - Idle screen: content-sized panels, prominent language selector, tighter rhythm - Settings: production row system across all nine sections (inline descriptions replace (i) icons, compact controls, switch for API server, inline icon-button rename) - Website: desktop token/design parity (paper/ink, Inter, aurora), new nav/footer/pages, new logo assets, fresh app preview screenshot - Mock: forward invoke headers so plugin-fs writes reach real paths - justfile: dev-web recipe (frontend-only browser mode) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Quiet dropzone (aurora on drag only), mock single/multi toggle, Save label Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix recents open, stable idle slot, smooth sidebar, mock seeds - Mock read_text_file now returns bytes (plugin-fs decodes via Uint8Array), fixing silent failures opening saved transcripts - Idle: fixed-height source slot so switching File/Record/Link never moves the pills; panels share the drop zone footprint - Sidebar animates width (no content jump) and auto-closes for multi-file sessions; language dropdown widened - Mock: seeded recents, single/multi file toggle pill - Transcript toolbar: 'Save Transcript' -> 'Save' Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bottom audio player bar, recents rename, mock copy_file - ElevenLabs-style docked player on the transcript view: title + show-in-folder path, play/pause, freely draggable aurora seek bar, download-a-copy (plugin-fs copyFile + new capability) - Recents: Rename action with inline edit; store rewrites the file keeping its timestamp - Mock: copy_file handler so download works in browser mode Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Drop unknown eslint disable directive Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Smooth player seek fill via requestAnimationFrame while playing Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * App shell like Claude desktop, full-width player, website polish sweep Desktop: - Full-height start-edge sidebar slot + full-width bottom player slot in Layout; topbar spans only the content column - Sidebar closed by default; player fill monochrome (no aurora) Website: - Nav slimmed (features + download; language selector dev-only with badge), version under platform picker, inline code-drawn app mock replaces the screenshot, RTL highlights fixes, Ko-fi-warm Wall of Love with per-person pastel tints, visible dark-mode CTA band, single merged footer, logical-property sweep, docs rail sticky offset Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Titlebar sidebar toggle (macOS overlay), website i18n keys - macOS: overlay titlebar; the sidebar toggle sits beside the traffic lights (in the sidebar strip when open, in the header when closed); titlebar row is a drag region - Website: 12 new i18n keys translated across all 12 locales replace hardcoded English (hero eyebrow, highlights, closing band, features eyebrow, post-download steps); dir=ltr workarounds removed; docs rail sticky offset fix Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fixed-position sidebar toggle, wordmark in sidebar, link-panel brand row - Toggle keeps the exact same titlebar spot whether the sidebar is open or closed (ChatGPT style); the Vibe wordmark shows only in the open sidebar - From-link panel: 'Works with' row of bundled monochrome yt-dlp source marks (simple-icons) fills the empty space Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Traffic lights aligned with toggle row, Codex-style wordmark row Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Glass sidebar via vibrancy, immovable toggle, wider sidebar, lights y=28 - macOS: transparent window + NSVisualEffectView (Sidebar material); the sidebar tint is translucent so the blurred desktop shows through, the content column stays opaque - One fixed-position toggle owned by the window — it no longer moves or swaps containers when the sidebar opens/closes - Sidebar widened to 288px; traffic lights nudged to align with the toggle row Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Light-mode glass fix (window theme sync, stronger tint), top-5 source icons Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Player bar spans only the content column beside the full-height sidebar Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Nearly opaque light-mode sidebar tint over vibrancy Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Sidebar toggle works in batch view (auto-close once, reopenable) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Thin 6px scrollbars, subtle at rest and stronger on hover Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Inline transcript editing + timestamp seek - Click a segment to edit in place (identical typography, no layout jump), Enter/blur commits, Esc cancels; empty edits revert; edits update the queue and rewrite the saved .vibe.json - Timestamps are quiet buttons that seek the bottom player and play - Copy/Save/exports automatically use the edited text Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Nudge sidebar toggle up to the traffic-light line Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ChatGPT-style palette: neutral grays + blue primary accent Light: pure white content, #f9f9f9 sidebar, #2563eb primary. Dark: #181818 background, #212121 surfaces, #339cff primary. Switches, active states, focus rings and primary buttons pick up the blue; neutrals lose the warm paper cast. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Project folders per transcription with bundled audio copy - Documents/Vibe/<name>-<stamp>/ holds transcript.vibe.json + a copy of the source media (audio.<ext>); reopening from Recents plays the project's own audio even if the original moved - Re-transcribe falls back to the project copy when the source is gone - Rename moves the folder, delete removes it recursively, reveal opens it - Legacy flat .vibe.json saves stay fully supported - fs:allow-rename capability; mock: rename handler + project seeds Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Live recording level meter from real capture frames - Rust: per-buffer peak level in the cpal callbacks (allocation-free, atomic max + 100ms throttle) emitted as 'record_level'; input and loopback streams share one meter - UI: five live bars with fast-attack/slow-release smoothing; sustained silence dims to stubs so a dead mic is visibly different from speech - Mock emits a plausible speech envelope while recording Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Joined segmented source switcher (File / Record / From link) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Icon-only segmented source switcher with tooltips Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Larger segmented switcher keys Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * FileAudio icon for the file segment (Upload stays on the dropzone) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Plain folder icon for the file segment Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Breathing room between device selects and record button Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Whole transcript line is the edit target, not just the text Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Redesigned language dropdown: pinned Auto-detect, flags, popular/all groups Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Settings moves to sidebar footer with small update badge Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Language picker is a searchable combobox Type to filter freely; grouped browse (pinned Auto-detect, Recents, Popular, Others) collapses to a flat result list while typing; arrow keys + Enter select; plain scrolling with no scroll arrows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Drop unknown eslint directive Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Language pill morphs into the search input in place, wider by default Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Language search: no autocorrect, matches English names and codes too Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Calmer dark-mode primary (#2563eb) — no neon glare on big buttons Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Remove the topbar settings menu — settings lives in the sidebar footer Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Whole language pill is clickable to open the picker Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Format icons in the Save menu Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Confirm-on-close only for in-flight runs or truly unsaved results Everything else persists instantly, so quitting is silent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Split website changes into their own PR; bump version to 3.1.0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Move new website logo asset to the website PR Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -6854,6 +6854,7 @@ dependencies = [
|
||||
"url",
|
||||
"urlencoding",
|
||||
"which 8.0.0",
|
||||
"window-vibrancy",
|
||||
"windows 0.62.2",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 61 KiB |
+32
-13
@@ -1,15 +1,34 @@
|
||||
<svg width="169" height="169" viewBox="0 0 169 169" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="84.5006" cy="84.5006" r="84.5006" fill="white"/>
|
||||
<circle cx="84.5007" cy="84.5007" r="75.437" fill="white"/>
|
||||
<path d="M169.001 84.5007C169.001 131.169 131.169 169.001 84.5006 169.001C37.8322 169.001 0 131.169 0 84.5007C0 37.8323 37.8322 3.05176e-05 84.5006 3.05176e-05C131.169 3.05176e-05 169.001 37.8323 169.001 84.5007ZM4.29053 84.5007C4.29053 128.799 40.2018 164.711 84.5006 164.711C128.799 164.711 164.711 128.799 164.711 84.5007C164.711 40.2019 128.799 4.29056 84.5006 4.29056C40.2018 4.29056 4.29053 40.2019 4.29053 84.5007Z" fill="#2088FF"/>
|
||||
<path d="M119.652 67.8957C121.459 67.648 123.139 68.9128 123.232 70.7344C123.458 75.1399 122.93 79.56 121.664 83.8026C120.083 89.1021 117.386 94.0023 113.756 98.1741C110.125 102.346 105.644 105.693 100.613 107.991C95.5829 110.288 90.1191 111.484 84.5886 111.496C79.0582 111.509 73.589 110.338 68.5481 108.063C63.5072 105.789 59.0111 102.462 55.3615 98.3066C51.7119 94.1513 48.9933 89.2634 47.388 83.9711C46.1029 79.7343 45.5551 75.3167 45.7603 70.9102C45.8451 69.0882 47.5199 67.8158 49.3281 68.0553C51.1362 68.2948 52.39 69.9563 52.337 71.7794C52.2362 75.2486 52.6972 78.7188 53.7088 82.0539C55.0407 86.4449 57.2963 90.5002 60.3243 93.9478C63.3523 97.3955 67.0826 100.156 71.265 102.043C75.4474 103.93 79.9851 104.902 84.5737 104.891C89.1622 104.881 93.6954 103.889 97.8692 101.983C102.043 100.076 105.761 97.2992 108.773 93.8379C111.785 90.3766 114.023 86.311 115.335 81.914C116.331 78.5744 116.776 75.1022 116.66 71.6335C116.598 69.8106 117.845 68.1434 119.652 67.8957Z" fill="#2088FF"/>
|
||||
<rect x="80.6226" y="107.005" width="7.75611" height="47.3815" rx="3.87805" fill="#2088FF"/>
|
||||
<rect x="62.4029" y="14.6147" width="44.1956" height="85.2479" rx="22.0978" fill="#2088FF"/>
|
||||
<path d="M100.297 56.158L96.5972 54.9653C94.2305 54.1844 92.8439 52.2692 92.1931 49.8627L90.5649 41.9311C90.5224 41.7744 90.4188 41.5645 90.1 41.5645C89.8317 41.5645 89.6777 41.7744 89.6352 41.9311L88.0069 49.8653C87.3534 52.2719 85.9695 54.187 83.6028 54.968L79.9027 56.1606C79.3794 56.3306 79.3714 57.0691 79.892 57.247L83.6188 58.53C85.9775 59.3136 87.3534 61.2261 88.0069 63.622L89.6378 71.4659C89.6803 71.6227 89.768 71.9042 90.1027 71.9042C90.4559 71.9042 90.525 71.6227 90.5675 71.4659L92.1984 63.622C92.8519 61.2234 94.2278 59.3109 96.5866 58.53L100.313 57.247C100.829 57.0664 100.821 56.328 100.297 56.158Z" fill="#FDD835"/>
|
||||
<path d="M100.648 56.49C100.59 56.3439 100.475 56.2164 100.297 56.158L96.5972 54.9653C94.2305 54.1844 92.8439 52.2692 92.1931 49.8627L90.5649 41.9311C90.5409 41.8408 90.4559 41.6761 90.3577 41.6283L90.8836 49.5891C91.2741 53.2228 91.6088 54.9334 94.5094 55.4328C97.0009 55.8631 99.883 56.3572 100.648 56.49Z" fill="#FFEE58"/>
|
||||
<path d="M100.656 56.9044L94.2384 58.3759C91.978 58.9497 90.7322 60.2592 90.7322 63.6858L90.1027 71.9042C90.3178 71.883 90.4931 71.7661 90.5675 71.4659L92.1984 63.622C92.8519 61.2234 94.2278 59.3109 96.5866 58.53L100.313 57.247C100.489 57.1833 100.6 57.0531 100.656 56.9044Z" fill="#F4B400"/>
|
||||
<path d="M79.1058 63.0589C76.8931 62.3284 76.6913 61.505 76.3194 60.1158L75.3923 56.8645C75.3366 56.6547 74.7894 56.6547 74.7309 56.8645L74.1014 59.8688C73.7269 61.2527 72.93 62.3523 71.57 62.8013L69.4025 63.7416C69.1023 63.8398 69.097 64.2648 69.3972 64.3658L71.5806 65.1388C72.9353 65.5877 73.7269 66.6873 74.1041 68.0659L74.7336 70.94C74.792 71.1498 75.3366 71.1498 75.3923 70.94L76.1308 68.0792C76.5053 66.6927 77.0791 65.5903 78.9198 65.1388L80.9705 64.3658C81.2706 64.2622 81.268 63.8372 80.9652 63.7389L79.1058 63.0589Z" fill="#FDD835"/>
|
||||
<path d="M75.5677 60.068C75.7934 62.1558 75.9077 62.7348 77.5997 63.1678L81.1245 63.8558C81.0874 63.8053 81.0342 63.7628 80.9625 63.7416L79.1031 63.0589C77.1614 62.3948 76.6249 61.5502 76.2609 59.8608C75.897 58.1714 75.5092 57.0691 75.5092 57.0691C75.3738 56.7052 75.1799 56.7237 75.1799 56.7237L75.5677 60.068Z" fill="#FFEE58"/>
|
||||
<path d="M75.6314 67.3567C75.6314 65.3884 76.7072 64.4136 78.4178 64.4136L81.0794 64.3047C81.0794 64.3047 80.9253 64.5092 80.657 64.573L78.9198 65.1387C77.4456 65.7311 76.7736 66.0286 76.3167 68.0447C76.3167 68.0447 75.727 70.3901 75.6155 70.6372C75.4614 70.9825 75.2781 71.0542 75.2781 71.0542L75.6314 67.3567Z" fill="#F4B400"/>
|
||||
<path d="M83.8684 47.4747C84.0172 47.4242 84.0119 47.2117 83.8605 47.1692L81.8098 46.6167C81.3795 46.4998 81.0448 46.1678 80.9226 45.7402L80.1151 42.4066C80.0753 42.2445 79.8442 42.2445 79.8044 42.4092L79.0447 45.7295C78.9278 46.1705 78.5825 46.5105 78.1416 46.6247L76.1042 47.1533C75.9528 47.1931 75.9422 47.4056 76.0909 47.4588L78.2398 48.2025C78.625 48.3353 78.9225 48.6488 79.0394 49.0392L79.807 52.1178C79.8469 52.2772 80.0753 52.2798 80.1151 52.1178L80.9094 49.0286C81.0289 48.6381 81.3291 48.3273 81.7169 48.1972L83.8684 47.4747Z" fill="#F4B400" stroke="#F4B400" stroke-width="0.894737" stroke-miterlimit="10"/>
|
||||
<defs>
|
||||
<radialGradient id="badge" cx="50%" cy="36%" r="80%">
|
||||
<stop offset="0%" stop-color="#FFFFFF"/>
|
||||
<stop offset="100%" stop-color="#F2F3F1"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="ink" x1="62" y1="14" x2="110" y2="118" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#4C8DF6"/>
|
||||
<stop offset="55%" stop-color="#6D7CEF"/>
|
||||
<stop offset="100%" stop-color="#8E6CE6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gold" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#FFE082"/>
|
||||
<stop offset="100%" stop-color="#F4B400"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- badge -->
|
||||
<circle cx="84.5" cy="84.5" r="84.5" fill="url(#badge)"/>
|
||||
<circle cx="84.5" cy="84.5" r="81.75" stroke="url(#ink)" stroke-width="4.5"/>
|
||||
|
||||
<!-- microphone capsule -->
|
||||
<rect x="62.4" y="14.6" width="44.2" height="85.2" rx="22.1" fill="url(#ink)"/>
|
||||
<rect x="65.4" y="17.6" width="38.2" height="79.2" rx="19.1" stroke="#FFFFFF" stroke-opacity="0.22" stroke-width="1.5"/>
|
||||
|
||||
<!-- cradle + stem -->
|
||||
<path d="M48.5 72 A36 36 0 0 0 120.5 72" stroke="url(#ink)" stroke-width="8" stroke-linecap="round"/>
|
||||
<rect x="80.62" y="106" width="7.76" height="48.5" rx="3.88" fill="url(#ink)"/>
|
||||
|
||||
<!-- sparkles -->
|
||||
<path d="M90 41.7 Q92.7 53.9 95 56.2 Q97.3 58.4 105 56.9 Q97.3 55.5 95 57.7 Q92.7 59.9 90 72.1 Q87.3 59.9 85 57.7 Q82.7 55.5 75 56.9 Q82.7 58.4 85 56.2 Q87.3 53.9 90 41.7 Z" fill="url(#gold)"/>
|
||||
<path d="M75 56.9 Q76.3 62.6 77.4 63.7 Q78.5 64.7 82.1 64 Q78.5 63.3 77.4 64.4 Q76.3 65.4 75 71.1 Q73.7 65.4 72.6 64.4 Q71.5 63.3 67.9 64 Q71.5 64.7 72.6 63.7 Q73.7 62.6 75 56.9 Z" fill="url(#gold)"/>
|
||||
<path d="M80 42.3 Q80.9 46.2 81.6 46.9 Q82.4 47.6 84.9 47.2 Q82.4 46.7 81.6 47.4 Q80.9 48.1 80 52.1 Q79.1 48.1 78.4 47.4 Q77.6 46.7 75.1 47.2 Q77.6 47.6 78.4 46.9 Q79.1 46.2 80 42.3 Z" fill="url(#gold)"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 1.9 KiB |
@@ -14,6 +14,7 @@
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource/roboto": "^5.2.9",
|
||||
"@formatjs/intl-durationformat": "^0.10.1",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
@@ -57,6 +58,7 @@
|
||||
"react-error-boundary": "^6.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"simple-icons": "^16.28.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
Generated
+17
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@fontsource-variable/inter':
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
'@fontsource/roboto':
|
||||
specifier: ^5.2.9
|
||||
version: 5.2.9
|
||||
@@ -137,6 +140,9 @@ importers:
|
||||
react-router-dom:
|
||||
specifier: ^7.13.0
|
||||
version: 7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
simple-icons:
|
||||
specifier: ^16.28.0
|
||||
version: 16.28.0
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -582,6 +588,9 @@ packages:
|
||||
'@floating-ui/utils@0.2.10':
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
|
||||
'@fontsource-variable/inter@5.3.0':
|
||||
resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==}
|
||||
|
||||
'@fontsource/roboto@5.2.9':
|
||||
resolution: {integrity: sha512-ZTkyHiPk74B/aj8BZWbsxD5Yu+Lq+nR64eV4wirlrac2qXR7jYk2h6JlLYuOuoruTkGQWNw2fMuKNavw7/rg0w==}
|
||||
|
||||
@@ -3147,6 +3156,10 @@ packages:
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
simple-icons@16.28.0:
|
||||
resolution: {integrity: sha512-sQPR5AtK/ijRjou7zw7mlLp08oB6FH7i0lOy5XJ2zp9mJs/yejgiOn7KvQoe2q4YJIx6VmgUSW5AOefebPt5kg==}
|
||||
engines: {node: '>=0.12.18'}
|
||||
|
||||
snake-case@3.0.4:
|
||||
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
|
||||
|
||||
@@ -3878,6 +3891,8 @@ snapshots:
|
||||
|
||||
'@floating-ui/utils@0.2.10': {}
|
||||
|
||||
'@fontsource-variable/inter@5.3.0': {}
|
||||
|
||||
'@fontsource/roboto@5.2.9': {}
|
||||
|
||||
'@formatjs/ecma402-abstract@3.1.1':
|
||||
@@ -6771,6 +6786,8 @@ snapshots:
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
simple-icons@16.28.0: {}
|
||||
|
||||
snake-case@3.0.4:
|
||||
dependencies:
|
||||
dot-case: 3.0.4
|
||||
|
||||
@@ -84,6 +84,7 @@ libc-stdhandle = "=0.1.0"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6.4"
|
||||
objc2-app-kit = { version = "0.3.2", features = ["NSApplication", "NSWindow"] }
|
||||
window-vibrancy = "0.6"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
"core:menu:default",
|
||||
"core:tray:default",
|
||||
"fs:allow-remove",
|
||||
"fs:allow-copy-file",
|
||||
"fs:allow-rename",
|
||||
"fs:allow-mkdir",
|
||||
"fs:allow-write-text-file",
|
||||
"fs:allow-read-dir",
|
||||
"fs:allow-read-text-file",
|
||||
"fs:allow-app-write",
|
||||
|
||||
@@ -7,7 +7,9 @@ use serde_json::json;
|
||||
use std::fs::File;
|
||||
use std::io::BufWriter;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
use tauri::{AppHandle, Emitter, Listener, Manager};
|
||||
|
||||
use crate::error::LogError;
|
||||
@@ -63,6 +65,72 @@ struct StreamHandle(Stream);
|
||||
unsafe impl Send for StreamHandle {}
|
||||
unsafe impl Sync for StreamHandle {}
|
||||
|
||||
/// At most ~10 `record_level` events per second reach the webview.
|
||||
const LEVEL_EMIT_INTERVAL_MS: u64 = 100;
|
||||
/// Peak is taken from every Nth sample — plenty for a meter, and keeps the callback cheap.
|
||||
const LEVEL_SAMPLE_STRIDE: usize = 4;
|
||||
|
||||
/// Shared by every capture stream of a recording session, so the emitted value is the max
|
||||
/// level across input + output (loopback) devices.
|
||||
///
|
||||
/// Everything here is atomic and allocation-free: the audio callback only does a few relaxed
|
||||
/// loads/stores, and once per 100ms one callback also performs the (non-blocking) `emit_to`.
|
||||
struct LevelMeter {
|
||||
app_handle: AppHandle,
|
||||
started_at: Instant,
|
||||
/// Max level seen since the last emit, stored as `f32::to_bits` (monotonic for +0.0..=1.0).
|
||||
peak_bits: AtomicU32,
|
||||
last_emit_ms: AtomicU64,
|
||||
}
|
||||
|
||||
impl LevelMeter {
|
||||
fn new(app_handle: AppHandle) -> Self {
|
||||
Self {
|
||||
app_handle,
|
||||
started_at: Instant::now(),
|
||||
peak_bits: AtomicU32::new(0),
|
||||
last_emit_ms: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Accumulate one buffer's peak and emit if the throttle window elapsed.
|
||||
fn push(&self, peak: f32) {
|
||||
self.peak_bits.fetch_max(peak.to_bits(), Ordering::Relaxed);
|
||||
|
||||
let now_ms = self.started_at.elapsed().as_millis() as u64;
|
||||
let last_ms = self.last_emit_ms.load(Ordering::Relaxed);
|
||||
if now_ms.saturating_sub(last_ms) < LEVEL_EMIT_INTERVAL_MS {
|
||||
return;
|
||||
}
|
||||
// Only the stream that wins the swap emits, so two devices can't double-emit a window.
|
||||
if self
|
||||
.last_emit_ms
|
||||
.compare_exchange(last_ms, now_ms, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let level = f32::from_bits(self.peak_bits.swap(0, Ordering::Relaxed)).clamp(0.0, 1.0);
|
||||
self.app_handle.emit_to("main", "record_level", level).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// Peak magnitude of a buffer, normalized to 0..1.
|
||||
fn buffer_peak<T>(input: &[T]) -> f32
|
||||
where
|
||||
T: Sample,
|
||||
f32: FromSample<T>,
|
||||
{
|
||||
let mut peak = 0.0f32;
|
||||
for &sample in input.iter().step_by(LEVEL_SAMPLE_STRIDE) {
|
||||
let value = f32::from_sample(sample).abs();
|
||||
if value > peak {
|
||||
peak = value;
|
||||
}
|
||||
}
|
||||
peak.min(1.0)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
/// Record audio from the given devices, store to wav, merge with ffmpeg, and return path
|
||||
pub async fn start_record(
|
||||
@@ -77,6 +145,9 @@ pub async fn start_record(
|
||||
let mut wav_paths: Vec<(PathBuf, u32)> = Vec::new();
|
||||
let mut stream_handles = Vec::new();
|
||||
let mut stream_writers = Vec::new();
|
||||
// One meter for the whole session: input and output streams both feed it, so the UI sees
|
||||
// the max of the two under a single throttled `record_level` event.
|
||||
let meter = Arc::new(LevelMeter::new(app_handle.clone()));
|
||||
|
||||
for device in devices {
|
||||
tracing::debug!("Recording from device: {}", device.name);
|
||||
@@ -102,7 +173,7 @@ pub async fn start_record(
|
||||
stream_writers.push(writer.clone());
|
||||
let writer_2 = writer.clone();
|
||||
|
||||
let stream = build_input_stream(&device, config, writer_2)?;
|
||||
let stream = build_input_stream(&device, config, writer_2, meter.clone())?;
|
||||
stream.play()?;
|
||||
tracing::debug!("Stream started playing");
|
||||
|
||||
@@ -236,25 +307,39 @@ fn get_output_device_and_config(host: &cpal::Host, audio_device: &AudioDevice) -
|
||||
}
|
||||
}
|
||||
|
||||
fn build_input_stream_typed<T>(device: &Device, config: SupportedStreamConfig, writer: WavWriterHandle) -> Result<Stream>
|
||||
fn build_input_stream_typed<T>(
|
||||
device: &Device,
|
||||
config: SupportedStreamConfig,
|
||||
writer: WavWriterHandle,
|
||||
meter: Arc<LevelMeter>,
|
||||
) -> Result<Stream>
|
||||
where
|
||||
T: SizedSample + hound::Sample + FromSample<T> + Mul<Output = T> + Copy,
|
||||
f32: FromSample<T>,
|
||||
{
|
||||
let stream = device.build_input_stream(
|
||||
config.into(),
|
||||
move |data: &[T], _: &_| write_input_data::<T, T>(data, &writer),
|
||||
move |data: &[T], _: &_| {
|
||||
meter.push(buffer_peak(data));
|
||||
write_input_data::<T, T>(data, &writer)
|
||||
},
|
||||
|err| tracing::error!("An error occurred on stream: {}", err),
|
||||
None,
|
||||
)?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
fn build_input_stream(device: &Device, config: SupportedStreamConfig, writer: WavWriterHandle) -> Result<Stream> {
|
||||
fn build_input_stream(
|
||||
device: &Device,
|
||||
config: SupportedStreamConfig,
|
||||
writer: WavWriterHandle,
|
||||
meter: Arc<LevelMeter>,
|
||||
) -> Result<Stream> {
|
||||
match config.sample_format() {
|
||||
cpal::SampleFormat::I8 => build_input_stream_typed::<i8>(device, config, writer),
|
||||
cpal::SampleFormat::I16 => build_input_stream_typed::<i16>(device, config, writer),
|
||||
cpal::SampleFormat::I32 => build_input_stream_typed::<i32>(device, config, writer),
|
||||
cpal::SampleFormat::F32 => build_input_stream_typed::<f32>(device, config, writer),
|
||||
cpal::SampleFormat::I8 => build_input_stream_typed::<i8>(device, config, writer, meter),
|
||||
cpal::SampleFormat::I16 => build_input_stream_typed::<i16>(device, config, writer, meter),
|
||||
cpal::SampleFormat::I32 => build_input_stream_typed::<i32>(device, config, writer, meter),
|
||||
cpal::SampleFormat::F32 => build_input_stream_typed::<f32>(device, config, writer, meter),
|
||||
sample_format => bail!("Unsupported sample format '{}'", sample_format),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ pub fn setup(app: &App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
} else {
|
||||
tracing::debug!("Non CLI mode");
|
||||
// Create main window
|
||||
let result = tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App("index.html".into()))
|
||||
let builder = tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App("index.html".into()))
|
||||
.inner_size(800.0, 700.0)
|
||||
.min_inner_size(800.0, 700.0)
|
||||
.center()
|
||||
@@ -115,10 +115,31 @@ pub fn setup(app: &App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
.resizable(true)
|
||||
.focused(true)
|
||||
.shadow(true)
|
||||
.visible(true)
|
||||
.build();
|
||||
if let Err(error) = result {
|
||||
tracing::error!("{:?}", error);
|
||||
.visible(true);
|
||||
// The web content extends under the titlebar so the sidebar toggle can sit
|
||||
// beside the traffic lights (ChatGPT-desktop style); the topbar is a drag region.
|
||||
#[cfg(target_os = "macos")]
|
||||
let builder = builder
|
||||
.title_bar_style(tauri::TitleBarStyle::Overlay)
|
||||
.hidden_title(true)
|
||||
// Centered on the 56px titlebar row so the lights align with the sidebar toggle.
|
||||
.traffic_light_position(tauri::LogicalPosition::new(16.0, 28.0))
|
||||
.transparent(true);
|
||||
match builder.build() {
|
||||
Ok(_window) => {
|
||||
// Glass sidebar: the window is transparent on macOS and an NSVisualEffectView
|
||||
// shows the blurred desktop wherever the web content leaves alpha (the sidebar).
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial};
|
||||
if let Err(error) = apply_vibrancy(&_window, NSVisualEffectMaterial::Sidebar, None, None) {
|
||||
tracing::warn!("failed to apply vibrancy: {:?}", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!("{:?}", error);
|
||||
}
|
||||
}
|
||||
crate::dictation_indicator::initialize(app.handle());
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "./gen/schemas/desktop-schema.json",
|
||||
"productName": "vibe",
|
||||
"version": "3.0.23",
|
||||
"version": "3.1.0",
|
||||
"identifier": "github.com.thewh1teagle.vibe",
|
||||
"app": {
|
||||
"macOSPrivateApi": true,
|
||||
|
||||
+4
-5
@@ -1,11 +1,10 @@
|
||||
import { useEffect } from 'react'
|
||||
import { getTextDirection } from '~/paraglide/runtime.js'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import UpdateProgress from '~/components/updater-progress'
|
||||
import '~/globals.css'
|
||||
import SetupPage from '~/pages/setup/page'
|
||||
import HomePage from '~/pages/home/page'
|
||||
import BatchPage from './pages/batch/page'
|
||||
import MainPage from '~/pages/main/page'
|
||||
import { ErrorModalProvider } from './providers/error-modal'
|
||||
import { UpdaterProvider } from './providers/updater'
|
||||
import { PreferenceProvider } from './providers/preference'
|
||||
@@ -48,9 +47,9 @@ function AppContent() {
|
||||
<UpdateProgress />
|
||||
<FilesProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/" element={<MainPage />} />
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
<Route path="/batch" element={<BatchPage />} />
|
||||
<Route path="/batch" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</FilesProvider>
|
||||
<Toaster position="bottom-right" />
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { ReactComponent as IndicatorIcon } from '~/icons/update-indicator.svg'
|
||||
import { ArrowLeft, MoreHorizontal, RefreshCcw, Settings2, Terminal } from 'lucide-react'
|
||||
import { RefreshCcw, Settings } from 'lucide-react'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '~/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip'
|
||||
|
||||
interface AppMenuProps {
|
||||
availableUpdate: boolean
|
||||
@@ -12,49 +10,31 @@ interface AppMenuProps {
|
||||
onClickSettings: (scrollTo?: string) => void
|
||||
}
|
||||
|
||||
export default function AppMenu({ availableUpdate, updateApp, onClickSettings }: AppMenuProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const disableBack = Boolean((location.state as { disableBack?: boolean } | null)?.disableBack)
|
||||
const canGoBack = location.key !== 'default' && !disableBack
|
||||
const [open, setOpen] = useState(false)
|
||||
const iconButtonClassName = 'h-9 w-9 rounded-full text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground [&_svg]:size-[18px]'
|
||||
|
||||
export default function AppMenu({ availableUpdate, updateApp, onClickSettings }: AppMenuProps) {
|
||||
return (
|
||||
<div dir="ltr">
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="group relative h-11 w-11 rounded-xl border-border/75 bg-card/92 shadow-xs transition-all hover:-translate-y-px hover:bg-card hover:shadow-sm"
|
||||
aria-label={m.moreOptions()}>
|
||||
<MoreHorizontal className="h-4.5 w-4.5 text-foreground/90 transition-colors group-hover:text-foreground" strokeWidth={2.35} />
|
||||
{availableUpdate && <IndicatorIcon className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 drop-shadow-sm" />}
|
||||
<div dir="ltr" className="flex items-center gap-1">
|
||||
{availableUpdate && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className={`${iconButtonClassName} relative`} aria-label={m.updateVersion()} onClick={updateApp}>
|
||||
<RefreshCcw strokeWidth={1.75} />
|
||||
<IndicatorIcon className="absolute right-1.5 top-1.5 h-2 w-2" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{m.updateVersion()}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className={iconButtonClassName} aria-label={m.settings()} onClick={() => onClickSettings()}>
|
||||
<Settings strokeWidth={1.75} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56 rounded-xl border-border/75 bg-popover/98 p-1.5 shadow-lg">
|
||||
<DropdownMenuItem onClick={() => onClickSettings()} className="h-10 rounded-md px-3 text-[15px] font-medium">
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
{m.settings()}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onClickSettings('api')} className="h-10 rounded-md px-3 text-[15px] font-medium">
|
||||
<Terminal className="h-4 w-4 text-muted-foreground" />
|
||||
{m.apiAndAgents()}
|
||||
</DropdownMenuItem>
|
||||
{canGoBack && (
|
||||
<DropdownMenuItem onClick={() => navigate(-1)} className="h-10 rounded-md px-3 text-[15px] font-medium">
|
||||
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
|
||||
{m.back()}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{availableUpdate && (
|
||||
<DropdownMenuItem onClick={updateApp} className="h-10 rounded-md px-3 text-[15px] font-medium text-primary">
|
||||
<RefreshCcw className="h-4 w-4 text-muted-foreground" />
|
||||
{m.updateVersion()}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{m.settings()}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,10 +15,10 @@ interface Position {
|
||||
function Document({ position, path }: { position: Position; path: string }) {
|
||||
return (
|
||||
<div
|
||||
className="absolute z-[100000] bg-accent p-4 -translate-x-[50%] -translate-y-[50%] rounded-2xl flex flex-col items-center justify-center gap-4"
|
||||
className="absolute z-[100000] flex -translate-x-[50%] -translate-y-[50%] flex-col items-center justify-center gap-3 rounded-2xl border border-border bg-card p-4 text-card-foreground shadow-lg"
|
||||
style={{ left: position.x, top: position.y, cursor: 'grabbing' }}>
|
||||
<DocumentIcon />
|
||||
<p className="text-md font-light font-mono">{path.length > 10 ? path.substring(0, 10) + '...' : path}</p>
|
||||
<p className="font-mono text-[13px] text-muted-foreground">{path.length > 10 ? path.substring(0, 10) + '...' : path}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export default function DropModal() {
|
||||
return (
|
||||
<div>
|
||||
{open && platform === 'windows' && <Document path={path} position={position} />}
|
||||
<div className={cn('fixed inset-0 backdrop-blur-sm bg-background/60 z-50', open ? 'block' : 'hidden')}></div>
|
||||
<div className={cn('fixed inset-0 z-50 bg-background/70 backdrop-blur-sm', open ? 'block' : 'hidden')}></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,86 +1,400 @@
|
||||
import { subDays, isAfter } from 'date-fns'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, Globe } from 'lucide-react'
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import { getI18nLanguageName, getLocalizedLanguageName } from '~/lib/i18n'
|
||||
import { cn } from '~/lib/style'
|
||||
import { usePreferenceProvider } from '~/providers/preference'
|
||||
import { Label } from '~/components/ui/label'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '~/components/ui/select'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '~/components/ui/popover'
|
||||
|
||||
export default function LanguageInput() {
|
||||
interface LanguageInputProps {
|
||||
/**
|
||||
* `prominent` renders a compact, self-labelled control (globe + current language) meant to stand
|
||||
* on its own in a toolbar row. `default` keeps the classic stacked label + full-width select.
|
||||
*/
|
||||
variant?: 'default' | 'prominent'
|
||||
}
|
||||
|
||||
/** The `auto` message ships a sparkle; it reads gimmicky inside a permanent toolbar control. */
|
||||
function stripDecoration(label: string) {
|
||||
return label.replace(/\s*[✨\u{1F300}-\u{1FAFF}]\s*$/u, '').trim()
|
||||
}
|
||||
|
||||
// Whisper reports languages either as ISO codes or as lowercase English names — key both.
|
||||
const FLAGS: Record<string, string> = {
|
||||
en: '🇺🇸',
|
||||
english: '🇺🇸',
|
||||
es: '🇪🇸',
|
||||
spanish: '🇪🇸',
|
||||
hi: '🇮🇳',
|
||||
hindi: '🇮🇳',
|
||||
fr: '🇫🇷',
|
||||
french: '🇫🇷',
|
||||
de: '🇩🇪',
|
||||
german: '🇩🇪',
|
||||
it: '🇮🇹',
|
||||
italian: '🇮🇹',
|
||||
pt: '🇧🇷',
|
||||
portuguese: '🇧🇷',
|
||||
ru: '🇷🇺',
|
||||
russian: '🇷🇺',
|
||||
ja: '🇯🇵',
|
||||
japanese: '🇯🇵',
|
||||
ko: '🇰🇷',
|
||||
korean: '🇰🇷',
|
||||
zh: '🇨🇳',
|
||||
chinese: '🇨🇳',
|
||||
ar: '🇸🇦',
|
||||
arabic: '🇸🇦',
|
||||
he: '🇮🇱',
|
||||
iw: '🇮🇱',
|
||||
hebrew: '🇮🇱',
|
||||
tr: '🇹🇷',
|
||||
turkish: '🇹🇷',
|
||||
nl: '🇳🇱',
|
||||
dutch: '🇳🇱',
|
||||
pl: '🇵🇱',
|
||||
polish: '🇵🇱',
|
||||
uk: '🇺🇦',
|
||||
ukrainian: '🇺🇦',
|
||||
sv: '🇸🇪',
|
||||
swedish: '🇸🇪',
|
||||
no: '🇳🇴',
|
||||
nb: '🇳🇴',
|
||||
norwegian: '🇳🇴',
|
||||
da: '🇩🇰',
|
||||
danish: '🇩🇰',
|
||||
fi: '🇫🇮',
|
||||
finnish: '🇫🇮',
|
||||
cs: '🇨🇿',
|
||||
czech: '🇨🇿',
|
||||
sk: '🇸🇰',
|
||||
slovak: '🇸🇰',
|
||||
hu: '🇭🇺',
|
||||
hungarian: '🇭🇺',
|
||||
ro: '🇷🇴',
|
||||
romanian: '🇷🇴',
|
||||
bg: '🇧🇬',
|
||||
bulgarian: '🇧🇬',
|
||||
hr: '🇭🇷',
|
||||
croatian: '🇭🇷',
|
||||
sr: '🇷🇸',
|
||||
serbian: '🇷🇸',
|
||||
sl: '🇸🇮',
|
||||
slovenian: '🇸🇮',
|
||||
el: '🇬🇷',
|
||||
greek: '🇬🇷',
|
||||
id: '🇮🇩',
|
||||
indonesian: '🇮🇩',
|
||||
ms: '🇲🇾',
|
||||
malay: '🇲🇾',
|
||||
vi: '🇻🇳',
|
||||
vietnamese: '🇻🇳',
|
||||
th: '🇹🇭',
|
||||
thai: '🇹🇭',
|
||||
fa: '🇮🇷',
|
||||
persian: '🇮🇷',
|
||||
ur: '🇵🇰',
|
||||
urdu: '🇵🇰',
|
||||
bn: '🇧🇩',
|
||||
bengali: '🇧🇩',
|
||||
ta: '🇮🇳',
|
||||
tamil: '🇮🇳',
|
||||
te: '🇮🇳',
|
||||
telugu: '🇮🇳',
|
||||
mr: '🇮🇳',
|
||||
marathi: '🇮🇳',
|
||||
kn: '🇮🇳',
|
||||
kannada: '🇮🇳',
|
||||
ml: '🇮🇳',
|
||||
malayalam: '🇮🇳',
|
||||
pa: '🇮🇳',
|
||||
punjabi: '🇮🇳',
|
||||
gu: '🇮🇳',
|
||||
gujarati: '🇮🇳',
|
||||
az: '🇦🇿',
|
||||
azerbaijani: '🇦🇿',
|
||||
kk: '🇰🇿',
|
||||
kazakh: '🇰🇿',
|
||||
uz: '🇺🇿',
|
||||
uzbek: '🇺🇿',
|
||||
ka: '🇬🇪',
|
||||
georgian: '🇬🇪',
|
||||
hy: '🇦🇲',
|
||||
armenian: '🇦🇲',
|
||||
et: '🇪🇪',
|
||||
estonian: '🇪🇪',
|
||||
lv: '🇱🇻',
|
||||
latvian: '🇱🇻',
|
||||
lt: '🇱🇹',
|
||||
lithuanian: '🇱🇹',
|
||||
be: '🇧🇾',
|
||||
belarusian: '🇧🇾',
|
||||
mk: '🇲🇰',
|
||||
macedonian: '🇲🇰',
|
||||
sq: '🇦🇱',
|
||||
albanian: '🇦🇱',
|
||||
bs: '🇧🇦',
|
||||
bosnian: '🇧🇦',
|
||||
is: '🇮🇸',
|
||||
icelandic: '🇮🇸',
|
||||
mt: '🇲🇹',
|
||||
maltese: '🇲🇹',
|
||||
sw: '🇰🇪',
|
||||
swahili: '🇰🇪',
|
||||
af: '🇿🇦',
|
||||
afrikaans: '🇿🇦',
|
||||
am: '🇪🇹',
|
||||
amharic: '🇪🇹',
|
||||
ne: '🇳🇵',
|
||||
nepali: '🇳🇵',
|
||||
si: '🇱🇰',
|
||||
sinhala: '🇱🇰',
|
||||
my: '🇲🇲',
|
||||
myanmar: '🇲🇲',
|
||||
burmese: '🇲🇲',
|
||||
km: '🇰🇭',
|
||||
khmer: '🇰🇭',
|
||||
lo: '🇱🇦',
|
||||
lao: '🇱🇦',
|
||||
mn: '🇲🇳',
|
||||
mongolian: '🇲🇳',
|
||||
tl: '🇵🇭',
|
||||
tagalog: '🇵🇭',
|
||||
filipino: '🇵🇭',
|
||||
ca: '🇦🇩',
|
||||
catalan: '🇦🇩',
|
||||
gl: '🇪🇸',
|
||||
galician: '🇪🇸',
|
||||
eu: '🇪🇸',
|
||||
basque: '🇪🇸',
|
||||
cy: '🇬🇧',
|
||||
welsh: '🇬🇧',
|
||||
ga: '🇮🇪',
|
||||
irish: '🇮🇪',
|
||||
yi: '🇮🇱',
|
||||
yiddish: '🇮🇱',
|
||||
}
|
||||
|
||||
function flagFor(code: string, name: string): string | null {
|
||||
return FLAGS[code.toLowerCase()] ?? FLAGS[name.toLowerCase()] ?? null
|
||||
}
|
||||
|
||||
function FlagSlot({ code, name }: { code: string; name: string }) {
|
||||
const flag = flagFor(code, name)
|
||||
return (
|
||||
<span aria-hidden className="inline-flex w-6 shrink-0 justify-center text-[15px] leading-none">
|
||||
{flag ?? <Globe className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LanguageInput({ variant = 'default' }: LanguageInputProps) {
|
||||
const preference = usePreferenceProvider()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [highlighted, setHighlighted] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const capabilities = preference.modelMetadata?.capabilities
|
||||
const displayNames = new Intl.DisplayNames([preference.displayLanguage], { type: 'language' })
|
||||
const entries = (capabilities?.languages ?? []).map((code) => ({ label: displayNames.of(code) ?? code, name: code, code }))
|
||||
if (capabilities?.language_detection) entries.push({ label: getLocalizedLanguageName('auto'), name: 'auto', code: 'auto' })
|
||||
const hasAutoDetect = capabilities?.language_detection ?? false
|
||||
const selectedLang = preference.modelOptions.lang
|
||||
|
||||
entries.sort((a, b) => a.label.localeCompare(b.label))
|
||||
const entries = useMemo(() => {
|
||||
const displayNames = new Intl.DisplayNames([preference.displayLanguage], { type: 'language' })
|
||||
const englishNames = new Intl.DisplayNames(['en'], { type: 'language' })
|
||||
const of = (names: Intl.DisplayNames, code: string) => {
|
||||
try {
|
||||
return names.of(code) ?? code
|
||||
} catch {
|
||||
return code
|
||||
}
|
||||
}
|
||||
const list = (capabilities?.languages ?? []).map((code) => ({
|
||||
label: of(displayNames, code),
|
||||
english: of(englishNames, code),
|
||||
name: code,
|
||||
code,
|
||||
}))
|
||||
list.sort((a, b) => a.label.localeCompare(b.label))
|
||||
return list
|
||||
}, [capabilities, preference.displayLanguage])
|
||||
|
||||
function onValueChange(value: string) {
|
||||
preference.setModelOptions({ ...preference.modelOptions, lang: value })
|
||||
if (value !== 'auto') {
|
||||
function select(code: string) {
|
||||
preference.setModelOptions({ ...preference.modelOptions, lang: code })
|
||||
if (code !== 'auto') {
|
||||
const now = Date.now()
|
||||
const recent = [{ code: value, ts: now }, ...preference.recentLanguages.filter((r) => r.code !== value)].slice(0, 5)
|
||||
const recent = [{ code, ts: now }, ...preference.recentLanguages.filter((r) => r.code !== code)].slice(0, 5)
|
||||
preference.setRecentLanguages(recent)
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Grouped view for browsing; a flat filtered list the moment the user types.
|
||||
const cutoff = subDays(new Date(), 7)
|
||||
const popularLanguages = new Set(['english', 'en', 'hindi', 'hi', 'spanish', 'es', getI18nLanguageName()])
|
||||
const recentCodes = new Set(preference.recentLanguages.filter((r) => isAfter(r.ts, cutoff)).map((r) => r.code))
|
||||
const needle = query.trim().toLowerCase()
|
||||
|
||||
type Entry = { label: string; english: string; name: string; code: string }
|
||||
const autoEntry: Entry | null = hasAutoDetect
|
||||
? { label: stripDecoration(getLocalizedLanguageName('auto')), english: 'auto detect', name: 'auto', code: 'auto' }
|
||||
: null
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (needle) {
|
||||
const matches = entries.filter(
|
||||
(entry) =>
|
||||
entry.label.toLowerCase().includes(needle) || entry.english.toLowerCase().includes(needle) || entry.code.toLowerCase().includes(needle),
|
||||
)
|
||||
if (autoEntry && autoEntry.label.toLowerCase().includes(needle)) matches.unshift(autoEntry)
|
||||
return [{ label: null as string | null, items: matches }]
|
||||
}
|
||||
const recent: Entry[] = []
|
||||
const popular: Entry[] = []
|
||||
const others: Entry[] = []
|
||||
for (const entry of entries) {
|
||||
if (recentCodes.has(entry.code)) recent.push(entry)
|
||||
else if (popularLanguages.has(entry.name.toLowerCase())) popular.push(entry)
|
||||
else others.push(entry)
|
||||
}
|
||||
const recentOrder = preference.recentLanguages.map((r) => r.code)
|
||||
recent.sort((a, b) => recentOrder.indexOf(a.code) - recentOrder.indexOf(b.code))
|
||||
const result: { label: string | null; items: Entry[] }[] = []
|
||||
if (autoEntry) result.push({ label: null, items: [autoEntry] })
|
||||
if (recent.length) result.push({ label: m.recentlyUsed(), items: recent })
|
||||
if (popular.length) result.push({ label: m.popular(), items: popular })
|
||||
if (others.length) result.push({ label: m.others(), items: others })
|
||||
return result
|
||||
}, [entries, needle, preference.recentLanguages])
|
||||
|
||||
const flat = useMemo(() => groups.flatMap((group) => group.items), [groups])
|
||||
|
||||
useEffect(() => {
|
||||
setHighlighted(0)
|
||||
}, [needle, open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setQuery('')
|
||||
}, [open])
|
||||
|
||||
function onInputKeyDown(event: React.KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setHighlighted((index) => Math.min(index + 1, flat.length - 1))
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setHighlighted((index) => Math.max(index - 1, 0))
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const pick = flat[highlighted] ?? flat[0]
|
||||
if (pick) select(pick.code)
|
||||
} else if (event.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cutoff = subDays(new Date(), 7)
|
||||
const popularLanguages = [getI18nLanguageName(), 'auto', 'english']
|
||||
const recentCodes = new Set(preference.recentLanguages.filter((r) => isAfter(r.ts, cutoff)).map((r) => r.code))
|
||||
const recentEntries: { label: string; code: string }[] = []
|
||||
const popularEntries: { label: string; code: string }[] = []
|
||||
const otherEntries: { label: string; code: string }[] = []
|
||||
// Keep the highlighted row in view while arrowing through the list.
|
||||
useEffect(() => {
|
||||
listRef.current?.querySelector(`[data-index="${highlighted}"]`)?.scrollIntoView({ block: 'nearest' })
|
||||
}, [highlighted])
|
||||
|
||||
entries.forEach(({ label, name, code }) => {
|
||||
if (recentCodes.has(code)) {
|
||||
recentEntries.push({ label, code })
|
||||
} else if (popularLanguages.includes(name)) {
|
||||
popularEntries.push({ label, code })
|
||||
} else {
|
||||
otherEntries.push({ label, code })
|
||||
}
|
||||
})
|
||||
const prominent = variant === 'prominent'
|
||||
const selected = entries.find(({ code }) => code === selectedLang)
|
||||
const triggerLabel = selectedLang === 'auto' && autoEntry ? autoEntry.label : selected ? stripDecoration(selected.label) : m.selectLanguage()
|
||||
|
||||
// Sort recent entries by recency order
|
||||
const recentOrder = preference.recentLanguages.map((r) => r.code)
|
||||
recentEntries.sort((a, b) => recentOrder.indexOf(a.code) - recentOrder.indexOf(b.code))
|
||||
let runningIndex = -1
|
||||
|
||||
return (
|
||||
<div className="space-y-2 w-full">
|
||||
<Label>{m.language()}</Label>
|
||||
<Select value={preference.modelOptions.lang} onValueChange={onValueChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={m.language()} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{recentEntries.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{m.recentlyUsed()}</SelectLabel>
|
||||
{recentEntries.map(({ label, code }) => (
|
||||
<SelectItem key={code} value={code}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
<SelectGroup>
|
||||
<SelectLabel>{m.popular()}</SelectLabel>
|
||||
{popularEntries.map(({ label, code }) => (
|
||||
<SelectItem key={code} value={code}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
<div className={cn('w-full', prominent ? '' : 'space-y-2')}>
|
||||
{prominent ? null : <Label>{m.language()}</Label>}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
{/* The pill itself becomes the input while open — same container, same shape. */}
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!open) setOpen(true)
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2 border text-start transition-colors duration-150',
|
||||
prominent ? 'h-9 w-[200px] rounded-full bg-card px-3 text-[13px] font-medium' : 'h-9 w-full rounded-lg bg-transparent px-3 text-sm',
|
||||
open ? 'border-ring/60' : 'cursor-pointer border-border hover:bg-muted/60',
|
||||
)}>
|
||||
<Globe className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{open ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={onInputKeyDown}
|
||||
placeholder={triggerLabel}
|
||||
aria-label={m.language()}
|
||||
className="w-full min-w-0 bg-transparent text-inherit outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.language()}
|
||||
aria-expanded={open}
|
||||
className="min-w-0 flex-1 cursor-pointer truncate text-start text-inherit outline-none">
|
||||
{triggerLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
className={cn('rounded-2xl p-0', prominent ? 'w-[240px]' : 'w-[var(--radix-popper-anchor-width)] min-w-[240px]')}>
|
||||
<div ref={listRef} className="max-h-72 overflow-y-auto p-1.5">
|
||||
{flat.length === 0 && <p className="px-3 py-6 text-center text-[13px] text-muted-foreground">No matches</p>}
|
||||
{groups.map((group, groupIndex) => (
|
||||
<div key={group.label ?? `group-${groupIndex}`}>
|
||||
{group.label && (
|
||||
<p className="px-2.5 pt-2.5 pb-1 text-[11px] font-medium tracking-[0.08em] text-muted-foreground uppercase">
|
||||
{group.label}
|
||||
</p>
|
||||
)}
|
||||
{group.items.map((entry) => {
|
||||
runningIndex += 1
|
||||
const index = runningIndex
|
||||
const active = entry.code === selectedLang
|
||||
return (
|
||||
<button
|
||||
key={entry.code}
|
||||
type="button"
|
||||
data-index={index}
|
||||
onClick={() => select(entry.code)}
|
||||
onMouseMove={() => setHighlighted(index)}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-start text-sm text-foreground',
|
||||
index === highlighted && 'bg-muted/70',
|
||||
entry.code === 'auto' && 'font-medium',
|
||||
)}>
|
||||
{entry.code === 'auto' ? (
|
||||
<span aria-hidden className="inline-flex w-6 shrink-0 justify-center">
|
||||
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
) : (
|
||||
<FlagSlot code={entry.code} name={entry.name} />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{entry.label}</span>
|
||||
{active && <Check className="h-4 w-4 shrink-0 text-foreground" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>{m.others()}</SelectLabel>
|
||||
{otherEntries.map(({ label, code }) => (
|
||||
<SelectItem key={code} value={code}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,44 +1,110 @@
|
||||
import { ReactNode, useContext, useEffect, useState } from 'react'
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import { UpdaterContext } from '~/providers/updater'
|
||||
import AppMenu from './app-menu'
|
||||
import DropModal from './drop-modal'
|
||||
import { PanelLeft } from 'lucide-react'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip'
|
||||
import SettingsModal from './settings-modal'
|
||||
import PageTransition from './page-transition'
|
||||
import ModelDownloadPrompt from './model-download-prompt'
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
/**
|
||||
* The topbar lives here but the Recents sidebar belongs to the main page, so the toggle travels as
|
||||
* a window event and the collapsed state is read from localStorage by whoever renders the sidebar.
|
||||
*/
|
||||
export const TOGGLE_SIDEBAR_EVENT = 'vibe:toggle-sidebar'
|
||||
export const SIDEBAR_STORAGE_KEY = 'vibe_sidebar_collapsed'
|
||||
|
||||
/** Start inset that clears the macOS traffic lights; identical for header and sidebar so the toggle never moves. */
|
||||
export function titlebarInset(): number {
|
||||
return isMacOverlayTitlebar() ? 76 : 10
|
||||
}
|
||||
|
||||
/** True in the real macOS app, where the titlebar is an overlay and traffic lights sit over our UI. */
|
||||
export function isMacOverlayTitlebar(): boolean {
|
||||
try {
|
||||
return '__TAURI_INTERNALS__' in window && !('__vibeMockTauriRuntime__' in globalThis) && platform() === 'macos'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function SidebarToggleButton() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Toggle recents"
|
||||
className="h-9 w-9 rounded-full text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground [&_svg]:size-[18px]"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent(TOGGLE_SIDEBAR_EVENT))}>
|
||||
<PanelLeft strokeWidth={1.75} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Toggle recents</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
/** Full-height panel docked at the window's start edge (Claude-desktop style). */
|
||||
sidebar?: ReactNode
|
||||
/** Full-width bar docked at the very bottom of the window (player). */
|
||||
bottomBar?: ReactNode
|
||||
}
|
||||
|
||||
export default function Layout({ children, sidebar, bottomBar }: LayoutProps) {
|
||||
const [settingsVisible, setSettingsVisible] = useState(false)
|
||||
const [settingsScrollTo, setSettingsScrollTo] = useState<string | undefined>(undefined)
|
||||
const { updateApp, availableUpdate } = useContext(UpdaterContext)
|
||||
// Only the main page renders a Recents sidebar, so only it gets the toggle.
|
||||
const showSidebarToggle = useLocation().pathname === '/'
|
||||
|
||||
function openSettings(scrollTo?: string) {
|
||||
setSettingsScrollTo(scrollTo)
|
||||
setSettingsVisible(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Vibrancy: the macOS window is transparent; html/body must not paint over it.
|
||||
if (isMacOverlayTitlebar()) document.documentElement.classList.add('mac-vibrancy')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function onOpenSettings(event: Event) {
|
||||
const scrollTo = (event as CustomEvent<{ scrollTo?: string }>).detail?.scrollTo
|
||||
openSettings(scrollTo)
|
||||
}
|
||||
window.addEventListener('vibe:open-settings', onOpenSettings)
|
||||
if (window.location.hash === '#settings') {
|
||||
openSettings()
|
||||
}
|
||||
return () => window.removeEventListener('vibe:open-settings', onOpenSettings)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{settingsVisible && <SettingsModal visible={settingsVisible} setVisible={setSettingsVisible} scrollTo={settingsScrollTo} />}
|
||||
<DropModal />
|
||||
<ModelDownloadPrompt />
|
||||
<div className="app-shell">
|
||||
<div className="stagger-in mb-6 flex items-center justify-between gap-4 pb-1">
|
||||
<h1 className="app-title">{m.appTitle()}</h1>
|
||||
<AppMenu onClickSettings={openSettings} availableUpdate={availableUpdate} updateApp={updateApp} />
|
||||
<div className="flex h-screen min-h-0 flex-col overflow-hidden">
|
||||
{/* Single toggle, fixed to the window so it never moves when the sidebar opens or closes. */}
|
||||
{showSidebarToggle && (
|
||||
<div className="fixed z-50" style={{ left: titlebarInset(), top: 7 }}>
|
||||
<SidebarToggleButton />
|
||||
</div>
|
||||
)}
|
||||
{settingsVisible && <SettingsModal visible={settingsVisible} setVisible={setSettingsVisible} scrollTo={settingsScrollTo} />}
|
||||
<ModelDownloadPrompt />
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{sidebar}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
|
||||
<header data-tauri-drag-region className="flex h-14 shrink-0 items-center justify-end gap-4 pe-4"></header>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
|
||||
<PageTransition>
|
||||
<div className="stagger-in h-full">{children}</div>
|
||||
</PageTransition>
|
||||
</div>
|
||||
{/* The player fills only the content column; the sidebar stays full height beside it. */}
|
||||
{bottomBar}
|
||||
</div>
|
||||
<PageTransition>
|
||||
<div className="stagger-in [animation-delay:120ms]">{children}</div>
|
||||
</PageTransition>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -9,8 +9,7 @@ const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground shadow-sm hover:bg-primary/94 dark:bg-[#2168b8] dark:text-white dark:shadow-none dark:hover:bg-[#2168b8] dark:active:bg-[#1d5fa9]',
|
||||
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline: 'border border-input/75 bg-card text-foreground shadow-xs hover:bg-accent/65',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/86',
|
||||
|
||||
@@ -27,4 +27,6 @@ const PopoverContent = React.forwardRef<React.ElementRef<typeof PopoverPrimitive
|
||||
)
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function UpdateProgress() {
|
||||
<DialogDescription>{m.updatingModalBody({ version: String(manifest?.version ?? '') })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-center">
|
||||
<Progress value={progress ?? 0} className="w-56" />
|
||||
<Progress value={progress ?? 0} className="progress-aurora h-1.5 w-56 bg-muted" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
+219
-137
@@ -1,3 +1,4 @@
|
||||
@import '@fontsource-variable/inter';
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@@ -32,9 +33,9 @@ html.dictation-indicator-window #root {
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--font-sans: 'SF Pro Text', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-serif: 'SF Pro Text', -apple-system, 'Helvetica Neue', Arial, sans-serif;
|
||||
--radius: 0.9rem;
|
||||
--font-sans: 'Inter Variable', 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-serif: 'Inter Variable', 'Inter', -apple-system, 'Helvetica Neue', Arial, sans-serif;
|
||||
--radius: 1rem;
|
||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
||||
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
|
||||
@@ -73,92 +74,111 @@ html.dictation-indicator-window #root {
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.75rem;
|
||||
--background: hsl(210 34% 96%);
|
||||
--foreground: hsl(215 22% 17%);
|
||||
--card: hsl(0 0% 100% / 0.95);
|
||||
--card-foreground: hsl(215 22% 17%);
|
||||
--popover: hsl(0 0% 100% / 0.98);
|
||||
--popover-foreground: hsl(215 22% 17%);
|
||||
--primary: hsl(205 74% 43%);
|
||||
--primary-foreground: hsl(0 0% 100%);
|
||||
--secondary: hsl(206 28% 92%);
|
||||
--secondary-foreground: hsl(214 20% 24%);
|
||||
--muted: hsl(207 24% 93%);
|
||||
--muted-foreground: hsl(214 12% 44%);
|
||||
--accent: hsl(206 44% 90%);
|
||||
--accent-foreground: hsl(214 20% 24%);
|
||||
--destructive: hsl(0 78% 57%);
|
||||
--destructive-foreground: hsl(210 40% 98%);
|
||||
--success: hsl(154 64% 38%);
|
||||
--success-foreground: hsl(154 100% 96%);
|
||||
--border: hsl(208 22% 83%);
|
||||
--input: hsl(208 22% 83%);
|
||||
--ring: hsl(205 72% 45%);
|
||||
--chart-1: hsl(205 74% 43%);
|
||||
--chart-2: hsl(197 60% 42%);
|
||||
--chart-3: hsl(28 70% 52%);
|
||||
--chart-4: hsl(45 80% 52%);
|
||||
--chart-5: hsl(265 45% 55%);
|
||||
--sidebar: hsl(0 0% 100% / 0.9);
|
||||
--sidebar-foreground: hsl(215 20% 18%);
|
||||
--sidebar-primary: hsl(205 74% 43%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(206 30% 92%);
|
||||
--sidebar-accent-foreground: hsl(214 20% 24%);
|
||||
--sidebar-border: hsl(208 20% 84%);
|
||||
--sidebar-ring: hsl(205 72% 45%);
|
||||
--shadow-2xs: 0 1px 1px hsl(211 60% 11% / 0.06);
|
||||
--shadow-xs: 0 1px 2px hsl(211 60% 11% / 0.08);
|
||||
--shadow-sm: 0 4px 12px hsl(220 18% 16% / 0.05);
|
||||
--shadow: 0 8px 20px hsl(220 18% 16% / 0.07);
|
||||
--shadow-md: 0 12px 28px hsl(220 18% 16% / 0.09);
|
||||
--shadow-lg: 0 16px 36px hsl(220 10% 20% / 0.12);
|
||||
--shadow-xl: 0 24px 52px hsl(220 10% 20% / 0.16);
|
||||
--shadow-2xl: 0 32px 68px hsl(220 10% 20% / 0.2);
|
||||
--tracking-normal: 0em;
|
||||
--radius: 1rem;
|
||||
|
||||
/* ChatGPT-style neutrals — light (default) */
|
||||
--background: #ffffff;
|
||||
--foreground: #1a1c1f;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #1a1c1f;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #1a1c1f;
|
||||
--primary: #2563eb;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #f4f4f4;
|
||||
--secondary-foreground: #1a1c1f;
|
||||
--muted: #f4f4f4;
|
||||
--muted-foreground: #6e6e6e;
|
||||
--accent: #f4f4f4;
|
||||
--accent-foreground: #1a1c1f;
|
||||
--destructive: #b4453c;
|
||||
--destructive-foreground: #fdf6f5;
|
||||
--success: #3f7d5f;
|
||||
--success-foreground: #f3faf6;
|
||||
--border: #e6e6e6;
|
||||
--input: #e6e6e6;
|
||||
--ring: rgb(37 99 235 / 0.35);
|
||||
|
||||
/* Aurora hues — the only chroma in the system */
|
||||
--aurora-1: #5b8def;
|
||||
--aurora-2: #b98de3;
|
||||
--aurora-3: #e8a87c;
|
||||
--aurora-4: #7cc5a0;
|
||||
|
||||
--chart-1: var(--aurora-1);
|
||||
--chart-2: var(--aurora-2);
|
||||
--chart-3: var(--aurora-3);
|
||||
--chart-4: var(--aurora-4);
|
||||
--chart-5: #6f6f6a;
|
||||
|
||||
--sidebar: #f9f9f9;
|
||||
--sidebar-foreground: #1a1c1f;
|
||||
--sidebar-primary: #2563eb;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: #f1f1ee;
|
||||
--sidebar-accent-foreground: #111110;
|
||||
--sidebar-border: #e7e7e2;
|
||||
--sidebar-ring: rgb(17 17 16 / 0.2);
|
||||
|
||||
--shadow-2xs: 0 1px 1px rgb(17 17 16 / 0.04);
|
||||
--shadow-xs: 0 1px 2px rgb(17 17 16 / 0.05);
|
||||
--shadow-sm: 0 2px 8px rgb(17 17 16 / 0.04);
|
||||
--shadow: 0 6px 18px rgb(17 17 16 / 0.05);
|
||||
--shadow-md: 0 10px 26px rgb(17 17 16 / 0.06);
|
||||
--shadow-lg: 0 16px 36px rgb(17 17 16 / 0.08);
|
||||
--shadow-xl: 0 24px 56px rgb(17 17 16 / 0.1);
|
||||
--shadow-2xl: 0 32px 72px rgb(17 17 16 / 0.12);
|
||||
|
||||
--tracking-normal: -0.01em;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(220 10% 10%);
|
||||
--foreground: hsl(210 18% 93%);
|
||||
--card: hsl(220 9% 16%);
|
||||
--card-foreground: hsl(210 18% 93%);
|
||||
--popover: hsl(220 9% 17%);
|
||||
--popover-foreground: hsl(210 18% 93%);
|
||||
--primary: hsl(212 100% 56%);
|
||||
--primary-foreground: hsl(0 0% 100%);
|
||||
--secondary: hsl(220 8% 22%);
|
||||
--secondary-foreground: hsl(210 14% 90%);
|
||||
--muted: hsl(220 8% 20%);
|
||||
--muted-foreground: hsl(214 10% 74%);
|
||||
--accent: hsl(220 8% 24%);
|
||||
--accent-foreground: hsl(210 14% 90%);
|
||||
--destructive: hsl(0 70% 48%);
|
||||
--destructive-foreground: hsl(210 28% 96%);
|
||||
--success: hsl(154 64% 45%);
|
||||
--success-foreground: hsl(153 58% 10%);
|
||||
--border: hsl(220 8% 34%);
|
||||
--input: hsl(220 8% 34%);
|
||||
--ring: hsl(212 100% 60%);
|
||||
--chart-1: hsl(212 100% 56%);
|
||||
--chart-2: hsl(197 38% 60%);
|
||||
--chart-3: hsl(30 76% 58%);
|
||||
--chart-4: hsl(50 74% 58%);
|
||||
--chart-5: hsl(270 58% 62%);
|
||||
--sidebar: hsl(220 9% 16%);
|
||||
--sidebar-foreground: hsl(210 18% 93%);
|
||||
--sidebar-primary: hsl(212 100% 56%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(220 8% 24%);
|
||||
--sidebar-accent-foreground: hsl(210 14% 90%);
|
||||
--sidebar-border: hsl(220 8% 34%);
|
||||
--sidebar-ring: hsl(212 100% 60%);
|
||||
--shadow-sm: 0 8px 20px hsl(225 28% 4% / 0.28);
|
||||
--shadow: 0 12px 28px hsl(225 28% 4% / 0.35);
|
||||
--shadow-md: 0 18px 38px hsl(225 28% 4% / 0.42);
|
||||
--shadow-lg: 0 26px 52px hsl(225 28% 4% / 0.5);
|
||||
--shadow-xl: 0 34px 70px hsl(225 28% 4% / 0.58);
|
||||
--background: #181818;
|
||||
--foreground: #ececec;
|
||||
--card: #212121;
|
||||
--card-foreground: #ececec;
|
||||
--popover: #2a2a2a;
|
||||
--popover-foreground: #ececec;
|
||||
--primary: #2563eb;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #242424;
|
||||
--secondary-foreground: #ececec;
|
||||
--muted: #242424;
|
||||
--muted-foreground: #9a9a9a;
|
||||
--accent: #2a2a2a;
|
||||
--accent-foreground: #ececec;
|
||||
--destructive: #c4655c;
|
||||
--destructive-foreground: #1a0f0e;
|
||||
--success: #6aab8b;
|
||||
--success-foreground: #0d1512;
|
||||
--border: #303030;
|
||||
--input: #303030;
|
||||
--ring: rgb(51 156 255 / 0.4);
|
||||
|
||||
--aurora-1: #5b8def;
|
||||
--aurora-2: #b98de3;
|
||||
--aurora-3: #e8a87c;
|
||||
--aurora-4: #7cc5a0;
|
||||
|
||||
--chart-5: #9a9a94;
|
||||
|
||||
--sidebar: #212121;
|
||||
--sidebar-foreground: #ececec;
|
||||
--sidebar-primary: #2563eb;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: #2a2a2a;
|
||||
--sidebar-accent-foreground: #ececec;
|
||||
--sidebar-border: #303030;
|
||||
--sidebar-ring: rgb(51 156 255 / 0.4);
|
||||
|
||||
--shadow-2xs: 0 1px 1px rgb(0 0 0 / 0.3);
|
||||
--shadow-xs: 0 1px 2px rgb(0 0 0 / 0.35);
|
||||
--shadow-sm: 0 2px 10px rgb(0 0 0 / 0.4);
|
||||
--shadow: 0 8px 22px rgb(0 0 0 / 0.45);
|
||||
--shadow-md: 0 14px 32px rgb(0 0 0 / 0.5);
|
||||
--shadow-lg: 0 22px 46px rgb(0 0 0 / 0.55);
|
||||
--shadow-xl: 0 30px 64px rgb(0 0 0 / 0.6);
|
||||
--shadow-2xl: 0 40px 80px rgb(0 0 0 / 0.65);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -170,30 +190,37 @@ html.dictation-indicator-window #root {
|
||||
|
||||
html {
|
||||
font-family:
|
||||
'SF Pro Text',
|
||||
'SF Pro Display',
|
||||
'Inter Variable',
|
||||
'Inter',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'SF Pro Text',
|
||||
'Helvetica Neue',
|
||||
Arial,
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
letter-spacing: -0.01em;
|
||||
font-feature-settings:
|
||||
'rlig' 1,
|
||||
'calt' 1;
|
||||
background-image:
|
||||
radial-gradient(1200px 700px at 15% -10%, hsl(205 90% 88% / 0.55) 0%, transparent 60%),
|
||||
radial-gradient(900px 600px at 100% 0%, hsl(280 70% 88% / 0.35) 0%, transparent 55%),
|
||||
linear-gradient(180deg, hsl(210 32% 97%) 0%, hsl(210 28% 95%) 100%);
|
||||
background-repeat: no-repeat;
|
||||
background-attachment: fixed;
|
||||
background-size: 100% 100%;
|
||||
'calt' 1,
|
||||
'cv05' 1;
|
||||
background-image: none;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
scrollbar-width: none;
|
||||
@@ -206,27 +233,24 @@ html.dictation-indicator-window #root {
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background-image:
|
||||
radial-gradient(1200px 700px at 15% -10%, hsl(212 100% 30% / 0.16) 0%, transparent 60%),
|
||||
radial-gradient(900px 600px at 100% 0%, hsl(280 60% 30% / 0.12) 0%, transparent 55%),
|
||||
linear-gradient(180deg, hsl(220 10% 11%) 0%, hsl(220 10% 9%) 100%);
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: hsl(205 74% 43% / 0.22);
|
||||
background: rgb(17 17 16 / 0.14);
|
||||
}
|
||||
|
||||
.dark ::selection {
|
||||
background: hsl(212 100% 56% / 0.26);
|
||||
background: rgb(244 244 241 / 0.2);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@@ -234,17 +258,25 @@ html.dictation-indicator-window #root {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(210 13% 52% / 0.35);
|
||||
background: rgb(17 17 16 / 0.14);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
:hover::-webkit-scrollbar-thumb {
|
||||
background: rgb(17 17 16 / 0.22);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: hsl(214 6% 56% / 0.42);
|
||||
background: rgb(244 244 241 / 0.14);
|
||||
}
|
||||
|
||||
.dark :hover::-webkit-scrollbar-thumb {
|
||||
background: rgb(244 244 241 / 0.22);
|
||||
}
|
||||
|
||||
.transcript-editor {
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: hsl(208 36% 52% / 0.78) hsl(210 18% 90% / 0.85);
|
||||
scrollbar-color: rgb(17 17 16 / 0.28) rgb(17 17 16 / 0.05);
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@@ -255,92 +287,135 @@ html.dictation-indicator-window #root {
|
||||
}
|
||||
|
||||
.transcript-editor::-webkit-scrollbar-thumb {
|
||||
background: hsl(208 36% 52% / 0.8) !important;
|
||||
border: 3px solid hsl(210 18% 90% / 0.85) !important;
|
||||
background: rgb(17 17 16 / 0.28) !important;
|
||||
border: 3px solid transparent !important;
|
||||
background-clip: padding-box !important;
|
||||
border-radius: 999px !important;
|
||||
min-height: 96px !important;
|
||||
}
|
||||
|
||||
.transcript-editor::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(208 36% 46% / 0.9) !important;
|
||||
background: rgb(17 17 16 / 0.4) !important;
|
||||
background-clip: padding-box !important;
|
||||
}
|
||||
|
||||
.transcript-editor::-webkit-scrollbar-track {
|
||||
background: hsl(210 18% 90% / 0.85) !important;
|
||||
border-left: 1px solid hsl(208 22% 83% / 0.75) !important;
|
||||
background: transparent !important;
|
||||
border-left: 1px solid var(--border) !important;
|
||||
}
|
||||
|
||||
.dark .transcript-editor::-webkit-scrollbar-thumb {
|
||||
background: hsl(214 10% 62% / 0.82) !important;
|
||||
border: 3px solid hsl(220 8% 18% / 0.96) !important;
|
||||
background: rgb(244 244 241 / 0.24) !important;
|
||||
border: 3px solid transparent !important;
|
||||
background-clip: padding-box !important;
|
||||
}
|
||||
|
||||
.dark .transcript-editor::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(214 12% 70% / 0.9) !important;
|
||||
background: rgb(244 244 241 / 0.36) !important;
|
||||
background-clip: padding-box !important;
|
||||
}
|
||||
|
||||
.dark .transcript-editor::-webkit-scrollbar-track {
|
||||
background: hsl(220 8% 18% / 0.96) !important;
|
||||
border-left: 1px solid hsl(220 8% 34% / 0.8) !important;
|
||||
background: transparent !important;
|
||||
border-left: 1px solid var(--border) !important;
|
||||
}
|
||||
|
||||
.dark .transcript-editor {
|
||||
scrollbar-color: hsl(214 10% 62% / 0.82) hsl(220 8% 18% / 0.96);
|
||||
scrollbar-color: rgb(244 244 241 / 0.24) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.app-shell {
|
||||
@apply mx-auto w-full max-w-5xl px-4 pb-8 pt-4 md:px-6 md:pt-6;
|
||||
@apply mx-auto w-full max-w-5xl px-5 pb-10 pt-3 md:px-8 md:pt-4;
|
||||
}
|
||||
|
||||
.app-hero {
|
||||
@apply relative mb-4 rounded-xl border border-border/70 bg-card/95 px-5 py-4 shadow-sm md:mb-5 md:px-6;
|
||||
@apply relative mb-5 rounded-xl border border-border bg-card px-6 py-5;
|
||||
}
|
||||
|
||||
.app-panel {
|
||||
@apply rounded-xl border border-border/70 bg-card/95 p-4 shadow-sm md:p-5;
|
||||
@apply rounded-xl border border-border bg-card p-5 md:p-6;
|
||||
}
|
||||
|
||||
.app-subtle {
|
||||
@apply rounded-xl border border-border/70 bg-background/80 p-3;
|
||||
}
|
||||
|
||||
.dark .app-hero {
|
||||
@apply border-border/70 bg-card text-card-foreground;
|
||||
background-image: none;
|
||||
@apply rounded-lg border border-border bg-muted/60 p-3;
|
||||
}
|
||||
|
||||
.dark .app-hero,
|
||||
.dark .app-panel {
|
||||
@apply border-border/85 bg-card text-card-foreground shadow-md;
|
||||
@apply bg-card text-card-foreground;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.dark .app-subtle {
|
||||
@apply border-border/80 bg-muted/85 text-card-foreground;
|
||||
@apply bg-muted text-card-foreground;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
@apply text-3xl font-semibold tracking-tight md:text-4xl;
|
||||
@apply text-[28px] font-semibold leading-[1.1] tracking-[-0.03em] md:text-[40px];
|
||||
}
|
||||
|
||||
.app-kicker {
|
||||
@apply text-xs font-medium uppercase tracking-[0.08em] text-muted-foreground;
|
||||
/* Eyebrow label — 11px, uppercase, +0.08em */
|
||||
.app-kicker,
|
||||
.eyebrow {
|
||||
@apply text-[11px] font-medium uppercase leading-none tracking-[0.08em] text-muted-foreground;
|
||||
}
|
||||
|
||||
.app-main-card {
|
||||
@apply rounded-3xl border border-border/60 bg-card/95 p-6 shadow-xl md:p-8;
|
||||
@apply rounded-2xl border border-border bg-card p-6 shadow-sm md:p-9;
|
||||
}
|
||||
|
||||
.dark .app-main-card {
|
||||
@apply border-border/70 bg-card shadow-2xl;
|
||||
@apply bg-card shadow-md;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Aurora — the single expressive surface. Soft multi-hue gradient plus a
|
||||
* grain overlay. Always behind a card or as a thin fill, never behind body text.
|
||||
*/
|
||||
.aurora {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
background-color: var(--muted);
|
||||
background-image:
|
||||
radial-gradient(58% 72% at 14% 16%, #5b8def8c 0%, transparent 68%), radial-gradient(54% 68% at 84% 10%, #b98de37a 0%, transparent 70%),
|
||||
radial-gradient(58% 66% at 80% 88%, #e8a87c73 0%, transparent 70%), radial-gradient(62% 70% at 18% 92%, #7cc5a073 0%, transparent 70%);
|
||||
background-repeat: no-repeat;
|
||||
filter: saturate(0.85);
|
||||
}
|
||||
|
||||
.aurora::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
opacity: 0.28;
|
||||
mix-blend-mode: overlay;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.dark .aurora {
|
||||
filter: saturate(0.7) brightness(0.8);
|
||||
}
|
||||
|
||||
/* Thin aurora fill for progress bars */
|
||||
.aurora-bar {
|
||||
background-image: linear-gradient(90deg, var(--aurora-1) 0%, var(--aurora-2) 34%, var(--aurora-3) 67%, var(--aurora-4) 100%);
|
||||
filter: saturate(0.85);
|
||||
}
|
||||
|
||||
/* Radix <Progress> whose indicator is filled with the aurora gradient */
|
||||
.progress-aurora > * {
|
||||
background-color: transparent;
|
||||
background-image: linear-gradient(90deg, var(--aurora-1) 0%, var(--aurora-2) 34%, var(--aurora-3) 67%, var(--aurora-4) 100%);
|
||||
filter: saturate(0.85);
|
||||
}
|
||||
|
||||
.stagger-in {
|
||||
animation: fade-up 380ms ease both;
|
||||
animation: fade-up 240ms ease-out both;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,7 +544,7 @@ html.dictation-indicator-window #root {
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
to {
|
||||
@@ -477,3 +552,10 @@ html.dictation-indicator-window #root {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* macOS vibrancy: the window itself is transparent; only the content column paints a solid
|
||||
background, so the sidebar area shows the blurred desktop through its translucent tint. */
|
||||
html.mac-vibrancy,
|
||||
html.mac-vibrancy body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import * as pathApi from '@tauri-apps/api/path'
|
||||
import * as fs from '@tauri-apps/plugin-fs'
|
||||
import type { Segment } from './transcript'
|
||||
|
||||
/**
|
||||
* Transcript persistence.
|
||||
*
|
||||
* The store is *a folder of project folders*: `Documents/Vibe/<name>-<yyyyMMdd-HHmmss>/` holding
|
||||
* `transcript.vibe.json` next to a copy of the media it was transcribed from (`audio.<ext>`), so a
|
||||
* saved transcript stays playable even after the original file moves or is deleted.
|
||||
*
|
||||
* There is no index and no database — listing scans the store and derives the display name and
|
||||
* creation date from the folder name, so the list never has to read (and parse) every transcript.
|
||||
* Legacy flat saves (`Documents/Vibe/<name>-<stamp>.vibe.json`) are still listed, read, renamed and
|
||||
* deleted in place; only new saves use project folders.
|
||||
*
|
||||
* Every operation here is best-effort: failures are logged and reported as null/empty so a broken
|
||||
* disk, a missing folder or a corrupt file can never break the transcription flow.
|
||||
*/
|
||||
|
||||
export const TRANSCRIPTS_FOLDER = 'Vibe'
|
||||
export const TRANSCRIPT_EXTENSION = '.vibe.json'
|
||||
/** Name of the record inside a project folder. */
|
||||
export const TRANSCRIPT_FILENAME = `transcript${TRANSCRIPT_EXTENSION}`
|
||||
|
||||
export const TRANSCRIPT_VERSION = 1
|
||||
|
||||
export interface TranscriptRecord {
|
||||
version: number
|
||||
name: string
|
||||
sourcePath: string
|
||||
/** ISO 8601 */
|
||||
createdAt: string
|
||||
language?: string
|
||||
modelPath?: string | null
|
||||
/** Filename of the media copy inside the project folder, relative to it (e.g. `audio.mp3`). */
|
||||
audioFile?: string
|
||||
segments: Segment[]
|
||||
}
|
||||
|
||||
export interface TranscriptEntry {
|
||||
/** absolute path of the .vibe.json file (inside its project folder, for non-legacy saves) */
|
||||
path: string
|
||||
/** display name, derived from the filename */
|
||||
name: string
|
||||
/** parsed from the filename stamp; falls back to the epoch when unparsable */
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export interface SaveTranscriptInput {
|
||||
name: string
|
||||
sourcePath: string
|
||||
segments: Segment[]
|
||||
language?: string
|
||||
modelPath?: string | null
|
||||
createdAt?: Date
|
||||
}
|
||||
|
||||
function pad(value: number, length = 2) {
|
||||
return String(value).padStart(length, '0')
|
||||
}
|
||||
|
||||
/** `yyyyMMdd-HHmmss` in local time — sortable and readable in the file manager. */
|
||||
function stamp(date: Date) {
|
||||
return (
|
||||
`${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` + `-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
|
||||
)
|
||||
}
|
||||
|
||||
const stampPattern = /-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/
|
||||
|
||||
function parseStamp(stem: string): { name: string; createdAt: Date } {
|
||||
const match = stem.match(stampPattern)
|
||||
if (!match) return { name: stem, createdAt: new Date(0) }
|
||||
const [, year, month, day, hours, minutes, seconds] = match
|
||||
const createdAt = new Date(Number(year), Number(month) - 1, Number(day), Number(hours), Number(minutes), Number(seconds))
|
||||
return {
|
||||
name: stem.slice(0, match.index) || stem,
|
||||
createdAt: Number.isNaN(createdAt.getTime()) ? new Date(0) : createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip the extension and anything a file system would object to. */
|
||||
function toFileStem(name: string) {
|
||||
const withoutExtension = name.replace(/\.[^./\\]+$/, '')
|
||||
const cleaned = withoutExtension
|
||||
.replace(/[/\\?%*:|"<>]/g, '-')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\u0000-\u001f]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^\.+/, '')
|
||||
return (cleaned || 'transcript').slice(0, 120)
|
||||
}
|
||||
|
||||
/** Last path segment, for both posix and windows separators. */
|
||||
function basename(path: string) {
|
||||
return path.split(/[/\\]/).pop() ?? path
|
||||
}
|
||||
|
||||
/** Containing folder of a path, or `''` when there is none. */
|
||||
function parentOf(path: string) {
|
||||
const index = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||||
return index > 0 ? path.slice(0, index) : ''
|
||||
}
|
||||
|
||||
/** Lowercase-free extension of a filename, without the dot; `''` when it has none. */
|
||||
function extensionOf(path: string) {
|
||||
const name = basename(path)
|
||||
const dot = name.lastIndexOf('.')
|
||||
if (dot <= 0) return ''
|
||||
const extension = name.slice(dot + 1)
|
||||
return /^[A-Za-z0-9]{1,10}$/.test(extension) ? extension : ''
|
||||
}
|
||||
|
||||
/** A project save is a `transcript.vibe.json` inside its own folder; anything else is legacy flat. */
|
||||
function isProjectRecordPath(path: string) {
|
||||
return basename(path) === TRANSCRIPT_FILENAME
|
||||
}
|
||||
|
||||
/** Absolute path of the store folder, created on demand. */
|
||||
export async function transcriptsFolder(): Promise<string> {
|
||||
const folder = await pathApi.join(await pathApi.documentDir(), TRANSCRIPTS_FOLDER)
|
||||
if (!(await fs.exists(folder))) await fs.mkdir(folder, { recursive: true })
|
||||
return folder
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is TranscriptRecord {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const candidate = value as Partial<TranscriptRecord>
|
||||
return typeof candidate.name === 'string' && Array.isArray(candidate.segments)
|
||||
}
|
||||
|
||||
function serialize(record: TranscriptRecord) {
|
||||
return JSON.stringify(record, null, '\t')
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the transcribed media next to its record so the project folder stays self-contained.
|
||||
* Best-effort: a missing extension, an unreadable source or a full disk only costs the copy.
|
||||
* @returns the relative filename of the copy, or undefined when there is none.
|
||||
*/
|
||||
async function copySourceMedia(projectFolder: string, sourcePath: string): Promise<string | undefined> {
|
||||
const extension = extensionOf(sourcePath)
|
||||
if (!sourcePath || !extension) return undefined
|
||||
const audioFile = `audio.${extension}`
|
||||
try {
|
||||
await fs.copyFile(sourcePath, await pathApi.join(projectFolder, audioFile))
|
||||
return audioFile
|
||||
} catch (error) {
|
||||
console.warn('failed to copy source media into the transcript folder:', sourcePath, error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one transcript into the store as a project folder holding the record and a copy of its
|
||||
* media.
|
||||
* @returns the path of the written `transcript.vibe.json`, or null when saving failed (never throws).
|
||||
*/
|
||||
export async function saveTranscript(input: SaveTranscriptInput): Promise<string | null> {
|
||||
try {
|
||||
const createdAt = input.createdAt ?? new Date()
|
||||
const folder = await transcriptsFolder()
|
||||
const projectFolder = await pathApi.join(folder, `${toFileStem(input.name)}-${stamp(createdAt)}`)
|
||||
await fs.mkdir(projectFolder, { recursive: true })
|
||||
const target = await pathApi.join(projectFolder, TRANSCRIPT_FILENAME)
|
||||
const record: TranscriptRecord = {
|
||||
version: TRANSCRIPT_VERSION,
|
||||
name: input.name,
|
||||
sourcePath: input.sourcePath,
|
||||
createdAt: createdAt.toISOString(),
|
||||
language: input.language,
|
||||
modelPath: input.modelPath ?? null,
|
||||
segments: input.segments,
|
||||
}
|
||||
await fs.writeTextFile(target, serialize(record))
|
||||
// The transcript itself is already safe on disk; the media copy may fail without losing it.
|
||||
const audioFile = await copySourceMedia(projectFolder, input.sourcePath)
|
||||
if (audioFile) {
|
||||
try {
|
||||
await fs.writeTextFile(target, serialize({ ...record, audioFile }))
|
||||
} catch (error) {
|
||||
console.warn('failed to record the media copy in the transcript:', target, error)
|
||||
}
|
||||
}
|
||||
return target
|
||||
} catch (error) {
|
||||
console.warn('failed to save transcript:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every saved transcript, newest first: project folders plus legacy flat files. Reads folder and
|
||||
* file *names* only — never the records themselves.
|
||||
*/
|
||||
export async function listTranscripts(): Promise<TranscriptEntry[]> {
|
||||
try {
|
||||
const folder = await pathApi.join(await pathApi.documentDir(), TRANSCRIPTS_FOLDER)
|
||||
if (!(await fs.exists(folder))) return []
|
||||
const entries = await fs.readDir(folder)
|
||||
const found: TranscriptEntry[] = []
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory) {
|
||||
const record = await pathApi.join(folder, entry.name, TRANSCRIPT_FILENAME)
|
||||
if (!(await fs.exists(record))) continue
|
||||
const { name, createdAt } = parseStamp(entry.name)
|
||||
found.push({ path: record, name, createdAt })
|
||||
continue
|
||||
}
|
||||
if (!entry.name.endsWith(TRANSCRIPT_EXTENSION)) continue
|
||||
const stem = entry.name.slice(0, -TRANSCRIPT_EXTENSION.length)
|
||||
const { name, createdAt } = parseStamp(stem)
|
||||
found.push({ path: await pathApi.join(folder, entry.name), name, createdAt })
|
||||
}
|
||||
return found.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || b.path.localeCompare(a.path))
|
||||
} catch (error) {
|
||||
console.warn('failed to list transcripts:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Read and validate one transcript. Corrupt or foreign files resolve to null. */
|
||||
export async function readTranscript(path: string): Promise<TranscriptRecord | null> {
|
||||
try {
|
||||
const raw = await fs.readTextFile(path)
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!isRecord(parsed)) {
|
||||
console.warn('skipping malformed transcript:', path)
|
||||
return null
|
||||
}
|
||||
return {
|
||||
version: typeof parsed.version === 'number' ? parsed.version : TRANSCRIPT_VERSION,
|
||||
name: parsed.name,
|
||||
sourcePath: typeof parsed.sourcePath === 'string' ? parsed.sourcePath : '',
|
||||
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : new Date(0).toISOString(),
|
||||
language: typeof parsed.language === 'string' ? parsed.language : undefined,
|
||||
modelPath: typeof parsed.modelPath === 'string' ? parsed.modelPath : null,
|
||||
audioFile: typeof parsed.audioFile === 'string' && parsed.audioFile ? basename(parsed.audioFile) : undefined,
|
||||
segments: parsed.segments.filter(
|
||||
(segment): segment is Segment => typeof segment === 'object' && segment !== null && typeof (segment as Segment).text === 'string',
|
||||
),
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('failed to read transcript:', path, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path of the media copy stored beside a record, when the project folder still has one.
|
||||
* Legacy flat saves (and records whose copy was deleted) resolve to null — the caller falls back to
|
||||
* `record.sourcePath`.
|
||||
*/
|
||||
export async function resolveProjectAudio(jsonPath: string, record: TranscriptRecord): Promise<string | null> {
|
||||
try {
|
||||
if (!record.audioFile) return null
|
||||
const audioPath = await pathApi.join(await pathApi.dirname(jsonPath), record.audioFile)
|
||||
return (await fs.exists(audioPath)) ? audioPath : null
|
||||
} catch (error) {
|
||||
console.warn('failed to resolve the transcript media copy:', jsonPath, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the segments of an already stored transcript, keeping every other field untouched.
|
||||
* Used by inline editing, which changes text only.
|
||||
* @returns whether the file now holds the new segments (never throws).
|
||||
*/
|
||||
export async function updateTranscriptSegments(path: string, segments: Segment[]): Promise<boolean> {
|
||||
try {
|
||||
const record = await readTranscript(path)
|
||||
if (!record) return false
|
||||
await fs.writeTextFile(path, serialize({ ...record, segments }))
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('failed to update transcript segments:', path, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired on `window` whenever the store changed (a save or a delete) so open lists can refresh
|
||||
* without the writer having to know about them.
|
||||
*/
|
||||
export const TRANSCRIPTS_CHANGED_EVENT = 'vibe:transcripts-changed'
|
||||
|
||||
export function notifyTranscriptsChanged() {
|
||||
window.dispatchEvent(new CustomEvent(TRANSCRIPTS_CHANGED_EVENT))
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one transcript: the whole project folder (media copy included) for project saves, the
|
||||
* single file for legacy ones. Returns whether it is gone.
|
||||
*/
|
||||
export async function deleteTranscript(path: string): Promise<boolean> {
|
||||
try {
|
||||
if (isProjectRecordPath(path)) {
|
||||
await fs.remove(await pathApi.dirname(path), { recursive: true })
|
||||
return true
|
||||
}
|
||||
await fs.remove(path)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('failed to delete transcript:', path, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** The stamp suffix of a folder/file stem, or a fresh one when the old name carries none. */
|
||||
function keptStampOf(stem: string) {
|
||||
const match = stem.match(stampPattern)
|
||||
return match ? stem.slice(match.index) : `-${stamp(new Date())}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a stored transcript: updates the record's `name` and moves the project folder (legacy: the
|
||||
* file) to one carrying the new name while keeping the original timestamp stamp.
|
||||
* @returns the new entry, or null on failure (never throws).
|
||||
*/
|
||||
export async function renameTranscript(path: string, newName: string): Promise<TranscriptEntry | null> {
|
||||
try {
|
||||
const record = await readTranscript(path)
|
||||
if (!record) return null
|
||||
const stem = toFileStem(newName)
|
||||
|
||||
if (isProjectRecordPath(path)) {
|
||||
const projectFolder = await pathApi.dirname(path)
|
||||
const oldFolderName = basename(projectFolder)
|
||||
const folderName = `${stem}${keptStampOf(oldFolderName)}`
|
||||
const createdAt = parseStamp(folderName).createdAt
|
||||
let target = path
|
||||
if (folderName !== oldFolderName) {
|
||||
const renamed = await pathApi.join(parentOf(projectFolder) || (await transcriptsFolder()), folderName)
|
||||
await fs.rename(projectFolder, renamed)
|
||||
target = await pathApi.join(renamed, TRANSCRIPT_FILENAME)
|
||||
}
|
||||
await fs.writeTextFile(target, serialize({ ...record, name: stem }))
|
||||
return { path: target, name: stem, createdAt }
|
||||
}
|
||||
|
||||
const filename = basename(path)
|
||||
const oldStem = filename.slice(0, -TRANSCRIPT_EXTENSION.length)
|
||||
const keptStamp = keptStampOf(oldStem)
|
||||
const folder = parentOf(path) || (await transcriptsFolder())
|
||||
const target = await pathApi.join(folder, `${stem}${keptStamp}${TRANSCRIPT_EXTENSION}`)
|
||||
if (target === path) return { path, name: stem, createdAt: parseStamp(oldStem).createdAt }
|
||||
await fs.writeTextFile(target, serialize({ ...record, name: stem }))
|
||||
await fs.remove(path)
|
||||
return { path: target, name: stem, createdAt: parseStamp(`${stem}${keptStamp}`).createdAt }
|
||||
} catch (error) {
|
||||
console.warn('failed to rename transcript:', path, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const MOCK_AUDIO_DEVICES: MockAudioDevice[] = [
|
||||
]
|
||||
|
||||
const RECORD_FINISH_DELAY_MS = 200
|
||||
const RECORD_LEVEL_TICK_MS = 100
|
||||
const YTDLP_TICKS = 20
|
||||
const YTDLP_TICK_MS = 100
|
||||
|
||||
@@ -25,6 +26,18 @@ function sleep(ms: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake speech envelope: two slow sines (syllables + phrase breathing) plus jitter, so the mock
|
||||
* meter moves like a voice instead of a metronome. Returns 0.05..0.9.
|
||||
*/
|
||||
function mockSpeechLevel(tick: number) {
|
||||
const syllables = 0.5 + 0.5 * Math.sin(tick * 0.55)
|
||||
const phrase = 0.55 + 0.45 * Math.sin(tick * 0.09 + 1.3)
|
||||
const jitter = Math.random() * 0.18
|
||||
const level = syllables * phrase * 0.8 + jitter
|
||||
return Math.min(0.9, Math.max(0.05, level))
|
||||
}
|
||||
|
||||
function basename(path: string) {
|
||||
const normalized = path.replace(/\/+$/, '')
|
||||
return normalized.slice(normalized.lastIndexOf('/') + 1)
|
||||
@@ -53,9 +66,16 @@ export const mediaMiscHandlers: CommandHandlerMap = {
|
||||
start_record: (args) => {
|
||||
console.info('[mock] start_record', args)
|
||||
const path = `${DOCUMENTS_FOLDER}/recording.wav`
|
||||
// Stand in for the capture callbacks: a level event every 100ms, like the throttled Rust side.
|
||||
let tick = 0
|
||||
const levelTimer = window.setInterval(() => {
|
||||
tick += 1
|
||||
emitMockEvent('record_level', mockSpeechLevel(tick))
|
||||
}, RECORD_LEVEL_TICK_MS)
|
||||
// The frontend stops recording by emitting `stop_record` on the event bus.
|
||||
const unsub = onMockEvent('stop_record', () => {
|
||||
unsub()
|
||||
window.clearInterval(levelTimer)
|
||||
setTimeout(() => {
|
||||
virtualFs.set(path, null)
|
||||
emitMockEvent('record_finish', { path, name: basename(path) })
|
||||
|
||||
@@ -54,7 +54,7 @@ function resolveWritePath(args: Record<string, unknown>): string {
|
||||
}
|
||||
|
||||
function resolveWriteData(args: Record<string, unknown>): Uint8Array {
|
||||
const candidate = args.data ?? args.__payload ?? args.payload ?? args
|
||||
const candidate = args.data ?? args.__body ?? args.__payload ?? args.payload ?? args
|
||||
if (candidate instanceof Uint8Array) {
|
||||
return candidate
|
||||
}
|
||||
@@ -197,6 +197,30 @@ const fsHandlers: CommandHandlerMap = {
|
||||
}))
|
||||
},
|
||||
'plugin:fs|mkdir': () => null,
|
||||
'plugin:fs|copy_file': (args) => {
|
||||
const from = toPosixPath(args.fromPath ?? args.from)
|
||||
const to = toPosixPath(args.toPath ?? args.to)
|
||||
virtualFs.set(to, virtualFs.get(from) ?? null)
|
||||
return null
|
||||
},
|
||||
// Moves a file, or a whole folder subtree (how a transcript project folder gets renamed).
|
||||
'plugin:fs|rename': (args) => {
|
||||
const from = toPosixPath(args.oldPath ?? args.from)
|
||||
const to = toPosixPath(args.newPath ?? args.to)
|
||||
if (virtualFs.has(from)) {
|
||||
virtualFs.set(to, virtualFs.get(from) ?? null)
|
||||
virtualFs.delete(from)
|
||||
return null
|
||||
}
|
||||
const prefix = `${from}/`
|
||||
for (const key of Array.from(virtualFs.keys())) {
|
||||
if (key.startsWith(prefix)) {
|
||||
virtualFs.set(`${to}/${key.slice(prefix.length)}`, virtualFs.get(key) ?? null)
|
||||
virtualFs.delete(key)
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
'plugin:fs|remove': (args) => {
|
||||
const path = toPosixPath(args.path)
|
||||
virtualFs.delete(path)
|
||||
@@ -224,11 +248,12 @@ const fsHandlers: CommandHandlerMap = {
|
||||
return contents ? Array.from(contents) : []
|
||||
},
|
||||
'plugin:fs|read_text_file': (args) => {
|
||||
// plugin-fs decodes the response via Uint8Array.from(...), so this must be raw bytes.
|
||||
const contents = virtualFs.get(toPosixPath(args.path))
|
||||
if (typeof contents === 'string') {
|
||||
return contents
|
||||
return Array.from(new TextEncoder().encode(contents))
|
||||
}
|
||||
return contents ? new TextDecoder().decode(contents) : ''
|
||||
return contents ? Array.from(contents) : []
|
||||
},
|
||||
}
|
||||
|
||||
@@ -248,8 +273,13 @@ const dialogHandlers: CommandHandlerMap = {
|
||||
if (options.directory) {
|
||||
return options.multiple ? [DOCUMENTS_FOLDER] : DOCUMENTS_FOLDER
|
||||
}
|
||||
const picked = `${DOCUMENTS_FOLDER}/sample.mp3`
|
||||
return options.multiple ? [picked] : picked
|
||||
// multiple:true simulates a multi-select so the batch queue UI is reachable in browser
|
||||
// mode; the floating mock toolbar (runtime.ts) can force single-file picks instead.
|
||||
if (options.multiple && localStorage.getItem('mock-dialog-multi') !== 'off') {
|
||||
return [...virtualFs.keys()].filter((key) => key.startsWith(`${DOCUMENTS_FOLDER}/`) && /\.(mp3|mp4|wav)$/.test(key))
|
||||
}
|
||||
const single = `${DOCUMENTS_FOLDER}/sample.mp3`
|
||||
return options.multiple ? [single] : single
|
||||
},
|
||||
'plugin:dialog|save': (args) => {
|
||||
const options = (args.options ?? {}) as DialogOpenOptions
|
||||
|
||||
@@ -139,6 +139,54 @@ export function installRuntime(handlers: CommandHandlerMap): void {
|
||||
|
||||
mockIPC(async (cmd, args) => route(cmd, (args ?? {}) as Record<string, unknown>))
|
||||
|
||||
// mockIPC drops the third invoke argument, but plugin-fs writes carry their target path in
|
||||
// its `headers` and send the file contents as a raw (non-record) payload. Re-wrap the real
|
||||
// internals.invoke so headers and raw bodies reach the handlers as `__headers`/`__body`.
|
||||
const tauriInternals = (window as Window & { __TAURI_INTERNALS__?: { invoke?: (...args: unknown[]) => Promise<unknown> } }).__TAURI_INTERNALS__
|
||||
const baseInvoke = tauriInternals?.invoke
|
||||
if (tauriInternals && baseInvoke) {
|
||||
tauriInternals.invoke = (cmd: unknown, payload?: unknown, options?: unknown) => {
|
||||
const headers = (options as { headers?: Record<string, unknown> } | undefined)?.headers
|
||||
if (headers) {
|
||||
const record =
|
||||
payload && typeof payload === 'object' && !ArrayBuffer.isView(payload) && !(payload instanceof ArrayBuffer) ? payload : { __body: payload }
|
||||
payload = { ...(record as Record<string, unknown>), __headers: headers }
|
||||
}
|
||||
return baseInvoke.call(tauriInternals, cmd, payload, options)
|
||||
}
|
||||
}
|
||||
|
||||
installOsPluginInternals()
|
||||
installGlobalTauri()
|
||||
installMockToolbar()
|
||||
}
|
||||
|
||||
// Tiny floating control for mock-only knobs. Plain DOM so it never touches app code.
|
||||
function installMockToolbar(): void {
|
||||
const ID = '__vibe-mock-toolbar'
|
||||
if (document.getElementById(ID)) {
|
||||
return
|
||||
}
|
||||
const label = () => (localStorage.getItem('mock-dialog-multi') === 'off' ? 'mock: pick 1 file' : 'mock: pick 3 files')
|
||||
const button = document.createElement('button')
|
||||
button.id = ID
|
||||
button.type = 'button'
|
||||
button.textContent = label()
|
||||
button.title = 'Browser mock: toggle how many files the fake open-dialog returns'
|
||||
button.style.cssText =
|
||||
'position:fixed;bottom:10px;left:10px;z-index:99999;padding:4px 10px;border-radius:999px;' +
|
||||
'border:1px solid rgba(128,128,128,.35);background:rgba(128,128,128,.12);color:inherit;' +
|
||||
'font:11px/1.6 ui-monospace,monospace;opacity:.55;cursor:pointer;backdrop-filter:blur(4px)'
|
||||
button.addEventListener('mouseenter', () => (button.style.opacity = '1'))
|
||||
button.addEventListener('mouseleave', () => (button.style.opacity = '.55'))
|
||||
button.addEventListener('click', () => {
|
||||
localStorage.setItem('mock-dialog-multi', localStorage.getItem('mock-dialog-multi') === 'off' ? 'on' : 'off')
|
||||
button.textContent = label()
|
||||
})
|
||||
const mount = () => document.body.appendChild(button)
|
||||
if (document.body) {
|
||||
mount()
|
||||
} else {
|
||||
window.addEventListener('DOMContentLoaded', mount, { once: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,49 @@ export const DEFAULT_MODEL_FILE = 'ggml-medium.bin'
|
||||
export const virtualFs = new Map<string, string | Uint8Array | null>([
|
||||
[`${MODELS_FOLDER}/${DEFAULT_MODEL_FILE}`, null],
|
||||
[`${DOCUMENTS_FOLDER}/sample.mp3`, null],
|
||||
[`${DOCUMENTS_FOLDER}/interview.mp4`, null],
|
||||
[`${DOCUMENTS_FOLDER}/meeting-notes.wav`, null],
|
||||
])
|
||||
|
||||
// Pre-saved transcripts so the Recents sidebar has entries to open on first load.
|
||||
// Shape, folder layout and stamp must match src/lib/transcripts-store.ts.
|
||||
function record(name: string, texts: string[], audioFile?: string) {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
name,
|
||||
sourcePath: `${DOCUMENTS_FOLDER}/${name}.mp3`,
|
||||
createdAt: new Date().toISOString(),
|
||||
language: 'english',
|
||||
modelPath: `${MODELS_FOLDER}/${DEFAULT_MODEL_FILE}`,
|
||||
...(audioFile ? { audioFile } : {}),
|
||||
segments: texts.map((text, index) => ({ start: index * 300, stop: index * 300 + 280, text })),
|
||||
})
|
||||
}
|
||||
|
||||
/** A project folder: the record plus the copy of the media it was transcribed from. */
|
||||
function seedProject(name: string, stamp: string, texts: string[]) {
|
||||
const folder = `${DOCUMENTS_FOLDER}/Vibe/${name}-${stamp}`
|
||||
virtualFs.set(`${folder}/transcript.vibe.json`, record(name, texts, 'audio.mp3'))
|
||||
virtualFs.set(`${folder}/audio.mp3`, null)
|
||||
}
|
||||
|
||||
seedProject('team-standup', '20260820-093015', [
|
||||
'Quick recap of what everyone shipped yesterday.',
|
||||
'The new release is scheduled for Friday morning.',
|
||||
'Open blockers move to the afternoon sync.',
|
||||
])
|
||||
seedProject('podcast-episode', '20260818-174200', [
|
||||
'Welcome back to the show, today we talk about local AI.',
|
||||
'On-device transcription keeps your audio private.',
|
||||
'Thanks for listening, see you next week.',
|
||||
])
|
||||
|
||||
// One save in the pre-project-folder layout, so the mock keeps exercising back-compat.
|
||||
virtualFs.set(
|
||||
`${DOCUMENTS_FOLDER}/Vibe/legacy-interview-20260810-101500.vibe.json`,
|
||||
record('legacy-interview', ['This transcript was saved as a flat file by an older build.', 'It still opens, renames and deletes in place.']),
|
||||
)
|
||||
|
||||
export const sampleSegments: { start: number; stop: number; text: string; speaker?: number }[] = [
|
||||
{ start: 0, stop: 240, text: 'Welcome to Vibe running in browser mock mode.' },
|
||||
{ start: 240, stop: 520, text: 'This transcript is generated by the mock Tauri layer.' },
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { AlertCircle, Check, CircleSlash, X } from 'lucide-react'
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import { Spinner } from '~/components/ui/spinner'
|
||||
import { cn } from '~/lib/style'
|
||||
import type { Job } from '../hooks/use-transcribe-queue'
|
||||
import { useSession } from '../session'
|
||||
|
||||
function StatusIcon({ job }: { job: Job }) {
|
||||
switch (job.status) {
|
||||
case 'running':
|
||||
return <Spinner className="h-3.5 w-3.5 text-foreground" />
|
||||
case 'done':
|
||||
return <Check className="h-3.5 w-3.5 text-success" strokeWidth={2.4} />
|
||||
case 'error':
|
||||
return <AlertCircle className="h-3.5 w-3.5 text-destructive" />
|
||||
case 'cancelled':
|
||||
return <CircleSlash className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
default:
|
||||
return <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/50" />
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(job: Job) {
|
||||
switch (job.status) {
|
||||
case 'running':
|
||||
return `${Math.round(job.progress)}%`
|
||||
case 'done':
|
||||
return job.seconds != null ? m.transcribeTook({ total: String(job.seconds) }) : m.transcribed()
|
||||
case 'error':
|
||||
return job.error ?? m.error()
|
||||
case 'cancelled':
|
||||
return m.cancel()
|
||||
default:
|
||||
return m.loading()
|
||||
}
|
||||
}
|
||||
|
||||
export default function FileQueue() {
|
||||
const { queue } = useSession()
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex items-center justify-between px-4 pt-4 pb-3">
|
||||
<p className="text-[11px] font-medium tracking-[0.08em] text-muted-foreground uppercase">
|
||||
{m.files()} · {queue.jobs.length}
|
||||
</p>
|
||||
{queue.running && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={queue.cancelAll}
|
||||
disabled={queue.isAborting}
|
||||
className="cursor-pointer text-[11px] font-medium tracking-[0.08em] text-muted-foreground uppercase transition-colors duration-150 hover:text-destructive disabled:opacity-50">
|
||||
{queue.isAborting ? m.aborting() : m.cancel()}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
|
||||
{queue.jobs.map((job) => {
|
||||
const selected = job.id === queue.selectedId
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={job.id}
|
||||
onClick={() => queue.selectJob(job.id)}
|
||||
className={cn(
|
||||
'group relative block w-full cursor-pointer rounded-xl px-3 py-2.5 text-start transition-colors duration-150',
|
||||
selected ? 'bg-muted' : 'hover:bg-muted/60',
|
||||
)}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<StatusIcon job={job} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">{job.name}</span>
|
||||
{job.status === 'running' && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
queue.cancelCurrent()
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') queue.cancelCurrent()
|
||||
}}
|
||||
className="cursor-pointer text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100 hover:text-destructive">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className={cn('mt-1 ps-6 truncate text-[11px]', job.status === 'error' ? 'text-destructive' : 'text-muted-foreground')}>
|
||||
{statusLabel(job)}
|
||||
</p>
|
||||
|
||||
{job.status === 'running' && (
|
||||
<div className="mt-2 ms-6 h-[3px] overflow-hidden rounded-full bg-muted-foreground/15">
|
||||
<motion.div
|
||||
className="aurora-bar h-full rounded-full bg-primary/70"
|
||||
initial={false}
|
||||
animate={{ width: `${Math.max(2, job.progress)}%` }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { FolderOpen, Link2, Mic, Square, Upload } from 'lucide-react'
|
||||
import { siFacebook, siInstagram, siTiktok, siX, siYoutube } from 'simple-icons'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import AudioDeviceInput from '~/components/audio-device-input'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { Input } from '~/components/ui/input'
|
||||
import { Spinner } from '~/components/ui/spinner'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip'
|
||||
import { cn } from '~/lib/style'
|
||||
import { useSession, type IdlePanel } from '../session'
|
||||
import QuietRow from './quiet-row'
|
||||
|
||||
function formatElapsed(seconds: number) {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Live capture meter — five hairline bars driven by the backend's `record_level` event
|
||||
// (a 0..1 peak, throttled to ~10/s). Center bars react hardest so the shape reads as a voice.
|
||||
const BAR_WEIGHTS = [0.5, 0.78, 1, 0.78, 0.5]
|
||||
const BAR_MIN_HEIGHT = 6
|
||||
const BAR_MAX_HEIGHT = 18
|
||||
/** Below this the capture counts as silence. */
|
||||
const SILENCE_LEVEL = 0.03
|
||||
/** How long silence must hold before the meter drops to dim stubs. */
|
||||
const SILENCE_HOLD_MS = 1000
|
||||
const METER_TICK_MS = 60
|
||||
/** No event for this long means the stream stalled — let the target fall back to zero. */
|
||||
const LEVEL_STALE_MS = 160
|
||||
|
||||
function LevelMeter() {
|
||||
const [level, setLevel] = useState(0)
|
||||
const [silent, setSilent] = useState(false)
|
||||
// Refs keep the audio-rate values out of the render path; only the smoothed level is state.
|
||||
const targetRef = useRef(0)
|
||||
const lastEventRef = useRef(Date.now())
|
||||
const lastSoundRef = useRef(Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = listen<number>('record_level', ({ payload }) => {
|
||||
const next = typeof payload === 'number' && Number.isFinite(payload) ? Math.min(Math.max(payload, 0), 1) : 0
|
||||
targetRef.current = next
|
||||
lastEventRef.current = Date.now()
|
||||
if (next > SILENCE_LEVEL) lastSoundRef.current = Date.now()
|
||||
})
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
const now = Date.now()
|
||||
// A dead device emits nothing at all — decay so it can't freeze mid-bar.
|
||||
if (now - lastEventRef.current > LEVEL_STALE_MS) targetRef.current *= 0.6
|
||||
// Fast attack, slower release: peaks stay legible, the fall stays calm.
|
||||
setLevel((prev) => {
|
||||
const target = targetRef.current
|
||||
const eased = prev + (target - prev) * (target > prev ? 0.7 : 0.3)
|
||||
return Math.abs(eased - target) < 0.004 ? target : eased
|
||||
})
|
||||
setSilent(now - lastSoundRef.current > SILENCE_HOLD_MS)
|
||||
}, METER_TICK_MS)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
unlisten.then((fn) => fn())
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<span aria-hidden className={cn('flex h-[18px] items-center gap-[3px]', silent && 'opacity-40')}>
|
||||
{BAR_WEIGHTS.map((weight, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="w-[2px] rounded-full bg-foreground transition-[height] duration-75 ease-out"
|
||||
style={{ height: silent ? BAR_MIN_HEIGHT : BAR_MIN_HEIGHT + level * weight * (BAR_MAX_HEIGHT - BAR_MIN_HEIGHT) }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** One segment of the joined source switcher: icon-only keys, the active one reads as raised. */
|
||||
function Segment({ active, label, onClick, children }: { active?: boolean; label: string; onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex h-10 w-14 cursor-pointer items-center justify-center rounded-full transition-colors duration-150',
|
||||
active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||
)}>
|
||||
{children}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function RecordPanel() {
|
||||
const { recording, recordElapsed, preference } = useSession()
|
||||
|
||||
useEffect(() => {
|
||||
// The recording hook loads audio devices for this tab only.
|
||||
preference.setHomeTab('record')
|
||||
}, [])
|
||||
|
||||
if (recording.isRecording) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<LevelMeter />
|
||||
<span className="font-mono text-2xl tracking-tight tabular-nums">{formatElapsed(recordElapsed)}</span>
|
||||
</div>
|
||||
<Button onClick={() => recording.stopRecord()} className="h-10 w-full rounded-xl">
|
||||
<Square className="h-3.5 w-3.5 fill-current" />
|
||||
{m.stopAndTranscribe()}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
// Device labels become eyebrows and the triggers drop to h-10 so the panel stays a compact
|
||||
// tool row instead of a form. Restyled from here to keep AudioDeviceInput's API untouched.
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full flex-col gap-5 py-2.5',
|
||||
'[&_label]:text-[11px] [&_label]:font-medium [&_label]:tracking-[0.08em] [&_label]:text-muted-foreground [&_label]:uppercase',
|
||||
)}>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 [&>div]:space-y-1.5 [&_button]:h-10 [&_button]:rounded-xl">
|
||||
<AudioDeviceInput type="input" devices={recording.devices} device={recording.inputDevice} setDevice={recording.setInputDevice} />
|
||||
<AudioDeviceInput type="output" devices={recording.devices} device={recording.outputDevice} setDevice={recording.setOutputDevice} />
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => recording.startRecord()}
|
||||
disabled={!preference.modelPath || (!recording.inputDevice && !recording.outputDevice)}
|
||||
className="h-10 w-full rounded-xl disabled:opacity-40">
|
||||
<Mic className="h-4 w-4" />
|
||||
{m.startRecord()}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Bundled monochrome brand marks of the most-used yt-dlp sources — fills the panel with a
|
||||
// "works with" row instead of dead space. Rendered tiny and muted so it stays quiet.
|
||||
const linkSources: { title: string; path: string }[] = [
|
||||
{ title: siYoutube.title, path: siYoutube.path },
|
||||
{ title: siTiktok.title, path: siTiktok.path },
|
||||
{ title: siInstagram.title, path: siInstagram.path },
|
||||
{ title: siX.title, path: siX.path },
|
||||
{ title: siFacebook.title, path: siFacebook.path },
|
||||
]
|
||||
|
||||
function LinkPanel() {
|
||||
const { link, preference } = useSession()
|
||||
|
||||
if (link.downloadingAudio) {
|
||||
return (
|
||||
<div className="flex h-10 items-center justify-center gap-3 py-2.5 text-sm text-muted-foreground">
|
||||
<Spinner />
|
||||
<span>{m.downloading({ progress: String(link.ytdlpProgress ?? 0) })}</span>
|
||||
<button type="button" className="cursor-pointer text-destructive hover:underline" onClick={() => link.cancelYtDlpDownload()}>
|
||||
{m.cancel()}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-5 py-2.5">
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<Input
|
||||
type="text"
|
||||
value={link.audioUrl}
|
||||
onChange={(event) => link.setAudioUrl(event.target.value)}
|
||||
onKeyDown={(event) => (event.key === 'Enter' ? link.downloadAudio() : null)}
|
||||
// Short enough to stay fully readable when the window is narrow.
|
||||
placeholder="Paste a video or audio link"
|
||||
className="h-10 min-w-0 flex-1 rounded-xl px-3.5 text-sm"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => link.downloadAudio()}
|
||||
disabled={!preference.modelPath || !link.audioUrl}
|
||||
className="h-10 shrink-0 rounded-xl px-4 disabled:opacity-40">
|
||||
{m.transcribe()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-[11px] font-medium tracking-[0.08em] text-muted-foreground/80 uppercase">Works with</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-5 gap-y-3 text-muted-foreground/70">
|
||||
{linkSources.map((source) => (
|
||||
<Tooltip key={source.title}>
|
||||
<TooltipTrigger asChild>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-label={source.title}
|
||||
role="img"
|
||||
className="h-[18px] w-[18px] transition-colors duration-150 hover:text-foreground">
|
||||
<path d={source.path} fill="currentColor" />
|
||||
</svg>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{source.title}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
<span className="text-[12px] text-muted-foreground/70">+1000 more</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function IdleHero() {
|
||||
const { dragging, browse, collectingFolder, panel, setPanel, link, recording } = useSession()
|
||||
|
||||
function selectPanel(next: IdlePanel) {
|
||||
if (recording.isRecording || next === panel) return
|
||||
setPanel(next)
|
||||
if (next === 'link') void link.switchToLinkTab()
|
||||
}
|
||||
|
||||
return (
|
||||
// One optical column: pills, active source and quiet row all span max-w-xl with a 20px rhythm.
|
||||
<div className="mx-auto flex w-full max-w-xl flex-1 flex-col justify-center gap-5 px-6 py-10">
|
||||
{/* Joined source switcher: one control, three keys; the active source replaces the drop area. */}
|
||||
<div className="mx-auto inline-flex items-center gap-1 rounded-full border border-border bg-muted/60 p-1">
|
||||
<Segment active={panel === 'none'} label="File" onClick={() => selectPanel('none')}>
|
||||
<FolderOpen className="h-[18px] w-[18px]" />
|
||||
</Segment>
|
||||
<Segment active={panel === 'record'} label={m.record()} onClick={() => selectPanel('record')}>
|
||||
<Mic className="h-[18px] w-[18px]" />
|
||||
</Segment>
|
||||
<Segment active={panel === 'link'} label="From link" onClick={() => selectPanel('link')}>
|
||||
<Link2 className="h-[18px] w-[18px]" />
|
||||
</Segment>
|
||||
</div>
|
||||
|
||||
{/* Fixed-height slot sized to the drop zone so switching sources never moves the pills or the quiet row. */}
|
||||
<div className="flex min-h-[220px] w-full flex-col justify-center">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{panel === 'none' ? (
|
||||
<motion.button
|
||||
key="drop"
|
||||
type="button"
|
||||
onClick={() => void browse()}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className={cn(
|
||||
'group relative cursor-pointer overflow-hidden rounded-[1.25rem] border-2 border-dashed transition-colors duration-150',
|
||||
dragging ? 'border-ring' : 'border-border bg-muted/30 hover:border-ring/50 hover:bg-muted/50',
|
||||
)}>
|
||||
{/* The aurora is drag feedback only — at rest the zone stays a quiet surface. */}
|
||||
<div
|
||||
className={cn(
|
||||
'aurora pointer-events-none absolute inset-0 transition-opacity duration-200',
|
||||
dragging ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="relative flex flex-col items-center gap-3.5 px-8 py-12 text-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-background/70 text-foreground shadow-sm">
|
||||
{collectingFolder ? <Spinner className="h-5 w-5" /> : <Upload className="h-5 w-5" />}
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl font-semibold tracking-[-0.02em] text-foreground">Drop audio, video or a folder here</h1>
|
||||
<p className="text-[13px] text-muted-foreground">or click to browse your files</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.div
|
||||
key={panel}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
// Same footprint as the drop zone so the composition stays anchored.
|
||||
className="flex min-h-[220px] w-full flex-col justify-center rounded-[1.25rem] border border-border bg-muted/30 px-7">
|
||||
{panel === 'record' ? <RecordPanel /> : <LinkPanel />}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<QuietRow />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
|
||||
import * as dialog from '@tauri-apps/plugin-dialog'
|
||||
import * as fs from '@tauri-apps/plugin-fs'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Download, Pause, Play } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip'
|
||||
import { cn } from '~/lib/style'
|
||||
import type { NamedPath } from '~/lib/types'
|
||||
import type { Job } from '../hooks/use-transcribe-queue'
|
||||
|
||||
/**
|
||||
* Fired on `window` with `{ seconds }` to move this player, so anything on screen (a transcript
|
||||
* timestamp, for one) can seek without a handle on the audio element.
|
||||
*/
|
||||
export const PLAYER_SEEK_EVENT = 'vibe:player-seek'
|
||||
|
||||
/** mm:ss, or --:-- when the media has no usable duration (streams, failed metadata). */
|
||||
function formatTime(seconds: number) {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '--:--'
|
||||
const total = Math.floor(seconds)
|
||||
const minutes = Math.floor(total / 60)
|
||||
return `${minutes}:${String(total % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** Containing folder of a path, for both posix and windows separators. */
|
||||
function dirname(path: string) {
|
||||
const index = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||||
return index > 0 ? path.slice(0, index) : path
|
||||
}
|
||||
|
||||
function extension(path: string) {
|
||||
const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
|
||||
const dot = name.lastIndexOf('.')
|
||||
return dot > 0 ? name.slice(dot + 1) : ''
|
||||
}
|
||||
|
||||
export default function PlayerBar({ job }: { job: Job }) {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(Number.NaN)
|
||||
|
||||
const sourcePath = job.path
|
||||
const seekable = Number.isFinite(duration) && duration > 0
|
||||
|
||||
// One audio element per source: switching files stops playback and swaps the stream.
|
||||
useEffect(() => {
|
||||
const audio = new Audio(convertFileSrc(sourcePath))
|
||||
audio.preload = 'metadata'
|
||||
audioRef.current = audio
|
||||
setPlaying(false)
|
||||
setCurrentTime(0)
|
||||
setDuration(Number.NaN)
|
||||
|
||||
const onTime = () => setCurrentTime(audio.currentTime)
|
||||
const onMetadata = () => setDuration(audio.duration)
|
||||
const onPlay = () => setPlaying(true)
|
||||
const onPause = () => setPlaying(false)
|
||||
const onEnded = () => {
|
||||
setPlaying(false)
|
||||
setCurrentTime(0)
|
||||
}
|
||||
|
||||
audio.addEventListener('timeupdate', onTime)
|
||||
audio.addEventListener('loadedmetadata', onMetadata)
|
||||
audio.addEventListener('durationchange', onMetadata)
|
||||
audio.addEventListener('play', onPlay)
|
||||
audio.addEventListener('pause', onPause)
|
||||
audio.addEventListener('ended', onEnded)
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', onTime)
|
||||
audio.removeEventListener('loadedmetadata', onMetadata)
|
||||
audio.removeEventListener('durationchange', onMetadata)
|
||||
audio.removeEventListener('play', onPlay)
|
||||
audio.removeEventListener('pause', onPause)
|
||||
audio.removeEventListener('ended', onEnded)
|
||||
audio.pause()
|
||||
audio.src = ''
|
||||
if (audioRef.current === audio) audioRef.current = null
|
||||
}
|
||||
}, [sourcePath])
|
||||
|
||||
// `timeupdate` only fires ~4x/second which makes the fill step visibly; while playing, the
|
||||
// position is sampled every animation frame instead so the bar glides.
|
||||
useEffect(() => {
|
||||
if (!playing) return
|
||||
let frame = 0
|
||||
const tick = () => {
|
||||
const audio = audioRef.current
|
||||
if (audio) setCurrentTime(audio.currentTime)
|
||||
frame = requestAnimationFrame(tick)
|
||||
}
|
||||
frame = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [playing])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
if (audio.paused) void audio.play().catch(() => setPlaying(false))
|
||||
else audio.pause()
|
||||
}, [])
|
||||
|
||||
const seekTo = useCallback(
|
||||
(seconds: number) => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !seekable) return
|
||||
const next = Math.min(Math.max(seconds, 0), duration)
|
||||
audio.currentTime = next
|
||||
setCurrentTime(next)
|
||||
},
|
||||
[duration, seekable],
|
||||
)
|
||||
|
||||
// Seek requests from elsewhere in the window (transcript timestamps). Read straight off the
|
||||
// element so the handler never goes stale, and no-op while there is nothing to seek yet.
|
||||
useEffect(() => {
|
||||
const onSeekRequest = (event: Event) => {
|
||||
const seconds = (event as CustomEvent<{ seconds?: number }>).detail?.seconds
|
||||
const audio = audioRef.current
|
||||
if (!audio || typeof seconds !== 'number' || !Number.isFinite(seconds)) return
|
||||
const limit = Number.isFinite(audio.duration) && audio.duration > 0 ? audio.duration : Number.POSITIVE_INFINITY
|
||||
const next = Math.min(Math.max(seconds, 0), limit)
|
||||
try {
|
||||
audio.currentTime = next
|
||||
} catch {
|
||||
// The media has no seekable range yet; nothing to do.
|
||||
return
|
||||
}
|
||||
setCurrentTime(next)
|
||||
if (audio.paused) void audio.play().catch(() => setPlaying(false))
|
||||
}
|
||||
window.addEventListener(PLAYER_SEEK_EVENT, onSeekRequest)
|
||||
return () => window.removeEventListener(PLAYER_SEEK_EVENT, onSeekRequest)
|
||||
}, [])
|
||||
|
||||
const seekToClientX = useCallback(
|
||||
(clientX: number) => {
|
||||
const track = trackRef.current
|
||||
if (!track) return
|
||||
const rect = track.getBoundingClientRect()
|
||||
if (rect.width === 0) return
|
||||
const ratio = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1)
|
||||
seekTo(ratio * duration)
|
||||
},
|
||||
[duration, seekTo],
|
||||
)
|
||||
|
||||
async function revealInFolder() {
|
||||
try {
|
||||
await invoke('open_path', { path: dirname(sourcePath) })
|
||||
} catch {
|
||||
toast.error('Could not open the containing folder', { position: 'bottom-center' })
|
||||
}
|
||||
}
|
||||
|
||||
async function saveACopy() {
|
||||
const ext = extension(sourcePath)
|
||||
try {
|
||||
let defaultPath = sourcePath
|
||||
try {
|
||||
const suggested = await invoke<NamedPath>('get_save_path', { srcPath: sourcePath, targetExt: ext })
|
||||
defaultPath = suggested.path
|
||||
} catch {
|
||||
// Falling back to the source path as the dialog suggestion is fine.
|
||||
}
|
||||
const target = await dialog.save({
|
||||
filters: ext ? [{ name: '', extensions: [ext] }] : undefined,
|
||||
canCreateDirectories: true,
|
||||
defaultPath,
|
||||
})
|
||||
if (!target) return
|
||||
await fs.copyFile(sourcePath, target)
|
||||
toast.success(m.saveSuccess(), {
|
||||
description: job.name,
|
||||
position: 'bottom-center',
|
||||
action: { label: m.findHere(), onClick: () => void invoke('open_path', { path: dirname(target) }) },
|
||||
})
|
||||
} catch {
|
||||
toast.error('Saving a copy of the audio is not available here', { position: 'bottom-center' })
|
||||
}
|
||||
}
|
||||
|
||||
const progress = seekable ? Math.min(Math.max(currentTime / duration, 0), 1) : 0
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="sticky bottom-0 z-10 flex h-16 shrink-0 items-center gap-4 border-t border-border bg-background/85 px-4 backdrop-blur-md">
|
||||
<div className="hidden min-w-0 basis-[200px] flex-col justify-center sm:flex">
|
||||
<div className="truncate text-[13px] font-medium text-foreground">{job.name}</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void revealInFolder()}
|
||||
className="truncate text-start text-[12px] text-muted-foreground transition-colors duration-150 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/80">
|
||||
{sourcePath}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Show in folder</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={toggle}
|
||||
aria-label={playing ? 'Pause' : 'Play'}
|
||||
className="h-9 w-9 shrink-0 rounded-full bg-primary text-primary-foreground hover:bg-primary/90">
|
||||
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
<span className="w-10 shrink-0 text-end font-mono text-[11px] tabular-nums text-muted-foreground">{formatTime(currentTime)}</span>
|
||||
|
||||
<div
|
||||
ref={trackRef}
|
||||
role="slider"
|
||||
tabIndex={seekable ? 0 : -1}
|
||||
aria-label="Seek"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={seekable ? Math.floor(duration) : 0}
|
||||
aria-valuenow={Math.floor(currentTime)}
|
||||
aria-disabled={!seekable}
|
||||
onPointerDown={(event) => {
|
||||
if (!seekable) return
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
seekToClientX(event.clientX)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!seekable || !event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
seekToClientX(event.clientX)
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!seekable) return
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
seekTo(currentTime + 5)
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
seekTo(currentTime - 5)
|
||||
} else if (event.key === ' ') {
|
||||
event.preventDefault()
|
||||
toggle()
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'group relative flex h-6 min-w-0 flex-1 touch-none items-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/80',
|
||||
seekable ? 'cursor-pointer' : 'cursor-default opacity-60',
|
||||
)}>
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-foreground" style={{ width: `${progress * 100}%` }} />
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary opacity-0 shadow-sm transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
style={{ left: `${progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="w-10 shrink-0 font-mono text-[11px] tabular-nums text-muted-foreground">{formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="iconSm" onClick={() => void saveACopy()} aria-label={m.save()} className="shrink-0 rounded-full">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{m.save()}</TooltipContent>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ChevronRight, SlidersHorizontal } from 'lucide-react'
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import LanguageInput from '~/components/language-input'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '~/components/ui/popover'
|
||||
import { Switch } from '~/components/ui/switch'
|
||||
import { openSettingsSection } from '~/lib/app'
|
||||
import { useSession } from '../session'
|
||||
|
||||
function OptionRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<Switch checked={checked} onCheckedChange={onChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function QuietRow() {
|
||||
const { preference } = useSession()
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Language leads the row: it changes the output, so it gets the only bordered control here. */}
|
||||
<div className="w-auto max-w-[220px]">
|
||||
<LanguageInput variant="prominent" />
|
||||
</div>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-9 rounded-full px-3 text-[13px] font-medium text-muted-foreground hover:text-foreground">
|
||||
<SlidersHorizontal className="h-3.5 w-3.5" />
|
||||
{m.moreOptions()}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-72 rounded-2xl p-4">
|
||||
<p className="mb-2 text-[11px] font-medium tracking-[0.08em] text-muted-foreground uppercase">{m.modelOptions()}</p>
|
||||
<div className="divide-y divide-border">
|
||||
<OptionRow label={m.recognizeSpeakers()} checked={preference.diarizeEnabled} onChange={preference.setDiarizeEnabled} />
|
||||
<OptionRow label={m.stableTimestamps()} checked={preference.stableTimestampsEnabled} onChange={preference.setStableTimestampsEnabled} />
|
||||
<OptionRow
|
||||
label={m.translateToEnglish()}
|
||||
checked={preference.modelOptions.translate ?? false}
|
||||
onChange={(value) => preference.setModelOptions({ ...preference.modelOptions, translate: value })}
|
||||
/>
|
||||
<OptionRow
|
||||
label={m.useWordTimestamps()}
|
||||
checked={preference.modelOptions.word_timestamps ?? false}
|
||||
onChange={(value) => preference.setModelOptions({ ...preference.modelOptions, word_timestamps: value })}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="mt-3 w-full justify-between rounded-xl px-3" onClick={() => openSettingsSection('tuning')}>
|
||||
{m.fineTuning()}
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import * as pathApi from '@tauri-apps/api/path'
|
||||
import * as dialog from '@tauri-apps/plugin-dialog'
|
||||
import * as fs from '@tauri-apps/plugin-fs'
|
||||
import { Download, MoreHorizontal, Plus, Search, Settings } from 'lucide-react'
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '~/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip'
|
||||
import { cn } from '~/lib/style'
|
||||
import {
|
||||
deleteTranscript,
|
||||
listTranscripts,
|
||||
readTranscript,
|
||||
renameTranscript,
|
||||
resolveProjectAudio,
|
||||
TRANSCRIPTS_CHANGED_EVENT,
|
||||
type TranscriptEntry,
|
||||
} from '~/lib/transcripts-store'
|
||||
|
||||
import { openSettingsSection } from '~/lib/app'
|
||||
import { UpdaterContext } from '~/providers/updater'
|
||||
import { useSession } from '../session'
|
||||
|
||||
/** "just now" / "14m ago" / "3h ago" / "2d ago" / "Aug 19" / "Aug 19, 2024" */
|
||||
function relativeDate(date: Date) {
|
||||
const time = date.getTime()
|
||||
if (!time) return ''
|
||||
const minutes = Math.floor((Date.now() - time) / 60_000)
|
||||
if (minutes < 1) return 'just now'
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days}d ago`
|
||||
const sameYear = date.getFullYear() === new Date().getFullYear()
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', ...(sameYear ? {} : { year: 'numeric' }) })
|
||||
}
|
||||
|
||||
interface RowMenuState {
|
||||
/** media to re-transcribe: the original file, else the project folder's copy; null when neither */
|
||||
sourcePath: string | null
|
||||
sourceExists: boolean
|
||||
}
|
||||
|
||||
function RecentRow({
|
||||
entry,
|
||||
active,
|
||||
disabled,
|
||||
onOpen,
|
||||
onDeleted,
|
||||
onRenamed,
|
||||
}: {
|
||||
entry: TranscriptEntry
|
||||
active: boolean
|
||||
disabled: boolean
|
||||
onOpen: () => void
|
||||
onDeleted: () => void
|
||||
onRenamed: () => void
|
||||
}) {
|
||||
const { queue } = useSession()
|
||||
const [menu, setMenu] = useState<RowMenuState>({ sourcePath: null, sourceExists: false })
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
const [draftName, setDraftName] = useState(entry.name)
|
||||
|
||||
// The list is built from names only, so the media is located lazily — the first time the row's
|
||||
// menu opens — to decide whether "Re-transcribe" can do anything. The original file wins; the
|
||||
// project folder's copy keeps re-transcribing possible once the original is gone.
|
||||
const loadSource = useCallback(async () => {
|
||||
const record = await readTranscript(entry.path)
|
||||
if (!record) {
|
||||
setMenu({ sourcePath: null, sourceExists: false })
|
||||
return
|
||||
}
|
||||
let sourceExists = false
|
||||
try {
|
||||
sourceExists = !!record.sourcePath && (await fs.exists(record.sourcePath))
|
||||
} catch (error) {
|
||||
console.warn('failed to check source file:', error)
|
||||
}
|
||||
if (sourceExists) {
|
||||
setMenu({ sourcePath: record.sourcePath, sourceExists: true })
|
||||
return
|
||||
}
|
||||
const copy = await resolveProjectAudio(entry.path, record)
|
||||
setMenu({ sourcePath: copy ?? (record.sourcePath || null), sourceExists: !!copy })
|
||||
}, [entry.path])
|
||||
|
||||
async function reveal() {
|
||||
try {
|
||||
await invoke('open_path', { path: await pathApi.dirname(entry.path) })
|
||||
} catch (error) {
|
||||
console.warn('failed to reveal transcript:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
const confirmed = await dialog.ask(`Delete “${entry.name}”? The transcript and its copy of the audio will be removed.`, {
|
||||
title: 'Delete transcript',
|
||||
kind: 'warning',
|
||||
})
|
||||
if (!confirmed) return
|
||||
await deleteTranscript(entry.path)
|
||||
onDeleted()
|
||||
}
|
||||
|
||||
function retranscribe() {
|
||||
if (!menu.sourcePath || !menu.sourceExists) return
|
||||
queue.enqueue([{ name: entry.name, path: menu.sourcePath }])
|
||||
}
|
||||
|
||||
async function commitRename() {
|
||||
const next = draftName.trim()
|
||||
setRenaming(false)
|
||||
if (!next || next === entry.name) return
|
||||
if (await renameTranscript(entry.path, next)) onRenamed()
|
||||
}
|
||||
|
||||
if (renaming) {
|
||||
return (
|
||||
<div className="flex items-center rounded-xl bg-muted px-2 py-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={draftName}
|
||||
onChange={(event) => setDraftName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void commitRename()
|
||||
if (event.key === 'Escape') setRenaming(false)
|
||||
}}
|
||||
onBlur={() => void commitRename()}
|
||||
aria-label="Transcript name"
|
||||
className="h-7 w-full min-w-0 rounded-lg border border-ring/40 bg-background px-2 text-[13px] text-foreground outline-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('group relative flex items-center rounded-xl transition-colors duration-150', active ? 'bg-muted' : 'hover:bg-muted/60')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
disabled={disabled}
|
||||
title={entry.name}
|
||||
className="min-w-0 flex-1 cursor-pointer px-3 py-2 text-start disabled:cursor-default disabled:opacity-50">
|
||||
<p className="truncate text-[13px] font-medium text-foreground">{entry.name}</p>
|
||||
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{relativeDate(entry.createdAt)}</p>
|
||||
</button>
|
||||
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
if (open) void loadSource()
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Transcript actions"
|
||||
className={cn(
|
||||
'me-1.5 flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-opacity duration-150',
|
||||
'opacity-0 group-hover:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100 hover:text-foreground',
|
||||
)}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem disabled={disabled || !menu.sourceExists} onSelect={retranscribe}>
|
||||
Re-transcribe
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setDraftName(entry.name)
|
||||
setRenaming(true)
|
||||
}}>
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void reveal()}>{m.showInFolder()}</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive focus:text-destructive" onSelect={() => void remove()}>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RecentsSidebar() {
|
||||
const { queue, startNew } = useSession()
|
||||
const { updateApp, availableUpdate } = useContext(UpdaterContext)
|
||||
const [entries, setEntries] = useState<TranscriptEntry[]>([])
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
void listTranscripts().then(setEntries)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
// Saves happen inside the queue; deletes happen in other rows. Both announce themselves.
|
||||
window.addEventListener(TRANSCRIPTS_CHANGED_EVENT, refresh)
|
||||
return () => window.removeEventListener(TRANSCRIPTS_CHANGED_EVENT, refresh)
|
||||
}, [refresh])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase()
|
||||
if (!needle) return entries
|
||||
return entries.filter((entry) => entry.name.toLowerCase().includes(needle))
|
||||
}, [entries, query])
|
||||
|
||||
const activePath = queue.selectedJob?.savedPath ?? null
|
||||
|
||||
async function open(entry: TranscriptEntry) {
|
||||
const record = await readTranscript(entry.path)
|
||||
if (!record) return
|
||||
// Resolved here rather than in the queue so `hydrate` stays synchronous.
|
||||
queue.hydrate(record, entry.path, await resolveProjectAudio(entry.path, record))
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-[288px] shrink-0 flex-col border-e border-border">
|
||||
{/* Titlebar strip: hosts the toggle beside the macOS traffic lights (ChatGPT style). */}
|
||||
<div data-tauri-drag-region className="h-14 shrink-0" />
|
||||
{/* Codex-style: the wordmark gets its own row under the titlebar strip. */}
|
||||
<div className="px-4 pt-0.5 pb-2">
|
||||
<span className="select-none text-[17px] font-semibold tracking-[-0.03em] text-foreground">{m.appTitle()}</span>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={startNew}
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded-xl px-3 py-2 text-[13px] font-medium text-foreground transition-colors duration-150 hover:bg-muted/60">
|
||||
<Plus className="h-4 w-4 text-muted-foreground" strokeWidth={1.75} />
|
||||
New transcription
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{entries.length > 8 && (
|
||||
<div className="relative mt-2 px-3">
|
||||
<Search className="pointer-events-none absolute start-6 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search recents"
|
||||
className="h-8 w-full rounded-full border border-border bg-transparent ps-7 pe-3 text-[13px] text-foreground outline-none placeholder:text-muted-foreground focus:border-ring/40"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="px-4 pt-4 pb-2 text-[11px] font-medium tracking-[0.08em] text-muted-foreground uppercase">Recents</p>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-3 py-2 text-[13px] text-muted-foreground">{entries.length === 0 ? 'Transcripts you create appear here' : 'No matches'}</p>
|
||||
) : (
|
||||
filtered.map((entry) => (
|
||||
<RecentRow
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
active={entry.path === activePath}
|
||||
// Loading another transcript mid-run would fight the queue for the view.
|
||||
disabled={queue.running}
|
||||
onOpen={() => void open(entry)}
|
||||
onDeleted={refresh}
|
||||
onRenamed={refresh}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer, ChatGPT-style: Settings row with a small update badge when one is waiting. */}
|
||||
<div className="mt-auto border-t border-border/60 p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openSettingsSection('')}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-xl px-3 py-2 text-[13px] font-medium text-foreground transition-colors duration-150 hover:bg-muted/60">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" strokeWidth={1.75} />
|
||||
{m.settings()}
|
||||
</button>
|
||||
{availableUpdate && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.updateVersion()}
|
||||
onClick={updateApp}
|
||||
className="me-1 flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground transition-opacity duration-150 hover:opacity-90">
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{m.updateVersion()}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSession } from '../session'
|
||||
import FileQueue from './file-queue'
|
||||
import TranscriptToolbar from './transcript-toolbar'
|
||||
import TranscriptView from './transcript-view'
|
||||
|
||||
export default function SessionView() {
|
||||
const { queue } = useSession()
|
||||
const [query, setQuery] = useState('')
|
||||
const multiple = queue.jobs.length > 1
|
||||
const selected = queue.selectedJob
|
||||
|
||||
useEffect(() => {
|
||||
setQuery('')
|
||||
}, [queue.selectedId])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
className="flex min-h-0 flex-1">
|
||||
{multiple && (
|
||||
<aside className="hidden w-[264px] shrink-0 border-e border-border md:block">
|
||||
<FileQueue />
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<TranscriptToolbar job={selected} query={query} setQuery={setQuery} />
|
||||
<div className="min-h-0 flex-1">{selected && <TranscriptView job={selected} query={query} />}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Check, ChevronDown, Copy, Download, Plus, Search, X, AlignLeft, Braces, Captions, CodeXml, FileText, FileType } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '~/components/ui/dropdown-menu'
|
||||
import { cn } from '~/lib/style'
|
||||
import type { Job } from '../hooks/use-transcribe-queue'
|
||||
import { exportFormatLabels, exportFormats, useTranscriptExport } from '../hooks/use-transcript-export'
|
||||
|
||||
// One glyph per format so the save menu scans visually.
|
||||
const formatIcons: Record<string, typeof AlignLeft> = {
|
||||
normal: AlignLeft,
|
||||
srt: Captions,
|
||||
vtt: Captions,
|
||||
html: CodeXml,
|
||||
pdf: FileText,
|
||||
json: Braces,
|
||||
docx: FileType,
|
||||
}
|
||||
import { useSession } from '../session'
|
||||
|
||||
export default function TranscriptToolbar({ job, query, setQuery }: { job: Job | null; query: string; setQuery: (value: string) => void }) {
|
||||
const { queue, startNew } = useSession()
|
||||
const { copy, exportAs } = useTranscriptExport(job?.segments ?? [], job ? { name: job.name, path: job.path } : null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const resetRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resetRef.current) window.clearTimeout(resetRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (searching) searchRef.current?.focus()
|
||||
}, [searching])
|
||||
|
||||
async function onCopy() {
|
||||
await copy()
|
||||
setCopied(true)
|
||||
if (resetRef.current) window.clearTimeout(resetRef.current)
|
||||
resetRef.current = window.setTimeout(() => setCopied(false), 1200)
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
setSearching(false)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
const hasText = (job?.segments.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-1 border-b border-border bg-background/85 px-4 backdrop-blur-md">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{searching ? (
|
||||
<motion.div
|
||||
key="search"
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||
className="flex w-full items-center gap-2">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => event.key === 'Escape' && closeSearch()}
|
||||
placeholder="Search transcript"
|
||||
className="h-9 w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
/>
|
||||
<Button variant="ghost" size="iconSm" onClick={closeSearch} className="rounded-full">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="actions"
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||
className="flex w-full items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={onCopy} disabled={!hasText} className="rounded-full px-3 text-[13px] font-medium">
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? m.copied() : m.copy()}
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" disabled={!hasText} className="rounded-full px-3 text-[13px] font-medium">
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{m.save()}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[9rem] rounded-xl">
|
||||
{exportFormats.map((format) => {
|
||||
const Icon = formatIcons[format] ?? FileText
|
||||
return (
|
||||
<DropdownMenuItem key={format} onSelect={() => void exportAs(format)} className="gap-2.5 rounded-lg text-[13px]">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
{exportFormatLabels[format]}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSearching(true)}
|
||||
disabled={!hasText}
|
||||
className="rounded-full px-3 text-[13px] font-medium">
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
<div className="ms-auto flex items-center gap-1">
|
||||
{queue.running && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={queue.cancelAll}
|
||||
disabled={queue.isAborting}
|
||||
className={cn('rounded-full px-3 text-[13px] font-medium text-muted-foreground hover:text-destructive')}>
|
||||
{queue.isAborting ? m.aborting() : m.cancel()}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={startNew} className="rounded-full px-3 text-[13px] font-medium">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import HTMLView from '~/components/html-view'
|
||||
import { formatTimestamp, type Segment } from '~/lib/transcript'
|
||||
import { cn } from '~/lib/style'
|
||||
import { usePreferenceProvider } from '~/providers/preference'
|
||||
import { useSession } from '../session'
|
||||
import type { Job } from '../hooks/use-transcribe-queue'
|
||||
import { PLAYER_SEEK_EVENT } from './player-bar'
|
||||
|
||||
/** Segment timestamps are centiseconds (see `formatTimestamp` / `asJson` in lib/transcript). */
|
||||
const CENTISECONDS_PER_SECOND = 100
|
||||
|
||||
function Highlight({ text, query }: { text: string; query: string }) {
|
||||
if (!query) return <>{text}</>
|
||||
const parts = text.split(new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'ig'))
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) =>
|
||||
part.toLowerCase() === query.toLowerCase() ? (
|
||||
<mark key={index} className="rounded bg-primary/15 text-foreground">
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={index}>{part}</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Typography shared by the static text and the editor, so swapping one for the other is invisible. */
|
||||
const textClass = 'text-[15px] leading-[1.75] text-foreground'
|
||||
|
||||
interface SegmentBlockProps {
|
||||
segment: Segment
|
||||
/** index inside `job.segments` — search filtering means it is not the render index */
|
||||
index: number
|
||||
query: string
|
||||
animate: boolean
|
||||
editable: boolean
|
||||
editing: boolean
|
||||
onStartEdit: (index: number, indent: number) => void
|
||||
onCancel: () => void
|
||||
onCommit: (index: number, text: string) => void
|
||||
/** distance from the paragraph's inline start to where the text begins, measured on open */
|
||||
indent: number
|
||||
}
|
||||
|
||||
function SegmentBlock({ segment, index, query, animate, editable, editing, onStartEdit, onCancel, onCommit, indent }: SegmentBlockProps) {
|
||||
const paragraphRef = useRef<HTMLParagraphElement>(null)
|
||||
const markerRef = useRef<HTMLSpanElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
// Set once the edit ended, so the blur that follows a commit or a cancel does not commit again.
|
||||
const settledRef = useRef(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
|
||||
const text = segment.text.trim()
|
||||
|
||||
function beginEdit() {
|
||||
if (!editable || editing) return
|
||||
const paragraph = paragraphRef.current
|
||||
const marker = markerRef.current
|
||||
let measured = 0
|
||||
if (paragraph && marker) {
|
||||
const box = paragraph.getBoundingClientRect()
|
||||
const at = marker.getBoundingClientRect()
|
||||
const rtl = window.getComputedStyle(paragraph).direction === 'rtl'
|
||||
measured = Math.max(0, Math.round(rtl ? box.right - at.right : at.left - box.left))
|
||||
}
|
||||
settledRef.current = false
|
||||
setDraft(text)
|
||||
onStartEdit(index, measured)
|
||||
}
|
||||
|
||||
// Focus with the caret at the end, the moment the editor takes over.
|
||||
useEffect(() => {
|
||||
if (!editing) return
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
textarea.focus()
|
||||
const end = textarea.value.length
|
||||
textarea.setSelectionRange(end, end)
|
||||
return () => {
|
||||
// Closed from the outside (file switch, search): whatever blur follows must not commit.
|
||||
settledRef.current = true
|
||||
}
|
||||
}, [editing])
|
||||
|
||||
function commit() {
|
||||
if (settledRef.current) return
|
||||
settledRef.current = true
|
||||
const next = draft.trim()
|
||||
// An empty segment would be lost text, not an edit: fall back to what was there.
|
||||
if (next) onCommit(index, next)
|
||||
else onCancel()
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (settledRef.current) return
|
||||
settledRef.current = true
|
||||
onCancel()
|
||||
}
|
||||
|
||||
function seek() {
|
||||
window.dispatchEvent(new CustomEvent(PLAYER_SEEK_EVENT, { detail: { seconds: segment.start / CENTISECONDS_PER_SECOND } }))
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.p
|
||||
ref={paragraphRef}
|
||||
initial={animate ? { opacity: 0, y: 4 } : false}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||
// The whole line is the edit target — clicking near the text (not just on it) starts editing.
|
||||
onClick={editable && !editing ? beginEdit : undefined}
|
||||
className={cn('relative', textClass, editable && !editing && 'cursor-text rounded-sm transition-colors duration-150 hover:bg-muted/40')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
seek()
|
||||
}}
|
||||
aria-label={`Play from ${formatTimestamp(segment.start, false, '', false)}`}
|
||||
className="me-3 cursor-pointer font-mono text-[11px] tracking-tight text-muted-foreground tabular-nums underline-offset-4 transition-colors duration-150 select-none hover:text-foreground hover:underline focus-visible:rounded-sm focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/80">
|
||||
{formatTimestamp(segment.start, false, '', false)}
|
||||
</button>
|
||||
{segment.speaker != null && (
|
||||
<span className="me-2 text-[11px] font-semibold text-muted-foreground">
|
||||
{m.speakerPrefix()} {segment.speaker + 1}
|
||||
</span>
|
||||
)}
|
||||
{/* Zero-width anchor marking where the text starts, so the editor can indent its first line to match. */}
|
||||
<span ref={markerRef} aria-hidden className="inline-block w-0 align-baseline" />
|
||||
|
||||
{editing ? (
|
||||
<>
|
||||
{/* The draft, invisible: it keeps the paragraph exactly as tall as the text being typed. */}
|
||||
<span aria-hidden className="invisible whitespace-pre-wrap">
|
||||
{draft || ' '}
|
||||
</span>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
rows={1}
|
||||
spellCheck={false}
|
||||
aria-label="Edit line"
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
commit()
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
cancel()
|
||||
}
|
||||
}}
|
||||
style={{ textIndent: `${indent}px` }}
|
||||
className={cn(
|
||||
'absolute inset-0 m-0 resize-none overflow-hidden border-0 bg-transparent p-0 outline-none focus:outline-none focus:ring-0',
|
||||
textClass,
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : editable ? (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={beginEdit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
beginEdit()
|
||||
}
|
||||
}}
|
||||
className="cursor-text rounded-sm focus-visible:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/80">
|
||||
<Highlight text={text} query={query} />
|
||||
</span>
|
||||
) : (
|
||||
<Highlight text={text} query={query} />
|
||||
)}
|
||||
</motion.p>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TranscriptView({ job, query }: { job: Job; query: string }) {
|
||||
const preference = usePreferenceProvider()
|
||||
const { queue } = useSession()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const running = job.status === 'running'
|
||||
const editable = !running && (job.status === 'done' || job.hydrated === true)
|
||||
|
||||
const [editing, setEditing] = useState<{ index: number; indent: number } | null>(null)
|
||||
|
||||
// Filtering keeps the original index so an edit lands on the right segment.
|
||||
const visible = useMemo(() => {
|
||||
const all = job.segments.map((segment, index) => ({ segment, index }))
|
||||
if (!query) return all
|
||||
return all.filter(({ segment }) => segment.text.toLowerCase().includes(query.toLowerCase()))
|
||||
}, [job.segments, query])
|
||||
|
||||
// Switching file, searching or a new run must never leave an editor open over other text.
|
||||
useEffect(() => {
|
||||
setEditing(null)
|
||||
}, [job.id, query])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editable) setEditing(null)
|
||||
}, [editable])
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return
|
||||
const node = scrollRef.current
|
||||
if (node) node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' })
|
||||
}, [job.segments.length, running])
|
||||
|
||||
const startEdit = useCallback((index: number, indent: number) => setEditing({ index, indent }), [])
|
||||
const cancelEdit = useCallback(() => setEditing(null), [])
|
||||
const commitEdit = useCallback(
|
||||
(index: number, text: string) => {
|
||||
setEditing(null)
|
||||
queue.updateSegmentText(job.id, index, text)
|
||||
},
|
||||
[job.id, queue],
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className="h-full min-h-0 overflow-y-auto">
|
||||
<div dir={preference.textAreaDirection} className="mx-auto w-full max-w-[68ch] px-8 py-10">
|
||||
<p className="mb-8 truncate text-[11px] font-medium tracking-[0.08em] text-muted-foreground uppercase">{job.name}</p>
|
||||
|
||||
{job.status === 'error' && <p className="mb-8 text-sm text-destructive">{job.error}</p>}
|
||||
|
||||
{visible.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{query ? 'No matching lines.' : running ? m.transcriptWillDisplayedShortly() : job.status === 'queued' ? m.loading() : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{visible.map(({ segment, index }) => (
|
||||
<SegmentBlock
|
||||
key={`${segment.start}-${index}`}
|
||||
segment={segment}
|
||||
index={index}
|
||||
query={query}
|
||||
animate={running}
|
||||
editable={editable}
|
||||
editing={editing?.index === index}
|
||||
indent={editing?.index === index ? editing.indent : 0}
|
||||
onStartEdit={startEdit}
|
||||
onCancel={cancelEdit}
|
||||
onCommit={commitEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{running && <div className={cn('mt-8 h-4 w-24 animate-pulse rounded-full bg-muted')} />}
|
||||
</div>
|
||||
|
||||
{/* Offscreen source for the html / pdf exports (print CSS pulls `.html` back on-page). */}
|
||||
{job.segments.length > 0 && (
|
||||
<div aria-hidden className="pointer-events-none fixed top-0 -left-[10000px] w-[1000px]">
|
||||
<HTMLView preference={preference} segments={job.segments} file={{ name: job.name, path: job.path }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
/**
|
||||
* Drag & drop for the whole window.
|
||||
*
|
||||
* The webview delivers real filesystem paths through `tauri://drag-drop`. In browser mock mode
|
||||
* those events never fire and the HTML5 `File` objects carry no path we could transcribe, so a
|
||||
* browser drop only explains that the mock dialog (Browse) is the way in.
|
||||
*/
|
||||
export function useDropTarget(onPaths: (paths: string[]) => void) {
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const onPathsRef = useRef(onPaths)
|
||||
const browserDepth = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
onPathsRef.current = onPaths
|
||||
}, [onPaths])
|
||||
|
||||
useEffect(() => {
|
||||
const unlisteners: Promise<UnlistenFn>[] = []
|
||||
unlisteners.push(listen('tauri://drag-enter', () => setDragging(true)))
|
||||
unlisteners.push(listen('tauri://drag-leave', () => setDragging(false)))
|
||||
unlisteners.push(
|
||||
listen<{ paths?: string[] }>('tauri://drag-drop', ({ payload }) => {
|
||||
setDragging(false)
|
||||
if (payload.paths?.length) onPathsRef.current(payload.paths)
|
||||
}),
|
||||
)
|
||||
|
||||
function onDragEnter(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
browserDepth.current += 1
|
||||
setDragging(true)
|
||||
}
|
||||
function onDragOver(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
}
|
||||
function onDragLeave(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
browserDepth.current = Math.max(0, browserDepth.current - 1)
|
||||
if (browserDepth.current === 0) setDragging(false)
|
||||
}
|
||||
function onDrop(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
browserDepth.current = 0
|
||||
setDragging(false)
|
||||
const files = Array.from(event.dataTransfer?.files ?? [])
|
||||
// Only reachable in a plain browser — the webview swallows HTML drops.
|
||||
if (files.length > 0) {
|
||||
toast.info(`Browser preview can't read “${files[0].name}” from disk. Use Browse to pick a file.`, { position: 'bottom-center' })
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('dragenter', onDragEnter)
|
||||
window.addEventListener('dragover', onDragOver)
|
||||
window.addEventListener('dragleave', onDragLeave)
|
||||
window.addEventListener('drop', onDrop)
|
||||
|
||||
return () => {
|
||||
unlisteners.forEach((promise) => promise.then((unlisten) => unlisten()))
|
||||
window.removeEventListener('dragenter', onDragEnter)
|
||||
window.removeEventListener('dragover', onDragOver)
|
||||
window.removeEventListener('dragleave', onDragLeave)
|
||||
window.removeEventListener('drop', onDrop)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return dragging
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import * as webview from '@tauri-apps/api/webviewWindow'
|
||||
import * as dialog from '@tauri-apps/plugin-dialog'
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import successSound from '~/assets/success.mp3'
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import { analyticsEvents, trackAnalyticsEvent } from '~/lib/analytics'
|
||||
import * as config from '~/lib/config'
|
||||
import { startKeepAwake, stopKeepAwake } from '~/lib/keep-awake'
|
||||
import { validPath } from '~/lib/media'
|
||||
import { isUserError } from '~/lib/sona-errors'
|
||||
import type { Segment, Transcript } from '~/lib/transcript'
|
||||
import { notifyTranscriptsChanged, saveTranscript, updateTranscriptSegments, type TranscriptRecord } from '~/lib/transcripts-store'
|
||||
import type { NamedPath } from '~/lib/types'
|
||||
import { ErrorModalContext } from '~/providers/error-modal'
|
||||
import { type Preference, usePreferenceProvider } from '~/providers/preference'
|
||||
|
||||
export type JobStatus = 'queued' | 'running' | 'done' | 'error' | 'cancelled'
|
||||
|
||||
export interface Job {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
status: JobStatus
|
||||
/** 0..100, only meaningful while running */
|
||||
progress: number
|
||||
segments: Segment[]
|
||||
error?: string
|
||||
/** wall clock seconds the transcription took */
|
||||
seconds?: number
|
||||
/** path of the .vibe.json this job was saved to / loaded from, when persisted */
|
||||
savedPath?: string
|
||||
/** true when the job was loaded from the store rather than transcribed in this session */
|
||||
hydrated?: boolean
|
||||
}
|
||||
|
||||
export interface TranscribeQueue {
|
||||
jobs: Job[]
|
||||
activeId: string | null
|
||||
selectedId: string | null
|
||||
selectedJob: Job | null
|
||||
running: boolean
|
||||
isAborting: boolean
|
||||
hasResults: boolean
|
||||
selectJob: (id: string) => void
|
||||
enqueue: (files: NamedPath[]) => void
|
||||
/**
|
||||
* Replace the session with a transcript loaded from the store, shown as a finished job.
|
||||
* `audioPath` is the media copy kept in the project folder, when it still has one; it becomes the
|
||||
* job's path so the player keeps working after the original file moved away.
|
||||
*/
|
||||
hydrate: (record: TranscriptRecord, savedPath: string, audioPath?: string | null) => void
|
||||
/** Inline edit of one segment's text. Persists to the job's saved file when it has one. */
|
||||
updateSegmentText: (jobId: string, segmentIndex: number, text: string) => void
|
||||
cancelCurrent: () => void
|
||||
cancelAll: () => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
let jobCounter = 0
|
||||
function nextJobId() {
|
||||
jobCounter += 1
|
||||
return `job-${jobCounter}`
|
||||
}
|
||||
|
||||
/** Options shared by every file of a run (diarize / vad model paths). */
|
||||
async function buildSharedOptions(preference: Preference) {
|
||||
const requiresVad = preference.modelMetadata?.capabilities.requires_vad ?? false
|
||||
const needsFolder = preference.diarizeEnabled || preference.stableTimestampsEnabled || requiresVad
|
||||
const modelsFolder = needsFolder ? await invoke<string>('get_models_folder') : null
|
||||
return {
|
||||
...(preference.diarizeEnabled ? { diarize_model: `${modelsFolder}/${config.diarizeModelFilename}` } : {}),
|
||||
...(preference.stableTimestampsEnabled || requiresVad ? { vad_model: `${modelsFolder}/${config.vadModelFilename}` } : {}),
|
||||
...(preference.stableTimestampsEnabled ? { stable_timestamps: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function errorParts(error: unknown) {
|
||||
const object = typeof error === 'object' && error !== null ? (error as { code?: string; message?: string }) : null
|
||||
return { code: object?.code, message: object?.message || String(error) }
|
||||
}
|
||||
|
||||
/**
|
||||
* One sequential transcription queue: the model is loaded once, then every queued file runs
|
||||
* in order. Segments of the currently running file stream in live through `new_segment`.
|
||||
*/
|
||||
export function useTranscribeQueue(): TranscribeQueue {
|
||||
const preference = usePreferenceProvider()
|
||||
const preferenceRef = useRef(preference)
|
||||
const { setState: setErrorModal } = useContext(ErrorModalContext)
|
||||
|
||||
const [jobs, setJobs] = useState<Job[]>([])
|
||||
const jobsRef = useRef<Job[]>([])
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
const activeIdRef = useRef<string | null>(null)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const selectedIdRef = useRef<string | null>(null)
|
||||
const pinnedRef = useRef(false)
|
||||
const [running, setRunning] = useState(false)
|
||||
const runningRef = useRef(false)
|
||||
const [isAborting, setIsAborting] = useState(false)
|
||||
const abortCurrentRef = useRef(false)
|
||||
const abortAllRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
preferenceRef.current = preference
|
||||
}, [preference])
|
||||
|
||||
const commit = useCallback((next: Job[]) => {
|
||||
jobsRef.current = next
|
||||
setJobs(next)
|
||||
}, [])
|
||||
|
||||
const patch = useCallback(
|
||||
(id: string, changes: Partial<Job>) => {
|
||||
commit(jobsRef.current.map((job) => (job.id === id ? { ...job, ...changes } : job)))
|
||||
},
|
||||
[commit],
|
||||
)
|
||||
|
||||
const select = useCallback((id: string | null) => {
|
||||
selectedIdRef.current = id
|
||||
setSelectedId(id)
|
||||
}, [])
|
||||
|
||||
const selectJob = useCallback(
|
||||
(id: string) => {
|
||||
pinnedRef.current = true
|
||||
select(id)
|
||||
},
|
||||
[select],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const unlisteners: Promise<UnlistenFn>[] = []
|
||||
|
||||
unlisteners.push(
|
||||
listen<number>('transcribe_progress', ({ payload }) => {
|
||||
const id = activeIdRef.current
|
||||
if (id && payload >= 0 && payload <= 100) patch(id, { progress: payload })
|
||||
}),
|
||||
)
|
||||
unlisteners.push(
|
||||
listen<Segment>('new_segment', ({ payload }) => {
|
||||
const id = activeIdRef.current
|
||||
if (!id) return
|
||||
commit(jobsRef.current.map((job) => (job.id === id ? { ...job, segments: [...job.segments, payload] } : job)))
|
||||
}),
|
||||
)
|
||||
|
||||
return () => {
|
||||
unlisteners.forEach((promise) => promise.then((unlisten) => unlisten()))
|
||||
}
|
||||
}, [commit, patch])
|
||||
|
||||
const failPending = useCallback(
|
||||
(message: string) => {
|
||||
commit(jobsRef.current.map((job) => (job.status === 'queued' || job.status === 'running' ? { ...job, status: 'error', error: message } : job)))
|
||||
},
|
||||
[commit],
|
||||
)
|
||||
|
||||
/**
|
||||
* Auto-save a finished job into the transcripts store. Fire-and-forget on purpose: persistence
|
||||
* must never delay or break the queue, so failures only surface in the console.
|
||||
*/
|
||||
const persist = useCallback(
|
||||
(job: Job, segments: Segment[]) => {
|
||||
const current = preferenceRef.current
|
||||
if (!current.saveTranscripts || segments.length === 0) return
|
||||
void saveTranscript({
|
||||
name: job.name,
|
||||
sourcePath: job.path,
|
||||
segments,
|
||||
language: current.modelOptions.lang,
|
||||
modelPath: current.modelPath,
|
||||
}).then((savedPath) => {
|
||||
if (!savedPath) return
|
||||
patch(job.id, { savedPath })
|
||||
notifyTranscriptsChanged()
|
||||
})
|
||||
},
|
||||
[patch],
|
||||
)
|
||||
|
||||
const runLoop = useCallback(async () => {
|
||||
if (runningRef.current) return
|
||||
runningRef.current = true
|
||||
setRunning(true)
|
||||
abortAllRef.current = false
|
||||
setIsAborting(false)
|
||||
startKeepAwake()
|
||||
let completedAny = false
|
||||
|
||||
try {
|
||||
const avx2 = await invoke<boolean>('is_avx2_enabled')
|
||||
if (!avx2) {
|
||||
trackAnalyticsEvent(analyticsEvents.AVX2_NOT_SUPPORTED)
|
||||
await dialog.message(m.avx2NotSupported(), { kind: 'error' })
|
||||
failPending(m.avx2NotSupported())
|
||||
return
|
||||
}
|
||||
|
||||
const current = preferenceRef.current
|
||||
if (!current.modelPath) {
|
||||
failPending(m.noModelSelected())
|
||||
toast.error(m.noModelSelected(), { position: 'bottom-center' })
|
||||
return
|
||||
}
|
||||
|
||||
let shared: Record<string, unknown>
|
||||
try {
|
||||
const loadResult = await invoke<string>('load_model', {
|
||||
modelPath: current.modelPath,
|
||||
gpuDevice: current.gpuDevice,
|
||||
unloadTimeoutMinutes: current.unloadTimeoutMinutes,
|
||||
})
|
||||
if (loadResult === 'gpu_fallback') toast.warning(m.gpuFallbackToCpu(), { position: 'bottom-center', duration: 8000 })
|
||||
shared = await buildSharedOptions(current)
|
||||
} catch (error) {
|
||||
const { message } = errorParts(error)
|
||||
failPending(message)
|
||||
setErrorModal?.({ log: message, open: true })
|
||||
return
|
||||
}
|
||||
|
||||
while (!abortAllRef.current) {
|
||||
const next = jobsRef.current.find((job) => job.status === 'queued')
|
||||
if (!next) break
|
||||
|
||||
activeIdRef.current = next.id
|
||||
setActiveId(next.id)
|
||||
if (!pinnedRef.current) select(next.id)
|
||||
patch(next.id, { status: 'running', progress: 0, segments: [], error: undefined })
|
||||
abortCurrentRef.current = false
|
||||
|
||||
const startedAt = performance.now()
|
||||
trackAnalyticsEvent(analyticsEvents.TRANSCRIBE_STARTED, { source: 'main' })
|
||||
try {
|
||||
const result = await invoke<Transcript>('transcribe', {
|
||||
options: { path: next.path, ...preferenceRef.current.modelOptions, ...shared },
|
||||
})
|
||||
const seconds = Math.round((performance.now() - startedAt) / 1000)
|
||||
patch(next.id, { status: 'done', progress: 100, segments: result.segments, seconds })
|
||||
completedAny = true
|
||||
persist(next, result.segments)
|
||||
trackAnalyticsEvent(analyticsEvents.TRANSCRIBE_SUCCEEDED, {
|
||||
source: 'main',
|
||||
duration_seconds: seconds,
|
||||
segments_count: result.segments.length,
|
||||
})
|
||||
} catch (error) {
|
||||
if (abortCurrentRef.current || abortAllRef.current) {
|
||||
patch(next.id, { status: 'cancelled', progress: 0 })
|
||||
} else {
|
||||
const { code, message } = errorParts(error)
|
||||
patch(next.id, { status: 'error', progress: 0, error: message })
|
||||
if (code && isUserError(code)) {
|
||||
toast.error(`${m.error()}: ${message}`, { position: 'bottom-center' })
|
||||
} else {
|
||||
trackAnalyticsEvent(analyticsEvents.TRANSCRIBE_FAILED, {
|
||||
source: 'main',
|
||||
error_message: message,
|
||||
file_ext: next.path.split('.').pop() ?? 'unknown',
|
||||
})
|
||||
setErrorModal?.({ log: message, open: true })
|
||||
// Every following file would fail the same way.
|
||||
if (message.includes('no model loaded')) {
|
||||
toast.error(m.noModelLoadedBatchStopped(), { position: 'bottom-center' })
|
||||
failPending(message)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
abortCurrentRef.current = false
|
||||
setIsAborting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (abortAllRef.current) {
|
||||
commit(jobsRef.current.map((job) => (job.status === 'queued' ? { ...job, status: 'cancelled' } : job)))
|
||||
}
|
||||
} finally {
|
||||
stopKeepAwake()
|
||||
activeIdRef.current = null
|
||||
setActiveId(null)
|
||||
runningRef.current = false
|
||||
setRunning(false)
|
||||
setIsAborting(false)
|
||||
|
||||
if (completedAny) {
|
||||
const finished = preferenceRef.current
|
||||
if (finished.soundOnFinish) new Audio(successSound).play()
|
||||
if (finished.focusOnFinish) {
|
||||
webview.getCurrentWebviewWindow().unminimize()
|
||||
webview.getCurrentWebviewWindow().setFocus()
|
||||
}
|
||||
}
|
||||
|
||||
// A file enqueued while the loop was winding down would otherwise stay queued forever.
|
||||
if (!abortAllRef.current && jobsRef.current.some((job) => job.status === 'queued')) void runLoop()
|
||||
}
|
||||
}, [commit, failPending, patch, persist, select, setErrorModal])
|
||||
|
||||
const enqueue = useCallback(
|
||||
(files: NamedPath[]) => {
|
||||
const accepted = files.filter((file) => validPath(file.path.toLowerCase()))
|
||||
if (accepted.length === 0) {
|
||||
if (files.length > 0) toast.error(m.supportsFormats(), { position: 'bottom-center' })
|
||||
return
|
||||
}
|
||||
|
||||
const created: Job[] = accepted.map((file) => ({
|
||||
id: nextJobId(),
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
status: 'queued',
|
||||
progress: 0,
|
||||
segments: [],
|
||||
}))
|
||||
commit([...jobsRef.current, ...created])
|
||||
if (!selectedIdRef.current) select(created[0].id)
|
||||
void runLoop()
|
||||
},
|
||||
[commit, runLoop, select],
|
||||
)
|
||||
|
||||
/** Load a saved transcript as the whole session: one finished job the done view can render. */
|
||||
const hydrate = useCallback(
|
||||
(record: TranscriptRecord, savedPath: string, audioPath?: string | null) => {
|
||||
if (runningRef.current) return
|
||||
const job: Job = {
|
||||
id: nextJobId(),
|
||||
name: record.name,
|
||||
// The project folder's own copy of the media, else the original path it came from.
|
||||
path: audioPath || record.sourcePath,
|
||||
status: 'done',
|
||||
progress: 100,
|
||||
segments: record.segments,
|
||||
savedPath,
|
||||
hydrated: true,
|
||||
}
|
||||
pinnedRef.current = true
|
||||
commit([job])
|
||||
select(job.id)
|
||||
},
|
||||
[commit, select],
|
||||
)
|
||||
|
||||
/**
|
||||
* Replace the text of one segment. The write back to the store is fire-and-forget for the same
|
||||
* reason `persist` is: an unwritable file must not cost the user their edit on screen.
|
||||
*/
|
||||
const updateSegmentText = useCallback(
|
||||
(jobId: string, segmentIndex: number, text: string) => {
|
||||
const job = jobsRef.current.find((candidate) => candidate.id === jobId)
|
||||
const segment = job?.segments[segmentIndex]
|
||||
if (!job || !segment || segment.text === text) return
|
||||
const segments = job.segments.map((item, index) => (index === segmentIndex ? { ...item, text } : item))
|
||||
commit(jobsRef.current.map((candidate) => (candidate.id === jobId ? { ...candidate, segments } : candidate)))
|
||||
if (job.savedPath) void updateTranscriptSegments(job.savedPath, segments)
|
||||
},
|
||||
[commit],
|
||||
)
|
||||
|
||||
const cancelCurrent = useCallback(() => {
|
||||
if (!activeIdRef.current) return
|
||||
abortCurrentRef.current = true
|
||||
setIsAborting(true)
|
||||
emit('abort_transcribe')
|
||||
}, [])
|
||||
|
||||
const cancelAll = useCallback(() => {
|
||||
abortAllRef.current = true
|
||||
abortCurrentRef.current = true
|
||||
setIsAborting(true)
|
||||
emit('abort_transcribe')
|
||||
commit(jobsRef.current.map((job) => (job.status === 'queued' ? { ...job, status: 'cancelled' } : job)))
|
||||
}, [commit])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
if (runningRef.current) cancelAll()
|
||||
pinnedRef.current = false
|
||||
commit([])
|
||||
select(null)
|
||||
}, [cancelAll, commit, select])
|
||||
|
||||
const selectedJob = jobs.find((job) => job.id === selectedId) ?? null
|
||||
const hasResults = jobs.some((job) => job.segments.length > 0)
|
||||
|
||||
return {
|
||||
jobs,
|
||||
activeId,
|
||||
selectedId,
|
||||
selectedJob,
|
||||
running,
|
||||
isAborting,
|
||||
hasResults,
|
||||
selectJob,
|
||||
enqueue,
|
||||
hydrate,
|
||||
updateSegmentText,
|
||||
cancelCurrent,
|
||||
cancelAll,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { path as pathApi } from '@tauri-apps/api'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import * as clipboard from '@tauri-apps/plugin-clipboard-manager'
|
||||
import * as dialog from '@tauri-apps/plugin-dialog'
|
||||
import * as fs from '@tauri-apps/plugin-fs'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import { formatExtensions, type TextFormat } from '~/components/format-select'
|
||||
import { openPath } from '~/lib/app'
|
||||
import { toDocx } from '~/lib/docx'
|
||||
import { asCsv, asJson, asSrt, asText, asVtt, type Segment } from '~/lib/transcript'
|
||||
import type { NamedPath } from '~/lib/types'
|
||||
import { usePreferenceProvider } from '~/providers/preference'
|
||||
|
||||
/** Formats offered by the Export dropdown, in menu order. */
|
||||
export const exportFormats: TextFormat[] = ['normal', 'srt', 'vtt', 'html', 'pdf', 'json', 'docx']
|
||||
|
||||
export const exportFormatLabels: Record<TextFormat, string> = {
|
||||
normal: 'txt',
|
||||
srt: 'srt',
|
||||
vtt: 'vtt',
|
||||
html: 'html',
|
||||
pdf: 'pdf',
|
||||
json: 'json',
|
||||
docx: 'docx',
|
||||
csv: 'csv',
|
||||
md: 'md',
|
||||
}
|
||||
|
||||
export function useTranscriptExport(segments: Segment[], file: NamedPath | null) {
|
||||
const preference = usePreferenceProvider()
|
||||
const speakerLabel = m.speakerPrefix()
|
||||
|
||||
const plainText = useMemo(() => asText(segments, speakerLabel), [segments, speakerLabel])
|
||||
|
||||
const asFormat = useCallback(
|
||||
(format: TextFormat) => {
|
||||
switch (format) {
|
||||
case 'srt':
|
||||
return asSrt(segments, speakerLabel)
|
||||
case 'vtt':
|
||||
return asVtt(segments, speakerLabel)
|
||||
case 'json':
|
||||
return asJson(segments)
|
||||
case 'csv':
|
||||
return asCsv(segments)
|
||||
case 'html':
|
||||
// The reading view keeps an offscreen HTMLView mounted for exactly this.
|
||||
return document.querySelector('.html')?.outerHTML.replace('contenteditable="true"', 'contenteditable="false"') ?? plainText
|
||||
default:
|
||||
return plainText
|
||||
}
|
||||
},
|
||||
[plainText, segments, speakerLabel],
|
||||
)
|
||||
|
||||
const copy = useCallback(async () => {
|
||||
await clipboard.writeText(plainText)
|
||||
}, [plainText])
|
||||
|
||||
const exportAs = useCallback(
|
||||
async (format: TextFormat) => {
|
||||
if (!file || segments.length === 0) return
|
||||
if (format === 'pdf') {
|
||||
window.print()
|
||||
return
|
||||
}
|
||||
|
||||
const extension = formatExtensions[format].slice(1)
|
||||
const suggested = await invoke<NamedPath>('get_save_path', { srcPath: file.path, targetExt: extension })
|
||||
const target = await dialog.save({
|
||||
filters: [{ name: '', extensions: [extension] }],
|
||||
canCreateDirectories: true,
|
||||
defaultPath: suggested.path,
|
||||
})
|
||||
if (!target) return
|
||||
|
||||
if (format === 'docx') {
|
||||
const title = await pathApi.basename(target)
|
||||
const document = await toDocx(title, segments, preference.textAreaDirection, speakerLabel)
|
||||
await fs.writeFile(target, new Uint8Array(await document.arrayBuffer()))
|
||||
} else {
|
||||
await fs.writeTextFile(target, asFormat(format))
|
||||
}
|
||||
|
||||
toast.success(m.saveSuccess(), {
|
||||
description: suggested.name,
|
||||
position: 'bottom-center',
|
||||
action: { label: m.findHere(), onClick: () => openPath({ name: '', path: target }) },
|
||||
})
|
||||
},
|
||||
[asFormat, file, preference.textAreaDirection, segments, speakerLabel],
|
||||
)
|
||||
|
||||
return { plainText, copy, exportAs }
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Layout, { SIDEBAR_STORAGE_KEY, TOGGLE_SIDEBAR_EVENT } from '~/components/layout'
|
||||
import IdleHero from './components/idle-hero'
|
||||
import PlayerBar from './components/player-bar'
|
||||
import RecentsSidebar from './components/recents-sidebar'
|
||||
import SessionView from './components/session-view'
|
||||
import { SessionProvider, useSession } from './session'
|
||||
|
||||
/** Collapsed = hidden entirely; the state lives in localStorage so it survives a reload. */
|
||||
function useSidebarVisible() {
|
||||
const [visible, setVisible] = useState(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(SIDEBAR_STORAGE_KEY) === 'expanded'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
function toggle() {
|
||||
setVisible((previous) => {
|
||||
const next = !previous
|
||||
try {
|
||||
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, next ? 'expanded' : 'collapsed')
|
||||
} catch {
|
||||
/* private mode — the toggle still works for this session */
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
window.addEventListener(TOGGLE_SIDEBAR_EVENT, toggle)
|
||||
return () => window.removeEventListener(TOGGLE_SIDEBAR_EVENT, toggle)
|
||||
}, [])
|
||||
|
||||
return visible
|
||||
}
|
||||
|
||||
function MainContent() {
|
||||
const { mode, dragging } = useSession()
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 w-full min-w-0 flex-col">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{mode === 'idle' ? (
|
||||
<motion.div
|
||||
key="idle"
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||
className="flex min-h-0 flex-1 flex-col">
|
||||
<IdleHero />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="session"
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||
className="flex min-h-0 flex-1 flex-col">
|
||||
<SessionView />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Dropping onto a running session appends to the queue — say so. */}
|
||||
<AnimatePresence>
|
||||
{dragging && mode !== 'idle' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||
className="pointer-events-none absolute inset-3 rounded-[1.25rem] border-2 border-dashed border-ring/40 bg-background/60 backdrop-blur-[2px]">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm font-medium text-muted-foreground">Drop to add to the queue</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar visibility: user toggle everywhere; entering a multi-file (batch) session
|
||||
* closes it ONCE (its own file rail takes the space) but the toggle still reopens it.
|
||||
*/
|
||||
function useShowSidebar() {
|
||||
const sidebarVisible = useSidebarVisible()
|
||||
const { queue } = useSession()
|
||||
const batch = queue.jobs.length > 1
|
||||
const wasBatch = useRef(batch)
|
||||
const visibleRef = useRef(sidebarVisible)
|
||||
visibleRef.current = sidebarVisible
|
||||
|
||||
useEffect(() => {
|
||||
if (batch && !wasBatch.current && visibleRef.current) {
|
||||
window.dispatchEvent(new CustomEvent(TOGGLE_SIDEBAR_EVENT))
|
||||
}
|
||||
wasBatch.current = batch
|
||||
}, [batch])
|
||||
|
||||
return sidebarVisible
|
||||
}
|
||||
|
||||
/** Full-width bottom player, ElevenLabs style: spans the whole window under sidebar and content. */
|
||||
function PlayerSlot() {
|
||||
const { queue } = useSession()
|
||||
const selected = queue.selectedJob
|
||||
if (!selected || (selected.status !== 'done' && !selected.hydrated)) return null
|
||||
return <PlayerBar key={selected.id} job={selected} />
|
||||
}
|
||||
|
||||
function Shell() {
|
||||
const showSidebar = useShowSidebar()
|
||||
|
||||
return (
|
||||
<Layout
|
||||
bottomBar={<PlayerSlot />}
|
||||
sidebar={
|
||||
<AnimatePresence initial={false}>
|
||||
{showSidebar && (
|
||||
<motion.div
|
||||
key="recents"
|
||||
initial={{ opacity: 0, width: 0 }}
|
||||
animate={{ opacity: 1, width: 'auto' }}
|
||||
exit={{ opacity: 0, width: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="flex min-h-0 overflow-hidden bg-background/90 dark:bg-background/55">
|
||||
<RecentsSidebar />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
}>
|
||||
<MainContent />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MainPage() {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<Shell />
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { path as pathApi } from '@tauri-apps/api'
|
||||
import { webviewWindow } from '@tauri-apps/api'
|
||||
import * as dialog from '@tauri-apps/plugin-dialog'
|
||||
import * as fs from '@tauri-apps/plugin-fs'
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import * as config from '~/lib/config'
|
||||
import { ls, pathToNamedPath } from '~/lib/fs'
|
||||
import { isModelFile } from '~/lib/model'
|
||||
import type { NamedPath } from '~/lib/types'
|
||||
import { useConfirmExit } from '~/lib/use-confirm-exit'
|
||||
import { hotkeyRecordingActive } from '~/providers/hotkey'
|
||||
import { usePreferenceProvider, type Preference } from '~/providers/preference'
|
||||
import { useAudioDownload } from '~/pages/home/hooks/use-audio-download'
|
||||
import { useRecording } from '~/pages/home/hooks/use-recording'
|
||||
import { useDropTarget } from './hooks/use-drop-target'
|
||||
import { useTranscribeQueue, type TranscribeQueue } from './hooks/use-transcribe-queue'
|
||||
|
||||
export type SessionMode = 'idle' | 'working' | 'done'
|
||||
export type IdlePanel = 'none' | 'record' | 'link'
|
||||
|
||||
type Recording = ReturnType<typeof useRecording>
|
||||
type AudioDownload = ReturnType<typeof useAudioDownload>
|
||||
|
||||
export interface Session {
|
||||
mode: SessionMode
|
||||
queue: TranscribeQueue
|
||||
preference: Preference
|
||||
dragging: boolean
|
||||
panel: IdlePanel
|
||||
setPanel: (panel: IdlePanel) => void
|
||||
recording: Recording
|
||||
recordElapsed: number
|
||||
link: AudioDownload
|
||||
collectingFolder: boolean
|
||||
browse: () => Promise<void>
|
||||
startNew: () => void
|
||||
}
|
||||
|
||||
const SessionContext = createContext<Session | null>(null)
|
||||
|
||||
export function useSession() {
|
||||
const session = useContext(SessionContext)
|
||||
if (!session) throw new Error('useSession must be used inside <SessionProvider>')
|
||||
return session
|
||||
}
|
||||
|
||||
const mediaExtensions = [...config.audioExtensions, ...config.videoExtensions]
|
||||
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const navigate = useNavigate()
|
||||
const preference = usePreferenceProvider()
|
||||
const queue = useTranscribeQueue()
|
||||
const [panel, setPanel] = useState<IdlePanel>('none')
|
||||
const [collectingFolder, setCollectingFolder] = useState(false)
|
||||
const [recordElapsed, setRecordElapsed] = useState(0)
|
||||
|
||||
const enqueueRef = useRef(queue.enqueue)
|
||||
useEffect(() => {
|
||||
enqueueRef.current = queue.enqueue
|
||||
}, [queue.enqueue])
|
||||
|
||||
const enqueuePaths = useCallback(
|
||||
async (paths: string[]) => {
|
||||
const files: NamedPath[] = []
|
||||
for (const item of paths) {
|
||||
// A picked or dropped path may be a folder — detect and expand to its media files.
|
||||
const isMediaFile = mediaExtensions.some((ext) => item.toLowerCase().endsWith(`.${ext.toLowerCase()}`))
|
||||
if (isMediaFile) {
|
||||
files.push(await pathToNamedPath(item))
|
||||
continue
|
||||
}
|
||||
setCollectingFolder(true)
|
||||
try {
|
||||
const expanded = await invoke<string[]>('glob_files', {
|
||||
folder: item,
|
||||
patterns: mediaExtensions,
|
||||
recursive: preference.advancedTranscribeOptions.includeSubFolders,
|
||||
})
|
||||
for (const path of expanded) files.push(await pathToNamedPath(path))
|
||||
} catch {
|
||||
files.push(await pathToNamedPath(item))
|
||||
} finally {
|
||||
setCollectingFolder(false)
|
||||
}
|
||||
}
|
||||
if (files.length) enqueueRef.current(files)
|
||||
},
|
||||
[preference.advancedTranscribeOptions.includeSubFolders],
|
||||
)
|
||||
|
||||
const dragging = useDropTarget(
|
||||
useCallback(
|
||||
(paths: string[]) => {
|
||||
setPanel('none')
|
||||
void enqueuePaths(paths)
|
||||
},
|
||||
[enqueuePaths],
|
||||
),
|
||||
)
|
||||
|
||||
/** Adapter for hooks that hand us a single produced file (recording / downloaded audio). */
|
||||
const transcribeOne = useCallback(async (filePath: string) => {
|
||||
await enqueuePaths([filePath])
|
||||
}, [])
|
||||
|
||||
const recording = useRecording(() => setPanel('record'))
|
||||
const link = useAudioDownload(transcribeOne)
|
||||
|
||||
useEffect(() => {
|
||||
if (!recording.isRecording) {
|
||||
setRecordElapsed(0)
|
||||
return
|
||||
}
|
||||
setRecordElapsed(0)
|
||||
const startedAt = Date.now()
|
||||
const timer = window.setInterval(() => setRecordElapsed(Math.floor((Date.now() - startedAt) / 1000)), 500)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [recording.isRecording])
|
||||
|
||||
// Recording finished in the backend -> the produced file goes straight into the queue.
|
||||
useEffect(() => {
|
||||
const unlisten: Promise<UnlistenFn> = listen<{ path: string; name: string }>('record_finish', ({ payload }) => {
|
||||
if (hotkeyRecordingActive) return
|
||||
recording.setIsRecording(false)
|
||||
setPanel('none')
|
||||
enqueueRef.current([{ name: payload.name, path: payload.path }])
|
||||
})
|
||||
return () => {
|
||||
unlisten.then((fn) => fn())
|
||||
}
|
||||
}, [recording.setIsRecording])
|
||||
|
||||
const browse = useCallback(async () => {
|
||||
const selected = await dialog.open({ multiple: true, filters: [{ name: 'Audio or Video files', extensions: mediaExtensions }] })
|
||||
if (!selected) return
|
||||
setPanel('none')
|
||||
await enqueuePaths(Array.isArray(selected) ? selected : [selected])
|
||||
}, [enqueuePaths])
|
||||
|
||||
const startNew = useCallback(() => {
|
||||
queue.reset()
|
||||
setPanel('none')
|
||||
}, [queue])
|
||||
|
||||
// Same guards the old home page ran on mount.
|
||||
useEffect(() => {
|
||||
async function checkIfCrashedRecently() {
|
||||
try {
|
||||
if (await invoke<boolean>('is_crashed_recently')) {
|
||||
dialog.message(m.crashedRecently())
|
||||
await invoke('rename_crash_file')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkModelExists() {
|
||||
try {
|
||||
const modelsFolder = await invoke<string>('get_models_folder')
|
||||
const models = (await ls(modelsFolder)).filter((entry) => isModelFile(entry.name))
|
||||
if (models.length === 0) {
|
||||
preference.setModelPath(null)
|
||||
if (!preference.skippedSetup) navigate('/setup')
|
||||
return
|
||||
}
|
||||
if (!preference.modelPath || !(await fs.exists(preference.modelPath))) {
|
||||
preference.setModelPath(await pathApi.join(modelsFolder, models[0].name))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
navigate('/setup')
|
||||
}
|
||||
}
|
||||
|
||||
async function showWindow() {
|
||||
const currentWindow = webviewWindow.getCurrentWebviewWindow()
|
||||
await currentWindow.show()
|
||||
if (import.meta.env.PROD) await currentWindow.setFocus()
|
||||
}
|
||||
|
||||
void checkIfCrashedRecently()
|
||||
void checkModelExists()
|
||||
void showWindow()
|
||||
}, [])
|
||||
|
||||
// Results persist the moment they exist, so closing only warns when something would
|
||||
// actually be lost: a run in flight, or finished results that never reached disk
|
||||
// (saving disabled or failed).
|
||||
const hasUnsavedResults = queue.jobs.some((job) => job.status === 'done' && !job.hydrated && !job.savedPath)
|
||||
useConfirmExit(queue.running || hasUnsavedResults)
|
||||
|
||||
const mode: SessionMode = queue.jobs.length === 0 ? 'idle' : queue.running || queue.jobs.some((job) => job.status === 'queued') ? 'working' : 'done'
|
||||
|
||||
const value = useMemo<Session>(
|
||||
() => ({
|
||||
mode,
|
||||
queue,
|
||||
preference,
|
||||
dragging,
|
||||
panel,
|
||||
setPanel,
|
||||
recording,
|
||||
recordElapsed,
|
||||
link,
|
||||
collectingFolder,
|
||||
browse,
|
||||
startNew,
|
||||
}),
|
||||
[mode, queue, preference, dragging, panel, recording, recordElapsed, link, collectingFolder, browse, startNew],
|
||||
)
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export default function SettingsPage({ setVisible, scrollTo }: SettingsPageProps
|
||||
<div className="flex min-h-screen items-center justify-center p-6">
|
||||
<div
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
className="flex h-[640px] w-full max-w-3xl overflow-hidden rounded-2xl border border-border/60 bg-card shadow-2xl">
|
||||
className="flex h-[640px] max-h-[calc(100dvh-3rem)] w-full max-w-3xl overflow-hidden rounded-2xl border border-border/60 bg-card shadow-2xl">
|
||||
<div className="flex w-56 shrink-0 flex-col border-r border-border/55 bg-muted/40 p-3">
|
||||
<div className="mb-2 flex items-center justify-between px-1 pb-1">
|
||||
<span className="text-sm font-semibold">{m.settings()}</span>
|
||||
|
||||
@@ -1,80 +1,44 @@
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import { InfoTooltip } from '~/components/info-tooltip'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { Switch } from '~/components/ui/switch'
|
||||
import { Input } from '~/components/ui/input'
|
||||
import { ReactComponent as CopyIcon } from '~/icons/copy.svg'
|
||||
import { ReactComponent as FolderIcon } from '~/icons/folder.svg'
|
||||
import { ReactComponent as ResetIcon } from '~/icons/reset.svg'
|
||||
import { SectionCard, type SettingsViewModel } from './shared'
|
||||
import { ActionRow, SettingsGroup, SettingsRow, rowControlClass, type SettingsViewModel } from './shared'
|
||||
|
||||
export function AdvancedSection({ vm }: { vm: SettingsViewModel }) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1 px-1">
|
||||
<InfoTooltip text={m.ytdlpOptionsInfo()} />
|
||||
<span className="text-sm font-semibold text-foreground/95">{m.ytdlpOptions()}</span>
|
||||
</div>
|
||||
<SectionCard>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{m.checkYtdlpUpdates()}</span>
|
||||
<Switch checked={vm.preference.shouldCheckYtDlpVersion} onCheckedChange={vm.preference.setShouldCheckYtDlpVersion} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1 px-1">
|
||||
<InfoTooltip text={`${m.unloadModelAfterInactivityInfo()} ${m.zeroMeansNever()}`} />
|
||||
<span className="text-sm font-semibold text-foreground/95">{m.modelMemory()}</span>
|
||||
</div>
|
||||
<SectionCard>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium">{m.unloadModelAfterInactivity()}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1440}
|
||||
step={1}
|
||||
value={vm.preference.unloadTimeoutMinutes}
|
||||
onChange={(event) => {
|
||||
const minutes = Number(event.target.value)
|
||||
if (Number.isFinite(minutes)) vm.preference.setUnloadTimeoutMinutes(Math.min(1440, Math.max(0, Math.floor(minutes))))
|
||||
}}
|
||||
className="h-6 w-20 rounded-lg px-2 py-0 text-right"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{m.minutes()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
<div className="divide-y divide-border/45 rounded-2xl border border-border/60 bg-card/92 shadow-xs">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onMouseDown={vm.copyLogs}
|
||||
className="h-12 w-full justify-between rounded-none px-4 font-medium first:rounded-t-2xl last:rounded-b-2xl hover:bg-accent/55">
|
||||
{m.copyLogs()} <CopyIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onMouseDown={vm.revealLogs}
|
||||
className="h-12 w-full justify-between rounded-none px-4 font-medium first:rounded-t-2xl last:rounded-b-2xl hover:bg-accent/55">
|
||||
{m.logsFolder()} <FolderIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onMouseDown={vm.revealTemp}
|
||||
className="h-12 w-full justify-between rounded-none px-4 font-medium first:rounded-t-2xl last:rounded-b-2xl hover:bg-accent/55">
|
||||
{m.tempFolder()} <FolderIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={vm.askAndReset}
|
||||
className="h-12 w-full justify-between rounded-none px-4 font-medium text-destructive first:rounded-t-2xl last:rounded-b-2xl hover:bg-destructive/12 hover:text-destructive">
|
||||
{m.resetApp()} <ResetIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<SettingsGroup title={m.ytdlpOptions()}>
|
||||
<SettingsRow label={m.checkYtdlpUpdates()} description={m.ytdlpOptionsInfo()}>
|
||||
<Switch checked={vm.preference.shouldCheckYtDlpVersion} onCheckedChange={vm.preference.setShouldCheckYtDlpVersion} />
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup title={m.modelMemory()}>
|
||||
<SettingsRow label={m.unloadModelAfterInactivity()} description={`${m.unloadModelAfterInactivityInfo()} ${m.zeroMeansNever()}`}>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1440}
|
||||
step={1}
|
||||
value={vm.preference.unloadTimeoutMinutes}
|
||||
onChange={(event) => {
|
||||
const minutes = Number(event.target.value)
|
||||
if (Number.isFinite(minutes)) vm.preference.setUnloadTimeoutMinutes(Math.min(1440, Math.max(0, Math.floor(minutes))))
|
||||
}}
|
||||
className={`w-24 text-end ${rowControlClass}`}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{m.minutes()}</span>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup>
|
||||
<ActionRow label={m.copyLogs()} icon={<CopyIcon className="h-4 w-4" />} onClick={vm.copyLogs} />
|
||||
<ActionRow label={m.logsFolder()} icon={<FolderIcon className="h-4 w-4" />} onClick={vm.revealLogs} />
|
||||
<ActionRow label={m.tempFolder()} icon={<FolderIcon className="h-4 w-4" />} onClick={vm.revealTemp} />
|
||||
<ActionRow label={m.resetApp()} icon={<ResetIcon className="h-4 w-4" />} onClick={vm.askAndReset} destructive activateOnClick />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,61 +3,36 @@ import { Bot } from 'lucide-react'
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import { ReactComponent as CopyIcon } from '~/icons/copy.svg'
|
||||
import { ReactComponent as LinkIcon } from '~/icons/link.svg'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { SectionCard, type SettingsViewModel } from './shared'
|
||||
import { Switch } from '~/components/ui/switch'
|
||||
import { ActionRow, SettingsGroup, SettingsRow, type SettingsViewModel } from './shared'
|
||||
|
||||
export function ApiSection({ vm }: { vm: SettingsViewModel }) {
|
||||
const apiDocsUrl = vm.apiBaseUrl ? `${vm.apiBaseUrl}/docs` : null
|
||||
const serverActionBusy = vm.isStartingApiServer || vm.isStoppingApiServer
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="px-1 text-sm text-muted-foreground">{m.apiAgentsDescription()}</p>
|
||||
<SectionCard>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${vm.apiBaseUrl ? 'bg-emerald-500 shadow-[0_0_0_4px_rgba(16,185,129,0.18)]' : 'bg-muted-foreground/40 shadow-[0_0_0_4px_rgba(148,163,184,0.12)]'}`}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{vm.apiBaseUrl ? m.apiServerRunning() : m.apiServerOff()}</p>
|
||||
{vm.apiBaseUrl && <p className="text-xs text-muted-foreground">{vm.apiBaseUrl}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={vm.apiBaseUrl ? 'outline' : 'default'}
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onMouseDown={vm.apiBaseUrl ? vm.stopApiServer : vm.startApiServer}
|
||||
disabled={serverActionBusy}>
|
||||
{vm.isStartingApiServer ? m.apiStarting() : vm.isStoppingApiServer ? m.apiStopping() : vm.apiBaseUrl ? m.stop() : m.start()}
|
||||
</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
const statusDescription = vm.isStartingApiServer ? m.apiStarting() : vm.isStoppingApiServer ? m.apiStopping() : (vm.apiBaseUrl ?? undefined)
|
||||
|
||||
<div
|
||||
className={`divide-y divide-border/45 rounded-2xl border border-border/60 bg-card/92 shadow-xs ${!vm.apiBaseUrl ? 'pointer-events-none opacity-50' : ''}`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onMouseDown={() => (apiDocsUrl ? openUrl(apiDocsUrl) : null)}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsGroup description={m.apiAgentsDescription()}>
|
||||
<SettingsRow label={vm.apiBaseUrl ? m.apiServerRunning() : m.apiServerOff()} description={statusDescription}>
|
||||
<Switch
|
||||
checked={Boolean(vm.apiBaseUrl)}
|
||||
disabled={serverActionBusy}
|
||||
onCheckedChange={(checked) => (checked ? vm.startApiServer() : vm.stopApiServer())}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup className={!vm.apiBaseUrl ? 'pointer-events-none opacity-50' : undefined}>
|
||||
<ActionRow
|
||||
label={m.swaggerDocs()}
|
||||
icon={<LinkIcon className="h-4 w-4" />}
|
||||
disabled={!apiDocsUrl}
|
||||
className="h-12 w-full justify-between rounded-none px-4 font-medium first:rounded-t-2xl last:rounded-b-2xl hover:bg-accent/55">
|
||||
{m.swaggerDocs()} <LinkIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onMouseDown={vm.copyCurlExample}
|
||||
disabled={!vm.apiBaseUrl}
|
||||
className="h-12 w-full justify-between rounded-none px-4 font-medium first:rounded-t-2xl last:rounded-b-2xl hover:bg-accent/55">
|
||||
{m.copyCurlExample()} <CopyIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onMouseDown={vm.copyAgentSkill}
|
||||
disabled={!vm.apiBaseUrl}
|
||||
className="h-12 w-full justify-between rounded-none px-4 font-medium first:rounded-t-2xl last:rounded-b-2xl hover:bg-accent/55">
|
||||
{m.copyAgentSkill()} <Bot className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
onClick={() => (apiDocsUrl ? openUrl(apiDocsUrl) : null)}
|
||||
/>
|
||||
<ActionRow label={m.copyCurlExample()} icon={<CopyIcon className="h-4 w-4" />} disabled={!vm.apiBaseUrl} onClick={vm.copyCurlExample} />
|
||||
<ActionRow label={m.copyAgentSkill()} icon={<Bot className="h-4 w-4" />} disabled={!vm.apiBaseUrl} onClick={vm.copyAgentSkill} />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import { InfoTooltip } from '~/components/info-tooltip'
|
||||
import { Input } from '~/components/ui/input'
|
||||
import { Switch } from '~/components/ui/switch'
|
||||
import { useHotkeyProvider, type HotkeyActivationMode, type HotkeyOutputMode } from '~/providers/hotkey'
|
||||
import { Field, SectionCard } from './shared'
|
||||
import { SettingsGroup, SettingsRow, rowControlClass } from './shared'
|
||||
import { getDictationIndicatorEnabled, setDictationIndicatorEnabled } from '~/lib/dictation-indicator'
|
||||
|
||||
function SegmentedControl<T extends string>({ value, options, onChange }: { value: T; options: { value: T; label: string }[]; onChange: (v: T) => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-lg border border-border/65 bg-muted/40 p-0.5">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`h-8 cursor-pointer rounded-md px-2.5 text-xs font-medium transition-colors ${
|
||||
value === option.value ? 'bg-card text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'
|
||||
}`}>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DictationSection() {
|
||||
const hotkey = useHotkeyProvider()
|
||||
const [indicatorEnabled, setIndicatorEnabled] = useState(true)
|
||||
@@ -23,18 +40,18 @@ export function DictationSection() {
|
||||
}
|
||||
}
|
||||
const isMac = navigator.platform.toUpperCase().includes('MAC')
|
||||
const activationLabels = {
|
||||
'push-to-talk': m.hotkeyActivationPushToTalk,
|
||||
toggle: m.hotkeyActivationToggle,
|
||||
} as const
|
||||
const activationDescriptions = {
|
||||
'push-to-talk': m.hotkeyActivationPushToTalkDescription,
|
||||
toggle: m.hotkeyActivationToggleDescription,
|
||||
} as const
|
||||
const outputLabels = {
|
||||
clipboard: m.hotkeyOutputClipboard,
|
||||
type: m.hotkeyOutputType,
|
||||
} as const
|
||||
const activationOptions: { value: HotkeyActivationMode; label: string }[] = [
|
||||
{ value: 'push-to-talk', label: m.hotkeyActivationPushToTalk() },
|
||||
{ value: 'toggle', label: m.hotkeyActivationToggle() },
|
||||
]
|
||||
const outputOptions: { value: HotkeyOutputMode; label: string }[] = [
|
||||
{ value: 'clipboard', label: m.hotkeyOutputClipboard() },
|
||||
{ value: 'type', label: m.hotkeyOutputType() },
|
||||
]
|
||||
const shortcutKeys = useMemo(() => {
|
||||
const keyMap: Record<string, string> = {
|
||||
CmdOrCtrl: isMac ? '⌘' : 'Ctrl',
|
||||
@@ -46,90 +63,55 @@ export function DictationSection() {
|
||||
}
|
||||
return hotkey.hotkeyShortcut.split('+').map((key) => keyMap[key] ?? key)
|
||||
}, [hotkey.hotkeyShortcut, isMac])
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="px-1 text-sm text-muted-foreground">{m.globalDictationPromo()}</p>
|
||||
<SectionCard>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{m.globalHotkeyEnabled()}</span>
|
||||
<Switch checked={hotkey.hotkeyEnabled} onCheckedChange={hotkey.setHotkeyEnabled} />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<SettingsGroup description={m.globalDictationPromo()}>
|
||||
<SettingsRow label={m.globalHotkeyEnabled()}>
|
||||
<Switch checked={hotkey.hotkeyEnabled} onCheckedChange={hotkey.setHotkeyEnabled} />
|
||||
</SettingsRow>
|
||||
|
||||
{hotkey.hotkeyEnabled && (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="flex items-center gap-1 text-sm font-medium">
|
||||
<InfoTooltip text={m.dictationIndicatorSettingInfo()} />
|
||||
{m.dictationIndicatorSetting()}
|
||||
</span>
|
||||
<Switch checked={indicatorEnabled} onCheckedChange={changeIndicatorEnabled} />
|
||||
</div>
|
||||
<div className="h-px bg-border/45" />
|
||||
<Field label={m.hotkeyActivationMode()}>
|
||||
<div className="flex gap-2">
|
||||
{(['push-to-talk', 'toggle'] as HotkeyActivationMode[]).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => hotkey.setHotkeyActivationMode(mode)}
|
||||
className={`flex-1 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
hotkey.hotkeyActivationMode === mode
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border/65 bg-background/50 text-muted-foreground hover:bg-accent/40'
|
||||
}`}>
|
||||
{activationLabels[mode]()}
|
||||
</button>
|
||||
{hotkey.hotkeyEnabled && (
|
||||
<>
|
||||
<SettingsRow label={m.dictationIndicatorSetting()} description={m.dictationIndicatorSettingInfo()}>
|
||||
<Switch checked={indicatorEnabled} onCheckedChange={changeIndicatorEnabled} />
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow label={m.hotkeyActivationMode()} description={activationDescriptions[hotkey.hotkeyActivationMode]()}>
|
||||
<SegmentedControl value={hotkey.hotkeyActivationMode} options={activationOptions} onChange={hotkey.setHotkeyActivationMode} />
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
label={m.globalHotkeyShortcut()}
|
||||
description={
|
||||
<span className="flex items-center gap-1">
|
||||
{shortcutKeys.map((key, i) => (
|
||||
<kbd
|
||||
key={i}
|
||||
className="inline-flex h-5 min-w-5 items-center justify-center rounded-md border border-border/80 bg-muted/50 px-1 font-mono text-[10px] font-medium text-foreground/80">
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<Field
|
||||
label={
|
||||
<span className="flex items-center gap-2">
|
||||
{m.globalHotkeyShortcut()}
|
||||
<span className="flex items-center gap-1">
|
||||
{shortcutKeys.map((key, i) => (
|
||||
<kbd
|
||||
key={i}
|
||||
className="inline-flex h-6 min-w-6 items-center justify-center rounded-md border border-border/80 bg-background/70 px-1.5 font-mono text-[11px] font-medium text-foreground/80 shadow-[0_1px_0_1px_rgba(0,0,0,0.04)]">
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
}>
|
||||
<Input type="text" value={hotkey.hotkeyShortcut} onChange={(e) => hotkey.setHotkeyShortcut(e.target.value)} />
|
||||
</Field>
|
||||
<div className="flex gap-2">
|
||||
{(['clipboard', 'type'] as HotkeyOutputMode[]).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => hotkey.setHotkeyOutputMode(mode)}
|
||||
className={`flex-1 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
hotkey.hotkeyOutputMode === mode
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border/65 bg-background/50 text-muted-foreground hover:bg-accent/40'
|
||||
}`}>
|
||||
{outputLabels[mode]()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs italic text-muted-foreground">{activationDescriptions[hotkey.hotkeyActivationMode]()}</p>
|
||||
|
||||
<div className="h-px bg-border/45" />
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="flex items-center gap-1 text-sm font-medium">
|
||||
<InfoTooltip text={m.normalizeHotkeyOutputInfo()} />
|
||||
{m.normalizeHotkeyOutput()}
|
||||
</span>
|
||||
<Switch checked={hotkey.hotkeyNormalizeOutput} onCheckedChange={hotkey.setHotkeyNormalizeOutput} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
}>
|
||||
<Input
|
||||
type="text"
|
||||
value={hotkey.hotkeyShortcut}
|
||||
onChange={(e) => hotkey.setHotkeyShortcut(e.target.value)}
|
||||
className={`w-48 ${rowControlClass}`}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow label={m.hotkeyOutputMode()}>
|
||||
<SegmentedControl value={hotkey.hotkeyOutputMode} options={outputOptions} onChange={hotkey.setHotkeyOutputMode} />
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow label={m.normalizeHotkeyOutput()} description={m.normalizeHotkeyOutputInfo()}>
|
||||
<Switch checked={hotkey.hotkeyNormalizeOutput} onCheckedChange={hotkey.setHotkeyNormalizeOutput} />
|
||||
</SettingsRow>
|
||||
</>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,79 +7,57 @@ import { ReactComponent as HeartIcon } from '~/icons/heart.svg'
|
||||
import { ReactComponent as LinkIcon } from '~/icons/link.svg'
|
||||
import * as config from '~/lib/config'
|
||||
import { getLocalizedLanguageName, supportedLanguages } from '~/lib/i18n'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { Label } from '~/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select'
|
||||
import { SectionCard, type SettingsViewModel } from './shared'
|
||||
import { ActionRow, SettingsGroup, SettingsRow, rowControlClass, type SettingsViewModel } from './shared'
|
||||
|
||||
export function GeneralSection({ vm }: { vm: SettingsViewModel }) {
|
||||
const themeLabels = { light: m.light, dark: m.dark } as const
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<SectionCard>
|
||||
<div className="grid grid-cols-1 divide-y divide-border/45 sm:grid-cols-2 sm:divide-x sm:divide-y-0">
|
||||
<div className="space-y-2 sm:pe-5">
|
||||
<Label>{m.language()}</Label>
|
||||
<Select
|
||||
value={supportedLanguages[vm.preference.displayLanguage] ? vm.preference.displayLanguage : 'en-US'}
|
||||
onValueChange={vm.preference.setDisplayLanguage}>
|
||||
<SelectTrigger className="capitalize">
|
||||
<SelectValue placeholder={m.selectLanguage()} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(supportedLanguages).map(([code, name]) => (
|
||||
<SelectItem key={code} value={code} className="capitalize">
|
||||
{code === getLocale() ? getLocalizedLanguageName(name) : name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2 pt-4 sm:ps-5 sm:pt-0">
|
||||
<Label>{m.theme()}</Label>
|
||||
<Select value={vm.preference.theme} onValueChange={(value) => vm.preference.setTheme(value as 'light' | 'dark')}>
|
||||
<SelectTrigger className="capitalize">
|
||||
<SelectValue placeholder={m.selectTheme()} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.themes.map((theme) => (
|
||||
<SelectItem key={theme} value={theme} className="capitalize">
|
||||
{themeLabels[theme as keyof typeof themeLabels]()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
<div className="space-y-6">
|
||||
<SettingsGroup>
|
||||
<SettingsRow label={m.language()}>
|
||||
<Select
|
||||
value={supportedLanguages[vm.preference.displayLanguage] ? vm.preference.displayLanguage : 'en-US'}
|
||||
onValueChange={vm.preference.setDisplayLanguage}>
|
||||
<SelectTrigger className={`w-52 capitalize ${rowControlClass}`}>
|
||||
<SelectValue placeholder={m.selectLanguage()} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(supportedLanguages).map(([code, name]) => (
|
||||
<SelectItem key={code} value={code} className="capitalize">
|
||||
{code === getLocale() ? getLocalizedLanguageName(name) : name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={m.theme()}>
|
||||
<Select value={vm.preference.theme} onValueChange={(value) => vm.preference.setTheme(value as 'light' | 'dark')}>
|
||||
<SelectTrigger className={`w-36 capitalize ${rowControlClass}`}>
|
||||
<SelectValue placeholder={m.selectTheme()} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.themes.map((theme) => (
|
||||
<SelectItem key={theme} value={theme} className="capitalize">
|
||||
{themeLabels[theme as keyof typeof themeLabels]()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
<div className="divide-y divide-border/45 rounded-2xl border border-border/60 bg-card/92 shadow-xs">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onMouseDown={() => openUrl(config.aboutURL)}
|
||||
className="h-12 w-full justify-between rounded-none px-4 font-medium first:rounded-t-2xl last:rounded-b-2xl hover:bg-accent/55">
|
||||
{m.projectLink()} <LinkIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onMouseDown={vm.reportIssue}
|
||||
className="h-12 w-full justify-between rounded-none px-4 font-medium first:rounded-t-2xl last:rounded-b-2xl hover:bg-accent/55">
|
||||
{m.reportIssue()} <GithubIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onMouseDown={() => openUrl(config.supportVibeURL)}
|
||||
className="h-12 w-full justify-between rounded-none px-4 font-medium first:rounded-t-2xl last:rounded-b-2xl hover:bg-accent/55">
|
||||
{m.supportTheProject()} <HeartIcon className="h-4 w-4 fill-red-500 text-red-500 dark:fill-red-400 dark:text-red-400" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onMouseDown={() => openUrl(config.discordURL)}
|
||||
className="h-12 w-full justify-between rounded-none px-4 font-medium first:rounded-t-2xl last:rounded-b-2xl hover:bg-accent/55">
|
||||
{m.discordCommunity()} <DiscordIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
<SettingsGroup>
|
||||
<ActionRow label={m.projectLink()} icon={<LinkIcon className="h-4 w-4" />} onClick={() => openUrl(config.aboutURL)} />
|
||||
<ActionRow label={m.reportIssue()} icon={<GithubIcon className="h-4 w-4" />} onClick={vm.reportIssue} />
|
||||
<ActionRow
|
||||
label={m.supportTheProject()}
|
||||
icon={<HeartIcon className="h-4 w-4 fill-red-500 text-red-500 dark:fill-red-400 dark:text-red-400" />}
|
||||
onClick={() => openUrl(config.supportVibeURL)}
|
||||
/>
|
||||
<ActionRow label={m.discordCommunity()} icon={<DiscordIcon className="h-4 w-4" />} onClick={() => openUrl(config.discordURL)} />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,180 +1,135 @@
|
||||
import { useState } from 'react'
|
||||
import { FolderOpen, PencilLine } from 'lucide-react'
|
||||
import { Check, Download, FolderOpen, PencilLine, X } from 'lucide-react'
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import { ReactComponent as FolderIcon } from '~/icons/folder.svg'
|
||||
import { ReactComponent as LinkIcon } from '~/icons/link.svg'
|
||||
import { ReactComponent as WrenchIcon } from '~/icons/wrench.svg'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { Input } from '~/components/ui/input'
|
||||
import { Label } from '~/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select'
|
||||
import { SectionCard, type SettingsViewModel } from './shared'
|
||||
import { ActionRow, IconAction, SettingsGroup, SettingsRow, rowControlClass, type SettingsViewModel } from './shared'
|
||||
import { getFriendlyModelName } from '~/lib/model'
|
||||
|
||||
export function ModelsSection({ vm }: { vm: SettingsViewModel }) {
|
||||
const [editingPath, setEditingPath] = useState<string | null>(null)
|
||||
const [editingName, setEditingName] = useState('')
|
||||
const currentModel = vm.models.find((model) => model.path === vm.preference.modelPath)
|
||||
const isRenaming = Boolean(currentModel && editingPath === currentModel.path)
|
||||
|
||||
function commitRename() {
|
||||
if (!currentModel) return
|
||||
const name = editingName.trim()
|
||||
if (name) vm.preference.setModelDisplayNames({ ...vm.preference.modelDisplayNames, [currentModel.path]: name })
|
||||
setEditingPath(null)
|
||||
}
|
||||
|
||||
function startRename() {
|
||||
if (!currentModel) return
|
||||
setEditingPath(currentModel.path)
|
||||
setEditingName(vm.preference.modelDisplayNames[currentModel.path] ?? getFriendlyModelName(currentModel.name))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<SectionCard>
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label>{m.downloadModel()}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="space-y-6">
|
||||
<SettingsGroup>
|
||||
<SettingsRow label={m.selectModel()}>
|
||||
{isRenaming ? (
|
||||
<>
|
||||
<Input
|
||||
type="text"
|
||||
value={vm.downloadURL}
|
||||
onChange={(event) => vm.setDownloadURL(event.target.value)}
|
||||
placeholder={m.pasteModelLink()}
|
||||
onKeyDown={(event) => (event.key === 'Enter' ? vm.downloadModel() : null)}
|
||||
autoFocus
|
||||
value={editingName}
|
||||
onChange={(event) => setEditingName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') commitRename()
|
||||
if (event.key === 'Escape') setEditingPath(null)
|
||||
}}
|
||||
className={`w-56 ${rowControlClass}`}
|
||||
/>
|
||||
<Button variant="default" size="icon" onClick={vm.downloadModel} className="shrink-0">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
role="img"
|
||||
className="octicon octicon-download"
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor">
|
||||
<path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"></path>
|
||||
<path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969Z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{m.selectModel()}</Label>
|
||||
<Select
|
||||
value={vm.preference.modelPath ?? undefined}
|
||||
onValueChange={vm.selectModel}
|
||||
onOpenChange={(open) => {
|
||||
if (open) vm.loadModels()
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={m.selectModel()} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vm.models.map((model, index) => (
|
||||
<SelectItem key={index} value={model.path}>
|
||||
{vm.preference.modelDisplayNames[model.path] ?? getFriendlyModelName(model.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{currentModel &&
|
||||
(editingPath === currentModel.path ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
autoFocus
|
||||
value={editingName}
|
||||
onChange={(event) => setEditingName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
const name = editingName.trim()
|
||||
if (name) vm.preference.setModelDisplayNames({ ...vm.preference.modelDisplayNames, [currentModel.path]: name })
|
||||
setEditingPath(null)
|
||||
}
|
||||
if (event.key === 'Escape') setEditingPath(null)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const name = editingName.trim()
|
||||
if (name) vm.preference.setModelDisplayNames({ ...vm.preference.modelDisplayNames, [currentModel.path]: name })
|
||||
setEditingPath(null)
|
||||
}}>
|
||||
{m.save()}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditingPath(null)}>
|
||||
{m.cancel()}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex items-center justify-end gap-1 px-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => vm.openSelectedModel(currentModel.path)}>
|
||||
<FolderOpen className="size-3.5" /> {m.showInFolder()}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setEditingPath(currentModel.path)
|
||||
setEditingName(vm.preference.modelDisplayNames[currentModel.path] ?? getFriendlyModelName(currentModel.name))
|
||||
}}>
|
||||
<PencilLine className="size-3.5" /> {m.rename()}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!vm.isMacOS && (
|
||||
<div className="space-y-2">
|
||||
<Label>{m.gpuDevice()}</Label>
|
||||
{vm.gpuDevices.length > 0 ? (
|
||||
<Select
|
||||
value={vm.preference.gpuDevice != null ? String(vm.preference.gpuDevice) : 'auto'}
|
||||
onValueChange={(value) => {
|
||||
vm.preference.setGpuDevice(value === 'auto' ? null : parseInt(value, 10))
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={m.gpuDevice()} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{m.auto()}</SelectItem>
|
||||
{vm.gpuDevices.map((device) => (
|
||||
<SelectItem key={device.index} value={String(device.index)}>
|
||||
{device.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
type="number"
|
||||
value={vm.preference.gpuDevice ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
vm.preference.setGpuDevice(val === '' ? null : parseInt(val, 10))
|
||||
}}
|
||||
placeholder={m.gpuDevicePlaceholder()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<IconAction label={m.save()} icon={<Check className="h-4 w-4" />} onClick={commitRename} />
|
||||
<IconAction label={m.cancel()} icon={<X className="h-4 w-4" />} onClick={() => setEditingPath(null)} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
value={vm.preference.modelPath ?? undefined}
|
||||
onValueChange={vm.selectModel}
|
||||
onOpenChange={(open) => {
|
||||
if (open) vm.loadModels()
|
||||
}}>
|
||||
<SelectTrigger className={`w-56 ${rowControlClass}`}>
|
||||
<SelectValue placeholder={m.selectModel()} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vm.models.map((model, index) => (
|
||||
<SelectItem key={index} value={model.path}>
|
||||
{vm.preference.modelDisplayNames[model.path] ?? getFriendlyModelName(model.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<IconAction
|
||||
label={m.showInFolder()}
|
||||
icon={<FolderOpen className="h-4 w-4" />}
|
||||
disabled={!currentModel}
|
||||
onClick={() => currentModel && vm.openSelectedModel(currentModel.path)}
|
||||
/>
|
||||
<IconAction label={m.rename()} icon={<PencilLine className="h-4 w-4" />} disabled={!currentModel} onClick={startRename} />
|
||||
</>
|
||||
)}
|
||||
</SettingsRow>
|
||||
|
||||
<div className="space-y-1 pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onMouseDown={vm.openModelsUrl}
|
||||
className="h-11 w-full justify-between rounded-lg px-3 font-medium hover:bg-accent/60">
|
||||
{m.downloadModelsLink()} <LinkIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onMouseDown={vm.openModelPath}
|
||||
className="h-11 w-full justify-between rounded-lg px-3 font-medium hover:bg-accent/60">
|
||||
{m.modelsFolder()} <FolderIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onMouseDown={vm.changeModelsFolder}
|
||||
className="h-11 w-full justify-between rounded-lg px-3 font-medium hover:bg-accent/60">
|
||||
{m.changeModelsFolder()} <WrenchIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
{!vm.isMacOS && (
|
||||
<SettingsRow label={m.gpuDevice()}>
|
||||
{vm.gpuDevices.length > 0 ? (
|
||||
<Select
|
||||
value={vm.preference.gpuDevice != null ? String(vm.preference.gpuDevice) : 'auto'}
|
||||
onValueChange={(value) => {
|
||||
vm.preference.setGpuDevice(value === 'auto' ? null : parseInt(value, 10))
|
||||
}}>
|
||||
<SelectTrigger className={`w-56 ${rowControlClass}`}>
|
||||
<SelectValue placeholder={m.gpuDevice()} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{m.auto()}</SelectItem>
|
||||
{vm.gpuDevices.map((device) => (
|
||||
<SelectItem key={device.index} value={String(device.index)}>
|
||||
{device.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
type="number"
|
||||
value={vm.preference.gpuDevice ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
vm.preference.setGpuDevice(val === '' ? null : parseInt(val, 10))
|
||||
}}
|
||||
placeholder={m.gpuDevicePlaceholder()}
|
||||
className={`w-40 ${rowControlClass}`}
|
||||
/>
|
||||
)}
|
||||
</SettingsRow>
|
||||
)}
|
||||
|
||||
<SettingsRow label={m.downloadModel()}>
|
||||
<Input
|
||||
type="text"
|
||||
value={vm.downloadURL}
|
||||
onChange={(event) => vm.setDownloadURL(event.target.value)}
|
||||
placeholder={m.pasteModelLink()}
|
||||
onKeyDown={(event) => (event.key === 'Enter' ? vm.downloadModel() : null)}
|
||||
className={`w-56 ${rowControlClass}`}
|
||||
/>
|
||||
<IconAction label={m.downloadModel()} icon={<Download className="h-4 w-4" />} onClick={vm.downloadModel} disabled={!vm.downloadURL} />
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup>
|
||||
<ActionRow label={m.downloadModelsLink()} icon={<LinkIcon className="h-4 w-4" />} onClick={vm.openModelsUrl} />
|
||||
<ActionRow label={m.modelsFolder()} icon={<FolderIcon className="h-4 w-4" />} onClick={vm.openModelPath} />
|
||||
<ActionRow label={m.changeModelsFolder()} icon={<WrenchIcon className="h-4 w-4" />} onClick={vm.changeModelsFolder} />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,26 +2,27 @@ import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import { ReactComponent as LinkIcon } from '~/icons/link.svg'
|
||||
import * as config from '~/lib/config'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { Switch } from '~/components/ui/switch'
|
||||
import { SectionCard, type SettingsViewModel } from './shared'
|
||||
import { ActionRow, SettingsGroup, SettingsRow, type SettingsViewModel } from './shared'
|
||||
|
||||
export function PrivacySection({ vm }: { vm: SettingsViewModel }) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<SectionCard>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{m.analyticsEnabled()}</span>
|
||||
<div className="space-y-6">
|
||||
<SettingsGroup>
|
||||
<SettingsRow
|
||||
label={m.analyticsEnabled()}
|
||||
description={!vm.preference.analyticsEnabled ? m.analyticsDisabledWarning() : undefined}
|
||||
clampDescription={false}>
|
||||
<Switch checked={vm.preference.analyticsEnabled} onCheckedChange={vm.preference.setAnalyticsEnabled} />
|
||||
</div>
|
||||
{!vm.preference.analyticsEnabled && <p className="mt-2 text-xs italic text-muted-foreground">{m.analyticsDisabledWarning()}</p>}
|
||||
</SectionCard>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onMouseDown={() => openUrl(config.privacyPolicyURL)}
|
||||
className="h-11 w-full justify-between rounded-xl border border-border/55 bg-card/92 px-4 font-medium hover:bg-accent/55">
|
||||
{m.privacyPolicy()} <LinkIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Save transcripts to Documents" description="Keep a local copy of every transcript so you can reopen it later">
|
||||
<Switch checked={vm.preference.saveTranscripts} onCheckedChange={vm.preference.setSaveTranscripts} />
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup>
|
||||
<ActionRow label={m.privacyPolicy()} icon={<LinkIcon className="h-4 w-4" />} onClick={() => openUrl(config.privacyPolicyURL)} />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,172 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Label } from '~/components/ui/label'
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { cn } from '~/lib/style'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip'
|
||||
import type { viewModel } from '../view-model'
|
||||
|
||||
export type SettingsViewModel = ReturnType<typeof viewModel>
|
||||
|
||||
export function SectionCard({ children }: { children: ReactNode }) {
|
||||
return <div className="rounded-2xl border border-border/60 bg-card/92 p-5 text-card-foreground shadow-xs">{children}</div>
|
||||
/** Shared control sizing for compact row controls (selects / inputs). */
|
||||
export const rowControlClass = 'h-9 rounded-lg'
|
||||
|
||||
/**
|
||||
* A settings group: optional heading + one bordered container whose rows are
|
||||
* separated by hairline borders.
|
||||
*/
|
||||
export function SettingsGroup({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
{(title || description) && (
|
||||
<div className="space-y-0.5 px-1">
|
||||
{title && <h3 className="text-[13px] font-medium text-foreground">{title}</h3>}
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('divide-y divide-border/45 overflow-hidden rounded-xl border border-border/60 bg-card', className)}>{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
|
||||
/** Label + optional one-line description, used on the left side of every row. */
|
||||
function RowLabel({ label, description, clamp = true }: { label: ReactNode; description?: ReactNode; clamp?: boolean }) {
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
<Label className="flex items-center gap-1">{label}</Label>
|
||||
{children}
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="text-sm text-foreground">{label}</div>
|
||||
{description && <p className={cn('text-xs text-muted-foreground', clamp && 'line-clamp-1')}>{description}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A single row: label (+ description) on the left, the control on the right. */
|
||||
export function SettingsRow({
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
clampDescription = true,
|
||||
}: {
|
||||
label: ReactNode
|
||||
description?: ReactNode
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
clampDescription?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex min-h-[52px] items-center justify-between gap-4 px-4 py-2.5', className)}>
|
||||
<RowLabel label={label} description={description} clamp={clampDescription} />
|
||||
{children && <div className="flex shrink-0 items-center gap-1.5">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A stacked row: label (+ description) on top, a full-width control below. */
|
||||
export function SettingsField({
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
}: {
|
||||
label?: ReactNode
|
||||
description?: ReactNode
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('space-y-2 px-4 py-3', className)}>
|
||||
{(label || description) && <RowLabel label={label} description={description} clamp={false} />}
|
||||
{children}
|
||||
{footer && <div className="text-end text-xs text-muted-foreground">{footer}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A quiet note inside a group (e.g. "supports up to 4 speakers"). */
|
||||
export function SettingsNote({ children }: { children: ReactNode }) {
|
||||
return <p className="px-4 py-2.5 text-xs text-muted-foreground">{children}</p>
|
||||
}
|
||||
|
||||
/** Whole-row clickable action: label on the left, small muted icon on the right. */
|
||||
export function ActionRow({
|
||||
label,
|
||||
description,
|
||||
icon,
|
||||
onClick,
|
||||
destructive,
|
||||
disabled,
|
||||
className,
|
||||
activateOnClick,
|
||||
}: {
|
||||
label: ReactNode
|
||||
description?: ReactNode
|
||||
icon?: ReactNode
|
||||
onClick?: () => void
|
||||
destructive?: boolean
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
/** Use the click event instead of mousedown (for actions that open dialogs). */
|
||||
activateOnClick?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onMouseDown={activateOnClick ? undefined : onClick}
|
||||
onClick={activateOnClick ? onClick : undefined}
|
||||
className={cn(
|
||||
'flex min-h-[52px] w-full cursor-pointer items-center justify-between gap-4 px-4 py-2.5 text-start transition-colors',
|
||||
destructive ? 'text-destructive hover:bg-destructive/10' : 'hover:bg-accent/45',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
className,
|
||||
)}>
|
||||
<RowLabel label={<span className="text-sm">{label}</span>} description={description} />
|
||||
{icon && <span className={cn('shrink-0', destructive ? 'text-destructive' : 'text-muted-foreground')}>{icon}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Small ghost icon button with a tooltip — for inline row actions. */
|
||||
export function IconAction({
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
disabled,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
label: string
|
||||
icon: ReactNode
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
} & Omit<ComponentProps<typeof Button>, 'children' | 'onClick'>) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="iconSm"
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn('h-9 w-9 rounded-lg text-muted-foreground hover:text-foreground', className)}
|
||||
{...props}>
|
||||
{icon}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,32 +1,43 @@
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { Check, Copy, ExternalLink } from 'lucide-react'
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import { getLocale } from '~/paraglide/runtime.js'
|
||||
import { ReactComponent as LinkIcon } from '~/icons/link.svg'
|
||||
import * as config from '~/lib/config'
|
||||
import { defaultClaudeConfig, defaultOllamaConfig, defaultOpenAIConfig } from '~/lib/llm'
|
||||
import { InfoTooltip } from '~/components/info-tooltip'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { Input } from '~/components/ui/input'
|
||||
import { Switch } from '~/components/ui/switch'
|
||||
import { Textarea } from '~/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select'
|
||||
import { Field, type SettingsViewModel } from './shared'
|
||||
import { ActionRow, IconAction, SettingsField, SettingsGroup, SettingsRow, rowControlClass, type SettingsViewModel } from './shared'
|
||||
|
||||
const inlineLinkClass = 'h-6 w-6 rounded-md [&_svg]:size-3.5'
|
||||
|
||||
function LabelWithLink({ label, tooltip, onClick }: { label: string; tooltip: string; onClick: () => void }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{label}
|
||||
<IconAction label={tooltip} icon={<ExternalLink />} onClick={onClick} className={inlineLinkClass} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function SummarizeSection({ vm }: { vm: SettingsViewModel }) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">{m.processWithLlm()} ✨</h3>
|
||||
<InfoTooltip text={m.infoLlmSummarize()} />
|
||||
</div>
|
||||
<Switch checked={vm.preference.llmConfig?.enabled} onCheckedChange={vm.onEnableLlm} />
|
||||
</div>
|
||||
const platform = vm.preference.llmConfig?.platform
|
||||
|
||||
<Field label={m.llmPlatform()}>
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsGroup>
|
||||
<SettingsRow label={m.processWithLlm()} description={m.infoLlmSummarize()}>
|
||||
<Switch checked={vm.preference.llmConfig?.enabled} onCheckedChange={vm.onEnableLlm} />
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup>
|
||||
<SettingsRow label={m.llmPlatform()}>
|
||||
<Select
|
||||
value={vm.preference.llmConfig?.platform}
|
||||
value={platform}
|
||||
onValueChange={(value) => {
|
||||
const lang = new Intl.DisplayNames([getLocale()], { type: 'language' }).of(getLocale()) ?? 'English'
|
||||
const defaults =
|
||||
@@ -40,7 +51,7 @@ export function SummarizeSection({ vm }: { vm: SettingsViewModel }) {
|
||||
enabled: vm.preference.llmConfig?.enabled ?? false,
|
||||
})
|
||||
}}>
|
||||
<SelectTrigger className="capitalize">
|
||||
<SelectTrigger className={`w-52 capitalize ${rowControlClass}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -51,159 +62,142 @@ export function SummarizeSection({ vm }: { vm: SettingsViewModel }) {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</SettingsRow>
|
||||
|
||||
{vm.preference.llmConfig?.platform === 'claude' && (
|
||||
{platform === 'claude' && (
|
||||
<>
|
||||
<Field
|
||||
label={
|
||||
<>
|
||||
<InfoTooltip text={m.infoLlmApiKey()} />
|
||||
{m.llmApiKey()}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 text-primary underline hover:text-primary/80"
|
||||
onClick={() => openUrl(config.llmApiKeyUrl)}>
|
||||
{m.findHere()}
|
||||
</button>
|
||||
</>
|
||||
}>
|
||||
<SettingsRow
|
||||
label={<LabelWithLink label={m.llmApiKey()} tooltip={m.findHere()} onClick={() => openUrl(config.llmApiKeyUrl)} />}
|
||||
description={m.infoLlmApiKey()}>
|
||||
<Input
|
||||
value={vm.preference.llmConfig?.claudeApiKey}
|
||||
onChange={(e) => vm.preference.setLlmConfig({ ...vm.preference.llmConfig, claudeApiKey: e.target.value })}
|
||||
placeholder={m.pasteApiKey()}
|
||||
type="text"
|
||||
className={`w-64 ${rowControlClass}`}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={
|
||||
<>
|
||||
{m.llmModel()}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 text-primary underline hover:text-primary/80"
|
||||
onClick={() => openUrl('https://docs.anthropic.com/en/docs/about-claude/models')}>
|
||||
{m.findHere()}
|
||||
</button>
|
||||
</>
|
||||
<LabelWithLink
|
||||
label={m.llmModel()}
|
||||
tooltip={m.findHere()}
|
||||
onClick={() => openUrl('https://docs.anthropic.com/en/docs/about-claude/models')}
|
||||
/>
|
||||
}>
|
||||
<Input
|
||||
value={vm.preference.llmConfig?.model}
|
||||
onChange={(e) => vm.preference.setLlmConfig({ ...vm.preference.llmConfig, model: e.target.value })}
|
||||
placeholder="claude-sonnet-4-5"
|
||||
className={`w-64 ${rowControlClass}`}
|
||||
/>
|
||||
</Field>
|
||||
</SettingsRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
{vm.preference.llmConfig?.platform === 'ollama' && (
|
||||
{platform === 'ollama' && (
|
||||
<>
|
||||
<Field label={m.ollamaBaseUrl()}>
|
||||
<SettingsRow label={m.ollamaBaseUrl()}>
|
||||
<Input
|
||||
value={vm.preference.llmConfig?.ollamaBaseUrl}
|
||||
onChange={(e) => vm.preference.setLlmConfig({ ...vm.preference.llmConfig, ollamaBaseUrl: e.target.value })}
|
||||
className={`w-64 ${rowControlClass}`}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={
|
||||
<>
|
||||
{m.llmModel()}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 text-primary underline hover:text-primary/80"
|
||||
onClick={() => openUrl(`https://ollama.com/library/${vm.preference.llmConfig.model}`)}>
|
||||
{m.findHere()}
|
||||
</button>
|
||||
</>
|
||||
<LabelWithLink
|
||||
label={m.llmModel()}
|
||||
tooltip={m.findHere()}
|
||||
onClick={() => openUrl(`https://ollama.com/library/${vm.preference.llmConfig.model}`)}
|
||||
/>
|
||||
}>
|
||||
<Input
|
||||
value={vm.preference.llmConfig?.model}
|
||||
onChange={(e) => vm.preference.setLlmConfig({ ...vm.preference.llmConfig, model: e.target.value })}
|
||||
placeholder="llama3.2"
|
||||
className={`w-64 ${rowControlClass}`}
|
||||
/>
|
||||
</Field>
|
||||
</SettingsRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
{vm.preference.llmConfig?.platform === 'openai' && (
|
||||
{platform === 'openai' && (
|
||||
<>
|
||||
<Field label={m.baseUrl()}>
|
||||
<SettingsRow label={m.baseUrl()}>
|
||||
<Input
|
||||
value={vm.preference.llmConfig?.openaiBaseUrl}
|
||||
onChange={(e) => vm.preference.setLlmConfig({ ...vm.preference.llmConfig, openaiBaseUrl: e.target.value })}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
className={`w-64 ${rowControlClass}`}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={m.apiKey()}>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={m.apiKey()}>
|
||||
<Input
|
||||
value={vm.preference.llmConfig?.openaiApiKey}
|
||||
onChange={(e) => vm.preference.setLlmConfig({ ...vm.preference.llmConfig, openaiApiKey: e.target.value })}
|
||||
placeholder={m.optionalLocalServerKey()}
|
||||
type="text"
|
||||
className={`w-64 ${rowControlClass}`}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={m.llmModel()}>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={m.llmModel()}>
|
||||
<Input
|
||||
value={vm.preference.llmConfig?.model}
|
||||
onChange={(e) => vm.preference.setLlmConfig({ ...vm.preference.llmConfig, model: e.target.value })}
|
||||
placeholder="gpt-4o-mini"
|
||||
className={`w-64 ${rowControlClass}`}
|
||||
/>
|
||||
</Field>
|
||||
</SettingsRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Field
|
||||
label={
|
||||
<>
|
||||
<InfoTooltip text={m.infoLlmPrompt()} />
|
||||
{m.llmPrompt()}
|
||||
</>
|
||||
}>
|
||||
<Textarea
|
||||
value={vm.preference.llmConfig?.prompt}
|
||||
onChange={(e) => vm.preference.setLlmConfig({ ...vm.preference.llmConfig, prompt: e.target.value })}
|
||||
onBlur={vm.validateLlmPrompt}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={
|
||||
<>
|
||||
<InfoTooltip text={m.infoMaxTokens()} />
|
||||
{m.maxTokens()}
|
||||
</>
|
||||
}>
|
||||
<SettingsRow label={m.maxTokens()} description={m.infoMaxTokens()}>
|
||||
<Input
|
||||
type="number"
|
||||
onChange={(e) => vm.preference.setLlmConfig({ ...vm.preference.llmConfig, maxTokens: vm.parseIntOr(e.target.value, 1) })}
|
||||
value={vm.preference.llmConfig?.maxTokens}
|
||||
className={`w-24 text-end ${rowControlClass}`}
|
||||
/>
|
||||
</Field>
|
||||
</SettingsRow>
|
||||
|
||||
<Button onClick={vm.checkLlm} size="sm" className="w-full">
|
||||
{m.runLlmCheck()}
|
||||
</Button>
|
||||
<SettingsField label={m.llmPrompt()} description={m.infoLlmPrompt()}>
|
||||
<Textarea
|
||||
value={vm.preference.llmConfig?.prompt}
|
||||
onChange={(e) => vm.preference.setLlmConfig({ ...vm.preference.llmConfig, prompt: e.target.value })}
|
||||
onBlur={vm.validateLlmPrompt}
|
||||
placeholder="Summarize the following transcript in clear bullet points:%s"
|
||||
className="min-h-24 max-h-60 w-full resize-none overflow-y-auto rounded-lg bg-muted/40 text-sm"
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup>
|
||||
<SettingsRow label="Connection" description={vm.llmError ? m.checkError() : undefined}>
|
||||
<Button variant="outline" size="sm" onClick={vm.checkLlm}>
|
||||
{m.runLlmCheck()}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
{vm.llmError && (
|
||||
<div className="relative rounded-lg border border-destructive/30 bg-destructive/5 p-3 pe-10">
|
||||
<button type="button" className="absolute end-2 top-2 p-1 text-muted-foreground hover:text-foreground" onClick={vm.copyLlmError}>
|
||||
<div className="relative px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute end-5 top-4 p-1 text-muted-foreground hover:text-foreground"
|
||||
onClick={vm.copyLlmError}
|
||||
aria-label={m.copy()}>
|
||||
{vm.llmErrorCopied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</button>
|
||||
<pre className="whitespace-pre-wrap break-all text-xs text-destructive">{vm.llmError}</pre>
|
||||
<pre className="max-h-40 overflow-y-auto whitespace-pre-wrap break-all pe-8 text-xs text-destructive">{vm.llmError}</pre>
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
|
||||
{vm.preference.llmConfig?.platform === 'claude' && (
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<button type="button" className="text-left text-primary underline hover:text-primary/80" onClick={() => openUrl(config.llmLimitsUrl)}>
|
||||
{m.setMonthlySpendLimit()}
|
||||
</button>
|
||||
<button type="button" className="text-left text-primary underline hover:text-primary/80" onClick={() => openUrl(config.llmCostUrl)}>
|
||||
{m.llmCurrentCost()}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{platform === 'claude' && (
|
||||
<SettingsGroup>
|
||||
<ActionRow label={m.setMonthlySpendLimit()} icon={<LinkIcon className="h-4 w-4" />} onClick={() => openUrl(config.llmLimitsUrl)} />
|
||||
<ActionRow label={m.llmCurrentCost()} icon={<LinkIcon className="h-4 w-4" />} onClick={() => openUrl(config.llmCostUrl)} />
|
||||
</SettingsGroup>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,49 +1,47 @@
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import LanguageInput from '~/components/language-input'
|
||||
import { InfoTooltip } from '~/components/info-tooltip'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { Switch } from '~/components/ui/switch'
|
||||
import { SectionCard, type SettingsViewModel } from './shared'
|
||||
import { SettingsGroup, SettingsRow, type SettingsViewModel } from './shared'
|
||||
|
||||
export function TranscriptionSection({ vm }: { vm: SettingsViewModel }) {
|
||||
const recordingPath = vm.preference.customRecordingPath ?? vm.defaultRecordingPath
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<SectionCard>
|
||||
<LanguageInput />
|
||||
</SectionCard>
|
||||
<SectionCard>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/45 py-2">
|
||||
<span className="text-sm font-medium">{m.playSoundOnFinish()}</span>
|
||||
<div className="space-y-6">
|
||||
<SettingsGroup>
|
||||
{/* LanguageInput lives outside this page; reflow it into a settings row. */}
|
||||
<div className="[&>div]:flex [&>div]:min-h-[52px] [&>div]:items-center [&>div]:justify-between [&>div]:gap-4 [&>div]:space-y-0 [&>div]:px-4 [&>div]:py-2.5 [&_label]:text-sm [&_label]:font-normal [&_button]:h-9 [&_button]:w-52 [&_button]:rounded-lg">
|
||||
<LanguageInput />
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup>
|
||||
<SettingsRow label={m.playSoundOnFinish()}>
|
||||
<Switch checked={vm.preference.soundOnFinish} onCheckedChange={vm.preference.setSoundOnFinish} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pb-1 pt-4">
|
||||
<span className="text-sm font-medium">{m.focusWindowOnFinish()}</span>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={m.focusWindowOnFinish()}>
|
||||
<Switch checked={vm.preference.focusOnFinish} onCheckedChange={vm.preference.setFocusOnFinish} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1 px-1">
|
||||
<InfoTooltip text={m.recordingSavePathInfo()} />
|
||||
<span className="text-sm font-semibold text-foreground/95">{m.recordingSavePath()}</span>
|
||||
</div>
|
||||
<SectionCard>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="min-w-0 truncate text-sm text-muted-foreground" title={vm.preference.customRecordingPath ?? vm.defaultRecordingPath}>
|
||||
{vm.preference.customRecordingPath ?? vm.defaultRecordingPath}
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{vm.preference.customRecordingPath && (
|
||||
<Button variant="ghost" size="sm" onMouseDown={vm.resetRecordingPath}>
|
||||
{m.resetToDefault()}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onMouseDown={vm.changeRecordingPath}>
|
||||
{m.changeRecordingPath()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup title={m.recordingSavePath()} description={m.recordingSavePathInfo()}>
|
||||
<SettingsRow
|
||||
label={
|
||||
<span title={recordingPath} className="block truncate font-mono text-xs text-muted-foreground">
|
||||
{recordingPath}
|
||||
</span>
|
||||
}>
|
||||
{vm.preference.customRecordingPath && (
|
||||
<Button variant="ghost" size="sm" onMouseDown={vm.resetRecordingPath}>
|
||||
{m.resetToDefault()}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onMouseDown={vm.changeRecordingPath}>
|
||||
{m.changeRecordingPath()}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,253 +1,164 @@
|
||||
import { message } from '@tauri-apps/plugin-dialog'
|
||||
import { RotateCcw, Wand2 } from 'lucide-react'
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import { InfoTooltip } from '~/components/info-tooltip'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { Input } from '~/components/ui/input'
|
||||
import { Switch } from '~/components/ui/switch'
|
||||
import { Textarea } from '~/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select'
|
||||
import { Field, SectionCard, type SettingsViewModel } from './shared'
|
||||
import { ActionRow, SettingsField, SettingsGroup, SettingsNote, SettingsRow, rowControlClass, type SettingsViewModel } from './shared'
|
||||
|
||||
const numberInputClass = 'w-24 text-end'
|
||||
const textareaClass = 'min-h-24 max-h-60 w-full resize-none overflow-y-auto rounded-lg bg-muted/40 text-sm'
|
||||
|
||||
export function TuningSection({ vm }: { vm: SettingsViewModel }) {
|
||||
const promptLength = vm.preference.modelOptions?.init_prompt?.length ?? 0
|
||||
const isGreedy = vm.preference.modelOptions.sampling_strategy === 'greedy'
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<span className="px-1 text-sm font-semibold text-foreground/95">{m.speakerTiming()}</span>
|
||||
<SectionCard>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1 text-sm font-medium">
|
||||
<InfoTooltip text={m.infoDiarization()} />
|
||||
{m.enableDiarization()}
|
||||
</span>
|
||||
<Switch checked={vm.preference.diarizeEnabled} onCheckedChange={vm.toggleDiarization} />
|
||||
</div>
|
||||
{vm.preference.diarizeEnabled && <p className="text-sm italic text-muted-foreground">{m.diarizeMaxSpeakersNote()}</p>}
|
||||
<div className="h-px bg-border/45" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1 text-sm font-medium">
|
||||
<InfoTooltip text={m.stableTimestampsInfo()} />
|
||||
{m.enableStableTimestamps()}
|
||||
</span>
|
||||
<Switch checked={vm.preference.stableTimestampsEnabled} onCheckedChange={vm.handleStableTimestampsToggle} />
|
||||
</div>
|
||||
{vm.preference.stableTimestampsEnabled && <p className="text-sm italic text-muted-foreground">{m.stableTimestampsSlowNote()}</p>}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<SettingsGroup title={m.speakerTiming()}>
|
||||
<SettingsRow label={m.enableDiarization()} description={m.infoDiarization()}>
|
||||
<Switch checked={vm.preference.diarizeEnabled} onCheckedChange={vm.toggleDiarization} />
|
||||
</SettingsRow>
|
||||
{vm.preference.diarizeEnabled && <SettingsNote>{m.diarizeMaxSpeakersNote()}</SettingsNote>}
|
||||
<SettingsRow label={m.enableStableTimestamps()} description={m.stableTimestampsInfo()}>
|
||||
<Switch checked={vm.preference.stableTimestampsEnabled} onCheckedChange={vm.handleStableTimestampsToggle} />
|
||||
</SettingsRow>
|
||||
{vm.preference.stableTimestampsEnabled && <SettingsNote>{m.stableTimestampsSlowNote()}</SettingsNote>}
|
||||
</SettingsGroup>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="px-1 text-sm font-semibold text-foreground/95">{m.modelOptions()}</span>
|
||||
<SectionCard>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1 text-sm font-medium">
|
||||
<InfoTooltip text={m.infoTranslateToEnglish()} />
|
||||
{m.translateToEnglish()}
|
||||
</span>
|
||||
<Switch
|
||||
checked={Boolean(vm.preference.modelOptions.translate)}
|
||||
onCheckedChange={(checked) => vm.preference.setModelOptions({ ...vm.preference.modelOptions, translate: checked })}
|
||||
/>
|
||||
</div>
|
||||
{vm.preference.modelOptions.translate && <p className="text-sm italic text-muted-foreground">{m.translateWhisperModelNote()}</p>}
|
||||
<SettingsGroup title={m.modelOptions()}>
|
||||
<SettingsRow label={m.translateToEnglish()} description={m.infoTranslateToEnglish()}>
|
||||
<Switch
|
||||
checked={Boolean(vm.preference.modelOptions.translate)}
|
||||
onCheckedChange={(checked) => vm.preference.setModelOptions({ ...vm.preference.modelOptions, translate: checked })}
|
||||
/>
|
||||
</SettingsRow>
|
||||
{vm.preference.modelOptions.translate && <SettingsNote>{m.translateWhisperModelNote()}</SettingsNote>}
|
||||
|
||||
<Field
|
||||
label={
|
||||
<>
|
||||
<InfoTooltip text={m.infoPrompt()} />
|
||||
{m.prompt()} ({m.leftover()} {1024 - (vm.preference.modelOptions?.init_prompt?.length ?? 0)} {m.characters()})
|
||||
</>
|
||||
}>
|
||||
<Textarea
|
||||
value={vm.preference.modelOptions?.init_prompt}
|
||||
onChange={(e) => vm.preference.setModelOptions({ ...vm.preference.modelOptions, init_prompt: e.target.value.slice(0, 1024) })}
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
</Field>
|
||||
<SettingsRow label={m.useWordTimestamps()} description={m.infoUseWordTimestamps()}>
|
||||
<Switch
|
||||
checked={Boolean(vm.preference.modelOptions.word_timestamps)}
|
||||
onCheckedChange={(checked) => vm.preference.setModelOptions({ ...vm.preference.modelOptions, word_timestamps: checked })}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1 text-sm font-medium">
|
||||
<InfoTooltip text={m.infoUseWordTimestamps()} />
|
||||
{m.useWordTimestamps()}
|
||||
</span>
|
||||
<Switch
|
||||
checked={Boolean(vm.preference.modelOptions.word_timestamps)}
|
||||
onCheckedChange={(checked) => vm.preference.setModelOptions({ ...vm.preference.modelOptions, word_timestamps: checked })}
|
||||
/>
|
||||
</div>
|
||||
<SettingsRow label={m.maxSentenceLen()} description={m.infoMaxSentenceLen()}>
|
||||
<Input
|
||||
type="number"
|
||||
value={vm.preference.modelOptions.max_sentence_len}
|
||||
onChange={(e) => {
|
||||
if (!vm.preference.modelOptions.word_timestamps) message(m.pleaseEnableWordTimestamps())
|
||||
vm.preference.setModelOptions({ ...vm.preference.modelOptions, max_sentence_len: vm.parseIntOr(e.target.value, 1) })
|
||||
}}
|
||||
className={`${numberInputClass} ${rowControlClass}`}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
label={
|
||||
<>
|
||||
<InfoTooltip text={m.infoMaxSentenceLen()} />
|
||||
{m.maxSentenceLen()}
|
||||
</>
|
||||
}>
|
||||
<Input
|
||||
type="number"
|
||||
value={vm.preference.modelOptions.max_sentence_len}
|
||||
onChange={(e) => {
|
||||
if (!vm.preference.modelOptions.word_timestamps) message(m.pleaseEnableWordTimestamps())
|
||||
vm.preference.setModelOptions({ ...vm.preference.modelOptions, max_sentence_len: vm.parseIntOr(e.target.value, 1) })
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<SettingsField
|
||||
label={m.prompt()}
|
||||
description={m.infoPrompt()}
|
||||
footer={promptLength > 0 ? `${1024 - promptLength} ${m.characters()} ${m.leftover().toLowerCase()}` : undefined}>
|
||||
<Textarea
|
||||
value={vm.preference.modelOptions?.init_prompt}
|
||||
onChange={(e) => vm.preference.setModelOptions({ ...vm.preference.modelOptions, init_prompt: e.target.value.slice(0, 1024) })}
|
||||
placeholder="Names, jargon or spellings you expect in the audio — for example: Vibe, Tauri, Whisper"
|
||||
className={textareaClass}
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsGroup>
|
||||
|
||||
<Field
|
||||
label={
|
||||
<>
|
||||
<InfoTooltip text={m.infoThreads()} />
|
||||
{m.threads()}
|
||||
</>
|
||||
}>
|
||||
<Input
|
||||
type="number"
|
||||
value={vm.preference.modelOptions.n_threads}
|
||||
onChange={(e) =>
|
||||
vm.preference.setModelOptions({ ...vm.preference.modelOptions, n_threads: vm.parseIntOr(e.target.value, 1) })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<SettingsGroup title="Decoding">
|
||||
<SettingsRow label={m.samplingStrategy()} description={m.samplingStrategyInfo()}>
|
||||
<Select
|
||||
value={vm.preference.modelOptions.sampling_strategy}
|
||||
onValueChange={(value) =>
|
||||
vm.preference.setModelOptions({ ...vm.preference.modelOptions, sampling_strategy: value as 'greedy' | 'beam search' })
|
||||
}>
|
||||
<SelectTrigger className={`w-40 capitalize ${rowControlClass}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['beam search', 'greedy'].map((name) => (
|
||||
<SelectItem key={name} value={name} className="capitalize">
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsRow>
|
||||
|
||||
<Field
|
||||
label={
|
||||
<>
|
||||
<InfoTooltip text={m.infoTemperature()} />
|
||||
{m.temperature()}
|
||||
</>
|
||||
}>
|
||||
<Input
|
||||
type="number"
|
||||
step={0.1}
|
||||
value={vm.preference.modelOptions.temperature}
|
||||
onChange={(e) =>
|
||||
vm.preference.setModelOptions({ ...vm.preference.modelOptions, temperature: parseFloat(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<SettingsRow label={isGreedy ? m.bestOf() : m.beamSize()} description={isGreedy ? m.greedyInfo() : m.beamInfo()}>
|
||||
<Input
|
||||
type="number"
|
||||
step={1}
|
||||
value={isGreedy ? (vm.preference.modelOptions.best_of ?? 5) : (vm.preference.modelOptions.beam_size ?? 5)}
|
||||
onChange={(e) => {
|
||||
const val = vm.parseIntOr(e.target.value, 5)
|
||||
if (isGreedy) {
|
||||
vm.preference.setModelOptions({ ...vm.preference.modelOptions, best_of: val })
|
||||
} else {
|
||||
vm.preference.setModelOptions({ ...vm.preference.modelOptions, beam_size: val })
|
||||
}
|
||||
}}
|
||||
className={`${numberInputClass} ${rowControlClass}`}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<Field
|
||||
label={
|
||||
<>
|
||||
<InfoTooltip text={m.infoMaxTextCtx()} />
|
||||
{m.maxTextCtx()}
|
||||
</>
|
||||
}>
|
||||
<Input
|
||||
type="number"
|
||||
step={1}
|
||||
value={vm.preference.modelOptions.max_text_ctx ?? 0}
|
||||
onChange={(e) =>
|
||||
vm.preference.setModelOptions({ ...vm.preference.modelOptions, max_text_ctx: vm.parseIntOr(e.target.value, 0) })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<SettingsRow label={m.temperature()} description={m.infoTemperature()}>
|
||||
<Input
|
||||
type="number"
|
||||
step={0.1}
|
||||
value={vm.preference.modelOptions.temperature}
|
||||
onChange={(e) => vm.preference.setModelOptions({ ...vm.preference.modelOptions, temperature: parseFloat(e.target.value) || 0 })}
|
||||
className={`${numberInputClass} ${rowControlClass}`}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
label={
|
||||
<>
|
||||
<InfoTooltip text={m.samplingStrategyInfo()} />
|
||||
{m.samplingStrategy()}
|
||||
</>
|
||||
}>
|
||||
<Select
|
||||
value={vm.preference.modelOptions.sampling_strategy}
|
||||
onValueChange={(value) =>
|
||||
vm.preference.setModelOptions({ ...vm.preference.modelOptions, sampling_strategy: value as 'greedy' | 'beam search' })
|
||||
}>
|
||||
<SelectTrigger className="capitalize">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['beam search', 'greedy'].map((name) => (
|
||||
<SelectItem key={name} value={name} className="capitalize">
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<SettingsRow label={m.maxTextCtx()} description={m.infoMaxTextCtx()}>
|
||||
<Input
|
||||
type="number"
|
||||
step={1}
|
||||
value={vm.preference.modelOptions.max_text_ctx ?? 0}
|
||||
onChange={(e) => vm.preference.setModelOptions({ ...vm.preference.modelOptions, max_text_ctx: vm.parseIntOr(e.target.value, 0) })}
|
||||
className={`${numberInputClass} ${rowControlClass}`}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<Field
|
||||
label={
|
||||
<>
|
||||
<InfoTooltip text={vm.preference.modelOptions.sampling_strategy === 'greedy' ? m.greedyInfo() : m.beamInfo()} />
|
||||
{vm.preference.modelOptions.sampling_strategy === 'greedy' ? m.bestOf() : m.beamSize()}
|
||||
</>
|
||||
}>
|
||||
<Input
|
||||
type="number"
|
||||
step={1}
|
||||
value={
|
||||
vm.preference.modelOptions.sampling_strategy === 'greedy'
|
||||
? (vm.preference.modelOptions.best_of ?? 5)
|
||||
: (vm.preference.modelOptions.beam_size ?? 5)
|
||||
}
|
||||
onChange={(e) => {
|
||||
const val = vm.parseIntOr(e.target.value, 5)
|
||||
if (vm.preference.modelOptions.sampling_strategy === 'greedy') {
|
||||
vm.preference.setModelOptions({ ...vm.preference.modelOptions, best_of: val })
|
||||
} else {
|
||||
vm.preference.setModelOptions({ ...vm.preference.modelOptions, beam_size: val })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
<SettingsRow label={m.threads()} description={m.infoThreads()}>
|
||||
<Input
|
||||
type="number"
|
||||
value={vm.preference.modelOptions.n_threads}
|
||||
onChange={(e) => vm.preference.setModelOptions({ ...vm.preference.modelOptions, n_threads: vm.parseIntOr(e.target.value, 1) })}
|
||||
className={`${numberInputClass} ${rowControlClass}`}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="px-1 text-sm font-semibold text-foreground/95">{m.ffmpegOptions()}</span>
|
||||
<SectionCard>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1 text-sm font-medium">
|
||||
<InfoTooltip text={m.infoNormalizeLoudness()} />
|
||||
{m.normalizeLoudness()}
|
||||
</span>
|
||||
<Switch
|
||||
checked={vm.preference.ffmpegOptions.normalize_loudness}
|
||||
onCheckedChange={(checked) => vm.preference.setFfmpegOptions({ ...vm.preference.ffmpegOptions, normalize_loudness: checked })}
|
||||
/>
|
||||
</div>
|
||||
<SettingsGroup title={m.ffmpegOptions()}>
|
||||
<SettingsRow label={m.normalizeLoudness()} description={m.infoNormalizeLoudness()}>
|
||||
<Switch
|
||||
checked={vm.preference.ffmpegOptions.normalize_loudness}
|
||||
onCheckedChange={(checked) => vm.preference.setFfmpegOptions({ ...vm.preference.ffmpegOptions, normalize_loudness: checked })}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={m.customFfmpegCommand()}
|
||||
description="ffmpeg -i {input} -ar 16000 -ac 1 -c:a pcm_s16le {custom_command} -hide_banner -y -loglevel error">
|
||||
<Input
|
||||
value={vm.preference.ffmpegOptions.custom_command ?? ''}
|
||||
onChange={(e) => vm.preference.setFfmpegOptions({ ...vm.preference.ffmpegOptions, custom_command: e.target.value || null })}
|
||||
placeholder={vm.preference.ffmpegOptions.normalize_loudness ? '-af loudnorm=I=-16:TP=-1.5:LRA=11' : '-af ...'}
|
||||
type="text"
|
||||
className={`w-64 ${rowControlClass}`}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
<Field
|
||||
label={
|
||||
<>
|
||||
<InfoTooltip text={'ffmpeg -i {input} -ar 16000 -ac 1 -c:a pcm_s16le {custom_command} -hide_banner -y -loglevel error'} />
|
||||
{m.customFfmpegCommand()}
|
||||
</>
|
||||
}>
|
||||
<Input
|
||||
value={vm.preference.ffmpegOptions.custom_command ?? ''}
|
||||
onChange={(e) => vm.preference.setFfmpegOptions({ ...vm.preference.ffmpegOptions, custom_command: e.target.value || null })}
|
||||
placeholder={vm.preference.ffmpegOptions.normalize_loudness ? '-af loudnorm=I=-16:TP=-1.5:LRA=11' : ''}
|
||||
type="text"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="px-1 text-sm font-semibold text-foreground/95">{m.presets()}</span>
|
||||
<SectionCard>
|
||||
<div className="flex gap-4">
|
||||
<Button variant="secondary" onClick={vm.preference.enableSubtitlesPreset} className="flex-1">
|
||||
{m.presetForSubtitles()}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={vm.preference.resetOptions} className="flex-1">
|
||||
{m.resetOptions()}
|
||||
</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
<SettingsGroup title={m.presets()}>
|
||||
<ActionRow label={m.presetForSubtitles()} icon={<Wand2 className="h-4 w-4" />} onClick={vm.preference.enableSubtitlesPreset} />
|
||||
<ActionRow label={m.resetOptions()} icon={<RotateCcw className="h-4 w-4" />} onClick={vm.preference.resetOptions} />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { m } from '~/paraglide/messages.js'
|
||||
import { viewModel } from './view-model'
|
||||
import { Progress } from '~/components/ui/progress'
|
||||
@@ -10,40 +11,56 @@ function App() {
|
||||
const vm = viewModel()
|
||||
|
||||
return (
|
||||
<div className="app-shell flex min-h-screen items-center justify-center">
|
||||
<div className="app-panel w-full max-w-xl text-center">
|
||||
<p className="app-kicker mb-2">{m.setup({ defaultValue: 'Setup' })}</p>
|
||||
<div className="text-balance text-2xl font-semibold md:text-3xl">{m.downloadingModel()}</div>
|
||||
<div className="flex min-h-screen items-center justify-center px-5 py-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.22, ease: 'easeOut' }}
|
||||
className="w-full max-w-lg text-center">
|
||||
<p className="eyebrow">{m.setup({ defaultValue: 'Setup' })}</p>
|
||||
<h1 className="mt-4 text-balance text-[28px] font-semibold leading-[1.1] tracking-[-0.03em] md:text-[36px]">{m.downloadingModel()}</h1>
|
||||
|
||||
<div className="mt-6 flex flex-col items-center gap-3">
|
||||
<div className="mt-9 flex flex-col items-center gap-4">
|
||||
{vm.downloadProgress > 0 && (
|
||||
<>
|
||||
<Progress className="w-full max-w-sm" value={vm.downloadProgress} />
|
||||
{!vm?.location?.state?.downloadURL && <p className="text-sm text-muted-foreground">{m.thisHappensOnce()}</p>}
|
||||
<Progress className="progress-aurora h-1.5 w-full max-w-sm bg-muted" value={vm.downloadProgress} />
|
||||
<div className="flex items-center gap-3 text-[13px] text-muted-foreground">
|
||||
<span className="tabular-nums">{Math.round(vm.downloadProgress)}%</span>
|
||||
{!vm?.location?.state?.downloadURL && (
|
||||
<>
|
||||
<span className="h-1 w-1 rounded-full bg-border" />
|
||||
<span>{m.thisHappensOnce()}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(vm.downloadProgress === 0 || vm.isOnline === null) && <Spinner className="h-8 w-8" />}
|
||||
{(vm.downloadProgress === 0 || vm.isOnline === null) && <Spinner className="h-6 w-6 text-muted-foreground" />}
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="mt-5 text-destructive text-xs" onClick={vm.cancelSetup}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-8 rounded-full px-4 text-[13px] font-medium text-muted-foreground hover:text-foreground"
|
||||
onClick={vm.cancelSetup}>
|
||||
{m.cancel()}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{m.infoCancelDownload()}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<Dialog open={vm.isOnline === false}>
|
||||
<DialogContent>
|
||||
<div className="text-center text-2xl font-semibold">{m.noConnection()}</div>
|
||||
<p className="mt-3 text-center text-muted-foreground">{m.infoManualDownload()}</p>
|
||||
<div className="mt-5 flex flex-col justify-center gap-2">
|
||||
<Button className="flex-1" onClick={vm.downloadIfOnline}>
|
||||
<DialogContent className="rounded-2xl">
|
||||
<h2 className="text-center text-[22px] font-semibold tracking-[-0.03em]">{m.noConnection()}</h2>
|
||||
<p className="mt-2 text-center text-[13px] leading-relaxed text-muted-foreground">{m.infoManualDownload()}</p>
|
||||
<div className="mt-6 flex flex-col justify-center gap-2">
|
||||
<Button className="rounded-full" onClick={vm.downloadIfOnline}>
|
||||
{m.tryAgain()}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={vm.cancelSetup}>
|
||||
<Button variant="ghost" size="sm" className="rounded-full text-muted-foreground hover:text-foreground" onClick={vm.cancelSetup}>
|
||||
{m.iPreferManualSetup()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactNode, SetStateAction, createContext, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { useLocalStorage } from 'usehooks-ts'
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||
import { load } from '@tauri-apps/plugin-store'
|
||||
import * as config from '~/lib/config'
|
||||
import { TextFormat } from '~/components/format-select'
|
||||
@@ -84,6 +85,10 @@ export interface Preference {
|
||||
|
||||
analyticsEnabled: boolean
|
||||
setAnalyticsEnabled: (value: boolean) => void
|
||||
|
||||
/** Auto-save every finished transcription into Documents/Vibe (powers the Recents sidebar). */
|
||||
saveTranscripts: boolean
|
||||
setSaveTranscripts: ModifyState<boolean>
|
||||
}
|
||||
|
||||
// Create the context
|
||||
@@ -181,6 +186,7 @@ export function PreferenceProvider({ children }: { children: ReactNode }) {
|
||||
const [stableTimestampsEnabled, setStableTimestampsEnabled] = useLocalStorage<boolean>('prefs_stable_timestamps_enabled', false)
|
||||
const [gpuDevice, setGpuDevice] = useLocalStorage<number | null>('prefs_gpu_device', null)
|
||||
const [unloadTimeoutMinutes, setUnloadTimeoutMinutes] = useLocalStorage<number>('prefs_unload_timeout_minutes', 5)
|
||||
const [saveTranscripts, setSaveTranscripts] = useLocalStorage<boolean>('prefs_save_transcripts', true)
|
||||
|
||||
const [analyticsEnabled, setAnalyticsEnabledLocal] = useState(true)
|
||||
useEffect(() => {
|
||||
@@ -222,6 +228,13 @@ export function PreferenceProvider({ children }: { children: ReactNode }) {
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
// Keep the native window appearance in sync so macOS vibrancy (glass sidebar)
|
||||
// renders light glass in light mode instead of the system appearance.
|
||||
try {
|
||||
void getCurrentWebviewWindow().setTheme(theme)
|
||||
} catch {
|
||||
/* browser mode */
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
function setLanguageDefaults() {
|
||||
@@ -327,6 +340,8 @@ export function PreferenceProvider({ children }: { children: ReactNode }) {
|
||||
setUnloadTimeoutMinutes,
|
||||
analyticsEnabled,
|
||||
setAnalyticsEnabled,
|
||||
saveTranscripts,
|
||||
setSaveTranscripts,
|
||||
}
|
||||
|
||||
return <PreferenceContext.Provider value={preference}>{children}</PreferenceContext.Provider>
|
||||
|
||||
@@ -16,6 +16,10 @@ install:
|
||||
dev: setup
|
||||
cd desktop && pnpm exec tauri dev
|
||||
|
||||
# Run only the desktop frontend in the browser (mock Tauri, no Rust build)
|
||||
dev-web:
|
||||
cd desktop && pnpm dev
|
||||
|
||||
# Build the app for production (runs pre-build first)
|
||||
build: setup
|
||||
cd desktop && pnpm exec tauri build
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Vibe UI Redesign — Design Contract
|
||||
|
||||
Premium, calm, editorial. Reference: ElevenLabs product pages. The product promise is
|
||||
**drop a file → get text**; everything else recedes.
|
||||
|
||||
## Principles
|
||||
|
||||
- One hero action per screen. Secondary actions are quiet pills or ghost buttons.
|
||||
- Generous whitespace, hairline borders (1px, low-contrast), big radii (cards 1.25rem, buttons pill).
|
||||
- Exactly one expressive element per screen: the **aurora** surface (soft multi-hue gradient with
|
||||
subtle grain) used for the hero dropzone and progress accents. Everything else is monochrome ink/paper.
|
||||
- Motion: framer-motion, 150–250ms ease-out, translate+fade only. No bouncy springs.
|
||||
|
||||
## Tokens (globals.css, shadcn-style vars — keep every existing var name working)
|
||||
|
||||
Light (default): background #FAFAF8, foreground #111110, card #FFFFFF, muted #F1F1EE,
|
||||
muted-foreground #6F6F6A, border #E7E7E2, primary = ink #111110 (primary-foreground #FFFFFF),
|
||||
ring #111110/20.
|
||||
Dark: background #0E0E0D, card #161615, foreground #F4F4F1, muted #1E1E1C,
|
||||
muted-foreground #9A9A94, border #262624, primary #F4F4F1 (primary-foreground #111110).
|
||||
Destructive/success: keep hues, desaturate slightly to match.
|
||||
|
||||
Aurora gradient (both themes): conic/radial blend of #5B8DEF, #B98DE3, #E8A87C, #7CC5A0 at low
|
||||
saturation, heavy blur, optional grain via inline SVG noise; always behind a card or as a thin
|
||||
progress fill, never behind body text.
|
||||
|
||||
Typography: 'Inter Variable' (@fontsource-variable/inter, already installed — import in globals.css)
|
||||
as --font-sans. Headlines: font-semibold, tracking -0.03em. Body 14px, headlines 28–40px.
|
||||
Eyebrow labels: 11px, uppercase, tracking +0.08em, muted-foreground.
|
||||
|
||||
## Architecture (single flow replaces home + batch)
|
||||
|
||||
New `src/pages/main/` — one page, three states, one session store:
|
||||
|
||||
1. **Idle** — full-window hero: aurora dropzone card centered, "Drop audio or video" headline,
|
||||
Browse button (primary pill), quiet pills: Record · From link · Folder. Below, a single quiet
|
||||
row: language select + model name (opens settings model section). Drag-anywhere on window
|
||||
highlights the zone.
|
||||
2. **Working** — dropping file(s) starts transcription immediately (no confirm). Left: file queue
|
||||
(each row: name, duration, thin aurora progress bar, per-file state check/spinner/error).
|
||||
Right (or full width for single file): live transcript streaming in as segments arrive,
|
||||
auto-scroll, timestamps in muted ink. Cancel per file + Cancel all.
|
||||
3. **Done** — transcript reading view: serif-free editorial text blocks with muted timestamps,
|
||||
sticky toolbar: Copy, Export (format dropdown: srt/vtt/txt/html/pdf/json/docx), Search,
|
||||
New transcription (resets to Idle). Batch: file list stays left, click to switch transcript.
|
||||
|
||||
Topbar (all states): wordmark "Vibe" left (text, tight tracking); right: ghost icon buttons —
|
||||
New, Settings (gear). No "..." menu.
|
||||
|
||||
`/batch` route redirects to `/`. `/setup` keeps its logic but restyled with the same tokens.
|
||||
Settings modal: keep as-is (it inherits new tokens automatically).
|
||||
|
||||
## Code rules
|
||||
|
||||
- Reuse existing invoke/event logic — hooks in src/pages/home/hooks and batch view-model show all
|
||||
wiring (load_model → transcribe, events transcribe_progress / new_segment, abort_transcribe).
|
||||
- Reuse src/components/ui primitives; restyle via tokens, not forks.
|
||||
- i18n: use existing `m.*` messages from ~/paraglide/messages.js where a fitting key exists;
|
||||
otherwise plain English (keys added later).
|
||||
- Preserve: drag-drop via tauri://drag-drop events (webview) AND browser dnd fallback, deep links,
|
||||
record + from-URL flows, advanced options reachable (small "Options" ghost button on the quiet row).
|
||||
Reference in New Issue
Block a user