diff --git a/app/src/components/CapturesTab/CapturesTab.tsx b/app/src/components/CapturesTab/CapturesTab.tsx index 7492d320..bb54c2fc 100644 --- a/app/src/components/CapturesTab/CapturesTab.tsx +++ b/app/src/components/CapturesTab/CapturesTab.tsx @@ -1,8 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; -import { save } from '@tauri-apps/plugin-dialog'; -import { writeFile, writeTextFile } from '@tauri-apps/plugin-fs'; import { Captions, Check, @@ -27,6 +25,14 @@ import { AudioBars } from '@/components/AudioBars'; import { CapturePill } from '@/components/CapturePill/CapturePill'; import { CaptureInlinePlayer } from '@/components/CapturesTab/CaptureInlinePlayer'; import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist'; +import { + ListPane, + ListPaneHeader, + ListPaneScroll, + ListPaneSearch, + ListPaneTitle, + ListPaneTitleRow, +} from '@/components/ListPane'; import { AlertDialog, AlertDialogAction, @@ -48,14 +54,6 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Textarea } from '@/components/ui/textarea'; -import { - ListPane, - ListPaneHeader, - ListPaneScroll, - ListPaneSearch, - ListPaneTitle, - ListPaneTitleRow, -} from '@/components/ListPane'; import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; import type { @@ -72,6 +70,7 @@ import { useCaptureSettings } from '@/lib/hooks/useSettings'; import { cn } from '@/lib/utils/cn'; import { formatAbsoluteDate, formatDate } from '@/lib/utils/format'; import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes'; +import { usePlatform } from '@/platform/PlatformContext'; import { useGenerationStore } from '@/stores/generationStore'; import { usePlayerStore } from '@/stores/playerStore'; @@ -135,6 +134,7 @@ export function CapturesTab() { const { t } = useTranslation(); const queryClient = useQueryClient(); const { toast } = useToast(); + const platform = usePlatform(); const fileInputRef = useRef(null); const uploadInputRef = useRef(null); @@ -202,6 +202,7 @@ export function CapturesTab() { // the race window between ``setSelectedId(new)`` and the refetched list // actually containing the new row. useEffect(() => { + if (!platform.metadata.isTauri) return; const unlistens: Promise[] = []; unlistens.push( listen<{ capture: CaptureResponse }>('capture:created', (event) => { @@ -225,7 +226,7 @@ export function CapturesTab() { return () => { for (const p of unlistens) p.then((fn) => fn()).catch(() => {}); }; - }, [queryClient]); + }, [queryClient, platform.metadata.isTauri]); const filtered = useMemo(() => { const q = search.trim().toLowerCase(); @@ -243,9 +244,7 @@ export function CapturesTab() { // referenced profile was deleted) fall through to the first profile. const storedVoiceId = captureSettings?.default_playback_voice_id ?? null; const playAsVoice = - (storedVoiceId && profiles?.find((p) => p.id === storedVoiceId)) || - profiles?.[0] || - null; + (storedVoiceId && profiles?.find((p) => p.id === storedVoiceId)) || profiles?.[0] || null; const playAsVoiceId = playAsVoice?.id ?? null; const deleteMutation = useMutation({ @@ -255,12 +254,22 @@ export function CapturesTab() { queryClient.invalidateQueries({ queryKey: ['captures'] }); }, onError: (err: Error) => { - toast({ title: t('captures.toast.deleteFailed'), description: err.message, variant: 'destructive' }); + toast({ + title: t('captures.toast.deleteFailed'), + description: err.message, + variant: 'destructive', + }); }, }); const playAsMutation = useMutation({ - mutationFn: async ({ capture, voice }: { capture: CaptureResponse; voice: VoiceProfileResponse }) => { + mutationFn: async ({ + capture, + voice, + }: { + capture: CaptureResponse; + voice: VoiceProfileResponse; + }) => { const text = capture.transcript_refined || capture.transcript_raw; if (!text.trim()) throw new Error(t('captures.noTranscriptError')); const language = (capture.language || voice.language) as LanguageCode; @@ -268,8 +277,13 @@ export function CapturesTab() { // profile's stored engine preference. Cloned profiles without an // override fall through to whatever the backend picks. const engine = voice.default_engine as - | 'qwen' | 'qwen_custom_voice' | 'luxtts' | 'chatterbox' - | 'chatterbox_turbo' | 'tada' | 'kokoro' + | 'qwen' + | 'qwen_custom_voice' + | 'luxtts' + | 'chatterbox' + | 'chatterbox_turbo' + | 'tada' + | 'kokoro' | undefined; return apiClient.generateSpeech({ profile_id: voice.id, @@ -286,7 +300,11 @@ export function CapturesTab() { addPendingGeneration(result.id); }, onError: (err: Error) => { - toast({ title: t('captures.toast.playAsFailed'), description: err.message, variant: 'destructive' }); + toast({ + title: t('captures.toast.playAsFailed'), + description: err.message, + variant: 'destructive', + }); }, }); @@ -336,16 +354,15 @@ export function CapturesTab() { const handleExportAudio = async () => { if (!selected) return; try { - const dest = await save({ - defaultPath: `capture_${selected.id.slice(0, 8)}.wav`, - filters: [{ name: 'Audio', extensions: ['wav'] }], - }); - if (!dest) return; const res = await fetch(apiClient.getCaptureAudioUrl(selected.id)); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const buf = new Uint8Array(await res.arrayBuffer()); - await writeFile(dest, buf); - exportToastSuccess(dest); + const blob = new Blob([await res.arrayBuffer()], { type: 'audio/wav' }); + const dest = await platform.filesystem.saveFile( + `capture_${selected.id.slice(0, 8)}.wav`, + blob, + [{ name: 'Audio', extensions: ['wav'] }], + ); + if (dest) exportToastSuccess(dest); } catch (err) { exportToastError(err); } @@ -359,13 +376,12 @@ export function CapturesTab() { return; } try { - const dest = await save({ - defaultPath: `capture_${selected.id.slice(0, 8)}.txt`, - filters: [{ name: 'Text', extensions: ['txt'] }], - }); - if (!dest) return; - await writeTextFile(dest, text); - exportToastSuccess(dest); + const dest = await platform.filesystem.saveFile( + `capture_${selected.id.slice(0, 8)}.txt`, + new Blob([text], { type: 'text/plain' }), + [{ name: 'Text', extensions: ['txt'] }], + ); + if (dest) exportToastSuccess(dest); } catch (err) { exportToastError(err); } @@ -376,7 +392,8 @@ export function CapturesTab() { lines.push(`# Capture ${capture.id}`, ''); lines.push(`- **Source:** ${capture.source}`); lines.push(`- **Created:** ${capture.created_at}`); - if (capture.duration_ms != null) lines.push(`- **Duration:** ${formatDuration(capture.duration_ms)}`); + if (capture.duration_ms != null) + lines.push(`- **Duration:** ${formatDuration(capture.duration_ms)}`); if (capture.language) lines.push(`- **Language:** ${capture.language}`); if (capture.stt_model) lines.push(`- **STT model:** ${capture.stt_model}`); if (capture.llm_model) lines.push(`- **LLM model:** ${capture.llm_model}`); @@ -398,13 +415,12 @@ export function CapturesTab() { return; } try { - const dest = await save({ - defaultPath: `capture_${selected.id.slice(0, 8)}.md`, - filters: [{ name: 'Markdown', extensions: ['md'] }], - }); - if (!dest) return; - await writeTextFile(dest, buildCaptureMarkdown(selected)); - exportToastSuccess(dest); + const dest = await platform.filesystem.saveFile( + `capture_${selected.id.slice(0, 8)}.md`, + new Blob([buildCaptureMarkdown(selected)], { type: 'text/markdown' }), + [{ name: 'Markdown', extensions: ['md'] }], + ); + if (dest) exportToastSuccess(dest); } catch (err) { exportToastError(err); } @@ -486,48 +502,48 @@ export function CapturesTab() { ) : ( filtered.map((capture) => { - const isActive = selectedId === capture.id; - const refined = !!capture.transcript_refined; - return ( - - ); - }) - )} + > +
+ + {formatDate(capture.created_at)} + +
+ + {formatDuration(capture.duration_ms)} + +
+
+ {snippetOf(capture)} +
+
+ + {refined && ( + + + {t('captures.transcript.refined')} + + )} +
+ + ); + }) + )}
@@ -578,7 +594,9 @@ export function CapturesTab() { ) : ( )} - {session.isUploading ? t('captures.actions.importing') : t('captures.actions.import')} + {session.isUploading + ? t('captures.actions.importing') + : t('captures.actions.import')} )} @@ -748,11 +766,7 @@ export function CapturesTab() { {profiles?.map((v) => ( - handlePlayAs(v)} - className="py-2" - > + handlePlayAs(v)} className="py-2">
{v.name}
@@ -864,9 +878,7 @@ export function CapturesTab() {
) : null}
-

- {t('captures.empty.pressShortcut')} -

+

{t('captures.empty.pressShortcut')}

) : (
@@ -888,7 +900,9 @@ export function CapturesTab() { {t('captures.deleteDialog.title')} - {t('captures.deleteDialog.description')} + + {t('captures.deleteDialog.description')} + {t('common.cancel')} @@ -898,7 +912,9 @@ export function CapturesTab() { disabled={deleteMutation.isPending} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > - {deleteMutation.isPending ? t('captures.deleteDialog.deleting') : t('common.delete')} + {deleteMutation.isPending + ? t('captures.deleteDialog.deleting') + : t('common.delete')} diff --git a/app/src/platform/types.ts b/app/src/platform/types.ts index 2e11af9a..ef120358 100644 --- a/app/src/platform/types.ts +++ b/app/src/platform/types.ts @@ -9,7 +9,8 @@ export interface FileFilter { } export interface PlatformFilesystem { - saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise; + /** Returns the saved path (or filename on web), or null if the user cancelled. */ + saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise; openPath(path: string): Promise; pickDirectory(title: string): Promise; } diff --git a/tauri/src/platform/filesystem.ts b/tauri/src/platform/filesystem.ts index 1d8ded39..4da11ab3 100644 --- a/tauri/src/platform/filesystem.ts +++ b/tauri/src/platform/filesystem.ts @@ -10,7 +10,7 @@ export const tauriFilesystem: PlatformFilesystem = { filters: filters || [], }); - if (!filePath) return; // User cancelled the dialog + if (!filePath) return null; // User cancelled the dialog const resolvedPath = typeof filePath === 'string' ? filePath : (filePath as { path: string }).path; @@ -21,6 +21,7 @@ export const tauriFilesystem: PlatformFilesystem = { const arrayBuffer = await blob.arrayBuffer(); await writeFile(resolvedPath, new Uint8Array(arrayBuffer)); + return resolvedPath; }, async openPath(path: string) { diff --git a/web/src/platform/filesystem.ts b/web/src/platform/filesystem.ts index 3a871ad2..a15441e8 100644 --- a/web/src/platform/filesystem.ts +++ b/web/src/platform/filesystem.ts @@ -11,6 +11,7 @@ export const webFilesystem: PlatformFilesystem = { a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); + return filename; }, async openPath(_path: string) {