mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-18 14:20:42 -07:00
feat(ui): shared ListPane primitive + misc polish
ListPane is a compound component (Header / TitleRow / Title / Actions /
Search / Scroll) that owns the relative wrapper, faded right divider
(50px top fade), top scroll mask, and absolute-positioned header used by
every list-detail tab. Wires up CapturesTab, StoryList, and EffectsList.
EffectsTab gets -mx-8 / pr-8 to match the edge-to-edge layout used
elsewhere.
Other changes:
- MCPPage: native <select> → shadcn <Select> for default voice and
per-binding voice pickers
- Button outline variant: add hover:border-accent
- Drop hover:text-destructive from trailing delete buttons
(HistoryTable, GpuAcceleration, GpuPage, EffectsChainEditor,
EffectsDetail)
- HistoryTable empty state moved behind t('history.empty')
- StoryContent scroll padding pt-14 → pt-16
- backend health reports the captures dir
- landing CapturesMockup: "Send to" → "Export" with Download icon
- CHANGELOG: drop [Unreleased] personality section
This commit is contained in:
@@ -1,16 +1,19 @@
|
||||
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,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
Copy,
|
||||
Download,
|
||||
FileAudio,
|
||||
FileText,
|
||||
Loader2,
|
||||
Mic,
|
||||
Send,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
Square,
|
||||
@@ -23,6 +26,16 @@ import { useTranslation } from 'react-i18next';
|
||||
import { CapturePill } from '@/components/CapturePill/CapturePill';
|
||||
import { CaptureInlinePlayer } from '@/components/CapturesTab/CaptureInlinePlayer';
|
||||
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -33,8 +46,15 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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 {
|
||||
@@ -126,6 +146,7 @@ export function CapturesTab() {
|
||||
const [showRefined, setShowRefined] = useState(true);
|
||||
const [playAsVoiceId, setPlayAsVoiceId] = useState<string | null>(null);
|
||||
const [playbackState, setPlaybackState] = useState<PlaybackState>('idle');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
|
||||
const audioUrl = usePlayerStore((s) => s.audioUrl);
|
||||
@@ -222,6 +243,7 @@ export function CapturesTab() {
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (captureId: string) => apiClient.deleteCapture(captureId),
|
||||
onSuccess: () => {
|
||||
setDeleteDialogOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['captures'] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
@@ -293,6 +315,96 @@ export function CapturesTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const exportToastSuccess = (path: string) => {
|
||||
const name = path.split(/[\\/]/).pop() ?? path;
|
||||
toast({ title: t('captures.toast.exportSuccess', { path: name }) });
|
||||
};
|
||||
|
||||
const exportToastError = (err: unknown) => {
|
||||
toast({
|
||||
title: t('captures.toast.exportFailed'),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
} catch (err) {
|
||||
exportToastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportTranscript = async () => {
|
||||
if (!selected) return;
|
||||
const text = (selected.transcript_refined || selected.transcript_raw || '').trim();
|
||||
if (!text) {
|
||||
toast({ title: t('captures.toast.exportEmpty'), variant: 'destructive' });
|
||||
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);
|
||||
} catch (err) {
|
||||
exportToastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const buildCaptureMarkdown = (capture: CaptureResponse): string => {
|
||||
const lines: string[] = [];
|
||||
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.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}`);
|
||||
lines.push('');
|
||||
if (capture.transcript_refined?.trim()) {
|
||||
lines.push('## Refined transcript', '', capture.transcript_refined.trim(), '');
|
||||
}
|
||||
if (capture.transcript_raw?.trim()) {
|
||||
lines.push('## Raw transcript', '', capture.transcript_raw.trim(), '');
|
||||
}
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
const handleExportMarkdown = async () => {
|
||||
if (!selected) return;
|
||||
const hasContent = (selected.transcript_refined || selected.transcript_raw || '').trim();
|
||||
if (!hasContent) {
|
||||
toast({ title: t('captures.toast.exportEmpty'), variant: 'destructive' });
|
||||
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);
|
||||
} catch (err) {
|
||||
exportToastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayAs = (voice?: VoiceProfileResponse) => {
|
||||
if (!selected) return;
|
||||
const target = voice ?? playAsVoice;
|
||||
@@ -327,50 +439,41 @@ export function CapturesTab() {
|
||||
/>
|
||||
|
||||
{/* Left: capture list */}
|
||||
<div className="w-[340px] shrink-0 flex flex-col relative overflow-hidden border-r border-border">
|
||||
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
|
||||
<div className="absolute top-0 left-0 right-0 z-20 pl-4 pr-4">
|
||||
<div className="flex items-center mb-3">
|
||||
<h1 className="text-2xl px-4 font-bold">{t('captures.title')}</h1>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 -ml-2 text-[10px] font-medium text-accent bg-accent/10 border border-accent/20"
|
||||
>
|
||||
{t('captures.beta')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t('captures.searchPlaceholder')}
|
||||
<div className="w-[340px] shrink-0">
|
||||
<ListPane>
|
||||
<ListPaneHeader>
|
||||
<ListPaneTitleRow>
|
||||
<ListPaneTitle>{t('captures.title')}</ListPaneTitle>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 -ml-2 text-[10px] font-medium text-accent bg-accent/10 border border-accent/20"
|
||||
>
|
||||
{t('captures.beta')}
|
||||
</Badge>
|
||||
</ListPaneTitleRow>
|
||||
<ListPaneSearch
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
onChange={setSearch}
|
||||
placeholder={t('captures.searchPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ListPaneHeader>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 overflow-y-auto overflow-x-hidden pt-24',
|
||||
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||
)}
|
||||
>
|
||||
<div className="px-4 pb-6 space-y-1">
|
||||
{capturesLoading ? (
|
||||
<div className="px-4 py-12 flex items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
{search ? (
|
||||
<p>{t('captures.empty.noMatches', { query: search })}</p>
|
||||
) : (
|
||||
<p>{t('captures.empty.none')}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((capture) => {
|
||||
<ListPaneScroll className={cn(isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}>
|
||||
<div className="px-4 pb-6 space-y-1">
|
||||
{capturesLoading ? (
|
||||
<div className="px-4 py-12 flex items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
{search ? (
|
||||
<p>{t('captures.empty.noMatches', { query: search })}</p>
|
||||
) : (
|
||||
<p>{t('captures.empty.none')}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((capture) => {
|
||||
const isActive = selectedId === capture.id;
|
||||
const refined = !!capture.transcript_refined;
|
||||
return (
|
||||
@@ -413,8 +516,9 @@ export function CapturesTab() {
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ListPaneScroll>
|
||||
</ListPane>
|
||||
</div>
|
||||
|
||||
{/* Right: capture detail */}
|
||||
@@ -670,17 +774,40 @@ export function CapturesTab() {
|
||||
? t('captures.actions.reRefine')
|
||||
: t('captures.actions.refine')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
<Send className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('captures.actions.sendTo')}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('captures.actions.export')}
|
||||
<ChevronDown className="h-3.5 w-3.5 ml-1 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{t('captures.actions.exportDropdownLabel')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleExportAudio}>
|
||||
<FileAudio className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
|
||||
{t('captures.actions.exportAudio')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleExportTranscript}>
|
||||
<Captions className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
|
||||
{t('captures.actions.exportTranscript')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleExportMarkdown}>
|
||||
<FileText className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
|
||||
{t('captures.actions.exportMarkdown')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteMutation.mutate(selected.id)}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
className="text-muted-foreground "
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
@@ -744,6 +871,27 @@ export function CapturesTab() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('captures.deleteDialog.title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t('captures.deleteDialog.description')}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
onClick={() => selected && deleteMutation.mutate(selected.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteMutation.isPending ? t('captures.deleteDialog.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@ function SortableEffectItem({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 text-muted-foreground hover:text-destructive"
|
||||
className="p-0.5 text-muted-foreground "
|
||||
onClick={onRemove}
|
||||
title={t('effects.chain.remove')}
|
||||
>
|
||||
|
||||
@@ -279,7 +279,7 @@ export function EffectsDetail() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-destructive hover:text-destructive gap-1.5"
|
||||
className="h-8 text-destructive gap-1.5"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2, Plus, Sparkles, Wand2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ListPane,
|
||||
ListPaneActions,
|
||||
ListPaneHeader,
|
||||
ListPaneScroll,
|
||||
ListPaneTitle,
|
||||
ListPaneTitleRow,
|
||||
} from '@/components/ListPane';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { EffectPresetResponse } from '@/lib/api/types';
|
||||
@@ -43,73 +51,74 @@ export function EffectsList() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">{t('effects.title')}</h2>
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1.5" onClick={handleCreateNew}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t('effects.newPreset')}
|
||||
</Button>
|
||||
</div>
|
||||
<ListPane>
|
||||
<ListPaneHeader>
|
||||
<ListPaneTitleRow>
|
||||
<ListPaneTitle>{t('effects.title')}</ListPaneTitle>
|
||||
<ListPaneActions>
|
||||
<Button onClick={handleCreateNew} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('effects.newPreset')}
|
||||
</Button>
|
||||
</ListPaneActions>
|
||||
</ListPaneTitleRow>
|
||||
</ListPaneHeader>
|
||||
|
||||
{/* Scrollable list */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-4">
|
||||
{/* Built-in presets */}
|
||||
{builtIn.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||
{t('effects.sections.builtin')}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{builtIn.map((preset) => (
|
||||
<PresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isSelected={selectedPresetId === preset.id && !isCreatingNew}
|
||||
onSelect={() => handleSelect(preset)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User presets */}
|
||||
{userPresets.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||
{t('effects.sections.custom')}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{userPresets.map((preset) => (
|
||||
<PresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isSelected={selectedPresetId === preset.id && !isCreatingNew}
|
||||
onSelect={() => handleSelect(preset)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New preset placeholder */}
|
||||
{isCreatingNew && (
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||
{t('effects.sections.new')}
|
||||
</div>
|
||||
<div className="rounded-xl border-2 border-accent/40 bg-accent/5 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-accent" />
|
||||
<span className="text-sm font-medium">{t('effects.unsaved.title')}</span>
|
||||
<ListPaneScroll className="pt-16">
|
||||
<div className="px-4 pb-6 space-y-4">
|
||||
{builtIn.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||
{t('effects.sections.builtin')}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{builtIn.map((preset) => (
|
||||
<PresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isSelected={selectedPresetId === preset.id && !isCreatingNew}
|
||||
onSelect={() => handleSelect(preset)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('effects.unsaved.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userPresets.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||
{t('effects.sections.custom')}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{userPresets.map((preset) => (
|
||||
<PresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isSelected={selectedPresetId === preset.id && !isCreatingNew}
|
||||
onSelect={() => handleSelect(preset)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCreatingNew && (
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||
{t('effects.sections.new')}
|
||||
</div>
|
||||
<div className="rounded-xl border-2 border-accent/40 bg-accent/5 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-accent" />
|
||||
<span className="text-sm font-medium">{t('effects.unsaved.title')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('effects.unsaved.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ListPaneScroll>
|
||||
</ListPane>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { EffectsList } from './EffectsList';
|
||||
|
||||
export function EffectsTab() {
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden -mx-8">
|
||||
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden">
|
||||
{/* Left - Presets list */}
|
||||
<div className="w-full max-w-[360px] shrink-0 flex flex-col min-h-0">
|
||||
@@ -11,7 +11,7 @@ export function EffectsTab() {
|
||||
</div>
|
||||
|
||||
{/* Right - Detail / editor */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="flex-1 min-h-0 flex flex-col pr-8">
|
||||
<EffectsDetail />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -462,7 +462,7 @@ export function HistoryTable() {
|
||||
<div className="flex flex-col h-full min-h-0 relative">
|
||||
{history.length === 0 ? (
|
||||
<div className="text-center py-12 px-5 border-2 border-dashed mb-5 border-muted rounded-md text-muted-foreground flex-1 flex items-center justify-center">
|
||||
No voice generations, yet...
|
||||
{t('history.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -474,7 +474,7 @@ export function HistoryTable() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-muted-foreground hover:text-destructive"
|
||||
className="h-7 text-xs text-muted-foreground"
|
||||
onClick={() => setClearFailedDialogOpen(true)}
|
||||
disabled={clearFailed.isPending}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface ListPaneProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ListPane({ className, children }: ListPaneProps) {
|
||||
return (
|
||||
<div className={cn('h-full flex flex-col relative overflow-hidden', className)}>
|
||||
<div
|
||||
className="absolute top-0 right-0 bottom-0 w-px bg-border pointer-events-none z-30"
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to bottom, transparent 0, black 50px)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0, black 50px)',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListPaneHeaderProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ListPaneHeader({ className, children }: ListPaneHeaderProps) {
|
||||
return (
|
||||
<div className={cn('absolute top-0 left-0 right-0 z-20 px-4', className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListPaneTitleRowProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ListPaneTitleRow({ className, children }: ListPaneTitleRowProps) {
|
||||
return <div className={cn('flex items-center mb-2', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
interface ListPaneTitleProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ListPaneTitle({ className, children }: ListPaneTitleProps) {
|
||||
return <h2 className={cn('text-2xl px-4 font-bold truncate', className)}>{children}</h2>;
|
||||
}
|
||||
|
||||
interface ListPaneActionsProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ListPaneActions({ className, children }: ListPaneActionsProps) {
|
||||
return <div className={cn('ml-auto flex items-center gap-2', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
interface ListPaneSearchProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ListPaneSearch({ value, onChange, placeholder, className }: ListPaneSearchProps) {
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListPaneScrollProps {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ListPaneScroll({ className, style, children }: ListPaneScrollProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex-1 overflow-y-auto overflow-x-hidden pt-24', className)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -366,7 +366,7 @@ export function GpuAcceleration() {
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
variant="ghost"
|
||||
className="w-full text-muted-foreground hover:text-destructive"
|
||||
className="w-full text-muted-foreground "
|
||||
size="sm"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Check, ChevronDown, Keyboard, Laptop, Lock, Trash2, Volume2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, ChevronDown, FolderOpen, Keyboard, Laptop, Lock, Volume2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AccessibilityNotice } from '@/components/AccessibilityGate/AccessibilityGate';
|
||||
import { InputMonitoringNotice } from '@/components/InputMonitoringGate/InputMonitoringGate';
|
||||
@@ -27,6 +27,8 @@ import { useToast } from '@/components/ui/use-toast';
|
||||
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
|
||||
import { useCaptureSettings } from '@/lib/hooks/useSettings';
|
||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
|
||||
import type { Qwen3ModelSize, VoiceProfileResponse, WhisperModelSize } from '@/lib/api/types';
|
||||
@@ -136,6 +138,8 @@ function HotkeyPillPreview({ enabled }: { enabled: boolean }) {
|
||||
|
||||
export function CapturesPage() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const { settings, update } = useCaptureSettings();
|
||||
const { data: profiles } = useProfiles();
|
||||
const { toast } = useToast();
|
||||
@@ -159,6 +163,32 @@ export function CapturesPage() {
|
||||
const [copyToClipboard, setCopyToClipboard] = useState(true);
|
||||
const [retention, setRetention] = useState('forever');
|
||||
const [chordEditor, setChordEditor] = useState<'push' | 'toggle' | null>(null);
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [capturesPath, setCapturesPath] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${serverUrl}/health/filesystem`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const dir = data.directories?.find((d: { path: string }) =>
|
||||
d.path.includes('captures'),
|
||||
);
|
||||
if (dir?.path) setCapturesPath(dir.path);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [serverUrl]);
|
||||
|
||||
const openCapturesFolder = useCallback(async () => {
|
||||
if (!capturesPath) return;
|
||||
setOpening(true);
|
||||
try {
|
||||
await platform.filesystem.openPath(capturesPath);
|
||||
} catch (e) {
|
||||
console.error('Failed to open captures folder:', e);
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
}, [platform, capturesPath]);
|
||||
|
||||
const voices: VoiceProfileResponse[] = profiles ?? [];
|
||||
const defaultVoice =
|
||||
@@ -561,16 +591,17 @@ export function CapturesPage() {
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.captures.storage.clearAll.title')}
|
||||
description={t('settings.captures.storage.clearAll.description')}
|
||||
title={t('settings.captures.storage.folder.title')}
|
||||
description={capturesPath ?? t('settings.captures.storage.folder.description')}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10 border-destructive/30"
|
||||
onClick={openCapturesFolder}
|
||||
disabled={opening || !capturesPath}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.captures.storage.clearAll.action')}
|
||||
<FolderOpen className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.captures.storage.folder.open')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -388,7 +388,7 @@ export function GpuPage() {
|
||||
onClick={handleDelete}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
className="text-muted-foreground "
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.gpu.remove.button')}
|
||||
|
||||
@@ -2,6 +2,13 @@ import { Check, Copy, Plug, Trash2, Waypoints } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useMCPBindings } from '@/lib/hooks/useMCPBindings';
|
||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||
import { useCaptureSettings } from '@/lib/hooks/useSettings';
|
||||
@@ -101,22 +108,28 @@ export function MCPPage() {
|
||||
title={t('settings.mcp.defaultVoice.label')}
|
||||
description={t('settings.mcp.defaultVoice.labelHint')}
|
||||
action={
|
||||
<select
|
||||
value={defaultProfileId}
|
||||
onChange={(e) =>
|
||||
<Select
|
||||
value={defaultProfileId || '__default__'}
|
||||
onValueChange={(v) =>
|
||||
updateCapture({
|
||||
default_playback_voice_id: e.target.value || null,
|
||||
default_playback_voice_id: v === '__default__' ? null : v,
|
||||
})
|
||||
}
|
||||
className="h-8 px-2 rounded-md border bg-background text-sm min-w-[180px]"
|
||||
>
|
||||
<option value="">{t('settings.mcp.defaultVoice.none')}</option>
|
||||
{(profiles ?? []).map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger className="w-[220px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">
|
||||
{t('settings.mcp.defaultVoice.none')}
|
||||
</SelectItem>
|
||||
{(profiles ?? []).map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</SettingSection>
|
||||
@@ -153,24 +166,30 @@ export function MCPPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={b.profile_id ?? ''}
|
||||
onChange={(e) =>
|
||||
<Select
|
||||
value={b.profile_id ?? '__default__'}
|
||||
onValueChange={(v) =>
|
||||
upsertAsync({
|
||||
client_id: b.client_id,
|
||||
label: b.label,
|
||||
profile_id: e.target.value || null,
|
||||
profile_id: v === '__default__' ? null : v,
|
||||
})
|
||||
}
|
||||
className="h-8 px-2 rounded-md border bg-background text-sm min-w-[160px]"
|
||||
>
|
||||
<option value="">{t('settings.mcp.bindings.defaultOption')}</option>
|
||||
{(profiles ?? []).map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">
|
||||
{t('settings.mcp.bindings.defaultOption')}
|
||||
</SelectItem>
|
||||
{(profiles ?? []).map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -201,18 +220,24 @@ export function MCPPage() {
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
className="h-9 px-3 rounded-md border bg-background text-sm"
|
||||
/>
|
||||
<select
|
||||
value={newProfileId}
|
||||
onChange={(e) => setNewProfileId(e.target.value)}
|
||||
className="h-9 px-2 rounded-md border bg-background text-sm min-w-[140px]"
|
||||
<Select
|
||||
value={newProfileId || '__default__'}
|
||||
onValueChange={(v) => setNewProfileId(v === '__default__' ? '' : v)}
|
||||
>
|
||||
<option value="">{t('settings.mcp.bindings.defaultOption')}</option>
|
||||
{(profiles ?? []).map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger className="h-9 min-w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">
|
||||
{t('settings.mcp.bindings.defaultOption')}
|
||||
</SelectItem>
|
||||
{(profiles ?? []).map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -364,7 +364,7 @@ export function StoryContent() {
|
||||
{/* Content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 min-h-0 overflow-y-auto space-y-3 pt-14 scroll-pt-14 relative z-0"
|
||||
className="flex-1 min-h-0 overflow-y-auto space-y-3 pt-16 scroll-pt-16 relative z-0"
|
||||
style={{ paddingBottom: bottomPadding > 0 ? `${bottomPadding}px` : undefined }}
|
||||
>
|
||||
{sortedItems.length === 0 ? (
|
||||
|
||||
@@ -29,6 +29,15 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
ListPane,
|
||||
ListPaneActions,
|
||||
ListPaneHeader,
|
||||
ListPaneScroll,
|
||||
ListPaneSearch,
|
||||
ListPaneTitle,
|
||||
ListPaneTitleRow,
|
||||
} from '@/components/ListPane';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import {
|
||||
@@ -202,32 +211,25 @@ export function StoryList() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col relative overflow-hidden border-r border-border">
|
||||
{/* Scroll Mask */}
|
||||
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
<ListPane>
|
||||
<ListPaneHeader>
|
||||
<ListPaneTitleRow>
|
||||
<ListPaneTitle>{t('stories.title')}</ListPaneTitle>
|
||||
<ListPaneActions>
|
||||
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('stories.newStory')}
|
||||
</Button>
|
||||
</ListPaneActions>
|
||||
</ListPaneTitleRow>
|
||||
<ListPaneSearch
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t('stories.searchPlaceholder')}
|
||||
/>
|
||||
</ListPaneHeader>
|
||||
|
||||
{/* Fixed Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-20 pl-4 pr-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-2xl px-4 font-bold">{t('stories.title')}</h2>
|
||||
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('stories.newStory')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t('stories.searchPlaceholder')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Story List */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto pt-24 relative z-0"
|
||||
<ListPaneScroll
|
||||
style={{ paddingBottom: hasTrackEditor ? `${trackEditorHeight + 140}px` : '170px' }}
|
||||
>
|
||||
{storyList.length === 0 ? (
|
||||
@@ -268,8 +270,14 @@ export function StoryList() {
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
<div className="text-[13px] text-foreground/90 line-clamp-2 leading-snug mb-2">
|
||||
{story.name}
|
||||
<div className="text-[13px] line-clamp-2 leading-snug mb-2">
|
||||
<span className="text-foreground font-medium">{story.name}</span>
|
||||
{story.description ? (
|
||||
<>
|
||||
<span className="mx-1.5 text-muted-foreground/50">·</span>
|
||||
<span className="text-muted-foreground">{story.description}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Badge
|
||||
@@ -311,7 +319,7 @@ export function StoryList() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ListPaneScroll>
|
||||
|
||||
{/* Create Story Dialog */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
@@ -422,6 +430,6 @@ export function StoryList() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</ListPane>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,18 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
const buttonVariants = cva([
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm',
|
||||
'font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2',
|
||||
'focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-accent text-accent-foreground hover:bg-accent/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:border-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-accent underline-offset-4 hover:underline',
|
||||
|
||||
@@ -53,7 +53,11 @@
|
||||
"copy": "Copy",
|
||||
"refine": "Refine",
|
||||
"reRefine": "Re-refine",
|
||||
"sendTo": "Send to",
|
||||
"export": "Export",
|
||||
"exportDropdownLabel": "Export capture as",
|
||||
"exportAudio": "Audio (WAV)",
|
||||
"exportTranscript": "Transcript (TXT)",
|
||||
"exportMarkdown": "Markdown (MD)",
|
||||
"delete": "Delete",
|
||||
"playAs": "Play as {{name}}",
|
||||
"playAsFallback": "Play as…",
|
||||
@@ -73,6 +77,11 @@
|
||||
"turnOnShortcut": "Turn on the global shortcut to dictate from anywhere — or click Dictate above for an in-app capture.",
|
||||
"openSettings": "Open Captures settings"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Delete capture",
|
||||
"description": "This will permanently delete the capture, its audio, and its transcript. This cannot be undone.",
|
||||
"deleting": "Deleting…"
|
||||
},
|
||||
"toast": {
|
||||
"deleteFailed": "Delete failed",
|
||||
"playAsFailed": "Play-as failed",
|
||||
@@ -80,6 +89,9 @@
|
||||
"noVoiceDescription": "Create a voice profile before using Play as.",
|
||||
"transcriptCopied": "Transcript copied",
|
||||
"copyFailed": "Copy failed",
|
||||
"exportSuccess": "Exported to {{path}}",
|
||||
"exportFailed": "Export failed",
|
||||
"exportEmpty": "Nothing to export",
|
||||
"shortcutNotArmed": "Shortcut on, but not yet armed",
|
||||
"shortcutNotArmedDescription_one": "{{names}} still needs to download. Open the Captures tab to start.",
|
||||
"shortcutNotArmedDescription_other": "{{names}} still need to download. Open the Captures tab to start."
|
||||
@@ -628,6 +640,7 @@
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"empty": "No voice generations, yet…",
|
||||
"actions": {
|
||||
"menu": "Actions",
|
||||
"play": "Play",
|
||||
@@ -983,10 +996,10 @@
|
||||
"d30": "30 days",
|
||||
"d7": "7 days"
|
||||
},
|
||||
"clearAll": {
|
||||
"title": "Clear all captures",
|
||||
"description": "Permanently delete every capture and its audio. This cannot be undone.",
|
||||
"action": "Clear captures"
|
||||
"folder": {
|
||||
"title": "Captures folder",
|
||||
"description": "Where capture audio and transcripts are stored on disk.",
|
||||
"open": "Open"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
|
||||
@@ -53,7 +53,11 @@
|
||||
"copy": "コピー",
|
||||
"refine": "整形",
|
||||
"reRefine": "再整形",
|
||||
"sendTo": "送信先",
|
||||
"export": "エクスポート",
|
||||
"exportDropdownLabel": "形式を選択",
|
||||
"exportAudio": "音声 (WAV)",
|
||||
"exportTranscript": "文字起こし (TXT)",
|
||||
"exportMarkdown": "Markdown (MD)",
|
||||
"delete": "削除",
|
||||
"playAs": "{{name}} で再生",
|
||||
"playAsFallback": "ボイスで再生…",
|
||||
@@ -73,6 +77,11 @@
|
||||
"turnOnShortcut": "グローバルショートカットを有効にしてどこからでもディクテーション — または上の「ディクテーション」をクリックしてアプリ内でキャプチャします。",
|
||||
"openSettings": "キャプチャ設定を開く"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "キャプチャを削除",
|
||||
"description": "このキャプチャと、その音声・文字起こしを完全に削除します。元に戻せません。",
|
||||
"deleting": "削除中…"
|
||||
},
|
||||
"toast": {
|
||||
"deleteFailed": "削除に失敗しました",
|
||||
"playAsFailed": "ボイスでの再生に失敗しました",
|
||||
@@ -80,6 +89,9 @@
|
||||
"noVoiceDescription": "「ボイスで再生」を使う前にボイスプロファイルを作成してください。",
|
||||
"transcriptCopied": "文字起こしをコピーしました",
|
||||
"copyFailed": "コピーに失敗しました",
|
||||
"exportSuccess": "{{path}} に書き出しました",
|
||||
"exportFailed": "書き出しに失敗しました",
|
||||
"exportEmpty": "書き出す内容がありません",
|
||||
"shortcutNotArmed": "ショートカットは有効ですが、まだ準備が完了していません",
|
||||
"shortcutNotArmedDescription_one": "{{names}} のダウンロードがまだ必要です。キャプチャタブを開いて開始してください。",
|
||||
"shortcutNotArmedDescription_other": "{{names}} のダウンロードがまだ必要です。キャプチャタブを開いて開始してください。"
|
||||
@@ -628,6 +640,7 @@
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"empty": "音声生成はまだありません…",
|
||||
"actions": {
|
||||
"menu": "操作",
|
||||
"play": "再生",
|
||||
@@ -983,10 +996,10 @@
|
||||
"d30": "30 日",
|
||||
"d7": "7 日"
|
||||
},
|
||||
"clearAll": {
|
||||
"title": "すべてのキャプチャをクリア",
|
||||
"description": "すべてのキャプチャと音声を完全に削除します。元に戻せません。",
|
||||
"action": "キャプチャをクリア"
|
||||
"folder": {
|
||||
"title": "キャプチャフォルダ",
|
||||
"description": "キャプチャの音声と文字起こしをディスクに保存する場所。",
|
||||
"open": "開く"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
|
||||
@@ -53,7 +53,11 @@
|
||||
"copy": "复制",
|
||||
"refine": "精修",
|
||||
"reRefine": "重新精修",
|
||||
"sendTo": "发送到",
|
||||
"export": "导出",
|
||||
"exportDropdownLabel": "导出格式",
|
||||
"exportAudio": "音频 (WAV)",
|
||||
"exportTranscript": "文字稿 (TXT)",
|
||||
"exportMarkdown": "Markdown (MD)",
|
||||
"delete": "删除",
|
||||
"playAs": "以 {{name}} 播放",
|
||||
"playAsFallback": "播放为……",
|
||||
@@ -73,6 +77,11 @@
|
||||
"turnOnShortcut": "开启全局快捷键以在任何位置进行听写——或点击上方的「听写」在应用内进行捕获。",
|
||||
"openSettings": "打开「捕获」设置"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "删除捕获",
|
||||
"description": "这将永久删除该捕获及其音频和转录。此操作不可撤销。",
|
||||
"deleting": "删除中…"
|
||||
},
|
||||
"toast": {
|
||||
"deleteFailed": "删除失败",
|
||||
"playAsFailed": "播放失败",
|
||||
@@ -80,6 +89,9 @@
|
||||
"noVoiceDescription": "使用「播放为」之前请先创建声音档案。",
|
||||
"transcriptCopied": "转录已复制",
|
||||
"copyFailed": "复制失败",
|
||||
"exportSuccess": "已导出到 {{path}}",
|
||||
"exportFailed": "导出失败",
|
||||
"exportEmpty": "无可导出的内容",
|
||||
"shortcutNotArmed": "快捷键已开启,但尚未就绪",
|
||||
"shortcutNotArmedDescription_one": "{{names}} 仍需下载。打开「捕获」标签页开始下载。",
|
||||
"shortcutNotArmedDescription_other": "{{names}} 仍需下载。打开「捕获」标签页开始下载。"
|
||||
@@ -628,6 +640,7 @@
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"empty": "暂无语音生成…",
|
||||
"actions": {
|
||||
"menu": "操作",
|
||||
"play": "播放",
|
||||
@@ -983,10 +996,10 @@
|
||||
"d30": "30 天",
|
||||
"d7": "7 天"
|
||||
},
|
||||
"clearAll": {
|
||||
"title": "清除所有捕获",
|
||||
"description": "永久删除所有捕获及其音频。此操作不可撤销。",
|
||||
"action": "清除捕获"
|
||||
"folder": {
|
||||
"title": "捕获文件夹",
|
||||
"description": "捕获的音频和转录在磁盘上的存储位置。",
|
||||
"open": "打开"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
|
||||
@@ -53,7 +53,11 @@
|
||||
"copy": "複製",
|
||||
"refine": "精修",
|
||||
"reRefine": "重新精修",
|
||||
"sendTo": "傳送至",
|
||||
"export": "匯出",
|
||||
"exportDropdownLabel": "匯出格式",
|
||||
"exportAudio": "音訊 (WAV)",
|
||||
"exportTranscript": "文字稿 (TXT)",
|
||||
"exportMarkdown": "Markdown (MD)",
|
||||
"delete": "刪除",
|
||||
"playAs": "以 {{name}} 播放",
|
||||
"playAsFallback": "以聲音播放……",
|
||||
@@ -73,6 +77,11 @@
|
||||
"turnOnShortcut": "開啟全域快捷鍵以從任何地方口述——或點選上方的「口述」進行 App 內擷取。",
|
||||
"openSettings": "開啟擷取設定"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "刪除擷取",
|
||||
"description": "這將永久刪除該擷取及其音訊與轉錄。此操作無法復原。",
|
||||
"deleting": "刪除中…"
|
||||
},
|
||||
"toast": {
|
||||
"deleteFailed": "刪除失敗",
|
||||
"playAsFailed": "以聲音播放失敗",
|
||||
@@ -80,6 +89,9 @@
|
||||
"noVoiceDescription": "使用「以聲音播放」前請先建立聲音檔案。",
|
||||
"transcriptCopied": "已複製轉錄文字",
|
||||
"copyFailed": "複製失敗",
|
||||
"exportSuccess": "已匯出至 {{path}}",
|
||||
"exportFailed": "匯出失敗",
|
||||
"exportEmpty": "沒有可匯出的內容",
|
||||
"shortcutNotArmed": "快捷鍵已開啟,但尚未就緒",
|
||||
"shortcutNotArmedDescription_one": "{{names}} 仍需下載。請開啟「擷取」分頁開始下載。",
|
||||
"shortcutNotArmedDescription_other": "{{names}} 仍需下載。請開啟「擷取」分頁開始下載。"
|
||||
@@ -628,6 +640,7 @@
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"empty": "尚無語音生成…",
|
||||
"actions": {
|
||||
"menu": "操作",
|
||||
"play": "播放",
|
||||
@@ -983,10 +996,10 @@
|
||||
"d30": "30 天",
|
||||
"d7": "7 天"
|
||||
},
|
||||
"clearAll": {
|
||||
"title": "清除所有擷取",
|
||||
"description": "永久刪除每一筆擷取與其音訊。此操作無法復原。",
|
||||
"action": "清除擷取"
|
||||
"folder": {
|
||||
"title": "擷取資料夾",
|
||||
"description": "擷取的音訊與轉錄在磁碟上的儲存位置。",
|
||||
"open": "開啟"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
|
||||
Reference in New Issue
Block a user