Merge pull request #755 from lfnovo/refactor/migrate-i18n-to-standard-t-function

refactor: migrate i18n from Proxy pattern to standard t() function
This commit is contained in:
Luis Novo 2026-04-15 21:56:01 -03:00 committed by GitHub
commit d7967a0fcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
86 changed files with 1295 additions and 1425 deletions

View file

@ -64,8 +64,8 @@ User interactions trigger mutations/queries via hooks, which communicate with th
#### `lib/locales/` — Internationalization (i18n) #### `lib/locales/` — Internationalization (i18n)
- **Locale files** (`en-US/`, `pt-BR/`, `zh-CN/`, `zh-TW/`, `ja-JP/`): Translation strings organized by feature - **Locale files** (`en-US/`, `pt-BR/`, `zh-CN/`, `zh-TW/`, `ja-JP/`): Translation strings organized by feature
- **`i18n.ts`**: i18next configuration with language detection - **`i18n.ts`**: i18next configuration with language detection
- **`use-translation.ts`**: Custom hook with Proxy-based `t.section.key` access pattern - **`use-translation.ts`**: Thin wrapper around react-i18next's `useTranslation` with language change events
- **Pattern**: Components call `useTranslation()` hook; access strings via `t.common.save`, `t.notebooks.title` - **Pattern**: Components call `useTranslation()` hook; access strings via `t('common.save')`, `t('notebooks.title')`
## Data & Control Flow Walkthrough ## Data & Control Flow Walkthrough

View file

@ -123,10 +123,10 @@ export function RebuildEmbeddings() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
{t.advanced.rebuildEmbeddings} {t('advanced.rebuildEmbeddings')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{t.advanced.rebuildEmbeddingsDesc} {t('advanced.rebuildEmbeddingsDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
@ -134,25 +134,25 @@ export function RebuildEmbeddings() {
{!isRebuildActive && ( {!isRebuildActive && (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-3"> <div className="space-y-3">
<Label htmlFor="mode">{t.advanced.rebuild.mode}</Label> <Label htmlFor="mode">{t('advanced.rebuild.mode')}</Label>
<Select value={mode} onValueChange={(value) => setMode(value as 'existing' | 'all')}> <Select value={mode} onValueChange={(value) => setMode(value as 'existing' | 'all')}>
<SelectTrigger id="mode"> <SelectTrigger id="mode">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="existing">{t.advanced.rebuild.existing}</SelectItem> <SelectItem value="existing">{t('advanced.rebuild.existing')}</SelectItem>
<SelectItem value="all">{t.advanced.rebuild.all}</SelectItem> <SelectItem value="all">{t('advanced.rebuild.all')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{mode === 'existing' {mode === 'existing'
? t.advanced.rebuild.existingDesc ? t('advanced.rebuild.existingDesc')
: t.advanced.rebuild.allDesc} : t('advanced.rebuild.allDesc')}
</p> </p>
</div> </div>
<div className="space-y-3" role="group" aria-labelledby="include-label"> <div className="space-y-3" role="group" aria-labelledby="include-label">
<span id="include-label" className="text-sm font-medium leading-none">{t.advanced.rebuild.include}</span> <span id="include-label" className="text-sm font-medium leading-none">{t('advanced.rebuild.include')}</span>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
@ -161,7 +161,7 @@ export function RebuildEmbeddings() {
onCheckedChange={(checked) => setIncludeSources(checked === true)} onCheckedChange={(checked) => setIncludeSources(checked === true)}
/> />
<Label htmlFor="sources" className="font-normal cursor-pointer"> <Label htmlFor="sources" className="font-normal cursor-pointer">
{t.navigation.sources} {t('navigation.sources')}
</Label> </Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@ -171,7 +171,7 @@ export function RebuildEmbeddings() {
onCheckedChange={(checked) => setIncludeNotes(checked === true)} onCheckedChange={(checked) => setIncludeNotes(checked === true)}
/> />
<Label htmlFor="notes" className="font-normal cursor-pointer"> <Label htmlFor="notes" className="font-normal cursor-pointer">
{t.common.notes} {t('common.notes')}
</Label> </Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@ -181,7 +181,7 @@ export function RebuildEmbeddings() {
onCheckedChange={(checked) => setIncludeInsights(checked === true)} onCheckedChange={(checked) => setIncludeInsights(checked === true)}
/> />
<Label htmlFor="insights" className="font-normal cursor-pointer"> <Label htmlFor="insights" className="font-normal cursor-pointer">
{t.common.insights} {t('common.insights')}
</Label> </Label>
</div> </div>
</div> </div>
@ -189,7 +189,7 @@ export function RebuildEmbeddings() {
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertDescription> <AlertDescription>
{t.advanced.rebuild.selectOneError} {t('advanced.rebuild.selectOneError')}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@ -203,10 +203,10 @@ export function RebuildEmbeddings() {
{rebuildMutation.isPending ? ( {rebuildMutation.isPending ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t.advanced.rebuild.starting} {t('advanced.rebuild.starting')}
</> </>
) : ( ) : (
t.advanced.rebuild.startBtn t('advanced.rebuild.startBtn')
)} )}
</Button> </Button>
@ -214,7 +214,7 @@ export function RebuildEmbeddings() {
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertDescription> <AlertDescription>
{t.advanced.rebuild.failed}: {(rebuildMutation.error as Error)?.message || t.common.error} {t('advanced.rebuild.failed')}: {(rebuildMutation.error as Error)?.message || t('common.error')}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@ -232,21 +232,21 @@ export function RebuildEmbeddings() {
{status.status === 'failed' && <XCircle className="h-5 w-5 text-red-500" />} {status.status === 'failed' && <XCircle className="h-5 w-5 text-red-500" />}
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium"> <span className="font-medium">
{status.status === 'queued' && t.advanced.rebuild.queued} {status.status === 'queued' && t('advanced.rebuild.queued')}
{status.status === 'running' && t.advanced.rebuild.running} {status.status === 'running' && t('advanced.rebuild.running')}
{status.status === 'completed' && t.advanced.rebuild.completed} {status.status === 'completed' && t('advanced.rebuild.completed')}
{status.status === 'failed' && t.advanced.rebuild.failed} {status.status === 'failed' && t('advanced.rebuild.failed')}
</span> </span>
{status.status === 'running' && ( {status.status === 'running' && (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{t.advanced.rebuild.leavePageHint} {t('advanced.rebuild.leavePageHint')}
</span> </span>
)} )}
</div> </div>
</div> </div>
{(status.status === 'completed' || status.status === 'failed') && ( {(status.status === 'completed' || status.status === 'failed') && (
<Button variant="outline" size="sm" onClick={handleReset}> <Button variant="outline" size="sm" onClick={handleReset}>
{t.advanced.rebuild.startNew} {t('advanced.rebuild.startNew')}
</Button> </Button>
)} )}
</div> </div>
@ -254,9 +254,9 @@ export function RebuildEmbeddings() {
{progressData && ( {progressData && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span>{t.common.progress}</span> <span>{t('common.progress')}</span>
<span className="font-medium"> <span className="font-medium">
{t.advanced.rebuild.itemsProcessed {t('advanced.rebuild.itemsProcessed')
.replace('{processed}', processedItems.toString()) .replace('{processed}', processedItems.toString())
.replace('{total}', totalItems.toString()) .replace('{total}', totalItems.toString())
.replace('{percent}', progressPercent.toFixed(1))} .replace('{percent}', progressPercent.toFixed(1))}
@ -265,7 +265,7 @@ export function RebuildEmbeddings() {
<Progress value={progressPercent} className="h-2" /> <Progress value={progressPercent} className="h-2" />
{failedItems > 0 && ( {failedItems > 0 && (
<p className="text-sm text-yellow-600"> <p className="text-sm text-yellow-600">
{t.advanced.rebuild.failedItems.replace('{count}', failedItems.toString())} {t('advanced.rebuild.failedItems').replace('{count}', failedItems.toString())}
</p> </p>
)} )}
</div> </div>
@ -274,19 +274,19 @@ export function RebuildEmbeddings() {
{stats && ( {stats && (
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-4 gap-4">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm text-muted-foreground">{t.navigation.sources}</p> <p className="text-sm text-muted-foreground">{t('navigation.sources')}</p>
<p className="text-2xl font-bold">{sourcesProcessed}</p> <p className="text-2xl font-bold">{sourcesProcessed}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm text-muted-foreground">{t.common.notes}</p> <p className="text-sm text-muted-foreground">{t('common.notes')}</p>
<p className="text-2xl font-bold">{notesProcessed}</p> <p className="text-2xl font-bold">{notesProcessed}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm text-muted-foreground">{t.common.insights}</p> <p className="text-sm text-muted-foreground">{t('common.insights')}</p>
<p className="text-2xl font-bold">{insightsProcessed}</p> <p className="text-2xl font-bold">{insightsProcessed}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm text-muted-foreground">{t.advanced.rebuild.time}</p> <p className="text-sm text-muted-foreground">{t('advanced.rebuild.time')}</p>
<p className="text-2xl font-bold"> <p className="text-2xl font-bold">
{processingTimeSeconds !== undefined ? `${processingTimeSeconds.toFixed(1)}s` : '—'} {processingTimeSeconds !== undefined ? `${processingTimeSeconds.toFixed(1)}s` : '—'}
</p> </p>
@ -303,9 +303,9 @@ export function RebuildEmbeddings() {
{status.started_at && ( {status.started_at && (
<div className="text-sm text-muted-foreground space-y-1"> <div className="text-sm text-muted-foreground space-y-1">
<p>{t.common.created.replace('{time}', new Date(status.started_at).toLocaleString())}</p> <p>{t('common.created').replace('{time}', new Date(status.started_at).toLocaleString())}</p>
{status.completed_at && ( {status.completed_at && (
<p>{t.notebooks.updated}: {new Date(status.completed_at).toLocaleString()}</p> <p>{t('notebooks.updated')}: {new Date(status.completed_at).toLocaleString()}</p>
)} )}
</div> </div>
)} )}
@ -315,23 +315,23 @@ export function RebuildEmbeddings() {
{/* Help Section */} {/* Help Section */}
<Accordion type="single" collapsible className="w-full"> <Accordion type="single" collapsible className="w-full">
<AccordionItem value="when"> <AccordionItem value="when">
<AccordionTrigger>{t.advanced.rebuild.whenToRebuild}</AccordionTrigger> <AccordionTrigger>{t('advanced.rebuild.whenToRebuild')}</AccordionTrigger>
<AccordionContent className="space-y-2 text-sm"> <AccordionContent className="space-y-2 text-sm">
<p>{t.advanced.rebuild.whenToRebuildAns}</p> <p>{t('advanced.rebuild.whenToRebuildAns')}</p>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem value="time"> <AccordionItem value="time">
<AccordionTrigger>{t.advanced.rebuild.howLong}</AccordionTrigger> <AccordionTrigger>{t('advanced.rebuild.howLong')}</AccordionTrigger>
<AccordionContent className="space-y-2 text-sm"> <AccordionContent className="space-y-2 text-sm">
<p>{t.advanced.rebuild.howLongAns}</p> <p>{t('advanced.rebuild.howLongAns')}</p>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem value="safe"> <AccordionItem value="safe">
<AccordionTrigger>{t.advanced.rebuild.isSafe}</AccordionTrigger> <AccordionTrigger>{t('advanced.rebuild.isSafe')}</AccordionTrigger>
<AccordionContent className="space-y-2 text-sm"> <AccordionContent className="space-y-2 text-sm">
<p>{t.advanced.rebuild.isSafeAns}</p> <p>{t('advanced.rebuild.isSafeAns')}</p>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>

View file

@ -34,8 +34,8 @@ export function SystemInfo() {
return ( return (
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-xl font-semibold">{t.advanced.systemInfo}</h2> <h2 className="text-xl font-semibold">{t('advanced.systemInfo')}</h2>
<div className="text-sm text-muted-foreground">{t.common.loading}</div> <div className="text-sm text-muted-foreground">{t('common.loading')}</div>
</div> </div>
</Card> </Card>
) )
@ -44,37 +44,37 @@ export function SystemInfo() {
return ( return (
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-xl font-semibold">{t.advanced.systemInfo}</h2> <h2 className="text-xl font-semibold">{t('advanced.systemInfo')}</h2>
<div className="space-y-3"> <div className="space-y-3">
{/* Current Version */} {/* Current Version */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium">{t.advanced.currentVersion}</span> <span className="text-sm font-medium">{t('advanced.currentVersion')}</span>
<Badge variant="outline">{config?.version || t.advanced.unknown}</Badge> <Badge variant="outline">{config?.version || t('advanced.unknown')}</Badge>
</div> </div>
{/* Latest Version */} {/* Latest Version */}
{config?.latestVersion && ( {config?.latestVersion && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium">{t.advanced.latestVersion}</span> <span className="text-sm font-medium">{t('advanced.latestVersion')}</span>
<Badge variant="outline">{config.latestVersion}</Badge> <Badge variant="outline">{config.latestVersion}</Badge>
</div> </div>
)} )}
{/* Update Status */} {/* Update Status */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium">{t.advanced.status}</span> <span className="text-sm font-medium">{t('advanced.status')}</span>
{config?.hasUpdate ? ( {config?.hasUpdate ? (
<Badge variant="destructive"> <Badge variant="destructive">
{t.advanced.updateAvailable.replace('{version}', config.latestVersion || '')} {t('advanced.updateAvailable').replace('{version}', config.latestVersion || '')}
</Badge> </Badge>
) : config?.latestVersion ? ( ) : config?.latestVersion ? (
<Badge variant="outline" className="text-green-600 border-green-600"> <Badge variant="outline" className="text-green-600 border-green-600">
{t.advanced.upToDate} {t('advanced.upToDate')}
</Badge> </Badge>
) : ( ) : (
<Badge variant="outline" className="text-muted-foreground"> <Badge variant="outline" className="text-muted-foreground">
{t.advanced.unknown} {t('advanced.unknown')}
</Badge> </Badge>
)} )}
</div> </div>
@ -88,7 +88,7 @@ export function SystemInfo() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-sm text-primary hover:underline inline-flex items-center gap-1" className="text-sm text-primary hover:underline inline-flex items-center gap-1"
> >
{t.advanced.viewOnGithub} {t('advanced.viewOnGithub')}
<svg <svg
className="w-4 h-4" className="w-4 h-4"
fill="none" fill="none"
@ -109,7 +109,7 @@ export function SystemInfo() {
{/* Version Check Failed Message */} {/* Version Check Failed Message */}
{!config?.latestVersion && config?.version && ( {!config?.latestVersion && config?.version && (
<div className="pt-2 text-xs text-muted-foreground"> <div className="pt-2 text-xs text-muted-foreground">
{t.advanced.updateCheckFailed} {t('advanced.updateCheckFailed')}
</div> </div>
)} )}
</div> </div>

View file

@ -13,9 +13,9 @@ export default function AdvancedPage() {
<div className="p-6"> <div className="p-6">
<div className="max-w-4xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
<div> <div>
<h1 className="text-3xl font-bold">{t.advanced.title}</h1> <h1 className="text-3xl font-bold">{t('advanced.title')}</h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">
{t.advanced.desc} {t('advanced.desc')}
</p> </p>
</div> </div>

View file

@ -119,8 +119,8 @@ export default function NotebookPage() {
return ( return (
<AppShell> <AppShell>
<div className="p-6"> <div className="p-6">
<h1 className="text-2xl font-bold mb-4">{t.notebooks.notFound}</h1> <h1 className="text-2xl font-bold mb-4">{t('notebooks.notFound')}</h1>
<p className="text-muted-foreground">{t.notebooks.notFoundDesc}</p> <p className="text-muted-foreground">{t('notebooks.notFoundDesc')}</p>
</div> </div>
</AppShell> </AppShell>
) )
@ -142,15 +142,15 @@ export default function NotebookPage() {
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="sources" className="gap-2"> <TabsTrigger value="sources" className="gap-2">
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
{t.navigation.sources} {t('navigation.sources')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="notes" className="gap-2"> <TabsTrigger value="notes" className="gap-2">
<StickyNote className="h-4 w-4" /> <StickyNote className="h-4 w-4" />
{t.common.notes} {t('common.notes')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="chat" className="gap-2"> <TabsTrigger value="chat" className="gap-2">
<MessageSquare className="h-4 w-4" /> <MessageSquare className="h-4 w-4" />
{t.common.chat} {t('common.chat')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>

View file

@ -83,8 +83,8 @@ export function ChatColumn({ notebookId, contextSelections, sources, sourcesLoad
<CardContent className="flex-1 flex items-center justify-center"> <CardContent className="flex-1 flex items-center justify-center">
<div className="text-center text-muted-foreground"> <div className="text-center text-muted-foreground">
<AlertCircle className="h-12 w-12 mx-auto mb-4 opacity-50" /> <AlertCircle className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">{t.chat.unableToLoadChat}</p> <p className="text-sm">{t('chat.unableToLoadChat')}</p>
<p className="text-xs mt-2">{t.common.refreshPage || 'Please try refreshing the page'}</p> <p className="text-xs mt-2">{t('common.refreshPage') || 'Please try refreshing the page'}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -93,7 +93,7 @@ export function ChatColumn({ notebookId, contextSelections, sources, sourcesLoad
return ( return (
<ChatPanel <ChatPanel
title={t.chat.chatWithNotebook} title={t('chat.chatWithNotebook')}
contextType="notebook" contextType="notebook"
messages={chat.messages} messages={chat.messages}
isStreaming={chat.isSending} isStreaming={chat.isSending}

View file

@ -124,12 +124,12 @@ export function NoteEditorDialog({ open, onOpenChange, notebookId, note }: NoteE
isEditorFullscreen && "!max-w-screen !max-h-screen border-none w-screen h-screen" isEditorFullscreen && "!max-w-screen !max-h-screen border-none w-screen h-screen"
)}> )}>
<DialogTitle className="sr-only"> <DialogTitle className="sr-only">
{isEditing ? t.sources.editNote : t.sources.createNote} {isEditing ? t('sources.editNote') : t('sources.createNote')}
</DialogTitle> </DialogTitle>
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col min-w-0"> <form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col min-w-0">
{isEditing && noteLoading ? ( {isEditing && noteLoading ? (
<div className="flex-1 flex items-center justify-center py-10"> <div className="flex-1 flex items-center justify-center py-10">
<span className="text-sm text-muted-foreground">{t.common.loading}</span> <span className="text-sm text-muted-foreground">{t('common.loading')}</span>
</div> </div>
) : ( ) : (
<> <>
@ -139,8 +139,8 @@ export function NoteEditorDialog({ open, onOpenChange, notebookId, note }: NoteE
name="title" name="title"
value={watchTitle ?? ''} value={watchTitle ?? ''}
onSave={(value) => setValue('title', value || '')} onSave={(value) => setValue('title', value || '')}
placeholder={t.sources.addTitle} placeholder={t('sources.addTitle')}
emptyText={t.sources.untitledNote} emptyText={t('sources.untitledNote')}
className="text-xl font-semibold" className="text-xl font-semibold"
inputClassName="text-xl font-semibold" inputClassName="text-xl font-semibold"
/> />
@ -160,7 +160,7 @@ export function NoteEditorDialog({ open, onOpenChange, notebookId, note }: NoteE
value={field.value} value={field.value}
onChange={field.onChange} onChange={field.onChange}
height={420} height={420}
placeholder={t.sources.writeNotePlaceholder} placeholder={t('sources.writeNotePlaceholder')}
className={cn( className={cn(
"w-full h-full min-h-[420px] max-h-[500px] overflow-hidden [&_.w-md-editor]:!static [&_.w-md-editor]:!w-full [&_.w-md-editor]:!h-full [&_.w-md-editor-content]:overflow-y-auto", "w-full h-full min-h-[420px] max-h-[500px] overflow-hidden [&_.w-md-editor]:!static [&_.w-md-editor]:!w-full [&_.w-md-editor]:!h-full [&_.w-md-editor-content]:overflow-y-auto",
!isEditorFullscreen && "rounded-md border" !isEditorFullscreen && "rounded-md border"
@ -177,17 +177,17 @@ export function NoteEditorDialog({ open, onOpenChange, notebookId, note }: NoteE
<div className="border-t px-6 py-4 flex justify-end gap-2"> <div className="border-t px-6 py-4 flex justify-end gap-2">
<Button type="button" variant="outline" onClick={handleClose}> <Button type="button" variant="outline" onClick={handleClose}>
{t.common.cancel} {t('common.cancel')}
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={isSaving || (isEditing && noteLoading)} disabled={isSaving || (isEditing && noteLoading)}
> >
{isSaving {isSaving
? isEditing ? `${t.common.saving}...` : `${t.common.creating}...` ? isEditing ? `${t('common.saving')}...` : `${t('common.creating')}...`
: isEditing : isEditing
? t.sources.saveNote ? t('sources.saveNote')
: t.sources.createNoteBtn} : t('sources.createNoteBtn')}
</Button> </Button>
</div> </div>
</form> </form>

View file

@ -55,7 +55,7 @@ export function NotebookCard({ notebook }: NotebookCardProps) {
</CardTitle> </CardTitle>
{notebook.archived && ( {notebook.archived && (
<Badge variant="secondary" className="mt-1"> <Badge variant="secondary" className="mt-1">
{t.notebooks.archived} {t('notebooks.archived')}
</Badge> </Badge>
)} )}
</div> </div>
@ -76,12 +76,12 @@ export function NotebookCard({ notebook }: NotebookCardProps) {
{notebook.archived ? ( {notebook.archived ? (
<> <>
<ArchiveRestore className="h-4 w-4 mr-2" /> <ArchiveRestore className="h-4 w-4 mr-2" />
{t.notebooks.unarchive} {t('notebooks.unarchive')}
</> </>
) : ( ) : (
<> <>
<Archive className="h-4 w-4 mr-2" /> <Archive className="h-4 w-4 mr-2" />
{t.notebooks.archive} {t('notebooks.archive')}
</> </>
)} )}
</DropdownMenuItem> </DropdownMenuItem>
@ -93,7 +93,7 @@ export function NotebookCard({ notebook }: NotebookCardProps) {
className="text-red-600" className="text-red-600"
> >
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
{t.common.delete} {t('common.delete')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -102,11 +102,11 @@ export function NotebookCard({ notebook }: NotebookCardProps) {
<CardContent> <CardContent>
<CardDescription className="line-clamp-2 text-sm"> <CardDescription className="line-clamp-2 text-sm">
{notebook.description || t.chat.noDescription} {notebook.description || t('chat.noDescription')}
</CardDescription> </CardDescription>
<div className="mt-3 text-xs text-muted-foreground"> <div className="mt-3 text-xs text-muted-foreground">
{t.common.updated.replace('{time}', formatDistanceToNow(new Date(notebook.updated), { {t('common.updated').replace('{time}', formatDistanceToNow(new Date(notebook.updated), {
addSuffix: true, addSuffix: true,
locale: getDateLocale(language) locale: getDateLocale(language)
}))} }))}

View file

@ -69,9 +69,9 @@ export function NotebookDeleteDialog({
<AlertDialog open={open} onOpenChange={onOpenChange}> <AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{t.notebooks.deleteNotebook}</AlertDialogTitle> <AlertDialogTitle>{t('notebooks.deleteNotebook')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{t.notebooks.deleteNotebookDesc.replace('{name}', notebookName)} {t('notebooks.deleteNotebookDesc').replace('{name}', notebookName)}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
@ -79,11 +79,11 @@ export function NotebookDeleteDialog({
{isLoadingPreview ? ( {isLoadingPreview ? (
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<LoadingSpinner size="sm" /> <LoadingSpinner size="sm" />
<span>{t.notebooks.deleteNotebookLoading}</span> <span>{t('notebooks.deleteNotebookLoading')}</span>
</div> </div>
) : previewError ? ( ) : previewError ? (
<div className="text-sm text-destructive"> <div className="text-sm text-destructive">
{t.common.error}: {previewError.message || 'Failed to load preview'} {t('common.error')}: {previewError.message || 'Failed to load preview'}
</div> </div>
) : preview ? ( ) : preview ? (
<> <>
@ -91,13 +91,13 @@ export function NotebookDeleteDialog({
<div className="text-sm"> <div className="text-sm">
{preview.note_count > 0 ? ( {preview.note_count > 0 ? (
<p className="text-destructive font-medium"> <p className="text-destructive font-medium">
{t.notebooks.deleteNotebookNotes.replace( {t('notebooks.deleteNotebookNotes').replace(
'{count}', '{count}',
String(preview.note_count) String(preview.note_count)
)} )}
</p> </p>
) : ( ) : (
<p className="text-muted-foreground">{t.notebooks.deleteNotebookNoNotes}</p> <p className="text-muted-foreground">{t('notebooks.deleteNotebookNoNotes')}</p>
)} )}
</div> </div>
@ -105,7 +105,7 @@ export function NotebookDeleteDialog({
{preview.shared_source_count > 0 && ( {preview.shared_source_count > 0 && (
<div className="text-sm"> <div className="text-sm">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t.notebooks.deleteNotebookSharedSources.replace( {t('notebooks.deleteNotebookSharedSources').replace(
'{count}', '{count}',
String(preview.shared_source_count) String(preview.shared_source_count)
)} )}
@ -116,7 +116,7 @@ export function NotebookDeleteDialog({
{/* No sources message */} {/* No sources message */}
{preview.exclusive_source_count === 0 && preview.shared_source_count === 0 && ( {preview.exclusive_source_count === 0 && preview.shared_source_count === 0 && (
<div className="text-sm"> <div className="text-sm">
<p className="text-muted-foreground">{t.notebooks.deleteNotebookNoSources}</p> <p className="text-muted-foreground">{t('notebooks.deleteNotebookNoSources')}</p>
</div> </div>
)} )}
@ -124,7 +124,7 @@ export function NotebookDeleteDialog({
{preview.exclusive_source_count > 0 && ( {preview.exclusive_source_count > 0 && (
<div className="pt-3 border-t space-y-3"> <div className="pt-3 border-t space-y-3">
<p className="text-sm text-destructive font-medium"> <p className="text-sm text-destructive font-medium">
{t.notebooks.deleteNotebookExclusiveSources.replace( {t('notebooks.deleteNotebookExclusiveSources').replace(
'{count}', '{count}',
String(preview.exclusive_source_count) String(preview.exclusive_source_count)
)} )}
@ -137,13 +137,13 @@ export function NotebookDeleteDialog({
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<RadioGroupItem value="delete" id="delete-sources" /> <RadioGroupItem value="delete" id="delete-sources" />
<Label htmlFor="delete-sources" className="text-sm cursor-pointer"> <Label htmlFor="delete-sources" className="text-sm cursor-pointer">
{t.notebooks.deleteExclusiveSourcesLabel} {t('notebooks.deleteExclusiveSourcesLabel')}
</Label> </Label>
</div> </div>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<RadioGroupItem value="keep" id="keep-sources" /> <RadioGroupItem value="keep" id="keep-sources" />
<Label htmlFor="keep-sources" className="text-sm cursor-pointer"> <Label htmlFor="keep-sources" className="text-sm cursor-pointer">
{t.notebooks.keepExclusiveSourcesLabel} {t('notebooks.keepExclusiveSourcesLabel')}
</Label> </Label>
</div> </div>
</RadioGroup> </RadioGroup>
@ -154,7 +154,7 @@ export function NotebookDeleteDialog({
</div> </div>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>{t.common.cancel}</AlertDialogCancel> <AlertDialogCancel disabled={isDeleting}>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleConfirm} onClick={handleConfirm}
disabled={isDeleting || isLoadingPreview} disabled={isDeleting || isLoadingPreview}
@ -163,10 +163,10 @@ export function NotebookDeleteDialog({
{isDeleting ? ( {isDeleting ? (
<> <>
<LoadingSpinner size="sm" className="mr-2" /> <LoadingSpinner size="sm" className="mr-2" />
{t.common.deleting} {t('common.deleting')}
</> </>
) : ( ) : (
t.common.delete t('common.delete')
)} )}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View file

@ -61,10 +61,10 @@ export function NotebookHeader({ notebook }: NotebookHeaderProps) {
onSave={handleUpdateName} onSave={handleUpdateName}
className="text-2xl font-bold" className="text-2xl font-bold"
inputClassName="text-2xl font-bold" inputClassName="text-2xl font-bold"
placeholder={t.notebooks.namePlaceholder} placeholder={t('notebooks.namePlaceholder')}
/> />
{notebook.archived && ( {notebook.archived && (
<Badge variant="secondary">{t.notebooks.archived}</Badge> <Badge variant="secondary">{t('notebooks.archived')}</Badge>
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@ -76,12 +76,12 @@ export function NotebookHeader({ notebook }: NotebookHeaderProps) {
{notebook.archived ? ( {notebook.archived ? (
<> <>
<ArchiveRestore className="h-4 w-4 mr-2" /> <ArchiveRestore className="h-4 w-4 mr-2" />
{t.notebooks.unarchive} {t('notebooks.unarchive')}
</> </>
) : ( ) : (
<> <>
<Archive className="h-4 w-4 mr-2" /> <Archive className="h-4 w-4 mr-2" />
{t.notebooks.archive} {t('notebooks.archive')}
</> </>
)} )}
</Button> </Button>
@ -92,7 +92,7 @@ export function NotebookHeader({ notebook }: NotebookHeaderProps) {
className="text-red-600 hover:text-red-700" className="text-red-600 hover:text-red-700"
> >
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
{t.common.delete} {t('common.delete')}
</Button> </Button>
</div> </div>
</div> </div>
@ -104,14 +104,14 @@ export function NotebookHeader({ notebook }: NotebookHeaderProps) {
onSave={handleUpdateDescription} onSave={handleUpdateDescription}
className="text-muted-foreground" className="text-muted-foreground"
inputClassName="text-muted-foreground" inputClassName="text-muted-foreground"
placeholder={t.notebooks.addDescription} placeholder={t('notebooks.addDescription')}
multiline multiline
emptyText={t.notebooks.addDescription} emptyText={t('notebooks.addDescription')}
/> />
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{t.common.created.replace('{time}', formatDistanceToNow(new Date(notebook.created), { addSuffix: true, locale: dfLocale }))} {t('common.created').replace('{time}', formatDistanceToNow(new Date(notebook.created), { addSuffix: true, locale: dfLocale }))}
{t.common.updated.replace('{time}', formatDistanceToNow(new Date(notebook.updated), { addSuffix: true, locale: dfLocale }))} {t('common.updated').replace('{time}', formatDistanceToNow(new Date(notebook.updated), { addSuffix: true, locale: dfLocale }))}
</div> </div>
</div> </div>
</div> </div>

View file

@ -45,8 +45,8 @@ export function NotebookList({
return ( return (
<EmptyState <EmptyState
icon={Book} icon={Book}
title={emptyTitle ?? t.common.noResults} title={emptyTitle ?? t('common.noResults')}
description={emptyDescription ?? t.chat.startByCreating} description={emptyDescription ?? t('chat.startByCreating')}
action={onAction && actionLabel ? ( action={onAction && actionLabel ? (
<Button onClick={onAction} variant="outline" className="mt-4"> <Button onClick={onAction} variant="outline" className="mt-4">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />

View file

@ -51,8 +51,8 @@ export function NotesColumn({
// Collapsible column state // Collapsible column state
const { notesCollapsed, toggleNotes } = useNotebookColumnsStore() const { notesCollapsed, toggleNotes } = useNotebookColumnsStore()
const collapseButton = useMemo( const collapseButton = useMemo(
() => createCollapseButton(toggleNotes, t.common.notes), () => createCollapseButton(toggleNotes, t('common.notes')),
[toggleNotes, t.common.notes] [toggleNotes, t('common.notes')]
) )
const handleDeleteClick = (noteId: string) => { const handleDeleteClick = (noteId: string) => {
@ -78,12 +78,12 @@ export function NotesColumn({
isCollapsed={notesCollapsed} isCollapsed={notesCollapsed}
onToggle={toggleNotes} onToggle={toggleNotes}
collapsedIcon={StickyNote} collapsedIcon={StickyNote}
collapsedLabel={t.common.notes} collapsedLabel={t('common.notes')}
> >
<Card className="h-full flex flex-col flex-1 overflow-hidden"> <Card className="h-full flex flex-col flex-1 overflow-hidden">
<CardHeader className="pb-3 flex-shrink-0"> <CardHeader className="pb-3 flex-shrink-0">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<CardTitle className="text-lg">{t.common.notes}</CardTitle> <CardTitle className="text-lg">{t('common.notes')}</CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
size="sm" size="sm"
@ -93,7 +93,7 @@ export function NotesColumn({
}} }}
> >
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
{t.common.writeNote} {t('common.writeNote')}
</Button> </Button>
{collapseButton} {collapseButton}
</div> </div>
@ -108,8 +108,8 @@ export function NotesColumn({
) : !notes || notes.length === 0 ? ( ) : !notes || notes.length === 0 ? (
<EmptyState <EmptyState
icon={StickyNote} icon={StickyNote}
title={t.notebooks.noNotesYet} title={t('notebooks.noNotesYet')}
description={t.sources.createFirstNote} description={t('sources.createFirstNote')}
/> />
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@ -127,7 +127,7 @@ export function NotesColumn({
<User className="h-4 w-4 text-muted-foreground" /> <User className="h-4 w-4 text-muted-foreground" />
)} )}
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{note.note_type === 'ai' ? t.common.aiGenerated : t.common.human} {note.note_type === 'ai' ? t('common.aiGenerated') : t('common.human')}
</Badge> </Badge>
</div> </div>
@ -171,7 +171,7 @@ export function NotesColumn({
className="text-red-600 focus:text-red-600" className="text-red-600 focus:text-red-600"
> >
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
{t.notebooks.deleteNote} {t('notebooks.deleteNote')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -212,9 +212,9 @@ export function NotesColumn({
<ConfirmDialog <ConfirmDialog
open={deleteDialogOpen} open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}
title={t.notebooks.deleteNote} title={t('notebooks.deleteNote')}
description={t.notebooks.deleteNoteConfirm} description={t('notebooks.deleteNoteConfirm')}
confirmText={t.common.delete} confirmText={t('common.delete')}
onConfirm={handleDeleteConfirm} onConfirm={handleDeleteConfirm}
isLoading={deleteNote.isPending} isLoading={deleteNote.isPending}
confirmVariant="destructive" confirmVariant="destructive"

View file

@ -66,8 +66,8 @@ export function SourcesColumn({
// Collapsible column state // Collapsible column state
const { sourcesCollapsed, toggleSources } = useNotebookColumnsStore() const { sourcesCollapsed, toggleSources } = useNotebookColumnsStore()
const collapseButton = useMemo( const collapseButton = useMemo(
() => createCollapseButton(toggleSources, t.navigation.sources), () => createCollapseButton(toggleSources, t('navigation.sources')),
[toggleSources, t.navigation.sources] [toggleSources, t('navigation.sources')]
) )
// Scroll container ref for infinite scroll // Scroll container ref for infinite scroll
@ -151,29 +151,29 @@ export function SourcesColumn({
isCollapsed={sourcesCollapsed} isCollapsed={sourcesCollapsed}
onToggle={toggleSources} onToggle={toggleSources}
collapsedIcon={FileText} collapsedIcon={FileText}
collapsedLabel={t.navigation.sources} collapsedLabel={t('navigation.sources')}
> >
<Card className="h-full flex flex-col flex-1 overflow-hidden"> <Card className="h-full flex flex-col flex-1 overflow-hidden">
<CardHeader className="pb-3 flex-shrink-0"> <CardHeader className="pb-3 flex-shrink-0">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<CardTitle className="text-lg">{t.navigation.sources}</CardTitle> <CardTitle className="text-lg">{t('navigation.sources')}</CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}> <DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button size="sm"> <Button size="sm">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
{t.sources.addSource} {t('sources.addSource')}
<ChevronDown className="h-4 w-4 ml-2" /> <ChevronDown className="h-4 w-4 ml-2" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => { setDropdownOpen(false); setAddDialogOpen(true); }}> <DropdownMenuItem onClick={() => { setDropdownOpen(false); setAddDialogOpen(true); }}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
{t.sources.addSource} {t('sources.addSource')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => { setDropdownOpen(false); setAddExistingDialogOpen(true); }}> <DropdownMenuItem onClick={() => { setDropdownOpen(false); setAddExistingDialogOpen(true); }}>
<Link2 className="h-4 w-4 mr-2" /> <Link2 className="h-4 w-4 mr-2" />
{t.sources.addExistingTitle} {t('sources.addExistingTitle')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -190,8 +190,8 @@ export function SourcesColumn({
) : !sources || sources.length === 0 ? ( ) : !sources || sources.length === 0 ? (
<EmptyState <EmptyState
icon={FileText} icon={FileText}
title={t.sources.noSourcesYet} title={t('sources.noSourcesYet')}
description={t.sources.createFirstSource} description={t('sources.createFirstSource')}
/> />
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@ -240,9 +240,9 @@ export function SourcesColumn({
<ConfirmDialog <ConfirmDialog
open={deleteDialogOpen} open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}
title={t.sources.delete} title={t('sources.delete')}
description={t.sources.deleteConfirm} description={t('sources.deleteConfirm')}
confirmText={t.common.delete} confirmText={t('common.delete')}
onConfirm={handleDeleteConfirm} onConfirm={handleDeleteConfirm}
isLoading={deleteSource.isPending} isLoading={deleteSource.isPending}
confirmVariant="destructive" confirmVariant="destructive"
@ -251,9 +251,9 @@ export function SourcesColumn({
<ConfirmDialog <ConfirmDialog
open={removeDialogOpen} open={removeDialogOpen}
onOpenChange={setRemoveDialogOpen} onOpenChange={setRemoveDialogOpen}
title={t.sources.removeFromNotebook} title={t('sources.removeFromNotebook')}
description={t.sources.removeConfirm} description={t('sources.removeConfirm')}
confirmText={t.common.remove} confirmText={t('common.remove')}
onConfirm={handleRemoveConfirm} onConfirm={handleRemoveConfirm}
isLoading={removeFromNotebook.isPending} isLoading={removeFromNotebook.isPending}
confirmVariant="default" confirmVariant="default"

View file

@ -53,7 +53,7 @@ export default function NotebooksPage() {
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<h1 className="text-2xl font-bold">{t.notebooks.title}</h1> <h1 className="text-2xl font-bold">{t('notebooks.title')}</h1>
<Button variant="outline" size="sm" onClick={() => refetch()}> <Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
</Button> </Button>
@ -64,14 +64,14 @@ export default function NotebooksPage() {
name="notebook-search" name="notebook-search"
value={searchTerm} value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)} onChange={(event) => setSearchTerm(event.target.value)}
placeholder={t.notebooks.searchPlaceholder} placeholder={t('notebooks.searchPlaceholder')}
autoComplete="off" autoComplete="off"
aria-label={t.common.accessibility?.searchNotebooks || "Search notebooks"} aria-label={t('common.accessibility.searchNotebooks') || "Search notebooks"}
className="w-full sm:w-64" className="w-full sm:w-64"
/> />
<Button onClick={() => setCreateDialogOpen(true)}> <Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
{t.notebooks.newNotebook} {t('notebooks.newNotebook')}
</Button> </Button>
</div> </div>
</div> </div>
@ -80,21 +80,21 @@ export default function NotebooksPage() {
<NotebookList <NotebookList
notebooks={filteredActive} notebooks={filteredActive}
isLoading={isLoading} isLoading={isLoading}
title={t.notebooks.activeNotebooks} title={t('notebooks.activeNotebooks')}
emptyTitle={isSearching ? t.common.noMatches : undefined} emptyTitle={isSearching ? t('common.noMatches') : undefined}
emptyDescription={isSearching ? t.common.tryDifferentSearch : undefined} emptyDescription={isSearching ? t('common.tryDifferentSearch') : undefined}
onAction={!isSearching ? () => setCreateDialogOpen(true) : undefined} onAction={!isSearching ? () => setCreateDialogOpen(true) : undefined}
actionLabel={!isSearching ? t.notebooks.newNotebook : undefined} actionLabel={!isSearching ? t('notebooks.newNotebook') : undefined}
/> />
{hasArchived && ( {hasArchived && (
<NotebookList <NotebookList
notebooks={filteredArchived} notebooks={filteredArchived}
isLoading={false} isLoading={false}
title={t.notebooks.archivedNotebooks} title={t('notebooks.archivedNotebooks')}
collapsible collapsible
emptyTitle={isSearching ? t.common.noMatches : undefined} emptyTitle={isSearching ? t('common.noMatches') : undefined}
emptyDescription={isSearching ? t.common.tryDifferentSearch : undefined} emptyDescription={isSearching ? t('common.tryDifferentSearch') : undefined}
/> />
)} )}
</div> </div>

View file

@ -29,18 +29,18 @@ export default function PodcastsPage() {
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="px-6 py-6 space-y-6"> <div className="px-6 py-6 space-y-6">
<header className="space-y-1"> <header className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">{t.podcasts.listTitle}</h1> <h1 className="text-2xl font-semibold tracking-tight">{t('podcasts.listTitle')}</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t.podcasts.listDesc} {t('podcasts.listDesc')}
</p> </p>
</header> </header>
{hasUnconfiguredProfiles ? ( {hasUnconfiguredProfiles ? (
<Alert className="bg-amber-50 text-amber-900 border-amber-200"> <Alert className="bg-amber-50 text-amber-900 border-amber-200">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertTitle>{t.podcasts.setupRequired}</AlertTitle> <AlertTitle>{t('podcasts.setupRequired')}</AlertTitle>
<AlertDescription> <AlertDescription>
{t.podcasts.setupRequiredDesc} {t('podcasts.setupRequiredDesc')}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : null} ) : null}
@ -51,15 +51,15 @@ export default function PodcastsPage() {
className="space-y-6" className="space-y-6"
> >
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t.podcasts.chooseAView}</p> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t('podcasts.chooseAView')}</p>
<TabsList aria-label={t.common.accessibility.podcastViews} className="w-full max-w-md"> <TabsList aria-label={t('common.accessibility.podcastViews')} className="w-full max-w-md">
<TabsTrigger value="episodes"> <TabsTrigger value="episodes">
<Mic className="h-4 w-4" /> <Mic className="h-4 w-4" />
{t.podcasts.episodesTab} {t('podcasts.episodesTab')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="templates"> <TabsTrigger value="templates">
<LayoutTemplate className="h-4 w-4" /> <LayoutTemplate className="h-4 w-4" />
{t.podcasts.templatesTab} {t('podcasts.templatesTab')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</div> </div>

View file

@ -72,7 +72,7 @@ export default function SearchPage() {
}, [availableModels]) }, [availableModels])
const resolveModelName = (id?: string | null) => { const resolveModelName = (id?: string | null) => {
if (!id) return t.searchPage.notSet if (!id) return t('searchPage.notSet')
return modelNameById.get(id) ?? id return modelNameById.get(id) ?? id
} }
@ -159,19 +159,19 @@ export default function SearchPage() {
return ( return (
<AppShell> <AppShell>
<div className="p-4 md:p-6"> <div className="p-4 md:p-6">
<h1 className="text-xl md:text-2xl font-bold mb-4 md:mb-6">{t.searchPage.askAndSearch}</h1> <h1 className="text-xl md:text-2xl font-bold mb-4 md:mb-6">{t('searchPage.askAndSearch')}</h1>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'ask' | 'search')} className="w-full space-y-6"> <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'ask' | 'search')} className="w-full space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t.searchPage.chooseAMode}</p> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t('searchPage.chooseAMode')}</p>
<TabsList aria-label={t.common.accessibility.searchKB} className="w-full max-w-xl"> <TabsList aria-label={t('common.accessibility.searchKB')} className="w-full max-w-xl">
<TabsTrigger value="ask"> <TabsTrigger value="ask">
<MessageCircleQuestion className="h-4 w-4" /> <MessageCircleQuestion className="h-4 w-4" />
{t.searchPage.askBeta} {t('searchPage.askBeta')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="search"> <TabsTrigger value="search">
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
{t.searchPage.search} {t('searchPage.search')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</div> </div>
@ -179,19 +179,19 @@ export default function SearchPage() {
<TabsContent value="ask" className="mt-6"> <TabsContent value="ask" className="mt-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">{t.searchPage.askYourKb}</CardTitle> <CardTitle className="text-lg">{t('searchPage.askYourKb')}</CardTitle>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t.searchPage.askYourKbDesc} {t('searchPage.askYourKbDesc')}
</p> </p>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Question Input */} {/* Question Input */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="ask-question">{t.searchPage.question}</Label> <Label htmlFor="ask-question">{t('searchPage.question')}</Label>
<Textarea <Textarea
id="ask-question" id="ask-question"
name="ask-question" name="ask-question"
placeholder={t.searchPage.enterQuestionPlaceholder} placeholder={t('searchPage.enterQuestionPlaceholder')}
value={askQuestion} value={askQuestion}
onChange={(e) => setAskQuestion(e.target.value)} onChange={(e) => setAskQuestion(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
@ -203,23 +203,23 @@ export default function SearchPage() {
}} }}
disabled={ask.isStreaming} disabled={ask.isStreaming}
rows={3} rows={3}
aria-label={t.common.accessibility.enterQuestion} aria-label={t('common.accessibility.enterQuestion')}
/> />
<p className="text-xs text-muted-foreground">{t.searchPage.pressToSubmit}</p> <p className="text-xs text-muted-foreground">{t('searchPage.pressToSubmit')}</p>
</div> </div>
{/* Models Display */} {/* Models Display */}
{!hasEmbeddingModel ? ( {!hasEmbeddingModel ? (
<div className="flex items-center gap-2 p-3 text-sm text-amber-600 dark:text-amber-500 bg-amber-50 dark:bg-amber-950/20 rounded-md"> <div className="flex items-center gap-2 p-3 text-sm text-amber-600 dark:text-amber-500 bg-amber-50 dark:bg-amber-950/20 rounded-md">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<span>{t.searchPage.noEmbeddingModel}</span> <span>{t('searchPage.noEmbeddingModel')}</span>
</div> </div>
) : ( ) : (
<> <>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground"> <Label className="text-xs text-muted-foreground">
{customModels ? t.searchPage.usingCustomModels : t.searchPage.usingDefaultModels} {customModels ? t('searchPage.usingCustomModels') : t('searchPage.usingDefaultModels')}
</Label> </Label>
<Button <Button
variant="ghost" variant="ghost"
@ -229,18 +229,18 @@ export default function SearchPage() {
className="h-auto py-1 px-2" className="h-auto py-1 px-2"
> >
<Settings className="h-3 w-3 mr-1" /> <Settings className="h-3 w-3 mr-1" />
{t.searchPage.advanced} {t('searchPage.advanced')}
</Button> </Button>
</div> </div>
<div className="flex gap-2 text-xs flex-wrap"> <div className="flex gap-2 text-xs flex-wrap">
<Badge variant="secondary"> <Badge variant="secondary">
{t.searchPage.strategy}: {resolveModelName(customModels?.strategy || modelDefaults?.default_chat_model)} {t('searchPage.strategy')}: {resolveModelName(customModels?.strategy || modelDefaults?.default_chat_model)}
</Badge> </Badge>
<Badge variant="secondary"> <Badge variant="secondary">
{t.searchPage.answer}: {resolveModelName(customModels?.answer || modelDefaults?.default_chat_model)} {t('searchPage.answer')}: {resolveModelName(customModels?.answer || modelDefaults?.default_chat_model)}
</Badge> </Badge>
<Badge variant="secondary"> <Badge variant="secondary">
{t.searchPage.final}: {resolveModelName(customModels?.finalAnswer || modelDefaults?.default_chat_model)} {t('searchPage.final')}: {resolveModelName(customModels?.finalAnswer || modelDefaults?.default_chat_model)}
</Badge> </Badge>
</div> </div>
</div> </div>
@ -254,10 +254,10 @@ export default function SearchPage() {
{ask.isStreaming ? ( {ask.isStreaming ? (
<> <>
<LoadingSpinner size="sm" className="mr-2" /> <LoadingSpinner size="sm" className="mr-2" />
{t.searchPage.processing} {t('searchPage.processing')}
</> </>
) : ( ) : (
t.searchPage.ask t('searchPage.ask')
)} )}
</Button> </Button>
@ -268,7 +268,7 @@ export default function SearchPage() {
className="w-full" className="w-full"
> >
<Save className="h-4 w-4 mr-2" /> <Save className="h-4 w-4 mr-2" />
{t.searchPage.saveToNotebooks} {t('searchPage.saveToNotebooks')}
</Button> </Button>
)} )}
</div> </div>
@ -311,34 +311,34 @@ export default function SearchPage() {
<TabsContent value="search" className="mt-6"> <TabsContent value="search" className="mt-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">{t.searchPage.search}</CardTitle> <CardTitle className="text-lg">{t('searchPage.search')}</CardTitle>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t.searchPage.searchDesc} {t('searchPage.searchDesc')}
</p> </p>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Search Input */} {/* Search Input */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="search-query" className="sr-only"> <Label htmlFor="search-query" className="sr-only">
{t.searchPage.search} {t('searchPage.search')}
</Label> </Label>
<div className="flex flex-col sm:flex-row gap-2"> <div className="flex flex-col sm:flex-row gap-2">
<Input <Input
id="search-query" id="search-query"
name="search-query" name="search-query"
placeholder={t.searchPage.enterSearchPlaceholder} placeholder={t('searchPage.enterSearchPlaceholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
disabled={searchMutation.isPending} disabled={searchMutation.isPending}
className="flex-1" className="flex-1"
aria-label={t.common.accessibility.enterSearch} aria-label={t('common.accessibility.enterSearch')}
autoComplete="off" autoComplete="off"
/> />
<Button <Button
onClick={handleSearch} onClick={handleSearch}
disabled={searchMutation.isPending || !searchQuery.trim()} disabled={searchMutation.isPending || !searchQuery.trim()}
aria-label={t.common.accessibility.searchKBBtn} aria-label={t('common.accessibility.searchKBBtn')}
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
{searchMutation.isPending ? ( {searchMutation.isPending ? (
@ -346,21 +346,21 @@ export default function SearchPage() {
) : ( ) : (
<Search className="h-4 w-4 mr-2" /> <Search className="h-4 w-4 mr-2" />
)} )}
{t.searchPage.search} {t('searchPage.search')}
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground">{t.searchPage.pressToSearch}</p> <p className="text-xs text-muted-foreground">{t('searchPage.pressToSearch')}</p>
</div> </div>
{/* Search Options */} {/* Search Options */}
<div className="space-y-4"> <div className="space-y-4">
{/* Search Type */} {/* Search Type */}
<div className="space-y-2" role="group" aria-labelledby="search-type-label"> <div className="space-y-2" role="group" aria-labelledby="search-type-label">
<span id="search-type-label" className="text-sm font-medium leading-none">{t.searchPage.searchType}</span> <span id="search-type-label" className="text-sm font-medium leading-none">{t('searchPage.searchType')}</span>
{!hasEmbeddingModel && ( {!hasEmbeddingModel && (
<div className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500"> <div className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<span>{t.searchPage.vectorSearchWarning}</span> <span>{t('searchPage.vectorSearchWarning')}</span>
</div> </div>
)} )}
<RadioGroup <RadioGroup
@ -372,7 +372,7 @@ export default function SearchPage() {
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="text" id="text" /> <RadioGroupItem value="text" id="text" />
<Label htmlFor="text" className="font-normal cursor-pointer"> <Label htmlFor="text" className="font-normal cursor-pointer">
{t.searchPage.textSearch} {t('searchPage.textSearch')}
</Label> </Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@ -385,7 +385,7 @@ export default function SearchPage() {
htmlFor="vector" htmlFor="vector"
className={`font-normal ${!hasEmbeddingModel ? 'text-muted-foreground cursor-not-allowed' : 'cursor-pointer'}`} className={`font-normal ${!hasEmbeddingModel ? 'text-muted-foreground cursor-not-allowed' : 'cursor-pointer'}`}
> >
{t.searchPage.vectorSearch} {t('searchPage.vectorSearch')}
</Label> </Label>
</div> </div>
</RadioGroup> </RadioGroup>
@ -393,7 +393,7 @@ export default function SearchPage() {
{/* Search Locations */} {/* Search Locations */}
<div className="space-y-2" role="group" aria-labelledby="search-in-label"> <div className="space-y-2" role="group" aria-labelledby="search-in-label">
<span id="search-in-label" className="text-sm font-medium leading-none">{t.searchPage.searchIn}</span> <span id="search-in-label" className="text-sm font-medium leading-none">{t('searchPage.searchIn')}</span>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
@ -404,7 +404,7 @@ export default function SearchPage() {
disabled={searchMutation.isPending} disabled={searchMutation.isPending}
/> />
<Label htmlFor="sources" className="font-normal cursor-pointer"> <Label htmlFor="sources" className="font-normal cursor-pointer">
{t.searchPage.searchSources} {t('searchPage.searchSources')}
</Label> </Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@ -416,7 +416,7 @@ export default function SearchPage() {
disabled={searchMutation.isPending} disabled={searchMutation.isPending}
/> />
<Label htmlFor="notes" className="font-normal cursor-pointer"> <Label htmlFor="notes" className="font-normal cursor-pointer">
{t.searchPage.searchNotes} {t('searchPage.searchNotes')}
</Label> </Label>
</div> </div>
</div> </div>
@ -428,15 +428,15 @@ export default function SearchPage() {
<div className="mt-6 space-y-3"> <div className="mt-6 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-medium"> <h3 className="text-sm font-medium">
{t.searchPage.resultsFound.replace('{count}', searchMutation.data.total_count.toString())} {t('searchPage.resultsFound').replace('{count}', searchMutation.data.total_count.toString())}
</h3> </h3>
<Badge variant="outline">{searchMutation.data.search_type === 'text' ? t.searchPage.textSearch : t.searchPage.vectorSearch}</Badge> <Badge variant="outline">{searchMutation.data.search_type === 'text' ? t('searchPage.textSearch') : t('searchPage.vectorSearch')}</Badge>
</div> </div>
{searchMutation.data.results.length === 0 ? ( {searchMutation.data.results.length === 0 ? (
<Card> <Card>
<CardContent className="pt-6 text-center text-muted-foreground"> <CardContent className="pt-6 text-center text-muted-foreground">
{t.searchPage.noResultsFor.replace('{query}', searchQuery)} {t('searchPage.noResultsFor').replace('{query}', searchQuery)}
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
@ -472,7 +472,7 @@ export default function SearchPage() {
<Collapsible className="mt-3"> <Collapsible className="mt-3">
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"> <CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
{t.searchPage.matches.replace('{count}', result.matches.length.toString())} {t('searchPage.matches').replace('{count}', result.matches.length.toString())}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-1"> <CollapsibleContent className="mt-2 space-y-1">
{result.matches.map((match, i) => ( {result.matches.map((match, i) => (

View file

@ -255,14 +255,14 @@ function CredentialFormDialog({
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{isEditing {isEditing
? t.apiKeys.editConfig.replace('{provider}', PROVIDER_DISPLAY_NAMES[provider] || provider) ? t('apiKeys.editConfig').replace('{provider}', PROVIDER_DISPLAY_NAMES[provider] || provider)
: t.apiKeys.addConfig.replace('{provider}', PROVIDER_DISPLAY_NAMES[provider] || provider)} : t('apiKeys.addConfig').replace('{provider}', PROVIDER_DISPLAY_NAMES[provider] || provider)}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Name */} {/* Name */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="cred-name">{t.apiKeys.configName}</Label> <Label htmlFor="cred-name">{t('apiKeys.configName')}</Label>
<input <input
id="cred-name" id="cred-name"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
@ -271,14 +271,14 @@ function CredentialFormDialog({
placeholder={`${PROVIDER_DISPLAY_NAMES[provider] || provider} Production`} placeholder={`${PROVIDER_DISPLAY_NAMES[provider] || provider} Production`}
disabled={isSubmitting} disabled={isSubmitting}
/> />
<p className="text-xs text-muted-foreground">{t.apiKeys.configNameHint}</p> <p className="text-xs text-muted-foreground">{t('apiKeys.configNameHint')}</p>
</div> </div>
{/* Vertex fields */} {/* Vertex fields */}
{isVertex ? ( {isVertex ? (
<> <>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="vertex-project">{t.apiKeys.vertexProject}</Label> <Label htmlFor="vertex-project">{t('apiKeys.vertexProject')}</Label>
<input <input
id="vertex-project" id="vertex-project"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
@ -289,7 +289,7 @@ function CredentialFormDialog({
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="vertex-location">{t.apiKeys.vertexLocation}</Label> <Label htmlFor="vertex-location">{t('apiKeys.vertexLocation')}</Label>
<input <input
id="vertex-location" id="vertex-location"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
@ -301,8 +301,8 @@ function CredentialFormDialog({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="vertex-creds"> <Label htmlFor="vertex-creds">
{t.apiKeys.vertexCredentials} {t('apiKeys.vertexCredentials')}
<span className="text-muted-foreground font-normal ml-1">({t.common.optional})</span> <span className="text-muted-foreground font-normal ml-1">({t('common.optional')})</span>
</Label> </Label>
<input <input
id="vertex-creds" id="vertex-creds"
@ -318,8 +318,8 @@ function CredentialFormDialog({
/* API Key */ /* API Key */
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="api-key"> <Label htmlFor="api-key">
{t.models.apiKey} {t('models.apiKey')}
{!requiresApiKey && <span className="text-muted-foreground font-normal ml-1">({t.common.optional})</span>} {!requiresApiKey && <span className="text-muted-foreground font-normal ml-1">({t('common.optional')})</span>}
</Label> </Label>
<div className="relative"> <div className="relative">
<input <input
@ -341,10 +341,10 @@ function CredentialFormDialog({
{showApiKey ? 'Hide' : 'Show'} {showApiKey ? 'Hide' : 'Show'}
</button> </button>
</div> </div>
{isEditing && <p className="text-xs text-muted-foreground">{t.apiKeys.apiKeyEditHint}</p>} {isEditing && <p className="text-xs text-muted-foreground">{t('apiKeys.apiKeyEditHint')}</p>}
{docsUrl && ( {docsUrl && (
<a href={docsUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline"> <a href={docsUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline">
{t.apiKeys.getApiKey} &rarr; {t('apiKeys.getApiKey')} &rarr;
</a> </a>
)} )}
</div> </div>
@ -353,7 +353,7 @@ function CredentialFormDialog({
{/* Base URL (non-Vertex) */} {/* Base URL (non-Vertex) */}
{!isVertex && ( {!isVertex && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="base-url" className="text-muted-foreground">{t.apiKeys.baseUrl}</Label> <Label htmlFor="base-url" className="text-muted-foreground">{t('apiKeys.baseUrl')}</Label>
<input <input
id="base-url" id="base-url"
type="url" type="url"
@ -363,18 +363,18 @@ function CredentialFormDialog({
placeholder={isOllama ? 'http://localhost:11434' : 'https://api.example.com/v1'} placeholder={isOllama ? 'http://localhost:11434' : 'https://api.example.com/v1'}
disabled={isSubmitting} disabled={isSubmitting}
/> />
<p className="text-xs text-muted-foreground">{t.apiKeys.baseUrlOverrideHint}</p> <p className="text-xs text-muted-foreground">{t('apiKeys.baseUrlOverrideHint')}</p>
</div> </div>
)} )}
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-2 pt-4 border-t"> <div className="flex justify-end gap-2 pt-4 border-t">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
{t.common.cancel} {t('common.cancel')}
</Button> </Button>
<Button type="submit" disabled={!isValid || isSubmitting}> <Button type="submit" disabled={!isValid || isSubmitting}>
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin mr-2" />} {isSubmitting && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
{isEditing ? t.common.save : t.apiKeys.addConfig} {isEditing ? t('common.save') : t('apiKeys.addConfig')}
</Button> </Button>
</div> </div>
</form> </form>
@ -518,7 +518,7 @@ function DiscoverModelsDialog({
<DialogContent className="sm:max-w-lg max-h-[80vh] overflow-y-auto"> <DialogContent className="sm:max-w-lg max-h-[80vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{t.models.discoverModels} - {PROVIDER_DISPLAY_NAMES[credential.provider] || credential.provider} {t('models.discoverModels')} - {PROVIDER_DISPLAY_NAMES[credential.provider] || credential.provider}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{credential.name} {credential.name}
@ -538,7 +538,7 @@ function DiscoverModelsDialog({
<div className="space-y-4"> <div className="space-y-4">
{/* Model type selector */} {/* Model type selector */}
<div className="space-y-2"> <div className="space-y-2">
<Label>{t.models.modelType}</Label> <Label>{t('models.modelType')}</Label>
<Select value={selectedType} onValueChange={(v) => setSelectedType(v as ModelType)}> <Select value={selectedType} onValueChange={(v) => setSelectedType(v as ModelType)}>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@ -554,14 +554,14 @@ function DiscoverModelsDialog({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-muted-foreground">{t.models.modelTypeHint}</p> <p className="text-xs text-muted-foreground">{t('models.modelTypeHint')}</p>
</div> </div>
{/* Search input */} {/* Search input */}
<input <input
type="text" type="text"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm placeholder:text-muted-foreground" className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm placeholder:text-muted-foreground"
placeholder={t.models.searchOrAddModel} placeholder={t('models.searchOrAddModel')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
@ -570,7 +570,7 @@ function DiscoverModelsDialog({
{filteredModels.length > 0 && ( {filteredModels.length > 0 && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button variant="outline" size="sm" onClick={toggleAll}> <Button variant="outline" size="sm" onClick={toggleAll}>
{filteredModels.every(m => selectedModels.has(m.name)) ? t.common.remove : t.common.addSelected} {filteredModels.every(m => selectedModels.has(m.name)) ? t('common.remove') : t('common.addSelected')}
{' '}({selectedModels.size}/{filteredModels.length}) {' '}({selectedModels.size}/{filteredModels.length})
</Button> </Button>
</div> </div>
@ -607,13 +607,13 @@ function DiscoverModelsDialog({
/> />
<Plus className="h-3.5 w-3.5 text-muted-foreground shrink-0" /> <Plus className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="truncate"> <span className="truncate">
{t.models.addCustomModel.replace('{name}', searchQuery.trim())} {t('models.addCustomModel').replace('{name}', searchQuery.trim())}
</span> </span>
</label> </label>
)} )}
{filteredModels.length === 0 && !showCustomOption && ( {filteredModels.length === 0 && !showCustomOption && (
<p className="text-center py-4 text-muted-foreground text-sm">{t.models.noModelsFound}</p> <p className="text-center py-4 text-muted-foreground text-sm">{t('models.noModelsFound')}</p>
)} )}
</div> </div>
</div> </div>
@ -621,14 +621,14 @@ function DiscoverModelsDialog({
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
{t.common.cancel} {t('common.cancel')}
</Button> </Button>
<Button <Button
onClick={handleRegister} onClick={handleRegister}
disabled={totalSelected === 0 || registerModels.isPending} disabled={totalSelected === 0 || registerModels.isPending}
> >
{registerModels.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />} {registerModels.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
{t.common.add} ({totalSelected}) {t('common.add')} ({totalSelected})
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@ -685,9 +685,9 @@ function DeleteCredentialDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t.apiKeys.deleteConfig}</DialogTitle> <DialogTitle>{t('apiKeys.deleteConfig')}</DialogTitle>
<DialogDescription> <DialogDescription>
{t.apiKeys.deleteConfigConfirm.replace('{name}', credential.name)} {t('apiKeys.deleteConfigConfirm').replace('{name}', credential.name)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -717,7 +717,7 @@ function DeleteCredentialDialog({
<DialogFooter className="flex-col sm:flex-row gap-2"> <DialogFooter className="flex-col sm:flex-row gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
{t.common.cancel} {t('common.cancel')}
</Button> </Button>
{credential.model_count > 0 && migrateToId && ( {credential.model_count > 0 && migrateToId && (
<Button onClick={handleMigrate} disabled={deleteCredential.isPending}> <Button onClick={handleMigrate} disabled={deleteCredential.isPending}>
@ -731,7 +731,7 @@ function DeleteCredentialDialog({
disabled={deleteCredential.isPending} disabled={deleteCredential.isPending}
> >
{deleteCredential.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />} {deleteCredential.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
{credential.model_count > 0 ? 'Delete with Models' : t.common.delete} {credential.model_count > 0 ? 'Delete with Models' : t('common.delete')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@ -769,8 +769,8 @@ function CredentialItem({
const testResult = testResults[credential.id] const testResult = testResults[credential.id]
// Extract translations used in model badge loops to avoid excessive Proxy accesses // Extract translations used in model badge loops to avoid excessive Proxy accesses
const testModelLabel = t.models.testModel const testModelLabel = t('models.testModel')
const deleteModelLabel = t.models.deleteModel const deleteModelLabel = t('models.deleteModel')
// Check which models are defaults // Check which models are defaults
const defaultSlots: Record<string, string> = {} const defaultSlots: Record<string, string> = {}
@ -824,7 +824,7 @@ function CredentialItem({
variant="ghost" size="sm" variant="ghost" size="sm"
onClick={() => testCredential(credential.id)} onClick={() => testCredential(credential.id)}
disabled={isTestPending || !!credential.decryption_error} disabled={isTestPending || !!credential.decryption_error}
title={t.apiKeys.testConnection} title={t('apiKeys.testConnection')}
> >
{isTestPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plug className="h-4 w-4" />} {isTestPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plug className="h-4 w-4" />}
<span className="hidden sm:inline text-xs">Test</span> <span className="hidden sm:inline text-xs">Test</span>
@ -833,19 +833,19 @@ function CredentialItem({
variant="ghost" size="sm" variant="ghost" size="sm"
onClick={() => setDiscoverOpen(true)} onClick={() => setDiscoverOpen(true)}
disabled={!!credential.decryption_error} disabled={!!credential.decryption_error}
title={t.apiKeys.syncModels} title={t('apiKeys.syncModels')}
> >
<Bot className="h-4 w-4" /> <Bot className="h-4 w-4" />
<span className="hidden sm:inline text-xs">Models</span> <span className="hidden sm:inline text-xs">Models</span>
</Button> </Button>
<Button variant="ghost" size="sm" onClick={() => setEditOpen(true)} disabled={!!credential.decryption_error} title={t.common.edit}> <Button variant="ghost" size="sm" onClick={() => setEditOpen(true)} disabled={!!credential.decryption_error} title={t('common.edit')}>
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" size="sm" variant="ghost" size="sm"
onClick={() => setDeleteOpen(true)} onClick={() => setDeleteOpen(true)}
className="text-destructive hover:text-destructive hover:bg-destructive/10" className="text-destructive hover:text-destructive hover:bg-destructive/10"
title={t.common.delete} title={t('common.delete')}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@ -856,9 +856,9 @@ function CredentialItem({
{credential.decryption_error && ( {credential.decryption_error && (
<Alert className="border-amber-500/50 bg-amber-50 dark:bg-amber-950/20"> <Alert className="border-amber-500/50 bg-amber-50 dark:bg-amber-950/20">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" /> <AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<AlertTitle className="text-amber-800 dark:text-amber-200">{t.apiKeys.decryptionError}</AlertTitle> <AlertTitle className="text-amber-800 dark:text-amber-200">{t('apiKeys.decryptionError')}</AlertTitle>
<AlertDescription className="text-amber-700 dark:text-amber-300 text-sm"> <AlertDescription className="text-amber-700 dark:text-amber-300 text-sm">
{t.apiKeys.decryptionErrorDescription} {t('apiKeys.decryptionErrorDescription')}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@ -1013,12 +1013,12 @@ function ProviderSection({
{hasCredentials ? ( {hasCredentials ? (
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 dark:bg-emerald-900/30 dark:text-emerald-300"> <Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 dark:bg-emerald-900/30 dark:text-emerald-300">
<Check className="mr-1 h-3 w-3" /> <Check className="mr-1 h-3 w-3" />
{t.apiKeys.configured} {t('apiKeys.configured')}
</Badge> </Badge>
) : ( ) : (
<Badge variant="outline" className="text-muted-foreground border-dashed"> <Badge variant="outline" className="text-muted-foreground border-dashed">
<X className="mr-1 h-3 w-3" /> <X className="mr-1 h-3 w-3" />
{t.apiKeys.notConfigured} {t('apiKeys.notConfigured')}
</Badge> </Badge>
)} )}
</div> </div>
@ -1043,7 +1043,7 @@ function ProviderSection({
disabled={!encryptionReady} disabled={!encryptionReady}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
{t.apiKeys.addConfig} {t('apiKeys.addConfig')}
</Button> </Button>
</CardContent> </CardContent>
@ -1098,16 +1098,16 @@ function DefaultModelSelectors({
} }
const primaryConfigs: DefaultConfig[] = [ const primaryConfigs: DefaultConfig[] = [
{ key: 'default_chat_model', label: t.models.chatModelLabel, description: t.models.chatModelDesc, modelType: 'language', required: true, id: `${generatedId}-chat` }, { key: 'default_chat_model', label: t('models.chatModelLabel'), description: t('models.chatModelDesc'), modelType: 'language', required: true, id: `${generatedId}-chat` },
{ key: 'default_embedding_model', label: t.models.embeddingModelLabel, description: t.models.embeddingModelDesc, modelType: 'embedding', required: true, id: `${generatedId}-embed` }, { key: 'default_embedding_model', label: t('models.embeddingModelLabel'), description: t('models.embeddingModelDesc'), modelType: 'embedding', required: true, id: `${generatedId}-embed` },
{ key: 'default_text_to_speech_model', label: t.models.ttsModelLabel, description: t.models.ttsModelDesc, modelType: 'text_to_speech', id: `${generatedId}-tts` }, { key: 'default_text_to_speech_model', label: t('models.ttsModelLabel'), description: t('models.ttsModelDesc'), modelType: 'text_to_speech', id: `${generatedId}-tts` },
{ key: 'default_speech_to_text_model', label: t.models.sttModelLabel, description: t.models.sttModelDesc, modelType: 'speech_to_text', id: `${generatedId}-stt` }, { key: 'default_speech_to_text_model', label: t('models.sttModelLabel'), description: t('models.sttModelDesc'), modelType: 'speech_to_text', id: `${generatedId}-stt` },
] ]
const advancedConfigs: DefaultConfig[] = [ const advancedConfigs: DefaultConfig[] = [
{ key: 'default_transformation_model', label: t.models.transformationModelLabel, description: t.models.transformationModelDesc, modelType: 'language', required: true, id: `${generatedId}-transform` }, { key: 'default_transformation_model', label: t('models.transformationModelLabel'), description: t('models.transformationModelDesc'), modelType: 'language', required: true, id: `${generatedId}-transform` },
{ key: 'default_tools_model', label: t.models.toolsModelLabel, description: t.models.toolsModelDesc, modelType: 'language', id: `${generatedId}-tools` }, { key: 'default_tools_model', label: t('models.toolsModelLabel'), description: t('models.toolsModelDesc'), modelType: 'language', id: `${generatedId}-tools` },
{ key: 'large_context_model', label: t.models.largeContextModelLabel, description: t.models.largeContextModelDesc, modelType: 'language', id: `${generatedId}-large` }, { key: 'large_context_model', label: t('models.largeContextModelLabel'), description: t('models.largeContextModelDesc'), modelType: 'language', id: `${generatedId}-large` },
] ]
const defaultConfigs = [...primaryConfigs, ...advancedConfigs] const defaultConfigs = [...primaryConfigs, ...advancedConfigs]
@ -1145,15 +1145,15 @@ function DefaultModelSelectors({
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t.models.defaultAssignments}</CardTitle> <CardTitle>{t('models.defaultAssignments')}</CardTitle>
<CardDescription>{t.models.defaultAssignmentsDesc}</CardDescription> <CardDescription>{t('models.defaultAssignmentsDesc')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{missingRequired.length > 0 && ( {missingRequired.length > 0 && (
<Alert> <Alert>
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertDescription className="flex items-center justify-between gap-4"> <AlertDescription className="flex items-center justify-between gap-4">
<span>{t.models.missingRequiredModels.replace('{models}', missingRequired.join(', '))}</span> <span>{t('models.missingRequiredModels').replace('{models}', missingRequired.join(', '))}</span>
<Button <Button
variant="outline" size="sm" variant="outline" size="sm"
onClick={() => autoAssign.mutate()} onClick={() => autoAssign.mutate()}
@ -1161,7 +1161,7 @@ function DefaultModelSelectors({
className="shrink-0 gap-1.5" className="shrink-0 gap-1.5"
> >
{autoAssign.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />} {autoAssign.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
{autoAssign.isPending ? t.models.autoAssigning : t.models.autoAssign} {autoAssign.isPending ? t('models.autoAssigning') : t('models.autoAssign')}
</Button> </Button>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@ -1191,8 +1191,8 @@ function DefaultModelSelectors({
> >
<SelectValue placeholder={ <SelectValue placeholder={
config.required && !isValid && available.length > 0 config.required && !isValid && available.length > 0
? t.models.requiredModelPlaceholder ? t('models.requiredModelPlaceholder')
: t.models.selectModelPlaceholder : t('models.selectModelPlaceholder')
} /> } />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -1219,7 +1219,7 @@ function DefaultModelSelectors({
{/* Advanced models: Transformation, Tools, Large Context */} {/* Advanced models: Transformation, Tools, Large Context */}
<div className="border-t pt-3"> <div className="border-t pt-3">
<p className="text-xs text-muted-foreground mb-3">{t.navigation.advanced}</p> <p className="text-xs text-muted-foreground mb-3">{t('navigation.advanced')}</p>
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
{advancedConfigs.map(config => { {advancedConfigs.map(config => {
const available = getModelsForType(config.modelType) const available = getModelsForType(config.modelType)
@ -1243,8 +1243,8 @@ function DefaultModelSelectors({
> >
<SelectValue placeholder={ <SelectValue placeholder={
config.required && !isValid && available.length > 0 config.required && !isValid && available.length > 0
? t.models.requiredModelPlaceholder ? t('models.requiredModelPlaceholder')
: t.models.selectModelPlaceholder : t('models.selectModelPlaceholder')
} /> } />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -1355,19 +1355,19 @@ export default function ApiKeysPage() {
<div> <div>
<h1 className="text-2xl font-bold flex items-center gap-2"> <h1 className="text-2xl font-bold flex items-center gap-2">
<Key className="h-6 w-6" /> <Key className="h-6 w-6" />
{t.apiKeys.title} {t('apiKeys.title')}
</h1> </h1>
<p className="text-muted-foreground mt-1">{t.apiKeys.description}</p> <p className="text-muted-foreground mt-1">{t('apiKeys.description')}</p>
</div> </div>
{/* Encryption warning */} {/* Encryption warning */}
{!encryptionReady && ( {!encryptionReady && (
<Alert className="border-red-500/50 bg-red-50 dark:bg-red-950/20"> <Alert className="border-red-500/50 bg-red-50 dark:bg-red-950/20">
<ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-400" /> <ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-400" />
<AlertTitle className="text-red-800 dark:text-red-200">{t.apiKeys.encryptionRequired}</AlertTitle> <AlertTitle className="text-red-800 dark:text-red-200">{t('apiKeys.encryptionRequired')}</AlertTitle>
<AlertDescription className="text-red-700 dark:text-red-300"> <AlertDescription className="text-red-700 dark:text-red-300">
<code className="text-xs bg-red-100 dark:bg-red-900/30 px-1 py-0.5 rounded"> <code className="text-xs bg-red-100 dark:bg-red-900/30 px-1 py-0.5 rounded">
{t.apiKeys.encryptionRequiredDescription} {t('apiKeys.encryptionRequiredDescription')}
</code> </code>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@ -1404,7 +1404,7 @@ export default function ApiKeysPage() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-sm text-primary hover:underline" className="text-sm text-primary hover:underline"
> >
{t.apiKeys.learnMore} {t('apiKeys.learnMore')}
</a> </a>
</div> </div>
</div> </div>

View file

@ -85,9 +85,9 @@ export function SettingsForm() {
if (error) { if (error) {
return ( return (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTitle>{t.settings.loadFailed}</AlertTitle> <AlertTitle>{t('settings.loadFailed')}</AlertTitle>
<AlertDescription> <AlertDescription>
{error instanceof Error ? error.message : t.common.error} {error instanceof Error ? error.message : t('common.error')}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) )
@ -97,14 +97,14 @@ export function SettingsForm() {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t.settings.contentProcessing}</CardTitle> <CardTitle>{t('settings.contentProcessing')}</CardTitle>
<CardDescription> <CardDescription>
{t.settings.contentProcessingDesc} {t('settings.contentProcessingDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="space-y-3"> <div className="space-y-3">
<Label htmlFor="doc_engine">{t.settings.docEngine}</Label> <Label htmlFor="doc_engine">{t('settings.docEngine')}</Label>
<Controller <Controller
name="default_content_processing_engine_doc" name="default_content_processing_engine_doc"
control={control} control={control}
@ -117,12 +117,12 @@ export function SettingsForm() {
disabled={field.disabled || isLoading} disabled={field.disabled || isLoading}
> >
<SelectTrigger id="doc_engine" className="w-full"> <SelectTrigger id="doc_engine" className="w-full">
<SelectValue placeholder={t.settings.docEnginePlaceholder} /> <SelectValue placeholder={t('settings.docEnginePlaceholder')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="auto">{t.settings.autoRecommended}</SelectItem> <SelectItem value="auto">{t('settings.autoRecommended')}</SelectItem>
<SelectItem value="docling">{t.settings.docling}</SelectItem> <SelectItem value="docling">{t('settings.docling')}</SelectItem>
<SelectItem value="simple">{t.settings.simple}</SelectItem> <SelectItem value="simple">{t('settings.simple')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
@ -130,16 +130,16 @@ export function SettingsForm() {
<Collapsible open={expandedSections.doc} onOpenChange={() => toggleSection('doc')}> <Collapsible open={expandedSections.doc} onOpenChange={() => toggleSection('doc')}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"> <CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
<ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.doc ? 'rotate-180' : ''}`} /> <ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.doc ? 'rotate-180' : ''}`} />
{t.settings.helpMeChoose} {t('settings.helpMeChoose')}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2"> <CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2">
<p>{t.settings.docHelp}</p> <p>{t('settings.docHelp')}</p>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<Label htmlFor="url_engine">{t.settings.urlEngine}</Label> <Label htmlFor="url_engine">{t('settings.urlEngine')}</Label>
<Controller <Controller
name="default_content_processing_engine_url" name="default_content_processing_engine_url"
control={control} control={control}
@ -152,13 +152,13 @@ export function SettingsForm() {
disabled={field.disabled || isLoading} disabled={field.disabled || isLoading}
> >
<SelectTrigger id="url_engine" className="w-full"> <SelectTrigger id="url_engine" className="w-full">
<SelectValue placeholder={t.settings.urlEnginePlaceholder} /> <SelectValue placeholder={t('settings.urlEnginePlaceholder')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="auto">{t.settings.autoRecommended}</SelectItem> <SelectItem value="auto">{t('settings.autoRecommended')}</SelectItem>
<SelectItem value="firecrawl">{t.settings.firecrawl}</SelectItem> <SelectItem value="firecrawl">{t('settings.firecrawl')}</SelectItem>
<SelectItem value="jina">{t.settings.jina}</SelectItem> <SelectItem value="jina">{t('settings.jina')}</SelectItem>
<SelectItem value="simple">{t.settings.simple}</SelectItem> <SelectItem value="simple">{t('settings.simple')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
@ -166,10 +166,10 @@ export function SettingsForm() {
<Collapsible open={expandedSections.url} onOpenChange={() => toggleSection('url')}> <Collapsible open={expandedSections.url} onOpenChange={() => toggleSection('url')}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"> <CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
<ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.url ? 'rotate-180' : ''}`} /> <ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.url ? 'rotate-180' : ''}`} />
{t.settings.helpMeChoose} {t('settings.helpMeChoose')}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2"> <CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2">
<p>{t.settings.urlHelp}</p> <p>{t('settings.urlHelp')}</p>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</div> </div>
@ -178,14 +178,14 @@ export function SettingsForm() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t.settings.embeddingAndSearch}</CardTitle> <CardTitle>{t('settings.embeddingAndSearch')}</CardTitle>
<CardDescription> <CardDescription>
{t.settings.embeddingAndSearchDesc} {t('settings.embeddingAndSearchDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="space-y-3"> <div className="space-y-3">
<Label htmlFor="embedding">{t.settings.defaultEmbeddingOption}</Label> <Label htmlFor="embedding">{t('settings.defaultEmbeddingOption')}</Label>
<Controller <Controller
name="default_embedding_option" name="default_embedding_option"
control={control} control={control}
@ -198,12 +198,12 @@ export function SettingsForm() {
disabled={field.disabled || isLoading} disabled={field.disabled || isLoading}
> >
<SelectTrigger id="embedding" className="w-full"> <SelectTrigger id="embedding" className="w-full">
<SelectValue placeholder={t.settings.embeddingOptionPlaceholder} /> <SelectValue placeholder={t('settings.embeddingOptionPlaceholder')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="ask">{t.settings.ask}</SelectItem> <SelectItem value="ask">{t('settings.ask')}</SelectItem>
<SelectItem value="always">{t.settings.always}</SelectItem> <SelectItem value="always">{t('settings.always')}</SelectItem>
<SelectItem value="never">{t.settings.never}</SelectItem> <SelectItem value="never">{t('settings.never')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
@ -211,10 +211,10 @@ export function SettingsForm() {
<Collapsible open={expandedSections.embedding} onOpenChange={() => toggleSection('embedding')}> <Collapsible open={expandedSections.embedding} onOpenChange={() => toggleSection('embedding')}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"> <CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
<ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.embedding ? 'rotate-180' : ''}`} /> <ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.embedding ? 'rotate-180' : ''}`} />
{t.settings.helpMeChoose} {t('settings.helpMeChoose')}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2"> <CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2">
<p>{t.settings.embeddingHelp}</p> <p>{t('settings.embeddingHelp')}</p>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</div> </div>
@ -223,14 +223,14 @@ export function SettingsForm() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t.settings.fileManagement}</CardTitle> <CardTitle>{t('settings.fileManagement')}</CardTitle>
<CardDescription> <CardDescription>
{t.settings.fileManagementDesc} {t('settings.fileManagementDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="space-y-3"> <div className="space-y-3">
<Label htmlFor="auto_delete">{t.settings.autoDeleteFiles}</Label> <Label htmlFor="auto_delete">{t('settings.autoDeleteFiles')}</Label>
<Controller <Controller
name="auto_delete_files" name="auto_delete_files"
control={control} control={control}
@ -243,11 +243,11 @@ export function SettingsForm() {
disabled={field.disabled || isLoading} disabled={field.disabled || isLoading}
> >
<SelectTrigger id="auto_delete" className="w-full"> <SelectTrigger id="auto_delete" className="w-full">
<SelectValue placeholder={t.settings.autoDeletePlaceholder} /> <SelectValue placeholder={t('settings.autoDeletePlaceholder')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="yes">{t.common.yes}</SelectItem> <SelectItem value="yes">{t('common.yes')}</SelectItem>
<SelectItem value="no">{t.common.no}</SelectItem> <SelectItem value="no">{t('common.no')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
@ -255,10 +255,10 @@ export function SettingsForm() {
<Collapsible open={expandedSections.files} onOpenChange={() => toggleSection('files')}> <Collapsible open={expandedSections.files} onOpenChange={() => toggleSection('files')}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"> <CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
<ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.files ? 'rotate-180' : ''}`} /> <ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.files ? 'rotate-180' : ''}`} />
{t.settings.helpMeChoose} {t('settings.helpMeChoose')}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2"> <CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2">
<p>{t.settings.filesHelp}</p> <p>{t('settings.filesHelp')}</p>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</div> </div>
@ -270,7 +270,7 @@ export function SettingsForm() {
type="submit" type="submit"
disabled={!isDirty || updateSettings.isPending} disabled={!isDirty || updateSettings.isPending}
> >
{updateSettings.isPending ? t.common.saving : t.navigation.settings} {updateSettings.isPending ? t('common.saving') : t('navigation.settings')}
</Button> </Button>
</div> </div>
</form> </form>

View file

@ -17,7 +17,7 @@ export default function SettingsPage() {
<div className="p-6"> <div className="p-6">
<div className="max-w-4xl"> <div className="max-w-4xl">
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
<h1 className="text-2xl font-bold">{t.navigation.settings}</h1> <h1 className="text-2xl font-bold">{t('navigation.settings')}</h1>
<Button variant="outline" size="sm" onClick={() => refetch()}> <Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
</Button> </Button>

View file

@ -75,14 +75,14 @@ export default function SourcesPage() {
offsetRef.current += data.length offsetRef.current += data.length
} catch (err) { } catch (err) {
console.error('Failed to fetch sources:', err) console.error('Failed to fetch sources:', err)
setError(t.sources.failedToLoad) setError(t('sources.failedToLoad'))
toast.error(t.sources.failedToLoad) toast.error(t('sources.failedToLoad'))
} finally { } finally {
setLoading(false) setLoading(false)
setLoadingMore(false) setLoadingMore(false)
loadingMoreRef.current = false loadingMoreRef.current = false
} }
}, [sortBy, sortOrder, t.sources.failedToLoad]) }, [sortBy, sortOrder, t('sources.failedToLoad')])
// Initial load and when sort changes // Initial load and when sort changes
useEffect(() => { useEffect(() => {
@ -220,9 +220,9 @@ export default function SourcesPage() {
} }
const getSourceType = (source: SourceListResponse) => { const getSourceType = (source: SourceListResponse) => {
if (source.asset?.url) return t.sources.type.link if (source.asset?.url) return t('sources.type.link')
if (source.asset?.file_path) return t.sources.type.file if (source.asset?.file_path) return t('sources.type.file')
return t.sources.type.text return t('sources.type.text')
} }
const handleRowClick = useCallback((index: number, sourceId: string) => { const handleRowClick = useCallback((index: number, sourceId: string) => {
@ -240,7 +240,7 @@ export default function SourcesPage() {
try { try {
await sourcesApi.delete(deleteDialog.source.id) await sourcesApi.delete(deleteDialog.source.id)
toast.success(t.sources.deleteSuccess) toast.success(t('sources.deleteSuccess'))
// Remove the deleted source from the list // Remove the deleted source from the list
setSources(prev => prev.filter(s => s.id !== deleteDialog.source?.id)) setSources(prev => prev.filter(s => s.id !== deleteDialog.source?.id))
setDeleteDialog({ open: false, source: null }) setDeleteDialog({ open: false, source: null })
@ -276,8 +276,8 @@ export default function SourcesPage() {
<AppShell> <AppShell>
<EmptyState <EmptyState
icon={FileText} icon={FileText}
title={t.sources.noSourcesYet} title={t('sources.noSourcesYet')}
description={t.sources.allSourcesDescShort} description={t('sources.allSourcesDescShort')}
/> />
</AppShell> </AppShell>
) )
@ -287,9 +287,9 @@ export default function SourcesPage() {
<AppShell> <AppShell>
<div className="flex flex-col h-full w-full max-w-none px-6 py-6"> <div className="flex flex-col h-full w-full max-w-none px-6 py-6">
<div className="mb-6 flex-shrink-0"> <div className="mb-6 flex-shrink-0">
<h1 className="text-3xl font-bold">{t.sources.allSources}</h1> <h1 className="text-3xl font-bold">{t('sources.allSources')}</h1>
<p className="mt-2 text-muted-foreground"> <p className="mt-2 text-muted-foreground">
{t.sources.allSourcesDesc} {t('sources.allSourcesDesc')}
</p> </p>
</div> </div>
@ -310,10 +310,10 @@ export default function SourcesPage() {
<thead className="sticky top-0 bg-background z-10"> <thead className="sticky top-0 bg-background z-10">
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground"> <th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
{t.common.type} {t('common.type')}
</th> </th>
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground"> <th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
{t.common.title} {t('common.title')}
</th> </th>
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden sm:table-cell"> <th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden sm:table-cell">
<Button <Button
@ -322,7 +322,7 @@ export default function SourcesPage() {
onClick={() => toggleSort('created')} onClick={() => toggleSort('created')}
className="h-8 px-2 hover:bg-muted" className="h-8 px-2 hover:bg-muted"
> >
{t.common.created_label} {t('common.created_label')}
<ArrowUpDown className={cn( <ArrowUpDown className={cn(
"ml-2 h-3 w-3", "ml-2 h-3 w-3",
sortBy === 'created' ? 'opacity-100' : 'opacity-30' sortBy === 'created' ? 'opacity-100' : 'opacity-30'
@ -335,13 +335,13 @@ export default function SourcesPage() {
</Button> </Button>
</th> </th>
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground hidden md:table-cell"> <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground hidden md:table-cell">
{t.sources.insights} {t('sources.insights')}
</th> </th>
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground hidden lg:table-cell"> <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground hidden lg:table-cell">
{t.sources.embedded} {t('sources.embedded')}
</th> </th>
<th className="h-12 px-4 text-right align-middle font-medium text-muted-foreground"> <th className="h-12 px-4 text-right align-middle font-medium text-muted-foreground">
{t.common.actions} {t('common.actions')}
</th> </th>
</tr> </tr>
</thead> </thead>
@ -369,7 +369,7 @@ export default function SourcesPage() {
<td className="h-12 px-4"> <td className="h-12 px-4">
<div className="flex flex-col overflow-hidden"> <div className="flex flex-col overflow-hidden">
<span className="font-medium truncate"> <span className="font-medium truncate">
{source.title || t.sources.untitledSource} {source.title || t('sources.untitledSource')}
</span> </span>
{source.asset?.url && ( {source.asset?.url && (
<span className="text-xs text-muted-foreground truncate"> <span className="text-xs text-muted-foreground truncate">
@ -389,7 +389,7 @@ export default function SourcesPage() {
</td> </td>
<td className="h-12 px-4 text-center hidden lg:table-cell"> <td className="h-12 px-4 text-center hidden lg:table-cell">
<Badge variant={source.embedded ? "default" : "secondary"} className="text-xs"> <Badge variant={source.embedded ? "default" : "secondary"} className="text-xs">
{source.embedded ? t.sources.yes : t.sources.no} {source.embedded ? t('sources.yes') : t('sources.no')}
</Badge> </Badge>
</td> </td>
<td className="h-12 px-4 text-right"> <td className="h-12 px-4 text-right">
@ -409,7 +409,7 @@ export default function SourcesPage() {
<td colSpan={6} className="h-16 text-center"> <td colSpan={6} className="h-16 text-center">
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<LoadingSpinner /> <LoadingSpinner />
<span className="ml-2 text-muted-foreground">{t.sources.loadingMore}</span> <span className="ml-2 text-muted-foreground">{t('sources.loadingMore')}</span>
</div> </div>
</td> </td>
</tr> </tr>
@ -422,9 +422,9 @@ export default function SourcesPage() {
<ConfirmDialog <ConfirmDialog
open={deleteDialog.open} open={deleteDialog.open}
onOpenChange={(open) => setDeleteDialog({ open, source: deleteDialog.source })} onOpenChange={(open) => setDeleteDialog({ open, source: deleteDialog.source })}
title={t.sources.delete} title={t('sources.delete')}
description={t.sources.deleteConfirmWithTitle.replace('{title}', deleteDialog.source?.title || t.sources.untitledSource)} description={t('sources.deleteConfirmWithTitle').replace('{title}', deleteDialog.source?.title || t('sources.untitledSource'))}
confirmText={t.common.delete} confirmText={t('common.delete')}
confirmVariant="destructive" confirmVariant="destructive"
onConfirm={handleDeleteConfirm} onConfirm={handleDeleteConfirm}
/> />

View file

@ -37,9 +37,9 @@ export function DefaultPromptEditor() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Settings className="h-5 w-5" /> <Settings className="h-5 w-5" />
<div className="text-left"> <div className="text-left">
<CardTitle className="text-lg">{t.transformations.defaultPrompt}</CardTitle> <CardTitle className="text-lg">{t('transformations.defaultPrompt')}</CardTitle>
<CardDescription> <CardDescription>
{t.transformations.defaultPromptDesc} {t('transformations.defaultPromptDesc')}
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
@ -55,14 +55,14 @@ export function DefaultPromptEditor() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={textareaId} className="sr-only"> <Label htmlFor={textareaId} className="sr-only">
{t.transformations.defaultPrompt} {t('transformations.defaultPrompt')}
</Label> </Label>
<Textarea <Textarea
id={textareaId} id={textareaId}
name="default-prompt" name="default-prompt"
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
placeholder={t.transformations.defaultPromptPlaceholder} placeholder={t('transformations.defaultPromptPlaceholder')}
className="min-h-[200px] font-mono text-sm" className="min-h-[200px] font-mono text-sm"
disabled={isLoading} disabled={isLoading}
/> />
@ -72,7 +72,7 @@ export function DefaultPromptEditor() {
onClick={handleSave} onClick={handleSave}
disabled={isLoading || updateDefaultPrompt.isPending} disabled={isLoading || updateDefaultPrompt.isPending}
> >
{t.common.save} {t('common.save')}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>

View file

@ -49,7 +49,7 @@ export function TransformationCard({ transformation, onPlayground, onEdit }: Tra
)} )}
</div> </div>
{transformation.apply_default && ( {transformation.apply_default && (
<Badge variant="secondary">{t.common.default}</Badge> <Badge variant="secondary">{t('common.default')}</Badge>
)} )}
</div> </div>
</CollapsibleTrigger> </CollapsibleTrigger>
@ -58,13 +58,13 @@ export function TransformationCard({ transformation, onPlayground, onEdit }: Tra
{onPlayground && ( {onPlayground && (
<Button variant="outline" size="sm" onClick={onPlayground}> <Button variant="outline" size="sm" onClick={onPlayground}>
<Wand2 className="h-4 w-4 mr-2" /> <Wand2 className="h-4 w-4 mr-2" />
{t.transformations.playground} {t('transformations.playground')}
</Button> </Button>
)} )}
{onEdit && ( {onEdit && (
<Button variant="outline" size="sm" onClick={onEdit}> <Button variant="outline" size="sm" onClick={onEdit}>
<Edit className="h-4 w-4 mr-2" /> <Edit className="h-4 w-4 mr-2" />
{t.common.edit} {t('common.edit')}
</Button> </Button>
)} )}
<Button <Button
@ -82,19 +82,19 @@ export function TransformationCard({ transformation, onPlayground, onEdit }: Tra
<CollapsibleContent> <CollapsibleContent>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div> <div>
<p className="text-sm text-muted-foreground">{t.common.title}</p> <p className="text-sm text-muted-foreground">{t('common.title')}</p>
<p className="text-sm font-medium">{transformation.title || t.sources.untitledSource}</p> <p className="text-sm font-medium">{transformation.title || t('sources.untitledSource')}</p>
</div> </div>
{transformation.description && ( {transformation.description && (
<div> <div>
<p className="text-sm text-muted-foreground">{t.common.description}</p> <p className="text-sm text-muted-foreground">{t('common.description')}</p>
<p className="text-sm leading-6">{transformation.description}</p> <p className="text-sm leading-6">{transformation.description}</p>
</div> </div>
)} )}
<div> <div>
<p className="text-sm text-muted-foreground">{t.transformations.systemPrompt}</p> <p className="text-sm text-muted-foreground">{t('transformations.systemPrompt')}</p>
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-muted p-3 text-sm font-mono"> <pre className="mt-2 whitespace-pre-wrap rounded-md bg-muted p-3 text-sm font-mono">
{transformation.prompt} {transformation.prompt}
</pre> </pre>
@ -107,9 +107,9 @@ export function TransformationCard({ transformation, onPlayground, onEdit }: Tra
<ConfirmDialog <ConfirmDialog
open={showDeleteDialog} open={showDeleteDialog}
onOpenChange={setShowDeleteDialog} onOpenChange={setShowDeleteDialog}
title={t.sources.delete} title={t('sources.delete')}
description={t.transformations.deleteConfirm} description={t('transformations.deleteConfirm')}
confirmText={t.common.delete} confirmText={t('common.delete')}
confirmVariant="destructive" confirmVariant="destructive"
onConfirm={handleDelete} onConfirm={handleDelete}
isLoading={deleteTransformation.isPending} isLoading={deleteTransformation.isPending}

View file

@ -118,22 +118,22 @@ export function TransformationEditorDialog({ open, onOpenChange, transformation
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-4xl w-full max-h-[90vh] overflow-hidden p-0"> <DialogContent className="sm:max-w-4xl w-full max-h-[90vh] overflow-hidden p-0">
<DialogTitle className="sr-only"> <DialogTitle className="sr-only">
{isEditing ? t.common.edit : t.transformations.createNew} {isEditing ? t('common.edit') : t('transformations.createNew')}
</DialogTitle> </DialogTitle>
<DialogDescription className="sr-only"> <DialogDescription className="sr-only">
{isEditing ? t.common.editTransformation : t.transformations.createNew} {isEditing ? t('common.editTransformation') : t('transformations.createNew')}
</DialogDescription> </DialogDescription>
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col"> <form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col">
{isEditing && isLoading ? ( {isEditing && isLoading ? (
<div className="flex-1 flex items-center justify-center py-10"> <div className="flex-1 flex items-center justify-center py-10">
<span className="text-sm text-muted-foreground">{t.common.loading}</span> <span className="text-sm text-muted-foreground">{t('common.loading')}</span>
</div> </div>
) : ( ) : (
<> <>
<div className="border-b px-6 py-4 space-y-4"> <div className="border-b px-6 py-4 space-y-4">
<div> <div>
<Label htmlFor={nameId} className="text-sm font-medium"> <Label htmlFor={nameId} className="text-sm font-medium">
{t.transformations.name} {t('transformations.name')}
</Label> </Label>
<Controller <Controller
control={control} control={control}
@ -142,7 +142,7 @@ export function TransformationEditorDialog({ open, onOpenChange, transformation
<Input <Input
id={nameId} id={nameId}
{...field} {...field}
placeholder={t.transformations.namePlaceholder} placeholder={t('transformations.namePlaceholder')}
autoComplete="off" autoComplete="off"
/> />
)} )}
@ -155,7 +155,7 @@ export function TransformationEditorDialog({ open, onOpenChange, transformation
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<Label htmlFor={titleId} className="text-sm font-medium"> <Label htmlFor={titleId} className="text-sm font-medium">
{t.common.title} {t('common.title')}
</Label> </Label>
<Controller <Controller
control={control} control={control}
@ -164,7 +164,7 @@ export function TransformationEditorDialog({ open, onOpenChange, transformation
<Input <Input
id={titleId} id={titleId}
{...field} {...field}
placeholder={t.transformations.titlePlaceholder} placeholder={t('transformations.titlePlaceholder')}
autoComplete="off" autoComplete="off"
/> />
)} )}
@ -183,14 +183,14 @@ export function TransformationEditorDialog({ open, onOpenChange, transformation
)} )}
/> />
<Label htmlFor={defaultId} className="text-sm"> <Label htmlFor={defaultId} className="text-sm">
{t.transformations.suggestDefault} {t('transformations.suggestDefault')}
</Label> </Label>
</div> </div>
</div> </div>
<div> <div>
<Label htmlFor={descriptionId} className="text-sm font-medium"> <Label htmlFor={descriptionId} className="text-sm font-medium">
{t.notebooks.addDescription.replace('...', '')} {t('notebooks.addDescription').replace('...', '')}
</Label> </Label>
<Controller <Controller
control={control} control={control}
@ -199,7 +199,7 @@ export function TransformationEditorDialog({ open, onOpenChange, transformation
<Textarea <Textarea
id={descriptionId} id={descriptionId}
{...field} {...field}
placeholder={t.transformations.descriptionPlaceholder} placeholder={t('transformations.descriptionPlaceholder')}
rows={2} rows={2}
autoComplete="off" autoComplete="off"
/> />
@ -209,7 +209,7 @@ export function TransformationEditorDialog({ open, onOpenChange, transformation
</div> </div>
<div className="flex-1 overflow-y-auto px-6 py-4"> <div className="flex-1 overflow-y-auto px-6 py-4">
<Label htmlFor={promptId} className="text-sm font-medium">{t.transformations.systemPrompt}</Label> <Label htmlFor={promptId} className="text-sm font-medium">{t('transformations.systemPrompt')}</Label>
<Controller <Controller
control={control} control={control}
name="prompt" name="prompt"
@ -219,7 +219,7 @@ export function TransformationEditorDialog({ open, onOpenChange, transformation
value={field.value} value={field.value}
onChange={field.onChange} onChange={field.onChange}
height={420} height={420}
placeholder={t.transformations.promptPlaceholder} placeholder={t('transformations.promptPlaceholder')}
className="rounded-md border" className="rounded-md border"
textareaId={promptId} textareaId={promptId}
name={field.name} name={field.name}
@ -230,7 +230,7 @@ export function TransformationEditorDialog({ open, onOpenChange, transformation
<p className="text-sm text-red-600 mt-1">{errors.prompt.message}</p> <p className="text-sm text-red-600 mt-1">{errors.prompt.message}</p>
)} )}
<p className="text-xs text-muted-foreground mt-3"> <p className="text-xs text-muted-foreground mt-3">
{t.transformations.promptHint} {t('transformations.promptHint')}
</p> </p>
</div> </div>
</> </>
@ -238,14 +238,14 @@ export function TransformationEditorDialog({ open, onOpenChange, transformation
<div className="border-t px-6 py-4 flex justify-end gap-2"> <div className="border-t px-6 py-4 flex justify-end gap-2">
<Button type="button" variant="outline" onClick={handleClose}> <Button type="button" variant="outline" onClick={handleClose}>
{t.common.cancel} {t('common.cancel')}
</Button> </Button>
<Button type="submit" disabled={isSaving || (isEditing && isLoading)}> <Button type="submit" disabled={isSaving || (isEditing && isLoading)}>
{isSaving {isSaving
? isEditing ? `${t.common.saving}...` : `${t.common.creating}...` ? isEditing ? `${t('common.saving')}...` : `${t('common.creating')}...`
: isEditing : isEditing
? t.common.editTransformation ? t('common.editTransformation')
: t.transformations.createNew} : t('transformations.createNew')}
</Button> </Button>
</div> </div>
</form> </form>

View file

@ -49,18 +49,18 @@ export function TransformationPlayground({ transformations, selectedTransformati
<div className="space-y-6"> <div className="space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t.transformations.playground}</CardTitle> <CardTitle>{t('transformations.playground')}</CardTitle>
<CardDescription> <CardDescription>
{t.transformations.desc} {t('transformations.desc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="transformation">{t.navigation.transformation}</Label> <Label htmlFor="transformation">{t('navigation.transformation')}</Label>
<Select name="transformation" value={selectedId} onValueChange={setSelectedId}> <Select name="transformation" value={selectedId} onValueChange={setSelectedId}>
<SelectTrigger id="transformation"> <SelectTrigger id="transformation">
<SelectValue placeholder={t.transformations.selectToStart} /> <SelectValue placeholder={t('transformations.selectToStart')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{transformations?.map((transformation) => ( {transformations?.map((transformation) => (
@ -74,24 +74,24 @@ export function TransformationPlayground({ transformations, selectedTransformati
<div> <div>
<ModelSelector <ModelSelector
label={t.transformations.model} label={t('transformations.model')}
name="model" name="model"
modelType="language" modelType="language"
value={modelId} value={modelId}
onChange={setModelId} onChange={setModelId}
placeholder={t.transformations.selectModel} placeholder={t('transformations.selectModel')}
/> />
</div> </div>
</div> </div>
<div> <div>
<Label htmlFor="input">{t.transformations.inputLabel}</Label> <Label htmlFor="input">{t('transformations.inputLabel')}</Label>
<Textarea <Textarea
id="input" id="input"
name="input" name="input"
value={inputText} value={inputText}
onChange={(e) => setInputText(e.target.value)} onChange={(e) => setInputText(e.target.value)}
placeholder={t.transformations.inputPlaceholder} placeholder={t('transformations.inputPlaceholder')}
rows={8} rows={8}
className="font-mono text-sm" className="font-mono text-sm"
/> />
@ -106,12 +106,12 @@ export function TransformationPlayground({ transformations, selectedTransformati
{executeTransformation.isPending ? ( {executeTransformation.isPending ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t.transformations.running} {t('transformations.running')}
</> </>
) : ( ) : (
<> <>
<Play className="h-4 w-4 mr-2" /> <Play className="h-4 w-4 mr-2" />
{t.transformations.runTest} {t('transformations.runTest')}
</> </>
)} )}
</Button> </Button>
@ -119,7 +119,7 @@ export function TransformationPlayground({ transformations, selectedTransformati
{output && ( {output && (
<div className="space-y-2"> <div className="space-y-2">
<span className="text-sm font-medium leading-none">{t.transformations.outputLabel}</span> <span className="text-sm font-medium leading-none">{t('transformations.outputLabel')}</span>
<Card> <Card>
<ScrollArea className="h-[400px]"> <ScrollArea className="h-[400px]">
<CardContent className="pt-6"> <CardContent className="pt-6">

View file

@ -39,12 +39,12 @@ export function TransformationsList({ transformations, isLoading, onPlayground }
return ( return (
<EmptyState <EmptyState
icon={Wand2} icon={Wand2}
title={t.transformations.noTransformations} title={t('transformations.noTransformations')}
description={t.transformations.createOne} description={t('transformations.createOne')}
action={ action={
<Button onClick={() => handleOpenEditor()}> <Button onClick={() => handleOpenEditor()}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
{t.transformations.createNew} {t('transformations.createNew')}
</Button> </Button>
} }
/> />
@ -55,10 +55,10 @@ export function TransformationsList({ transformations, isLoading, onPlayground }
<> <>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h2 className="text-lg font-semibold">{t.transformations.listTitle}</h2> <h2 className="text-lg font-semibold">{t('transformations.listTitle')}</h2>
<Button onClick={() => handleOpenEditor()}> <Button onClick={() => handleOpenEditor()}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
{t.transformations.createNew} {t('transformations.createNew')}
</Button> </Button>
</div> </div>

View file

@ -29,7 +29,7 @@ export default function TransformationsPage() {
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<h1 className="text-2xl font-bold">{t.transformations.title}</h1> <h1 className="text-2xl font-bold">{t('transformations.title')}</h1>
<Button variant="outline" size="sm" onClick={() => refetch()}> <Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
</Button> </Button>
@ -38,21 +38,21 @@ export default function TransformationsPage() {
<div className="max-w-5xl"> <div className="max-w-5xl">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t.transformations.desc} {t('transformations.desc')}
</p> </p>
</div> </div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6"> <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t.transformations.workspace}</p> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t('transformations.workspace')}</p>
<TabsList aria-label={t.common.accessibility.transformationViews} className="w-full max-w-xl"> <TabsList aria-label={t('common.accessibility.transformationViews')} className="w-full max-w-xl">
<TabsTrigger value="transformations" className="flex items-center gap-2"> <TabsTrigger value="transformations" className="flex items-center gap-2">
<Wand2 className="h-4 w-4" /> <Wand2 className="h-4 w-4" />
{t.transformations.title} {t('transformations.title')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="playground" className="flex items-center gap-2"> <TabsTrigger value="playground" className="flex items-center gap-2">
<Play className="h-4 w-4" /> <Play className="h-4 w-4" />
{t.transformations.playground} {t('transformations.playground')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</div> </div>

View file

@ -83,9 +83,9 @@ export function LoginForm() {
<div className="min-h-screen flex items-center justify-center bg-background p-4"> <div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader className="text-center"> <CardHeader className="text-center">
<CardTitle>{t.common.connectionError}</CardTitle> <CardTitle>{t('common.connectionError')}</CardTitle>
<CardDescription> <CardDescription>
{t.common.unableToConnect} {t('common.unableToConnect')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -93,21 +93,21 @@ export function LoginForm() {
<div className="flex items-start gap-2 text-red-600 text-sm"> <div className="flex items-start gap-2 text-red-600 text-sm">
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" /> <AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div className="flex-1"> <div className="flex-1">
{error || t.auth.connectErrorHint} {error || t('auth.connectErrorHint')}
</div> </div>
</div> </div>
{configInfo && ( {configInfo && (
<div className="space-y-2 text-xs text-muted-foreground border-t pt-3"> <div className="space-y-2 text-xs text-muted-foreground border-t pt-3">
<div className="font-medium">{t.common.diagnosticInfo}:</div> <div className="font-medium">{t('common.diagnosticInfo')}:</div>
<div className="space-y-1 font-mono"> <div className="space-y-1 font-mono">
<div>{t.common.version}: {configInfo.version}</div> <div>{t('common.version')}: {configInfo.version}</div>
<div>{t.common.built}: {new Date(configInfo.buildTime).toLocaleString(language === 'zh-CN' ? 'zh-CN' : language === 'zh-TW' ? 'zh-TW' : 'en-US')}</div> <div>{t('common.built')}: {new Date(configInfo.buildTime).toLocaleString(language === 'zh-CN' ? 'zh-CN' : language === 'zh-TW' ? 'zh-TW' : 'en-US')}</div>
<div className="break-all">{t.common.apiUrl}: {configInfo.apiUrl}</div> <div className="break-all">{t('common.apiUrl')}: {configInfo.apiUrl}</div>
<div className="break-all">{t.common.frontendUrl}: {typeof window !== 'undefined' ? window.location.href : 'N/A'}</div> <div className="break-all">{t('common.frontendUrl')}: {typeof window !== 'undefined' ? window.location.href : 'N/A'}</div>
</div> </div>
<div className="text-xs pt-2"> <div className="text-xs pt-2">
{t.common.checkConsoleLogs} {t('common.checkConsoleLogs')}
</div> </div>
</div> </div>
)} )}
@ -116,7 +116,7 @@ export function LoginForm() {
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
className="w-full" className="w-full"
> >
{t.common.retryConnection} {t('common.retryConnection')}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@ -141,9 +141,9 @@ export function LoginForm() {
<div className="min-h-screen flex items-center justify-center bg-background p-4"> <div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader className="text-center"> <CardHeader className="text-center">
<CardTitle>{t.auth.loginTitle}</CardTitle> <CardTitle>{t('auth.loginTitle')}</CardTitle>
<CardDescription> <CardDescription>
{t.auth.loginDesc} {t('auth.loginDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -151,7 +151,7 @@ export function LoginForm() {
<div> <div>
<Input <Input
type="password" type="password"
placeholder={t.auth.passwordPlaceholder} placeholder={t('auth.passwordPlaceholder')}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
disabled={isLoading} disabled={isLoading}
@ -170,12 +170,12 @@ export function LoginForm() {
className="w-full" className="w-full"
disabled={isLoading || !password.trim()} disabled={isLoading || !password.trim()}
> >
{isLoading ? t.auth.signingIn : t.auth.signIn} {isLoading ? t('auth.signingIn') : t('auth.signIn')}
</Button> </Button>
{configInfo && ( {configInfo && (
<div className="text-xs text-center text-muted-foreground pt-2 border-t"> <div className="text-xs text-center text-muted-foreground pt-2 border-t">
<div>{t.common.version} {configInfo.version}</div> <div>{t('common.version')} {configInfo.version}</div>
<div className="font-mono text-[10px]">{configInfo.apiUrl}</div> <div className="font-mono text-[10px]">{configInfo.apiUrl}</div>
</div> </div>
)} )}

View file

@ -30,29 +30,29 @@ import {
Loader2, Loader2,
} from 'lucide-react' } from 'lucide-react'
import { useTranslation } from '@/lib/hooks/use-translation' import { useTranslation } from '@/lib/hooks/use-translation'
import { TranslationKeys } from '@/lib/locales' import type { TFunction } from 'i18next'
const getNavigationItems = (t: TranslationKeys) => [ const getNavigationItems = (t: TFunction) => [
{ name: t.navigation.sources, href: '/sources', icon: FileText, keywords: ['files', 'documents', 'upload'] }, { name: t('navigation.sources'), href: '/sources', icon: FileText, keywords: ['files', 'documents', 'upload'] },
{ name: t.navigation.notebooks, href: '/notebooks', icon: Book, keywords: ['notes', 'research', 'projects'] }, { name: t('navigation.notebooks'), href: '/notebooks', icon: Book, keywords: ['notes', 'research', 'projects'] },
{ name: t.navigation.askAndSearch, href: '/search', icon: Search, keywords: ['find', 'query'] }, { name: t('navigation.askAndSearch'), href: '/search', icon: Search, keywords: ['find', 'query'] },
{ name: t.navigation.podcasts, href: '/podcasts', icon: Mic, keywords: ['audio', 'episodes', 'generate'] }, { name: t('navigation.podcasts'), href: '/podcasts', icon: Mic, keywords: ['audio', 'episodes', 'generate'] },
{ name: t.navigation.models, href: '/settings/api-keys', icon: Bot, keywords: ['ai', 'llm', 'providers', 'openai', 'anthropic'] }, { name: t('navigation.models'), href: '/settings/api-keys', icon: Bot, keywords: ['ai', 'llm', 'providers', 'openai', 'anthropic'] },
{ name: t.navigation.transformations, href: '/transformations', icon: Shuffle, keywords: ['prompts', 'templates', 'actions'] }, { name: t('navigation.transformations'), href: '/transformations', icon: Shuffle, keywords: ['prompts', 'templates', 'actions'] },
{ name: t.navigation.settings, href: '/settings', icon: Settings, keywords: ['preferences', 'config', 'options'] }, { name: t('navigation.settings'), href: '/settings', icon: Settings, keywords: ['preferences', 'config', 'options'] },
{ name: t.navigation.advanced, href: '/advanced', icon: Wrench, keywords: ['debug', 'system', 'tools'] }, { name: t('navigation.advanced'), href: '/advanced', icon: Wrench, keywords: ['debug', 'system', 'tools'] },
] ]
const getCreateItems = (t: TranslationKeys) => [ const getCreateItems = (t: TFunction) => [
{ name: t.common.newSource, action: 'source', icon: FileText }, { name: t('common.newSource'), action: 'source', icon: FileText },
{ name: t.common.newNotebook, action: 'notebook', icon: Book }, { name: t('common.newNotebook'), action: 'notebook', icon: Book },
{ name: t.common.newPodcast, action: 'podcast', icon: Mic }, { name: t('common.newPodcast'), action: 'podcast', icon: Mic },
] ]
const getThemeItems = (t: TranslationKeys) => [ const getThemeItems = (t: TFunction) => [
{ name: t.common.light, value: 'light' as const, icon: Sun, keywords: ['bright', 'day'] }, { name: t('common.light'), value: 'light' as const, icon: Sun, keywords: ['bright', 'day'] },
{ name: t.common.dark, value: 'dark' as const, icon: Moon, keywords: ['night'] }, { name: t('common.dark'), value: 'dark' as const, icon: Moon, keywords: ['night'] },
{ name: t.common.system, value: 'system' as const, icon: Monitor, keywords: ['auto', 'default'] }, { name: t('common.system'), value: 'system' as const, icon: Monitor, keywords: ['auto', 'default'] },
] ]
export function CommandPalette() { export function CommandPalette() {
@ -164,30 +164,30 @@ export function CommandPalette() {
<CommandDialog <CommandDialog
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
title={t.common.quickActions} title={t('common.quickActions')}
description={t.common.quickActionsDesc} description={t('common.quickActionsDesc')}
className="sm:max-w-lg" className="sm:max-w-lg"
> >
<CommandInput <CommandInput
id={commandInputId} id={commandInputId}
name="command-search" name="command-search"
placeholder={t.searchPage.enterSearchPlaceholder} placeholder={t('searchPage.enterSearchPlaceholder')}
value={query} value={query}
onValueChange={setQuery} onValueChange={setQuery}
aria-label={t.common.search} aria-label={t('common.search')}
autoComplete="off" autoComplete="off"
/> />
<CommandList> <CommandList>
{/* Search/Ask - show FIRST when there's a query with no command match */} {/* Search/Ask - show FIRST when there's a query with no command match */}
{showSearchFirst && ( {showSearchFirst && (
<CommandGroup heading={t.searchPage.searchAndAsk} forceMount> <CommandGroup heading={t('searchPage.searchAndAsk')} forceMount>
<CommandItem <CommandItem
value={`__search__ ${query}`} value={`__search__ ${query}`}
onSelect={handleSearch} onSelect={handleSearch}
forceMount forceMount
> >
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
<span>{t.searchPage.searchResultsFor.replace('{query}', query)}</span> <span>{t('searchPage.searchResultsFor').replace('{query}', query)}</span>
</CommandItem> </CommandItem>
<CommandItem <CommandItem
value={`__ask__ ${query}`} value={`__ask__ ${query}`}
@ -195,13 +195,13 @@ export function CommandPalette() {
forceMount forceMount
> >
<MessageCircleQuestion className="h-4 w-4" /> <MessageCircleQuestion className="h-4 w-4" />
<span>{t.searchPage.askAbout.replace('{query}', query)}</span> <span>{t('searchPage.askAbout').replace('{query}', query)}</span>
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
)} )}
{/* Navigation */} {/* Navigation */}
<CommandGroup heading={t.navigation.nav}> <CommandGroup heading={t('navigation.nav')}>
{navigationItems.map((item) => ( {navigationItems.map((item) => (
<CommandItem <CommandItem
key={item.href} key={item.href}
@ -215,11 +215,11 @@ export function CommandPalette() {
</CommandGroup> </CommandGroup>
{/* Notebooks */} {/* Notebooks */}
<CommandGroup heading={t.notebooks.title}> <CommandGroup heading={t('notebooks.title')}>
{notebooksLoading ? ( {notebooksLoading ? (
<CommandItem disabled> <CommandItem disabled>
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
<span>{t.common.loading}</span> <span>{t('common.loading')}</span>
</CommandItem> </CommandItem>
) : notebooks && notebooks.length > 0 ? ( ) : notebooks && notebooks.length > 0 ? (
notebooks.map((notebook) => ( notebooks.map((notebook) => (
@ -236,7 +236,7 @@ export function CommandPalette() {
</CommandGroup> </CommandGroup>
{/* Create */} {/* Create */}
<CommandGroup heading={t.navigation.create}> <CommandGroup heading={t('navigation.create')}>
{createItems.map((item) => ( {createItems.map((item) => (
<CommandItem <CommandItem
key={item.action} key={item.action}
@ -250,7 +250,7 @@ export function CommandPalette() {
</CommandGroup> </CommandGroup>
{/* Theme */} {/* Theme */}
<CommandGroup heading={t.navigation.theme}> <CommandGroup heading={t('navigation.theme')}>
{themeItems.map((item) => ( {themeItems.map((item) => (
<CommandItem <CommandItem
key={item.value} key={item.value}
@ -267,14 +267,14 @@ export function CommandPalette() {
{query.trim() && hasCommandMatch && ( {query.trim() && hasCommandMatch && (
<> <>
<CommandSeparator /> <CommandSeparator />
<CommandGroup heading={t.searchPage.orSearchKb} forceMount> <CommandGroup heading={t('searchPage.orSearchKb')} forceMount>
<CommandItem <CommandItem
value={`__search__ ${query}`} value={`__search__ ${query}`}
onSelect={handleSearch} onSelect={handleSearch}
forceMount forceMount
> >
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
<span>{t.searchPage.searchResultsFor.replace('{query}', query)}</span> <span>{t('searchPage.searchResultsFor').replace('{query}', query)}</span>
</CommandItem> </CommandItem>
<CommandItem <CommandItem
value={`__ask__ ${query}`} value={`__ask__ ${query}`}
@ -282,7 +282,7 @@ export function CommandPalette() {
forceMount forceMount
> >
<MessageCircleQuestion className="h-4 w-4" /> <MessageCircleQuestion className="h-4 w-4" />
<span>{t.searchPage.askAbout.replace('{query}', query)}</span> <span>{t('searchPage.askAbout').replace('{query}', query)}</span>
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
</> </>

View file

@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react' import { render, screen, fireEvent } from '@testing-library/react'
import { ConfirmDialog } from './ConfirmDialog' import { ConfirmDialog } from './ConfirmDialog'
// useTranslation is mocked globally in setup.ts // useTranslation is mocked globally in setup.ts (t returns the key string)
describe('ConfirmDialog', () => { describe('ConfirmDialog', () => {
const onConfirmMock = vi.fn() const onConfirmMock = vi.fn()
@ -21,15 +21,14 @@ describe('ConfirmDialog', () => {
expect(screen.getByText('Test Title')).toBeInTheDocument() expect(screen.getByText('Test Title')).toBeInTheDocument()
expect(screen.getByText('Test Description')).toBeInTheDocument() expect(screen.getByText('Test Description')).toBeInTheDocument()
// Localized text from our setup.ts mock should be visible expect(screen.getByText('common.confirm')).toBeInTheDocument()
expect(screen.getByText('Confirm')).toBeInTheDocument() expect(screen.getByText('common.cancel')).toBeInTheDocument()
expect(screen.getByText('Cancel')).toBeInTheDocument()
}) })
it('should call onConfirm when confirm button is clicked', () => { it('should call onConfirm when confirm button is clicked', () => {
render(<ConfirmDialog {...defaultProps} />) render(<ConfirmDialog {...defaultProps} />)
const confirmBtn = screen.getByText('Confirm') const confirmBtn = screen.getByText('common.confirm')
fireEvent.click(confirmBtn) fireEvent.click(confirmBtn)
expect(onConfirmMock).toHaveBeenCalledTimes(1) expect(onConfirmMock).toHaveBeenCalledTimes(1)
@ -43,8 +42,8 @@ describe('ConfirmDialog', () => {
it('should show loading state and disable buttons', () => { it('should show loading state and disable buttons', () => {
render(<ConfirmDialog {...defaultProps} isLoading={true} />) render(<ConfirmDialog {...defaultProps} isLoading={true} />)
const confirmBtn = screen.getByText('Confirm').closest('button') const confirmBtn = screen.getByText('common.confirm').closest('button')
const cancelBtn = screen.getByText('Cancel').closest('button') const cancelBtn = screen.getByText('common.cancel').closest('button')
expect(confirmBtn).toBeDisabled() expect(confirmBtn).toBeDisabled()
expect(cancelBtn).toBeDisabled() expect(cancelBtn).toBeDisabled()

View file

@ -35,7 +35,7 @@ export function ConfirmDialog({
isLoading = false, isLoading = false,
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
const { t } = useTranslation() const { t } = useTranslation()
const finalConfirmText = confirmText || t.common.confirm const finalConfirmText = confirmText || t('common.confirm')
return ( return (
<AlertDialog open={open} onOpenChange={onOpenChange}> <AlertDialog open={open} onOpenChange={onOpenChange}>
@ -45,7 +45,7 @@ export function ConfirmDialog({
<AlertDialogDescription>{description}</AlertDialogDescription> <AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>{t.common.cancel}</AlertDialogCancel> <AlertDialogCancel disabled={isLoading}>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={onConfirm} onClick={onConfirm}
disabled={isLoading} disabled={isLoading}

View file

@ -25,19 +25,19 @@ export function ContextToggle({ mode, hasInsights = false, onChange, className }
const MODE_CONFIG = { const MODE_CONFIG = {
off: { off: {
icon: EyeOff, icon: EyeOff,
label: t.common.contextModes.off, label: t('common.contextModes.off'),
color: 'text-muted-foreground', color: 'text-muted-foreground',
bgColor: 'hover:bg-muted' bgColor: 'hover:bg-muted'
}, },
insights: { insights: {
icon: Lightbulb, icon: Lightbulb,
label: t.common.contextModes.insights, label: t('common.contextModes.insights'),
color: 'text-amber-600', color: 'text-amber-600',
bgColor: 'hover:bg-amber-50' bgColor: 'hover:bg-amber-50'
}, },
full: { full: {
icon: FileText, icon: FileText,
label: t.common.contextModes.full, label: t('common.contextModes.full'),
color: 'text-primary', color: 'text-primary',
bgColor: 'hover:bg-primary/10' bgColor: 'hover:bg-primary/10'
} }
@ -79,7 +79,7 @@ export function ContextToggle({ mode, hasInsights = false, onChange, className }
<TooltipContent> <TooltipContent>
<p className="text-xs">{config.label}</p> <p className="text-xs">{config.label}</p>
<p className="text-[10px] text-muted-foreground mt-1"> <p className="text-[10px] text-muted-foreground mt-1">
{t.common.contextModes.clickToCycle} {t('common.contextModes.clickToCycle')}
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>

View file

@ -32,7 +32,7 @@ export function InlineEdit({
const generatedId = useId() const generatedId = useId()
const id = providedId || generatedId const id = providedId || generatedId
const { t } = useTranslation() const { t } = useTranslation()
const defaultEmptyText = emptyText || t.common.clickToEdit const defaultEmptyText = emptyText || t('common.clickToEdit')
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState(value) const [editValue, setEditValue] = useState(value)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)

View file

@ -29,8 +29,8 @@ export function LanguageToggle({ iconOnly = false }: LanguageToggleProps) {
className={iconOnly ? "h-9 w-full sidebar-menu-item" : "w-full justify-start gap-2 sidebar-menu-item"} className={iconOnly ? "h-9 w-full sidebar-menu-item" : "w-full justify-start gap-2 sidebar-menu-item"}
> >
<Languages className="h-[1.2rem] w-[1.2rem]" /> <Languages className="h-[1.2rem] w-[1.2rem]" />
{!iconOnly && <span>{t.common.language}</span>} {!iconOnly && <span>{t('common.language')}</span>}
<span className="sr-only">{t.navigation.language}</span> <span className="sr-only">{t('navigation.language')}</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
@ -38,49 +38,49 @@ export function LanguageToggle({ iconOnly = false }: LanguageToggleProps) {
onClick={() => setLanguage('en-US')} onClick={() => setLanguage('en-US')}
className={currentLang === 'en-US' || currentLang.startsWith('en') ? 'bg-accent' : ''} className={currentLang === 'en-US' || currentLang.startsWith('en') ? 'bg-accent' : ''}
> >
<span>{t.common.english}</span> <span>{t('common.english')}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => setLanguage('zh-CN')} onClick={() => setLanguage('zh-CN')}
className={currentLang === 'zh-CN' || currentLang.startsWith('zh-Hans') || currentLang === 'zh' ? 'bg-accent' : ''} className={currentLang === 'zh-CN' || currentLang.startsWith('zh-Hans') || currentLang === 'zh' ? 'bg-accent' : ''}
> >
<span>{t.common.chinese}</span> <span>{t('common.chinese')}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => setLanguage('zh-TW')} onClick={() => setLanguage('zh-TW')}
className={currentLang === 'zh-TW' || currentLang.startsWith('zh-Hant') ? 'bg-accent' : ''} className={currentLang === 'zh-TW' || currentLang.startsWith('zh-Hant') ? 'bg-accent' : ''}
> >
<span>{t.common.traditionalChinese}</span> <span>{t('common.traditionalChinese')}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => setLanguage('pt-BR')} onClick={() => setLanguage('pt-BR')}
className={currentLang === 'pt-BR' || currentLang.startsWith('pt') ? 'bg-accent' : ''} className={currentLang === 'pt-BR' || currentLang.startsWith('pt') ? 'bg-accent' : ''}
> >
<span>{t.common.portuguese}</span> <span>{t('common.portuguese')}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => setLanguage('ja-JP')} onClick={() => setLanguage('ja-JP')}
className={currentLang === 'ja-JP' || currentLang.startsWith('ja') ? 'bg-accent' : ''} className={currentLang === 'ja-JP' || currentLang.startsWith('ja') ? 'bg-accent' : ''}
> >
<span>{t.common.japanese}</span> <span>{t('common.japanese')}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => setLanguage('fr-FR')} onClick={() => setLanguage('fr-FR')}
className={currentLang === 'fr-FR' || currentLang.startsWith('fr') ? 'bg-accent' : ''} className={currentLang === 'fr-FR' || currentLang.startsWith('fr') ? 'bg-accent' : ''}
> >
<span>{t.common.french}</span> <span>{t('common.french')}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => setLanguage('ru-RU')} onClick={() => setLanguage('ru-RU')}
className={currentLang === 'ru-RU' || currentLang.startsWith('ru') ? 'bg-accent' : ''} className={currentLang === 'ru-RU' || currentLang.startsWith('ru') ? 'bg-accent' : ''}
> >
<span>{t.common.russian}</span> <span>{t('common.russian')}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => setLanguage('bn-IN')} onClick={() => setLanguage('bn-IN')}
className={currentLang === 'bn-IN' || currentLang.startsWith('bn') ? 'bg-accent' : ''} className={currentLang === 'bn-IN' || currentLang.startsWith('bn') ? 'bg-accent' : ''}
> >
<span>{t.common.bengali}</span> <span>{t('common.bengali')}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View file

@ -38,7 +38,7 @@ export function ModelSelector({
{label && <Label htmlFor={selectId}>{label}</Label>} {label && <Label htmlFor={selectId}>{label}</Label>}
<Select name={name} value={value} onValueChange={onChange} disabled={disabled || isLoading}> <Select name={name} value={value} onValueChange={onChange} disabled={disabled || isLoading}>
<SelectTrigger id={selectId}> <SelectTrigger id={selectId}>
<SelectValue placeholder={placeholder || t.settings.embeddingOptionPlaceholder} /> <SelectValue placeholder={placeholder || t('settings.embeddingOptionPlaceholder')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{isLoading ? ( {isLoading ? (
@ -47,7 +47,7 @@ export function ModelSelector({
</div> </div>
) : filteredModels.length === 0 ? ( ) : filteredModels.length === 0 ? (
<div className="text-sm text-muted-foreground py-2 px-2"> <div className="text-sm text-muted-foreground py-2 px-2">
{t.common.noResults} {t('common.noResults')}
</div> </div>
) : ( ) : (
filteredModels.map((model) => ( filteredModels.map((model) => (

View file

@ -31,8 +31,8 @@ export function ThemeToggle({ iconOnly = false }: ThemeToggleProps) {
<Sun className="absolute inset-0 h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> <Sun className="absolute inset-0 h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute inset-0 h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> <Moon className="absolute inset-0 h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</div> </div>
{!iconOnly && <span>{t.common.theme}</span>} {!iconOnly && <span>{t('common.theme')}</span>}
<span className="sr-only">{t.navigation.theme}</span> <span className="sr-only">{t('navigation.theme')}</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
@ -41,21 +41,21 @@ export function ThemeToggle({ iconOnly = false }: ThemeToggleProps) {
className={theme === 'light' ? 'bg-accent' : ''} className={theme === 'light' ? 'bg-accent' : ''}
> >
<Sun className="mr-2 h-4 w-4" /> <Sun className="mr-2 h-4 w-4" />
<span>{t.common.light}</span> <span>{t('common.light')}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => setTheme('dark')} onClick={() => setTheme('dark')}
className={theme === 'dark' ? 'bg-accent' : ''} className={theme === 'dark' ? 'bg-accent' : ''}
> >
<Moon className="mr-2 h-4 w-4" /> <Moon className="mr-2 h-4 w-4" />
<span>{t.common.dark}</span> <span>{t('common.dark')}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => setTheme('system')} onClick={() => setTheme('system')}
className={theme === 'system' ? 'bg-accent' : ''} className={theme === 'system' ? 'bg-accent' : ''}
> >
<Monitor className="mr-2 h-4 w-4" /> <Monitor className="mr-2 h-4 w-4" />
<span>{t.common.system}</span> <span>{t('common.system')}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View file

@ -43,56 +43,56 @@ export function ConnectionErrorOverlay({
<div> <div>
<h1 className="text-2xl font-bold" id="error-title"> <h1 className="text-2xl font-bold" id="error-title">
{isApiError {isApiError
? t.connectionErrors.apiTitle ? t('connectionErrors.apiTitle')
: t.connectionErrors.dbTitle} : t('connectionErrors.dbTitle')}
</h1> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{isApiError {isApiError
? t.connectionErrors.apiDesc ? t('connectionErrors.apiDesc')
: t.connectionErrors.dbDesc} : t('connectionErrors.dbDesc')}
</p> </p>
</div> </div>
</div> </div>
{/* Troubleshooting instructions */} {/* Troubleshooting instructions */}
<div className="space-y-4 border-l-4 border-primary pl-4"> <div className="space-y-4 border-l-4 border-primary pl-4">
<h2 className="font-semibold">{t.connectionErrors.troubleshooting}</h2> <h2 className="font-semibold">{t('connectionErrors.troubleshooting')}</h2>
<ul className="list-disc list-inside space-y-2 text-sm"> <ul className="list-disc list-inside space-y-2 text-sm">
{isApiError ? ( {isApiError ? (
<> <>
<li>{t.connectionErrors.apiUnreachable1}</li> <li>{t('connectionErrors.apiUnreachable1')}</li>
<li>{t.connectionErrors.apiUnreachable2}</li> <li>{t('connectionErrors.apiUnreachable2')}</li>
<li>{t.connectionErrors.apiUnreachable3}</li> <li>{t('connectionErrors.apiUnreachable3')}</li>
</> </>
) : ( ) : (
<> <>
<li>{t.connectionErrors.dbFailed1}</li> <li>{t('connectionErrors.dbFailed1')}</li>
<li>{t.connectionErrors.dbFailed2}</li> <li>{t('connectionErrors.dbFailed2')}</li>
<li>{t.connectionErrors.dbFailed3}</li> <li>{t('connectionErrors.dbFailed3')}</li>
</> </>
)} )}
</ul> </ul>
<h2 className="font-semibold mt-4">{t.connectionErrors.quickFixes}</h2> <h2 className="font-semibold mt-4">{t('connectionErrors.quickFixes')}</h2>
{isApiError ? ( {isApiError ? (
<div className="space-y-2 text-sm bg-muted p-4 rounded"> <div className="space-y-2 text-sm bg-muted p-4 rounded">
<p className="font-medium">{t.connectionErrors.setApiUrl}</p> <p className="font-medium">{t('connectionErrors.setApiUrl')}</p>
<code className="block bg-background p-2 rounded text-xs"> <code className="block bg-background p-2 rounded text-xs">
# {t.connectionErrors.dockerLabel}: # {t('connectionErrors.dockerLabel')}:
<br /> <br />
docker run -e API_URL=http://your-host:5055 ... docker run -e API_URL=http://your-host:5055 ...
<br /> <br />
<br /> <br />
# {t.connectionErrors.localDevLabel}: # {t('connectionErrors.localDevLabel')}:
<br /> <br />
API_URL=http://localhost:5055 API_URL=http://localhost:5055
</code> </code>
</div> </div>
) : ( ) : (
<div className="space-y-2 text-sm bg-muted p-4 rounded"> <div className="space-y-2 text-sm bg-muted p-4 rounded">
<p className="font-medium">{t.connectionErrors.checkSurreal}</p> <p className="font-medium">{t('connectionErrors.checkSurreal')}</p>
<code className="block bg-background p-2 rounded text-xs"> <code className="block bg-background p-2 rounded text-xs">
# {t.connectionErrors.dockerLabel}: # {t('connectionErrors.dockerLabel')}:
<br /> <br />
docker compose ps | grep surrealdb docker compose ps | grep surrealdb
<br /> <br />
@ -104,14 +104,14 @@ export function ConnectionErrorOverlay({
{/* Documentation link */} {/* Documentation link */}
<div className="text-sm"> <div className="text-sm">
<p>{t.connectionErrors.seeDocumentation}</p> <p>{t('connectionErrors.seeDocumentation')}</p>
<a <a
href="https://github.com/lfnovo/open-notebook" href="https://github.com/lfnovo/open-notebook"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1" className="text-primary hover:underline inline-flex items-center gap-1"
> >
{t.connectionErrors.docLink} {t('connectionErrors.docLink')}
<ExternalLink className="w-4 h-4" /> <ExternalLink className="w-4 h-4" />
</a> </a>
</div> </div>
@ -121,7 +121,7 @@ export function ConnectionErrorOverlay({
<Collapsible open={showDetails} onOpenChange={setShowDetails}> <Collapsible open={showDetails} onOpenChange={setShowDetails}>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-full justify-between"> <Button variant="ghost" size="sm" className="w-full justify-between">
<span>{t.connectionErrors.showTechnical}</span> <span>{t('connectionErrors.showTechnical')}</span>
<ChevronDown <ChevronDown
className={`w-4 h-4 transition-transform ${ className={`w-4 h-4 transition-transform ${
showDetails ? 'rotate-180' : '' showDetails ? 'rotate-180' : ''
@ -133,23 +133,23 @@ export function ConnectionErrorOverlay({
<div className="space-y-2 text-sm bg-muted p-4 rounded font-mono"> <div className="space-y-2 text-sm bg-muted p-4 rounded font-mono">
{error.details.attemptedUrl && ( {error.details.attemptedUrl && (
<div> <div>
<strong>{t.connectionErrors.attemptedUrl}:</strong> {error.details.attemptedUrl} <strong>{t('connectionErrors.attemptedUrl')}:</strong> {error.details.attemptedUrl}
</div> </div>
)} )}
{error.details.message && ( {error.details.message && (
<div> <div>
<strong>{t.connectionErrors.message}:</strong> {error.details.message} <strong>{t('connectionErrors.message')}:</strong> {error.details.message}
</div> </div>
)} )}
{error.details.technicalMessage && ( {error.details.technicalMessage && (
<div> <div>
<strong>{t.connectionErrors.technicalDetails}:</strong>{' '} <strong>{t('connectionErrors.technicalDetails')}:</strong>{' '}
{error.details.technicalMessage} {error.details.technicalMessage}
</div> </div>
)} )}
{error.details.stack && ( {error.details.stack && (
<div> <div>
<strong>{t.connectionErrors.stackTrace}:</strong> <strong>{t('connectionErrors.stackTrace')}:</strong>
<pre className="mt-2 overflow-x-auto text-xs"> <pre className="mt-2 overflow-x-auto text-xs">
{error.details.stack} {error.details.stack}
</pre> </pre>
@ -163,10 +163,10 @@ export function ConnectionErrorOverlay({
{/* Retry button */} {/* Retry button */}
<div className="pt-4 border-t"> <div className="pt-4 border-t">
<Button onClick={onRetry} className="w-full" size="lg"> <Button onClick={onRetry} className="w-full" size="lg">
{t.connectionErrors.retryLabel} {t('connectionErrors.retryLabel')}
</Button> </Button>
<p className="text-xs text-muted-foreground text-center mt-2"> <p className="text-xs text-muted-foreground text-center mt-2">
{t.connectionErrors.retryHint} {t('connectionErrors.retryHint')}
</p> </p>
</div> </div>
</Card> </Card>

View file

@ -11,18 +11,15 @@ vi.mock('@/components/ui/tooltip', () => ({
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>, TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
})) }))
// But setup.ts has some basic mocks, let's see.
describe('AppSidebar', () => { describe('AppSidebar', () => {
it('renders correctly when expanded', () => { it('renders correctly when expanded', () => {
render(<AppSidebar />) render(<AppSidebar />)
// Check for logo or app name (using actual locale value) // With mocked t() returning keys, check for translation key strings
expect(screen.getByText(/Open Notebook/i)).toBeDefined() expect(screen.getByText('common.appName')).toBeDefined()
expect(screen.getByText('navigation.sources')).toBeDefined()
// Check for navigation items (using actual locale values) expect(screen.getByText('navigation.notebooks')).toBeDefined()
expect(screen.getByText(/Sources/i)).toBeDefined()
expect(screen.getByText(/Notebooks/i)).toBeDefined()
}) })
it('toggles collapse state when clicking handle', () => { it('toggles collapse state when clicking handle', () => {
@ -34,13 +31,6 @@ describe('AppSidebar', () => {
render(<AppSidebar />) render(<AppSidebar />)
// The collapse button has ChevronLeft icon when expanded
// The collapse button has ChevronLeft icon when expanded
// const toggleButton = screen.getAllByRole('button')[0]
// Let's use more specific selector if possible, but AppSidebar has many buttons
// Actually, line 147 has the button
// Use data-testid for reliable selection
fireEvent.click(screen.getByTestId('sidebar-toggle')) fireEvent.click(screen.getByTestId('sidebar-toggle'))
expect(toggleCollapse).toHaveBeenCalled() expect(toggleCollapse).toHaveBeenCalled()
@ -55,6 +45,6 @@ describe('AppSidebar', () => {
render(<AppSidebar />) render(<AppSidebar />)
// In collapsed mode, app name shouldn't be visible (as text) // In collapsed mode, app name shouldn't be visible (as text)
expect(screen.queryByText(/Open Notebook/i)).toBeNull() expect(screen.queryByText('common.appName')).toBeNull()
}) })
}) })

View file

@ -24,7 +24,7 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { ThemeToggle } from '@/components/common/ThemeToggle' import { ThemeToggle } from '@/components/common/ThemeToggle'
import { LanguageToggle } from '@/components/common/LanguageToggle' import { LanguageToggle } from '@/components/common/LanguageToggle'
import { TranslationKeys } from '@/lib/locales' import type { TFunction } from 'i18next'
import { useTranslation } from '@/lib/hooks/use-translation' import { useTranslation } from '@/lib/hooks/use-translation'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { import {
@ -43,33 +43,33 @@ import {
Command, Command,
} from 'lucide-react' } from 'lucide-react'
const getNavigation = (t: TranslationKeys) => [ const getNavigation = (t: TFunction) => [
{ {
title: t.navigation.collect, title: t('navigation.collect'),
items: [ items: [
{ name: t.navigation.sources, href: '/sources', icon: FileText }, { name: t('navigation.sources'), href: '/sources', icon: FileText },
], ],
}, },
{ {
title: t.navigation.process, title: t('navigation.process'),
items: [ items: [
{ name: t.navigation.notebooks, href: '/notebooks', icon: Book }, { name: t('navigation.notebooks'), href: '/notebooks', icon: Book },
{ name: t.navigation.askAndSearch, href: '/search', icon: Search }, { name: t('navigation.askAndSearch'), href: '/search', icon: Search },
], ],
}, },
{ {
title: t.navigation.create, title: t('navigation.create'),
items: [ items: [
{ name: t.navigation.podcasts, href: '/podcasts', icon: Mic }, { name: t('navigation.podcasts'), href: '/podcasts', icon: Mic },
], ],
}, },
{ {
title: t.navigation.manage, title: t('navigation.manage'),
items: [ items: [
{ name: t.navigation.models, href: '/settings/api-keys', icon: Bot }, { name: t('navigation.models'), href: '/settings/api-keys', icon: Bot },
{ name: t.navigation.transformations, href: '/transformations', icon: Shuffle }, { name: t('navigation.transformations'), href: '/transformations', icon: Shuffle },
{ name: t.navigation.settings, href: '/settings', icon: Settings }, { name: t('navigation.settings'), href: '/settings', icon: Settings },
{ name: t.navigation.advanced, href: '/advanced', icon: Wrench }, { name: t('navigation.advanced'), href: '/advanced', icon: Wrench },
], ],
}, },
] as const ] as const
@ -139,9 +139,9 @@ export function AppSidebar() {
) : ( ) : (
<> <>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Image src="/logo.svg" alt={t.common.appName} width={32} height={32} /> <Image src="/logo.svg" alt={t('common.appName')} width={32} height={32} />
<span className="text-base font-medium text-sidebar-foreground"> <span className="text-base font-medium text-sidebar-foreground">
{t.common.appName} {t('common.appName')}
</span> </span>
</div> </div>
<Button <Button
@ -179,13 +179,13 @@ export function AppSidebar() {
variant="default" variant="default"
size="sm" size="sm"
className="w-full justify-center px-2 bg-primary hover:bg-primary/90 text-primary-foreground border-0" className="w-full justify-center px-2 bg-primary hover:bg-primary/90 text-primary-foreground border-0"
aria-label={t.common.create} aria-label={t('common.create')}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right">{t.common.create}</TooltipContent> <TooltipContent side="right">{t('common.create')}</TooltipContent>
</Tooltip> </Tooltip>
) : ( ) : (
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@ -196,7 +196,7 @@ export function AppSidebar() {
className="w-full justify-start bg-primary hover:bg-primary/90 text-primary-foreground border-0" className="w-full justify-start bg-primary hover:bg-primary/90 text-primary-foreground border-0"
> >
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
{t.common.create} {t('common.create')}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
)} )}
@ -214,7 +214,7 @@ export function AppSidebar() {
className="gap-2" className="gap-2"
> >
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
{t.common.source} {t('common.source')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onSelect={(event) => { onSelect={(event) => {
@ -224,7 +224,7 @@ export function AppSidebar() {
className="gap-2" className="gap-2"
> >
<Book className="h-4 w-4" /> <Book className="h-4 w-4" />
{t.common.notebook} {t('common.notebook')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onSelect={(event) => { onSelect={(event) => {
@ -234,7 +234,7 @@ export function AppSidebar() {
className="gap-2" className="gap-2"
> >
<Mic className="h-4 w-4" /> <Mic className="h-4 w-4" />
{t.common.podcast} {t('common.podcast')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -304,14 +304,14 @@ export function AppSidebar() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Command className="h-3 w-3" /> <Command className="h-3 w-3" />
{t.common.quickActions} {t('common.quickActions')}
</span> </span>
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground"> <kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
{isMac ? <span className="text-xs"></span> : <span>Ctrl+</span>}K {isMac ? <span className="text-xs"></span> : <span>Ctrl+</span>}K
</kbd> </kbd>
</div> </div>
<p className="mt-1 text-[10px] text-sidebar-foreground/40"> <p className="mt-1 text-[10px] text-sidebar-foreground/40">
{t.common.quickActionsDesc} {t('common.quickActionsDesc')}
</p> </p>
</div> </div>
)} )}
@ -330,7 +330,7 @@ export function AppSidebar() {
<ThemeToggle iconOnly /> <ThemeToggle iconOnly />
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right">{t.common.theme}</TooltipContent> <TooltipContent side="right">{t('common.theme')}</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -338,7 +338,7 @@ export function AppSidebar() {
<LanguageToggle iconOnly /> <LanguageToggle iconOnly />
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right">{t.common.language}</TooltipContent> <TooltipContent side="right">{t('common.language')}</TooltipContent>
</Tooltip> </Tooltip>
</> </>
) : ( ) : (
@ -356,22 +356,22 @@ export function AppSidebar() {
variant="outline" variant="outline"
className="w-full justify-center sidebar-menu-item" className="w-full justify-center sidebar-menu-item"
onClick={logout} onClick={logout}
aria-label={t.common.signOut} aria-label={t('common.signOut')}
> >
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right">{t.common.signOut}</TooltipContent> <TooltipContent side="right">{t('common.signOut')}</TooltipContent>
</Tooltip> </Tooltip>
) : ( ) : (
<Button <Button
variant="outline" variant="outline"
className="w-full justify-start gap-3 sidebar-menu-item" className="w-full justify-start gap-3 sidebar-menu-item"
onClick={logout} onClick={logout}
aria-label={t.common.signOut} aria-label={t('common.signOut')}
> >
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
{t.common.signOut} {t('common.signOut')}
</Button> </Button>
)} )}
</div> </div>

View file

@ -36,17 +36,17 @@ export function SetupBanner() {
<Alert className="border-red-500/50 bg-red-50 dark:bg-red-950/20"> <Alert className="border-red-500/50 bg-red-50 dark:bg-red-950/20">
<ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-400" /> <ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-400" />
<AlertTitle className="text-red-800 dark:text-red-200"> <AlertTitle className="text-red-800 dark:text-red-200">
{t.setupBanner.encryptionRequired} {t('setupBanner.encryptionRequired')}
</AlertTitle> </AlertTitle>
<AlertDescription className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between text-red-700 dark:text-red-300"> <AlertDescription className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between text-red-700 dark:text-red-300">
<span>{t.setupBanner.encryptionRequiredDescription}</span> <span>{t('setupBanner.encryptionRequiredDescription')}</span>
<a <a
href="https://github.com/lfnovo/open-notebook/blob/main/docs/3-USER-GUIDE/api-configuration.md#encryption-setup" href="https://github.com/lfnovo/open-notebook/blob/main/docs/3-USER-GUIDE/api-configuration.md#encryption-setup"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center shrink-0 text-sm font-medium underline underline-offset-2 hover:text-red-900 dark:hover:text-red-100" className="inline-flex items-center shrink-0 text-sm font-medium underline underline-offset-2 hover:text-red-900 dark:hover:text-red-100"
> >
{t.setupBanner.viewDocs} {t('setupBanner.viewDocs')}
<ExternalLink className="ml-1 h-3 w-3" /> <ExternalLink className="ml-1 h-3 w-3" />
</a> </a>
</AlertDescription> </AlertDescription>
@ -60,11 +60,11 @@ export function SetupBanner() {
<Alert className="border-amber-500/50 bg-amber-50 dark:bg-amber-950/20"> <Alert className="border-amber-500/50 bg-amber-50 dark:bg-amber-950/20">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" /> <AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<AlertTitle className="text-amber-800 dark:text-amber-200"> <AlertTitle className="text-amber-800 dark:text-amber-200">
{t.setupBanner.migrationAvailable} {t('setupBanner.migrationAvailable')}
</AlertTitle> </AlertTitle>
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span className="text-amber-700 dark:text-amber-300"> <span className="text-amber-700 dark:text-amber-300">
{t.setupBanner.migrationDescription.replace('{count}', providersToMigrate.length.toString())} {t('setupBanner.migrationDescription').replace('{count}', providersToMigrate.length.toString())}
</span> </span>
<Button <Button
variant="outline" variant="outline"
@ -73,7 +73,7 @@ export function SetupBanner() {
className="shrink-0 border-amber-500 text-amber-700 hover:bg-amber-100 dark:border-amber-400 dark:text-amber-300 dark:hover:bg-amber-900/30" className="shrink-0 border-amber-500 text-amber-700 hover:bg-amber-100 dark:border-amber-400 dark:text-amber-300 dark:hover:bg-amber-900/30"
> >
<Link href="/settings/api-keys"> <Link href="/settings/api-keys">
{t.setupBanner.goToSettings} {t('setupBanner.goToSettings')}
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
</Link> </Link>
</Button> </Button>

View file

@ -67,19 +67,19 @@ export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialo
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px]"> <DialogContent className="sm:max-w-[480px]">
<DialogHeader> <DialogHeader>
<DialogTitle>{t.notebooks.createNew}</DialogTitle> <DialogTitle>{t('notebooks.createNew')}</DialogTitle>
<DialogDescription> <DialogDescription>
{t.notebooks.createNewDesc} {t('notebooks.createNewDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="notebook-name">{t.common.name} *</Label> <Label htmlFor="notebook-name">{t('common.name')} *</Label>
<Input <Input
id="notebook-name" id="notebook-name"
{...register('name')} {...register('name')}
placeholder={t.notebooks.namePlaceholder} placeholder={t('notebooks.namePlaceholder')}
autoComplete="off" autoComplete="off"
/> />
{errors.name && ( {errors.name && (
@ -88,21 +88,21 @@ export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialo
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="notebook-description">{t.common.description}</Label> <Label htmlFor="notebook-description">{t('common.description')}</Label>
<Textarea <Textarea
id="notebook-description" id="notebook-description"
{...register('description')} {...register('description')}
placeholder={t.notebooks.descPlaceholder} placeholder={t('notebooks.descPlaceholder')}
rows={4} rows={4}
/> />
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="outline" onClick={closeDialog}> <Button type="button" variant="outline" onClick={closeDialog}>
{t.common.cancel} {t('common.cancel')}
</Button> </Button>
<Button type="submit" disabled={!isValid || createNotebook.isPending}> <Button type="submit" disabled={!isValid || createNotebook.isPending}>
{createNotebook.isPending ? t.common.creating : t.notebooks.createNew} {createNotebook.isPending ? t('common.creating') : t('notebooks.createNew')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>

View file

@ -33,7 +33,7 @@ import {
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useTranslation } from '@/lib/hooks/use-translation' import { useTranslation } from '@/lib/hooks/use-translation'
import { TranslationKeys } from '@/lib/locales' import type { TFunction } from 'i18next'
interface EpisodeCardProps { interface EpisodeCardProps {
episode: PodcastEpisode episode: PodcastEpisode
@ -43,40 +43,40 @@ interface EpisodeCardProps {
retrying?: boolean retrying?: boolean
} }
const getSTATUS_META = (t: TranslationKeys): Record< const getSTATUS_META = (t: TFunction): Record<
EpisodeStatus | 'unknown', EpisodeStatus | 'unknown',
{ label: string; className: string } { label: string; className: string }
> => ({ > => ({
running: { running: {
label: t.podcasts.processingLabel, label: t('podcasts.processingLabel'),
className: 'bg-amber-100 text-amber-800 border-amber-200', className: 'bg-amber-100 text-amber-800 border-amber-200',
}, },
processing: { processing: {
label: t.podcasts.processingLabel, label: t('podcasts.processingLabel'),
className: 'bg-amber-100 text-amber-800 border-amber-200', className: 'bg-amber-100 text-amber-800 border-amber-200',
}, },
completed: { completed: {
label: t.podcasts.completedLabel, label: t('podcasts.completedLabel'),
className: 'bg-emerald-100 text-emerald-800 border-emerald-200', className: 'bg-emerald-100 text-emerald-800 border-emerald-200',
}, },
failed: { failed: {
label: t.podcasts.failedLabel, label: t('podcasts.failedLabel'),
className: 'bg-red-100 text-red-800 border-red-200', className: 'bg-red-100 text-red-800 border-red-200',
}, },
error: { error: {
label: t.podcasts.failedLabel, label: t('podcasts.failedLabel'),
className: 'bg-red-100 text-red-800 border-red-200', className: 'bg-red-100 text-red-800 border-red-200',
}, },
pending: { pending: {
label: t.podcasts.pendingLabel, label: t('podcasts.pendingLabel'),
className: 'bg-sky-100 text-sky-800 border-sky-200', className: 'bg-sky-100 text-sky-800 border-sky-200',
}, },
submitted: { submitted: {
label: t.podcasts.pendingLabel, label: t('podcasts.pendingLabel'),
className: 'bg-sky-100 text-sky-800 border-sky-200', className: 'bg-sky-100 text-sky-800 border-sky-200',
}, },
unknown: { unknown: {
label: t.common.unknown, label: t('common.unknown'),
className: 'bg-muted text-muted-foreground border-transparent', className: 'bg-muted text-muted-foreground border-transparent',
}, },
}) })
@ -190,7 +190,7 @@ export function EpisodeCard({ episode, onDelete, deleting, onRetry, retrying }:
setAudioSrc(revokeUrl) setAudioSrc(revokeUrl)
} catch (error) { } catch (error) {
console.error('Unable to load podcast audio', error) console.error('Unable to load podcast audio', error)
setAudioError(t.podcasts.audioUnavailable) setAudioError(t('podcasts.audioUnavailable'))
setAudioSrc(undefined) setAudioSrc(undefined)
} }
} }
@ -212,7 +212,7 @@ export function EpisodeCard({ episode, onDelete, deleting, onRetry, retrying }:
: null : null
const createdLabel = distance const createdLabel = distance
? t.podcasts.created.replace('{time}', distance) ? t('podcasts.created').replace('{time}', distance)
: null : null
const handleDelete = () => { const handleDelete = () => {
@ -239,7 +239,7 @@ export function EpisodeCard({ episode, onDelete, deleting, onRetry, retrying }:
<StatusBadge status={episode.job_status} /> <StatusBadge status={episode.job_status} />
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t.podcasts.profile}: {episode.episode_profile?.name || t.common.unknown} {t('podcasts.profile')}: {episode.episode_profile?.name || t('common.unknown')}
{createdLabel ? `${createdLabel}` : ''} {createdLabel ? `${createdLabel}` : ''}
</p> </p>
</div> </div>
@ -247,14 +247,14 @@ export function EpisodeCard({ episode, onDelete, deleting, onRetry, retrying }:
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}> <Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<InfoIcon className="mr-2 h-4 w-4" /> {t.podcasts.details} <InfoIcon className="mr-2 h-4 w-4" /> {t('podcasts.details')}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="w-[min(90vw,720px)] max-h-[85vh] overflow-hidden"> <DialogContent className="w-[min(90vw,720px)] max-h-[85vh] overflow-hidden">
<DialogHeader> <DialogHeader>
<DialogTitle>{episode.name}</DialogTitle> <DialogTitle>{episode.name}</DialogTitle>
<DialogDescription> <DialogDescription>
{episode.episode_profile?.name || t.common.unknown} {episode.episode_profile?.name || t('common.unknown')}
{createdLabel ? `${createdLabel}` : ''} {createdLabel ? `${createdLabel}` : ''}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -267,19 +267,19 @@ export function EpisodeCard({ episode, onDelete, deleting, onRetry, retrying }:
<Tabs defaultValue="summary" className="h-[60vh] flex flex-col"> <Tabs defaultValue="summary" className="h-[60vh] flex flex-col">
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="summary">{t.podcasts.summaryTab}</TabsTrigger> <TabsTrigger value="summary">{t('podcasts.summaryTab')}</TabsTrigger>
<TabsTrigger value="outline">{t.podcasts.outlineTab}</TabsTrigger> <TabsTrigger value="outline">{t('podcasts.outlineTab')}</TabsTrigger>
<TabsTrigger value="transcript">{t.podcasts.transcriptTab}</TabsTrigger> <TabsTrigger value="transcript">{t('podcasts.transcriptTab')}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="summary" className="flex-1 overflow-hidden"> <TabsContent value="summary" className="flex-1 overflow-hidden">
<ScrollArea className="h-full pr-4"> <ScrollArea className="h-full pr-4">
<div className="space-y-6"> <div className="space-y-6">
<section className="space-y-2"> <section className="space-y-2">
<h4 className="text-sm font-semibold text-foreground">{t.podcasts.episodeProfile}</h4> <h4 className="text-sm font-semibold text-foreground">{t('podcasts.episodeProfile')}</h4>
<div className="grid gap-2 text-sm md:grid-cols-2"> <div className="grid gap-2 text-sm md:grid-cols-2">
<div> <div>
<p className="text-muted-foreground">{t.podcasts.outlineModel}</p> <p className="text-muted-foreground">{t('podcasts.outlineModel')}</p>
<p> <p>
{episode.episode_profile?.outline_provider ?? '—'} / {episode.episode_profile?.outline_provider ?? '—'} /
{' '} {' '}
@ -287,7 +287,7 @@ export function EpisodeCard({ episode, onDelete, deleting, onRetry, retrying }:
</p> </p>
</div> </div>
<div> <div>
<p className="text-muted-foreground">{t.podcasts.transcriptModel}</p> <p className="text-muted-foreground">{t('podcasts.transcriptModel')}</p>
<p> <p>
{episode.episode_profile?.transcript_provider ?? '—'} / {episode.episode_profile?.transcript_provider ?? '—'} /
{' '} {' '}
@ -295,7 +295,7 @@ export function EpisodeCard({ episode, onDelete, deleting, onRetry, retrying }:
</p> </p>
</div> </div>
<div> <div>
<p className="text-muted-foreground">{t.podcasts.segments}</p> <p className="text-muted-foreground">{t('podcasts.segments')}</p>
<p>{episode.episode_profile?.num_segments ?? '—'}</p> <p>{episode.episode_profile?.num_segments ?? '—'}</p>
</div> </div>
</div> </div>
@ -307,7 +307,7 @@ export function EpisodeCard({ episode, onDelete, deleting, onRetry, retrying }:
</section> </section>
<section className="space-y-2"> <section className="space-y-2">
<h4 className="text-sm font-semibold text-foreground">{t.podcasts.speakerProfile}</h4> <h4 className="text-sm font-semibold text-foreground">{t('podcasts.speakerProfile')}</h4>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{episode.speaker_profile?.tts_provider ?? '—'} /{' '} {episode.speaker_profile?.tts_provider ?? '—'} /{' '}
{episode.speaker_profile?.tts_model ?? '—'} {episode.speaker_profile?.tts_model ?? '—'}
@ -318,12 +318,12 @@ export function EpisodeCard({ episode, onDelete, deleting, onRetry, retrying }:
className="rounded-md border bg-muted/20 p-3 text-xs" className="rounded-md border bg-muted/20 p-3 text-xs"
> >
<p className="font-semibold text-foreground">{speaker.name}</p> <p className="font-semibold text-foreground">{speaker.name}</p>
<p className="text-muted-foreground">{t.podcasts.voiceId}: {speaker.voice_id}</p> <p className="text-muted-foreground">{t('podcasts.voiceId')}: {speaker.voice_id}</p>
<p className="mt-2 whitespace-pre-wrap text-muted-foreground"> <p className="mt-2 whitespace-pre-wrap text-muted-foreground">
<span className="font-semibold">{t.podcasts.backstory}:</span> {speaker.backstory} <span className="font-semibold">{t('podcasts.backstory')}:</span> {speaker.backstory}
</p> </p>
<p className="mt-2 whitespace-pre-wrap text-muted-foreground"> <p className="mt-2 whitespace-pre-wrap text-muted-foreground">
<span className="font-semibold">{t.podcasts.personality}:</span> {speaker.personality} <span className="font-semibold">{t('podcasts.personality')}:</span> {speaker.personality}
</p> </p>
</div> </div>
))} ))}
@ -331,7 +331,7 @@ export function EpisodeCard({ episode, onDelete, deleting, onRetry, retrying }:
{episode.briefing ? ( {episode.briefing ? (
<section className="space-y-2"> <section className="space-y-2">
<h4 className="text-sm font-semibold text-foreground">{t.podcasts.briefing}</h4> <h4 className="text-sm font-semibold text-foreground">{t('podcasts.briefing')}</h4>
<div className="rounded border bg-muted/30 p-3 text-xs whitespace-pre-wrap"> <div className="rounded border bg-muted/30 p-3 text-xs whitespace-pre-wrap">
{episode.briefing} {episode.briefing}
</div> </div>
@ -348,17 +348,17 @@ export function EpisodeCard({ episode, onDelete, deleting, onRetry, retrying }:
{outlineSegments.map((segment, index) => ( {outlineSegments.map((segment, index) => (
<div key={index} className="rounded border bg-muted/20 p-3 text-xs space-y-1"> <div key={index} className="rounded border bg-muted/20 p-3 text-xs space-y-1">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<p className="font-semibold text-foreground">{segment.name ?? `${t.podcasts.segment} ${index + 1}`}</p> <p className="font-semibold text-foreground">{segment.name ?? `${t('podcasts.segment')} ${index + 1}`}</p>
{segment.size ? ( {segment.size ? (
<Badge variant="outline" className="text-[10px] uppercase tracking-wide">{segment.size}</Badge> <Badge variant="outline" className="text-[10px] uppercase tracking-wide">{segment.size}</Badge>
) : null} ) : null}
</div> </div>
<p className="text-muted-foreground whitespace-pre-wrap">{segment.description ?? t.podcasts.noDescription}</p> <p className="text-muted-foreground whitespace-pre-wrap">{segment.description ?? t('podcasts.noDescription')}</p>
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<p className="text-xs text-muted-foreground">{t.podcasts.noOutline}</p> <p className="text-xs text-muted-foreground">{t('podcasts.noOutline')}</p>
)} )}
</ScrollArea> </ScrollArea>
</TabsContent> </TabsContent>
@ -368,12 +368,12 @@ export function EpisodeCard({ episode, onDelete, deleting, onRetry, retrying }:
{transcriptEntries.length > 0 ? ( {transcriptEntries.length > 0 ? (
transcriptEntries.map((entry, index) => ( transcriptEntries.map((entry, index) => (
<div key={index} className="rounded border bg-muted/20 p-3 text-xs space-y-1"> <div key={index} className="rounded border bg-muted/20 p-3 text-xs space-y-1">
<p className="font-semibold text-foreground">{entry.speaker ?? t.podcasts.speaker}</p> <p className="font-semibold text-foreground">{entry.speaker ?? t('podcasts.speaker')}</p>
<p className="text-muted-foreground whitespace-pre-wrap">{entry.dialogue ?? ''}</p> <p className="text-muted-foreground whitespace-pre-wrap">{entry.dialogue ?? ''}</p>
</div> </div>
)) ))
) : ( ) : (
<p className="text-xs text-muted-foreground">{t.podcasts.noTranscript}</p> <p className="text-xs text-muted-foreground">{t('podcasts.noTranscript')}</p>
)} )}
</ScrollArea> </ScrollArea>
</TabsContent> </TabsContent>
@ -389,27 +389,27 @@ export function EpisodeCard({ episode, onDelete, deleting, onRetry, retrying }:
disabled={retrying} disabled={retrying}
> >
<RefreshCcw className={cn('mr-2 h-4 w-4', retrying && 'animate-spin')} /> <RefreshCcw className={cn('mr-2 h-4 w-4', retrying && 'animate-spin')} />
{retrying ? t.podcasts.retrying : t.podcasts.retry} {retrying ? t('podcasts.retrying') : t('podcasts.retry')}
</Button> </Button>
) : null} ) : null}
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive"> <Button variant="ghost" size="sm" className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
{t.podcasts.delete} {t('podcasts.delete')}
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{t.podcasts.deleteEpisodeTitle}</AlertDialogTitle> <AlertDialogTitle>{t('podcasts.deleteEpisodeTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{t.podcasts.deleteEpisodeDesc.replace('{name}', episode.name)} {t('podcasts.deleteEpisodeDesc').replace('{name}', episode.name)}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>{t.common.cancel}</AlertDialogCancel> <AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={deleting}> <AlertDialogAction onClick={handleDelete} disabled={deleting}>
{deleting ? t.podcasts.deleting : t.podcasts.delete} {deleting ? t('podcasts.deleting') : t('podcasts.delete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@ -425,7 +425,7 @@ export function EpisodeCard({ episode, onDelete, deleting, onRetry, retrying }:
{isFailed && episode.error_message ? ( {isFailed && episode.error_message ? (
<div className="rounded-md border border-red-200 bg-red-50 p-3 dark:border-red-900 dark:bg-red-950/30"> <div className="rounded-md border border-red-200 bg-red-50 p-3 dark:border-red-900 dark:bg-red-950/30">
<p className="text-xs font-medium text-red-800 dark:text-red-300">{t.podcasts.errorDetails}</p> <p className="text-xs font-medium text-red-800 dark:text-red-300">{t('podcasts.errorDetails')}</p>
<p className="mt-1 text-xs whitespace-pre-wrap text-red-700 dark:text-red-400">{episode.error_message}</p> <p className="mt-1 text-xs whitespace-pre-wrap text-red-700 dark:text-red-400">{episode.error_message}</p>
</div> </div>
) : null} ) : null}

View file

@ -83,25 +83,25 @@ export function EpisodeProfilesPanel({
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-lg font-semibold">{t.podcasts.episodeProfilesTitle}</h2> <h2 className="text-lg font-semibold">{t('podcasts.episodeProfilesTitle')}</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t.podcasts.episodeProfilesDesc} {t('podcasts.episodeProfilesDesc')}
</p> </p>
</div> </div>
<Button onClick={() => setCreateOpen(true)} disabled={disableCreate}> <Button onClick={() => setCreateOpen(true)} disabled={disableCreate}>
{t.podcasts.createProfile} {t('podcasts.createProfile')}
</Button> </Button>
</div> </div>
{disableCreate ? ( {disableCreate ? (
<p className="rounded-lg border border-dashed bg-amber-50 p-4 text-sm text-amber-900"> <p className="rounded-lg border border-dashed bg-amber-50 p-4 text-sm text-amber-900">
{t.podcasts.createSpeakerFirst} {t('podcasts.createSpeakerFirst')}
</p> </p>
) : null} ) : null}
{sortedProfiles.length === 0 ? ( {sortedProfiles.length === 0 ? (
<div className="rounded-lg border border-dashed bg-muted/30 p-10 text-center text-sm text-muted-foreground"> <div className="rounded-lg border border-dashed bg-muted/30 p-10 text-center text-sm text-muted-foreground">
{t.podcasts.noEpisodeProfiles} {t('podcasts.noEpisodeProfiles')}
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
@ -123,12 +123,12 @@ export function EpisodeProfilesPanel({
{unconfigured ? ( {unconfigured ? (
<Badge variant="outline" className="text-amber-600 border-amber-300 text-xs"> <Badge variant="outline" className="text-amber-600 border-amber-300 text-xs">
<AlertTriangle className="h-3 w-3 mr-1" /> <AlertTriangle className="h-3 w-3 mr-1" />
{t.podcasts.setupRequired} {t('podcasts.setupRequired')}
</Badge> </Badge>
) : null} ) : null}
</div> </div>
<CardDescription className="text-sm text-muted-foreground"> <CardDescription className="text-sm text-muted-foreground">
{profile.description || t.podcasts.noDescription} {profile.description || t('podcasts.noDescription')}
</CardDescription> </CardDescription>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -137,7 +137,7 @@ export function EpisodeProfilesPanel({
size="sm" size="sm"
onClick={() => setEditProfile(profile)} onClick={() => setEditProfile(profile)}
> >
<Edit3 className="mr-2 h-4 w-4" /> {t.podcasts.edit} <Edit3 className="mr-2 h-4 w-4" /> {t('podcasts.edit')}
</Button> </Button>
<AlertDialog> <AlertDialog>
<DropdownMenu> <DropdownMenu>
@ -161,31 +161,31 @@ export function EpisodeProfilesPanel({
disabled={duplicateProfile.isPending} disabled={duplicateProfile.isPending}
> >
<Copy className="h-4 w-4 mr-2" /> <Copy className="h-4 w-4 mr-2" />
{t.podcasts.duplicate} {t('podcasts.duplicate')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<DropdownMenuItem className="text-destructive focus:text-destructive"> <DropdownMenuItem className="text-destructive focus:text-destructive">
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
{t.podcasts.delete} {t('podcasts.delete')}
</DropdownMenuItem> </DropdownMenuItem>
</AlertDialogTrigger> </AlertDialogTrigger>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{t.podcasts.deleteProfileTitle}</AlertDialogTitle> <AlertDialogTitle>{t('podcasts.deleteProfileTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{t.podcasts.deleteProfileDesc.replace('{name}', profile.name)} {t('podcasts.deleteProfileDesc').replace('{name}', profile.name)}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>{t.common.cancel}</AlertDialogCancel> <AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={() => deleteProfile.mutate(profile.id)} onClick={() => deleteProfile.mutate(profile.id)}
disabled={deleteProfile.isPending} disabled={deleteProfile.isPending}
> >
{deleteProfile.isPending ? t.podcasts.deleting : t.podcasts.delete} {deleteProfile.isPending ? t('podcasts.deleting') : t('podcasts.delete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@ -197,45 +197,45 @@ export function EpisodeProfilesPanel({
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.outlineModel} {t('podcasts.outlineModel')}
</p> </p>
<p className="text-foreground"> <p className="text-foreground">
{profile.outline_llm {profile.outline_llm
? (modelNameMap[profile.outline_llm] ?? profile.outline_llm) ? (modelNameMap[profile.outline_llm] ?? profile.outline_llm)
: (profile.outline_provider && profile.outline_model : (profile.outline_provider && profile.outline_model
? `${profile.outline_provider} / ${profile.outline_model}` ? `${profile.outline_provider} / ${profile.outline_model}`
: t.podcasts.notConfigured)} : t('podcasts.notConfigured'))}
</p> </p>
</div> </div>
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.transcriptModel} {t('podcasts.transcriptModel')}
</p> </p>
<p className="text-foreground"> <p className="text-foreground">
{profile.transcript_llm {profile.transcript_llm
? (modelNameMap[profile.transcript_llm] ?? profile.transcript_llm) ? (modelNameMap[profile.transcript_llm] ?? profile.transcript_llm)
: (profile.transcript_provider && profile.transcript_model : (profile.transcript_provider && profile.transcript_model
? `${profile.transcript_provider} / ${profile.transcript_model}` ? `${profile.transcript_provider} / ${profile.transcript_model}`
: t.podcasts.notConfigured)} : t('podcasts.notConfigured'))}
</p> </p>
</div> </div>
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.segments} {t('podcasts.segments')}
</p> </p>
<p className="text-foreground">{profile.num_segments}</p> <p className="text-foreground">{profile.num_segments}</p>
</div> </div>
{profile.language ? ( {profile.language ? (
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.language} {t('podcasts.language')}
</p> </p>
<p className="text-foreground">{profile.language}</p> <p className="text-foreground">{profile.language}</p>
</div> </div>
) : null} ) : null}
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.speakerProfile} {t('podcasts.speakerProfile')}
</p> </p>
<div className="flex items-center gap-2 text-foreground"> <div className="flex items-center gap-2 text-foreground">
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
@ -256,7 +256,7 @@ export function EpisodeProfilesPanel({
{profile.default_briefing ? ( {profile.default_briefing ? (
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.defaultBriefingTitle} {t('podcasts.defaultBriefingTitle')}
</p> </p>
<p className="mt-1 whitespace-pre-wrap text-muted-foreground"> <p className="mt-1 whitespace-pre-wrap text-muted-foreground">
{profile.default_briefing} {profile.default_briefing}

View file

@ -11,32 +11,32 @@ import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { GeneratePodcastDialog } from '@/components/podcasts/GeneratePodcastDialog' import { GeneratePodcastDialog } from '@/components/podcasts/GeneratePodcastDialog'
import { useTranslation } from '@/lib/hooks/use-translation' import { useTranslation } from '@/lib/hooks/use-translation'
import { TranslationKeys } from '@/lib/locales' import type { TFunction } from 'i18next'
const getSTATUS_ORDER = (t: TranslationKeys): Array<{ const getSTATUS_ORDER = (t: TFunction): Array<{
key: 'running' | 'completed' | 'failed' | 'pending' key: 'running' | 'completed' | 'failed' | 'pending'
title: string title: string
description?: string description?: string
}> => [ }> => [
{ {
key: 'running', key: 'running',
title: t.podcasts.statusRunningTitle, title: t('podcasts.statusRunningTitle'),
description: t.podcasts.statusRunningDesc, description: t('podcasts.statusRunningDesc'),
}, },
{ {
key: 'pending', key: 'pending',
title: t.podcasts.statusPendingTitle, title: t('podcasts.statusPendingTitle'),
description: t.podcasts.statusPendingDesc, description: t('podcasts.statusPendingDesc'),
}, },
{ {
key: 'completed', key: 'completed',
title: t.podcasts.statusCompletedTitle, title: t('podcasts.statusCompletedTitle'),
description: t.podcasts.statusCompletedDesc, description: t('podcasts.statusCompletedDesc'),
}, },
{ {
key: 'failed', key: 'failed',
title: t.podcasts.statusFailedTitle, title: t('podcasts.statusFailedTitle'),
description: t.podcasts.statusFailedDesc, description: t('podcasts.statusFailedDesc'),
}, },
] ]
@ -84,14 +84,14 @@ export function EpisodesTab() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-xl font-semibold">{t.podcasts.overviewTitle}</h2> <h2 className="text-xl font-semibold">{t('podcasts.overviewTitle')}</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t.podcasts.overviewDesc} {t('podcasts.overviewDesc')}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button onClick={() => setShowGenerateDialog(true)}> <Button onClick={() => setShowGenerateDialog(true)}>
{t.podcasts.generateBtn} {t('podcasts.generateBtn')}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@ -104,25 +104,25 @@ export function EpisodesTab() {
) : ( ) : (
<RefreshCcw className="mr-2 h-4 w-4" /> <RefreshCcw className="mr-2 h-4 w-4" />
)} )}
{t.common.refresh} {t('common.refresh')}
</Button> </Button>
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<SummaryBadge label={t.podcasts.total} value={statusCounts.total} /> <SummaryBadge label={t('podcasts.total')} value={statusCounts.total} />
<SummaryBadge label={t.podcasts.processingLabel} value={statusCounts.running} /> <SummaryBadge label={t('podcasts.processingLabel')} value={statusCounts.running} />
<SummaryBadge label={t.podcasts.completedLabel} value={statusCounts.completed} /> <SummaryBadge label={t('podcasts.completedLabel')} value={statusCounts.completed} />
<SummaryBadge label={t.podcasts.failedLabel} value={statusCounts.failed} /> <SummaryBadge label={t('podcasts.failedLabel')} value={statusCounts.failed} />
<SummaryBadge label={t.podcasts.pendingLabel} value={statusCounts.pending} /> <SummaryBadge label={t('podcasts.pendingLabel')} value={statusCounts.pending} />
</div> </div>
{isError ? ( {isError ? (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertTitle>{t.podcasts.loadErrorTitle}</AlertTitle> <AlertTitle>{t('podcasts.loadErrorTitle')}</AlertTitle>
<AlertDescription> <AlertDescription>
{t.podcasts.loadErrorDesc} {t('podcasts.loadErrorDesc')}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : null} ) : null}
@ -130,14 +130,14 @@ export function EpisodesTab() {
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-3 rounded-lg border border-dashed p-6 text-sm text-muted-foreground"> <div className="flex items-center gap-3 rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
{t.podcasts.loadingEpisodes} {t('podcasts.loadingEpisodes')}
</div> </div>
) : null} ) : null}
{emptyState ? ( {emptyState ? (
<div className="rounded-lg border border-dashed bg-muted/30 p-10 text-center"> <div className="rounded-lg border border-dashed bg-muted/30 p-10 text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t.podcasts.noEpisodesYet} {t('podcasts.noEpisodesYet')}
</p> </p>
</div> </div>
) : null} ) : null}

View file

@ -117,28 +117,28 @@ function ContentSelectionPanel({
// Cache all translation strings at render time to avoid repeated Proxy accesses in loops // Cache all translation strings at render time to avoid repeated Proxy accesses in loops
// This prevents the infinite loop detection from triggering // This prevents the infinite loop detection from triggering
const tr = { const tr = {
content: t.podcasts.content, content: t('podcasts.content'),
contentDesc: t.podcasts.contentDesc, contentDesc: t('podcasts.contentDesc'),
itemsSelected: t.podcasts.itemsSelected, itemsSelected: t('podcasts.itemsSelected'),
tokens: t.podcasts.tokens, tokens: t('podcasts.tokens'),
chars: t.podcasts.chars, chars: t('podcasts.chars'),
loadingNotebooks: t.podcasts.loadingNotebooks, loadingNotebooks: t('podcasts.loadingNotebooks'),
noNotebooksFoundInPodcasts: t.podcasts.noNotebooksFoundInPodcasts, noNotebooksFoundInPodcasts: t('podcasts.noNotebooksFoundInPodcasts'),
sources: t.podcasts.sources, sources: t('podcasts.sources'),
notes: t.podcasts.notes, notes: t('podcasts.notes'),
noContentSelected: t.podcasts.noContentSelected, noContentSelected: t('podcasts.noContentSelected'),
noSources: t.podcasts.noSources, noSources: t('podcasts.noSources'),
untitledSource: t.podcasts.untitledSource, untitledSource: t('podcasts.untitledSource'),
link: t.podcasts.link, link: t('podcasts.link'),
file: t.podcasts.file, file: t('podcasts.file'),
embedded: t.podcasts.embedded, embedded: t('podcasts.embedded'),
notEmbedded: t.podcasts.notEmbedded, notEmbedded: t('podcasts.notEmbedded'),
selectMode: t.podcasts.selectMode, selectMode: t('podcasts.selectMode'),
noNotes: t.podcasts.noNotes, noNotes: t('podcasts.noNotes'),
untitledNote: t.podcasts.untitledNote, untitledNote: t('podcasts.untitledNote'),
commonUpdated: t.common.updated, commonUpdated: t('common.updated'),
summary: t.podcasts.summary, summary: t('podcasts.summary'),
fullContent: t.podcasts.fullContent, fullContent: t('podcasts.fullContent'),
} }
// Pre-compute source modes once to avoid repeated t.podcasts access in loops // Pre-compute source modes once to avoid repeated t.podcasts access in loops
@ -768,11 +768,11 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
const response = await chatApi.buildContext(task.payload) const response = await chatApi.buildContext(task.payload)
const notebookName = notebooks.find((nb) => nb.id === task.notebookId)?.name ?? task.notebookId const notebookName = notebooks.find((nb) => nb.id === task.notebookId)?.name ?? task.notebookId
const contextString = JSON.stringify(response.context, null, 2) const contextString = JSON.stringify(response.context, null, 2)
const snippet = `${t.common.notebookLabel.replace('{name}', notebookName)}\n${contextString}` const snippet = `${t('common.notebookLabel').replace('{name}', notebookName)}\n${contextString}`
parts.push(snippet) parts.push(snippet)
} catch (error) { } catch (error) {
console.error('Failed to build context for notebook', task.notebookId, error) console.error('Failed to build context for notebook', task.notebookId, error)
throw new Error(t.podcasts.buildContextFailed) throw new Error(t('podcasts.buildContextFailed'))
} }
} }
@ -782,8 +782,8 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
if (!selectedEpisodeProfile) { if (!selectedEpisodeProfile) {
toast({ toast({
title: t.podcasts.profileRequired, title: t('podcasts.profileRequired'),
description: t.podcasts.profileRequiredDesc, description: t('podcasts.profileRequiredDesc'),
variant: 'destructive', variant: 'destructive',
}) })
return return
@ -791,8 +791,8 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
if (!episodeName.trim()) { if (!episodeName.trim()) {
toast({ toast({
title: t.podcasts.nameRequired, title: t('podcasts.nameRequired'),
description: t.podcasts.nameRequiredDesc, description: t('podcasts.nameRequiredDesc'),
variant: 'destructive', variant: 'destructive',
}) })
return return
@ -803,8 +803,8 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
const content = await buildContentFromSelections() const content = await buildContentFromSelections()
if (!content.trim()) { if (!content.trim()) {
toast({ toast({
title: t.podcasts.addContext, title: t('podcasts.addContext'),
description: t.podcasts.addContextDesc, description: t('podcasts.addContextDesc'),
variant: 'destructive', variant: 'destructive',
}) })
return return
@ -821,8 +821,8 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
await generatePodcast.mutateAsync(payload) await generatePodcast.mutateAsync(payload)
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.podcasts.podcastTaskStarted, description: t('podcasts.podcastTaskStarted'),
}) })
// Delay closing dialog slightly to ensure refetch completes // Delay closing dialog slightly to ensure refetch completes
@ -833,8 +833,8 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
} catch (error) { } catch (error) {
console.error('Failed to generate podcast', error) console.error('Failed to generate podcast', error)
toast({ toast({
title: t.podcasts.generationFailed, title: t('podcasts.generationFailed'),
description: error instanceof Error ? error.message : t.common.refreshPage, description: error instanceof Error ? error.message : t('common.refreshPage'),
variant: 'destructive', variant: 'destructive',
}) })
} finally { } finally {
@ -863,9 +863,9 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
}}> }}>
<DialogContent className="w-[80vw] max-w-[1080px] max-h-[90vh] overflow-hidden"> <DialogContent className="w-[80vw] max-w-[1080px] max-h-[90vh] overflow-hidden">
<DialogHeader> <DialogHeader>
<DialogTitle>{t.podcasts.generateEpisode}</DialogTitle> <DialogTitle>{t('podcasts.generateEpisode')}</DialogTitle>
<DialogDescription> <DialogDescription>
{t.podcasts.generateEpisodeDesc} {t('podcasts.generateEpisodeDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -891,27 +891,27 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground"> <h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.episodeSettings} {t('podcasts.episodeSettings')}
</h3> </h3>
{episodeProfilesQuery.isLoading ? ( {episodeProfilesQuery.isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> {t.podcasts.loadingProfiles} <Loader2 className="h-4 w-4 animate-spin" /> {t('podcasts.loadingProfiles')}
</div> </div>
) : episodeProfiles.length === 0 ? ( ) : episodeProfiles.length === 0 ? (
<div className="rounded-lg border border-dashed bg-muted/30 p-4 text-sm text-muted-foreground"> <div className="rounded-lg border border-dashed bg-muted/30 p-4 text-sm text-muted-foreground">
{t.podcasts.noProfilesFound} {t('podcasts.noProfilesFound')}
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="episode_profile">{t.podcasts.episodeProfile}</Label> <Label htmlFor="episode_profile">{t('podcasts.episodeProfile')}</Label>
<Select <Select
value={episodeProfileId} value={episodeProfileId}
onValueChange={setEpisodeProfileId} onValueChange={setEpisodeProfileId}
disabled={episodeProfiles.length === 0} disabled={episodeProfiles.length === 0}
> >
<SelectTrigger id="episode_profile"> <SelectTrigger id="episode_profile">
<SelectValue placeholder={t.podcasts.episodeProfilePlaceholder} /> <SelectValue placeholder={t('podcasts.episodeProfilePlaceholder')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{episodeProfiles.map((profile) => ( {episodeProfiles.map((profile) => (
@ -923,30 +923,30 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
</Select> </Select>
{selectedEpisodeProfile && ( {selectedEpisodeProfile && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t.podcasts.usesSpeakerProfile}{' '} {t('podcasts.usesSpeakerProfile')}{' '}
<strong>{selectedEpisodeProfile.speaker_config}</strong> <strong>{selectedEpisodeProfile.speaker_config}</strong>
</p> </p>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="episode_name">{t.podcasts.episodeName}</Label> <Label htmlFor="episode_name">{t('podcasts.episodeName')}</Label>
<Input <Input
id="episode_name" id="episode_name"
name="episode_name" name="episode_name"
value={episodeName} value={episodeName}
onChange={(event) => setEpisodeName(event.target.value)} onChange={(event) => setEpisodeName(event.target.value)}
placeholder={t.podcasts.episodeNamePlaceholder} placeholder={t('podcasts.episodeNamePlaceholder')}
autoComplete="off" autoComplete="off"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="instructions">{t.podcasts.additionalInstructions}</Label> <Label htmlFor="instructions">{t('podcasts.additionalInstructions')}</Label>
<Textarea <Textarea
id="instructions" id="instructions"
name="instructions" name="instructions"
placeholder={t.podcasts.instructionsPlaceholder} placeholder={t('podcasts.instructionsPlaceholder')}
value={instructions} value={instructions}
onChange={(event) => setInstructions(event.target.value)} onChange={(event) => setInstructions(event.target.value)}
className="min-h-[100px] text-xs" className="min-h-[100px] text-xs"
@ -964,7 +964,7 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
className="w-full" className="w-full"
> >
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isSubmitting ? t.podcasts.generating : t.podcasts.generate} {isSubmitting ? t('podcasts.generating') : t('podcasts.generate')}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@ -972,7 +972,7 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
disabled={isSubmitting} disabled={isSubmitting}
className="w-full" className="w-full"
> >
{t.common.cancel} {t('common.cancel')}
</Button> </Button>
</div> </div>
</div> </div>

View file

@ -74,17 +74,17 @@ export function SpeakerProfilesPanel({
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-lg font-semibold">{t.podcasts.speakerProfilesTitle}</h2> <h2 className="text-lg font-semibold">{t('podcasts.speakerProfilesTitle')}</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t.podcasts.speakerProfilesDesc} {t('podcasts.speakerProfilesDesc')}
</p> </p>
</div> </div>
<Button onClick={() => setCreateOpen(true)}>{t.podcasts.createSpeaker}</Button> <Button onClick={() => setCreateOpen(true)}>{t('podcasts.createSpeaker')}</Button>
</div> </div>
{sortedProfiles.length === 0 ? ( {sortedProfiles.length === 0 ? (
<div className="rounded-lg border border-dashed bg-muted/30 p-8 text-center text-sm text-muted-foreground"> <div className="rounded-lg border border-dashed bg-muted/30 p-8 text-center text-sm text-muted-foreground">
{t.podcasts.noSpeakerProfiles} {t('podcasts.noSpeakerProfiles')}
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
@ -105,12 +105,12 @@ export function SpeakerProfilesPanel({
{unconfigured ? ( {unconfigured ? (
<Badge variant="outline" className="text-amber-600 border-amber-300 text-xs"> <Badge variant="outline" className="text-amber-600 border-amber-300 text-xs">
<AlertTriangle className="h-3 w-3 mr-1" /> <AlertTriangle className="h-3 w-3 mr-1" />
{t.podcasts.setupRequired} {t('podcasts.setupRequired')}
</Badge> </Badge>
) : null} ) : null}
</div> </div>
<CardDescription className="text-sm text-muted-foreground"> <CardDescription className="text-sm text-muted-foreground">
{profile.description || t.podcasts.noDescription} {profile.description || t('podcasts.noDescription')}
</CardDescription> </CardDescription>
</div> </div>
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
@ -118,7 +118,7 @@ export function SpeakerProfilesPanel({
? (modelNameMap[profile.voice_model] ?? profile.voice_model) ? (modelNameMap[profile.voice_model] ?? profile.voice_model)
: (profile.tts_provider : (profile.tts_provider
? `${profile.tts_provider} / ${profile.tts_model}` ? `${profile.tts_provider} / ${profile.tts_model}`
: t.podcasts.notConfigured)} : t('podcasts.notConfigured'))}
</Badge> </Badge>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@ -127,8 +127,8 @@ export function SpeakerProfilesPanel({
className="text-xs" className="text-xs"
> >
{usageCount > 0 {usageCount > 0
? (usageCount === 1 ? t.podcasts.usedByCount_one : t.podcasts.usedByCount_other.replace('{count}', usageCount.toString())) ? (usageCount === 1 ? t('podcasts.usedByCount_one') : t('podcasts.usedByCount_other').replace('{count}', usageCount.toString()))
: t.podcasts.unused} : t('podcasts.unused')}
</Badge> </Badge>
</div> </div>
</CardHeader> </CardHeader>
@ -149,7 +149,7 @@ export function SpeakerProfilesPanel({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{t.podcasts.voiceId}: {speaker.voice_id} {t('podcasts.voiceId')}: {speaker.voice_id}
</span> </span>
{speaker.voice_model ? ( {speaker.voice_model ? (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
@ -159,10 +159,10 @@ export function SpeakerProfilesPanel({
</div> </div>
</div> </div>
<p className="mt-2 text-xs text-muted-foreground whitespace-pre-wrap"> <p className="mt-2 text-xs text-muted-foreground whitespace-pre-wrap">
<span className="font-semibold">{t.podcasts.backstory}:</span> {speaker.backstory} <span className="font-semibold">{t('podcasts.backstory')}:</span> {speaker.backstory}
</p> </p>
<p className="mt-2 text-xs text-muted-foreground whitespace-pre-wrap"> <p className="mt-2 text-xs text-muted-foreground whitespace-pre-wrap">
<span className="font-semibold">{t.podcasts.personality}:</span> {speaker.personality} <span className="font-semibold">{t('podcasts.personality')}:</span> {speaker.personality}
</p> </p>
</div> </div>
))} ))}
@ -174,7 +174,7 @@ export function SpeakerProfilesPanel({
size="sm" size="sm"
onClick={() => setEditProfile(profile)} onClick={() => setEditProfile(profile)}
> >
<Edit3 className="mr-2 h-4 w-4" /> {t.podcasts.edit} <Edit3 className="mr-2 h-4 w-4" /> {t('podcasts.edit')}
</Button> </Button>
<AlertDialog> <AlertDialog>
<DropdownMenu> <DropdownMenu>
@ -198,7 +198,7 @@ export function SpeakerProfilesPanel({
disabled={duplicateProfile.isPending} disabled={duplicateProfile.isPending}
> >
<Copy className="h-4 w-4 mr-2" /> <Copy className="h-4 w-4 mr-2" />
{t.podcasts.duplicate} {t('podcasts.duplicate')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
@ -207,30 +207,30 @@ export function SpeakerProfilesPanel({
disabled={deleteDisabled} disabled={deleteDisabled}
> >
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
{t.podcasts.delete} {t('podcasts.delete')}
</DropdownMenuItem> </DropdownMenuItem>
</AlertDialogTrigger> </AlertDialogTrigger>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{t.podcasts.deleteSpeakerProfileTitle}</AlertDialogTitle> <AlertDialogTitle>{t('podcasts.deleteSpeakerProfileTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{t.podcasts.deleteSpeakerProfileDesc.replace('{name}', profile.name)} {t('podcasts.deleteSpeakerProfileDesc').replace('{name}', profile.name)}
</AlertDialogDescription> </AlertDialogDescription>
{deleteDisabled ? ( {deleteDisabled ? (
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
{t.podcasts.deleteSpeakerDisabledHint} {t('podcasts.deleteSpeakerDisabledHint')}
</p> </p>
) : null} ) : null}
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>{t.common.cancel}</AlertDialogCancel> <AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={() => deleteProfile.mutate(profile.id)} onClick={() => deleteProfile.mutate(profile.id)}
disabled={deleteDisabled || deleteProfile.isPending} disabled={deleteDisabled || deleteProfile.isPending}
> >
{deleteProfile.isPending ? t.podcasts.deleting : t.podcasts.delete} {deleteProfile.isPending ? t('podcasts.deleting') : t('podcasts.delete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View file

@ -30,9 +30,9 @@ export function TemplatesTab() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-xl font-semibold">{t.podcasts.templatesWorkspaceTitle}</h2> <h2 className="text-xl font-semibold">{t('podcasts.templatesWorkspaceTitle')}</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t.podcasts.templatesWorkspaceDesc} {t('podcasts.templatesWorkspaceDesc')}
</p> </p>
</div> </div>
@ -44,42 +44,42 @@ export function TemplatesTab() {
<AccordionTrigger className="gap-2 py-4 text-left text-sm font-semibold"> <AccordionTrigger className="gap-2 py-4 text-left text-sm font-semibold">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Lightbulb className="h-4 w-4 text-primary" /> <Lightbulb className="h-4 w-4 text-primary" />
{t.podcasts.howTemplatesPowerTitle} {t('podcasts.howTemplatesPowerTitle')}
</div> </div>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="text-sm text-muted-foreground"> <AccordionContent className="text-sm text-muted-foreground">
<div className="space-y-4"> <div className="space-y-4">
<p className="text-muted-foreground/90"> <p className="text-muted-foreground/90">
{t.podcasts.howTemplatesPowerDesc} {t('podcasts.howTemplatesPowerDesc')}
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-medium text-foreground">{t.podcasts.episodeProfilesSetFormat}</h4> <h4 className="font-medium text-foreground">{t('podcasts.episodeProfilesSetFormat')}</h4>
<ul className="list-disc space-y-1 pl-5"> <ul className="list-disc space-y-1 pl-5">
<li>{t.podcasts.episodeProfilesList1}</li> <li>{t('podcasts.episodeProfilesList1')}</li>
<li>{t.podcasts.episodeProfilesList2}</li> <li>{t('podcasts.episodeProfilesList2')}</li>
<li>{t.podcasts.episodeProfilesList3}</li> <li>{t('podcasts.episodeProfilesList3')}</li>
</ul> </ul>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-medium text-foreground">{t.podcasts.speakerProfilesBringVoices}</h4> <h4 className="font-medium text-foreground">{t('podcasts.speakerProfilesBringVoices')}</h4>
<ul className="list-disc space-y-1 pl-5"> <ul className="list-disc space-y-1 pl-5">
<li>{t.podcasts.speakerProfilesList1}</li> <li>{t('podcasts.speakerProfilesList1')}</li>
<li>{t.podcasts.speakerProfilesList2}</li> <li>{t('podcasts.speakerProfilesList2')}</li>
<li>{t.podcasts.speakerProfilesList3}</li> <li>{t('podcasts.speakerProfilesList3')}</li>
</ul> </ul>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-medium text-foreground">{t.podcasts.recommendedWorkflow}</h4> <h4 className="font-medium text-foreground">{t('podcasts.recommendedWorkflow')}</h4>
<ol className="list-decimal space-y-1 pl-5"> <ol className="list-decimal space-y-1 pl-5">
<li>{t.podcasts.workflowStep1}</li> <li>{t('podcasts.workflowStep1')}</li>
<li>{t.podcasts.workflowStep2}</li> <li>{t('podcasts.workflowStep2')}</li>
<li>{t.podcasts.workflowStep3}</li> <li>{t('podcasts.workflowStep3')}</li>
</ol> </ol>
<p className="text-xs text-muted-foreground/80"> <p className="text-xs text-muted-foreground/80">
{t.podcasts.workflowHint} {t('podcasts.workflowHint')}
</p> </p>
</div> </div>
</div> </div>
@ -90,9 +90,9 @@ export function TemplatesTab() {
{hasError ? ( {hasError ? (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertTitle>{t.podcasts.failedToLoadTemplates}</AlertTitle> <AlertTitle>{t('podcasts.failedToLoadTemplates')}</AlertTitle>
<AlertDescription> <AlertDescription>
{t.podcasts.failedToLoadTemplatesDesc} {t('podcasts.failedToLoadTemplatesDesc')}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : null} ) : null}
@ -100,7 +100,7 @@ export function TemplatesTab() {
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-3 rounded-lg border border-dashed p-6 text-sm text-muted-foreground"> <div className="flex items-center gap-3 rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
{t.podcasts.loadingTemplates} {t('podcasts.loadingTemplates')}
</div> </div>
) : ( ) : (
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">

View file

@ -33,20 +33,20 @@ import {
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { ModelSelector } from '@/components/common/ModelSelector' import { ModelSelector } from '@/components/common/ModelSelector'
import { TranslationKeys } from '@/lib/locales' import type { TFunction } from 'i18next'
const episodeProfileSchema = (t: TranslationKeys) => z.object({ const episodeProfileSchema = (t: TFunction) => z.object({
name: z.string().min(1, t.podcasts.nameRequired || 'Name is required'), name: z.string().min(1, t('podcasts.nameRequired') || 'Name is required'),
description: z.string().optional(), description: z.string().optional(),
speaker_config: z.string().min(1, t.podcasts.profileRequired || 'Speaker profile is required'), speaker_config: z.string().min(1, t('podcasts.profileRequired') || 'Speaker profile is required'),
outline_llm: z.string().min(1, t.podcasts.outlineModelRequired || 'Outline model is required'), outline_llm: z.string().min(1, t('podcasts.outlineModelRequired') || 'Outline model is required'),
transcript_llm: z.string().min(1, t.podcasts.transcriptModelRequired || 'Transcript model is required'), transcript_llm: z.string().min(1, t('podcasts.transcriptModelRequired') || 'Transcript model is required'),
language: z.string().nullable().optional(), language: z.string().nullable().optional(),
default_briefing: z.string().min(1, t.podcasts.defaultBriefingRequired || 'Default briefing is required'), default_briefing: z.string().min(1, t('podcasts.defaultBriefingRequired') || 'Default briefing is required'),
num_segments: z.number() num_segments: z.number()
.int(t.podcasts.segmentsInteger || 'Must be an integer') .int(t('podcasts.segmentsInteger') || 'Must be an integer')
.min(3, t.podcasts.segmentsMin || 'At least 3 segments') .min(3, t('podcasts.segmentsMin') || 'At least 3 segments')
.max(20, t.podcasts.segmentsMax || 'Maximum 20 segments'), .max(20, t('podcasts.segmentsMax') || 'Maximum 20 segments'),
}) })
export type EpisodeProfileFormValues = z.infer<ReturnType<typeof episodeProfileSchema>> export type EpisodeProfileFormValues = z.infer<ReturnType<typeof episodeProfileSchema>>
@ -145,18 +145,18 @@ export function EpisodeProfileFormDialog({
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl"> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{isEdit ? t.podcasts.editEpisodeProfile : t.podcasts.createEpisodeProfile} {isEdit ? t('podcasts.editEpisodeProfile') : t('podcasts.createEpisodeProfile')}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{t.podcasts.episodeProfileFormDesc} {t('podcasts.episodeProfileFormDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{speakerProfiles.length === 0 ? ( {speakerProfiles.length === 0 ? (
<Alert className="bg-amber-50 text-amber-900 border-amber-200"> <Alert className="bg-amber-50 text-amber-900 border-amber-200">
<AlertTitle>{t.podcasts.noSpeakerProfilesAvailable}</AlertTitle> <AlertTitle>{t('podcasts.noSpeakerProfilesAvailable')}</AlertTitle>
<AlertDescription> <AlertDescription>
{t.podcasts.noSpeakerProfilesDesc} {t('podcasts.noSpeakerProfilesDesc')}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : null} ) : null}
@ -164,15 +164,15 @@ export function EpisodeProfileFormDialog({
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 pt-2"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6 pt-2">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">{t.podcasts.profileName} *</Label> <Label htmlFor="name">{t('podcasts.profileName')} *</Label>
<Input id="name" placeholder={t.podcasts.profileNamePlaceholder} {...register('name')} /> <Input id="name" placeholder={t('podcasts.profileNamePlaceholder')} {...register('name')} />
{errors.name ? ( {errors.name ? (
<p className="text-xs text-red-600">{errors.name.message}</p> <p className="text-xs text-red-600">{errors.name.message}</p>
) : null} ) : null}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="num_segments">{t.podcasts.segments} *</Label> <Label htmlFor="num_segments">{t('podcasts.segments')} *</Label>
<Input <Input
id="num_segments" id="num_segments"
type="number" type="number"
@ -187,11 +187,11 @@ export function EpisodeProfileFormDialog({
</div> </div>
<div className="md:col-span-2 space-y-2"> <div className="md:col-span-2 space-y-2">
<Label htmlFor="description">{t.common.description}</Label> <Label htmlFor="description">{t('common.description')}</Label>
<Textarea <Textarea
id="description" id="description"
rows={3} rows={3}
placeholder={t.podcasts.descriptionPlaceholder} placeholder={t('podcasts.descriptionPlaceholder')}
{...register('description')} {...register('description')}
autoComplete="off" autoComplete="off"
/> />
@ -201,7 +201,7 @@ export function EpisodeProfileFormDialog({
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground"> <h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.speakerConfig} {t('podcasts.speakerConfig')}
</h3> </h3>
<Separator className="mt-2" /> <Separator className="mt-2" />
</div> </div>
@ -210,12 +210,12 @@ export function EpisodeProfileFormDialog({
name="speaker_config" name="speaker_config"
render={({ field }) => ( render={({ field }) => (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="speaker_config">{t.podcasts.speakerProfile} *</Label> <Label htmlFor="speaker_config">{t('podcasts.speakerProfile')} *</Label>
<Select value={field.value} onValueChange={field.onChange}> <Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="speaker_config"> <SelectTrigger id="speaker_config">
<SelectValue placeholder={t.podcasts.selectSpeakerProfile} /> <SelectValue placeholder={t('podcasts.selectSpeakerProfile')} />
</SelectTrigger> </SelectTrigger>
<SelectContent title={t.podcasts.speakerProfile}> <SelectContent title={t('podcasts.speakerProfile')}>
{speakerProfiles.map((profile) => ( {speakerProfiles.map((profile) => (
<SelectItem key={profile.id} value={profile.name}> <SelectItem key={profile.id} value={profile.name}>
{profile.name} {profile.name}
@ -236,7 +236,7 @@ export function EpisodeProfileFormDialog({
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground"> <h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.outlineGeneration} {t('podcasts.outlineGeneration')}
</h3> </h3>
<Separator className="mt-2" /> <Separator className="mt-2" />
</div> </div>
@ -246,11 +246,11 @@ export function EpisodeProfileFormDialog({
render={({ field }) => ( render={({ field }) => (
<div> <div>
<ModelSelector <ModelSelector
label={`${t.podcasts.outlineModel} *`} label={`${t('podcasts.outlineModel')} *`}
modelType="language" modelType="language"
value={field.value} value={field.value}
onChange={field.onChange} onChange={field.onChange}
placeholder={t.podcasts.selectOutlineModel} placeholder={t('podcasts.selectOutlineModel')}
/> />
{errors.outline_llm ? ( {errors.outline_llm ? (
<p className="text-xs text-red-600 mt-1"> <p className="text-xs text-red-600 mt-1">
@ -265,7 +265,7 @@ export function EpisodeProfileFormDialog({
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground"> <h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.transcriptGeneration} {t('podcasts.transcriptGeneration')}
</h3> </h3>
<Separator className="mt-2" /> <Separator className="mt-2" />
</div> </div>
@ -275,11 +275,11 @@ export function EpisodeProfileFormDialog({
render={({ field }) => ( render={({ field }) => (
<div> <div>
<ModelSelector <ModelSelector
label={`${t.podcasts.transcriptModel} *`} label={`${t('podcasts.transcriptModel')} *`}
modelType="language" modelType="language"
value={field.value} value={field.value}
onChange={field.onChange} onChange={field.onChange}
placeholder={t.podcasts.selectTranscriptModel} placeholder={t('podcasts.selectTranscriptModel')}
/> />
{errors.transcript_llm ? ( {errors.transcript_llm ? (
<p className="text-xs text-red-600 mt-1"> <p className="text-xs text-red-600 mt-1">
@ -294,7 +294,7 @@ export function EpisodeProfileFormDialog({
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground"> <h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.podcastLanguage} {t('podcasts.podcastLanguage')}
</h3> </h3>
<Separator className="mt-2" /> <Separator className="mt-2" />
</div> </div>
@ -303,15 +303,15 @@ export function EpisodeProfileFormDialog({
name="language" name="language"
render={({ field }) => ( render={({ field }) => (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="language">{t.podcasts.language}</Label> <Label htmlFor="language">{t('podcasts.language')}</Label>
<Select <Select
value={field.value ?? ''} value={field.value ?? ''}
onValueChange={(v) => field.onChange(v || null)} onValueChange={(v) => field.onChange(v || null)}
> >
<SelectTrigger id="language"> <SelectTrigger id="language">
<SelectValue placeholder={t.podcasts.languagePlaceholder} /> <SelectValue placeholder={t('podcasts.languagePlaceholder')} />
</SelectTrigger> </SelectTrigger>
<SelectContent title={t.podcasts.language}> <SelectContent title={t('podcasts.language')}>
{languages.map((lang) => ( {languages.map((lang) => (
<SelectItem key={lang.code} value={lang.code}> <SelectItem key={lang.code} value={lang.code}>
{lang.name} ({lang.code}) {lang.name} ({lang.code})
@ -325,11 +325,11 @@ export function EpisodeProfileFormDialog({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="default_briefing">{t.podcasts.defaultBriefingTitle} *</Label> <Label htmlFor="default_briefing">{t('podcasts.defaultBriefingTitle')} *</Label>
<Textarea <Textarea
id="default_briefing" id="default_briefing"
rows={6} rows={6}
placeholder={t.podcasts.defaultBriefingPlaceholder} placeholder={t('podcasts.defaultBriefingPlaceholder')}
{...register('default_briefing')} {...register('default_briefing')}
/> />
{errors.default_briefing ? ( {errors.default_briefing ? (
@ -345,14 +345,14 @@ export function EpisodeProfileFormDialog({
variant="outline" variant="outline"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
{t.common.cancel} {t('common.cancel')}
</Button> </Button>
<Button type="submit" disabled={disableSubmit}> <Button type="submit" disabled={disableSubmit}>
{isSubmitting {isSubmitting
? t.common.saving ? t('common.saving')
: isEdit : isEdit
? t.common.saveChanges ? t('common.saveChanges')
: t.podcasts.createProfile} : t('podcasts.createProfile')}
</Button> </Button>
</div> </div>
</form> </form>

View file

@ -26,25 +26,25 @@ import { Textarea } from '@/components/ui/textarea'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { ModelSelector } from '@/components/common/ModelSelector' import { ModelSelector } from '@/components/common/ModelSelector'
import { TranslationKeys } from '@/lib/locales' import type { TFunction } from 'i18next'
import { useTranslation } from '@/lib/hooks/use-translation' import { useTranslation } from '@/lib/hooks/use-translation'
const speakerConfigSchema = (t: TranslationKeys) => z.object({ const speakerConfigSchema = (t: TFunction) => z.object({
name: z.string().min(1, t.common.nameRequired || 'Name is required'), name: z.string().min(1, t('common.nameRequired') || 'Name is required'),
voice_id: z.string().min(1, t.podcasts.voiceIdRequired || 'Voice ID is required'), voice_id: z.string().min(1, t('podcasts.voiceIdRequired') || 'Voice ID is required'),
backstory: z.string().min(1, t.podcasts.backstoryRequired || 'Backstory is required'), backstory: z.string().min(1, t('podcasts.backstoryRequired') || 'Backstory is required'),
personality: z.string().min(1, t.podcasts.personalityRequired || 'Personality is required'), personality: z.string().min(1, t('podcasts.personalityRequired') || 'Personality is required'),
voice_model: z.string().nullable().optional(), voice_model: z.string().nullable().optional(),
}) })
const speakerProfileSchema = (t: TranslationKeys) => z.object({ const speakerProfileSchema = (t: TFunction) => z.object({
name: z.string().min(1, t.common.nameRequired || 'Name is required'), name: z.string().min(1, t('common.nameRequired') || 'Name is required'),
description: z.string().optional(), description: z.string().optional(),
voice_model: z.string().min(1, t.podcasts.voiceModelRequired || 'Voice model is required'), voice_model: z.string().min(1, t('podcasts.voiceModelRequired') || 'Voice model is required'),
speakers: z speakers: z
.array(speakerConfigSchema(t)) .array(speakerConfigSchema(t))
.min(1, t.podcasts.speakerCountMin || 'At least one speaker is required') .min(1, t('podcasts.speakerCountMin') || 'At least one speaker is required')
.max(4, t.podcasts.speakerCountMax || 'You can configure up to 4 speakers'), .max(4, t('podcasts.speakerCountMax') || 'You can configure up to 4 speakers'),
}) })
export type SpeakerProfileFormValues = z.infer<ReturnType<typeof speakerProfileSchema>> export type SpeakerProfileFormValues = z.infer<ReturnType<typeof speakerProfileSchema>>
@ -157,29 +157,29 @@ export function SpeakerProfileFormDialog({
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl"> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{isEdit ? t.podcasts.editSpeakerProfile : t.podcasts.createSpeakerProfile} {isEdit ? t('podcasts.editSpeakerProfile') : t('podcasts.createSpeakerProfile')}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{t.podcasts.speakerProfileFormDesc} {t('podcasts.speakerProfileFormDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 pt-2"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6 pt-2">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">{t.podcasts.profileName} *</Label> <Label htmlFor="name">{t('podcasts.profileName')} *</Label>
<Input id="name" placeholder={t.podcasts.profileNamePlaceholder} {...register('name')} /> <Input id="name" placeholder={t('podcasts.profileNamePlaceholder')} {...register('name')} />
{errors.name ? ( {errors.name ? (
<p className="text-xs text-red-600">{errors.name.message}</p> <p className="text-xs text-red-600">{errors.name.message}</p>
) : null} ) : null}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">{t.common.description}</Label> <Label htmlFor="description">{t('common.description')}</Label>
<Textarea <Textarea
id="description" id="description"
rows={3} rows={3}
placeholder={t.podcasts.descriptionPlaceholder} placeholder={t('podcasts.descriptionPlaceholder')}
{...register('description')} {...register('description')}
/> />
</div> </div>
@ -188,7 +188,7 @@ export function SpeakerProfileFormDialog({
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground"> <h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.voiceModel} {t('podcasts.voiceModel')}
</h3> </h3>
<Separator className="mt-2" /> <Separator className="mt-2" />
</div> </div>
@ -198,11 +198,11 @@ export function SpeakerProfileFormDialog({
render={({ field }) => ( render={({ field }) => (
<div> <div>
<ModelSelector <ModelSelector
label={`${t.podcasts.voiceModel} *`} label={`${t('podcasts.voiceModel')} *`}
modelType="text_to_speech" modelType="text_to_speech"
value={field.value} value={field.value}
onChange={field.onChange} onChange={field.onChange}
placeholder={t.podcasts.selectVoiceModel} placeholder={t('podcasts.selectVoiceModel')}
/> />
{errors.voice_model ? ( {errors.voice_model ? (
<p className="text-xs text-red-600 mt-1"> <p className="text-xs text-red-600 mt-1">
@ -218,10 +218,10 @@ export function SpeakerProfileFormDialog({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground"> <h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t.podcasts.speakers} {t('podcasts.speakers')}
</h3> </h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t.podcasts.speakersDesc} {t('podcasts.speakersDesc')}
</p> </p>
</div> </div>
<Button <Button
@ -231,7 +231,7 @@ export function SpeakerProfileFormDialog({
onClick={() => append({ ...EMPTY_SPEAKER })} onClick={() => append({ ...EMPTY_SPEAKER })}
disabled={fields.length >= 4} disabled={fields.length >= 4}
> >
<Plus className="mr-2 h-4 w-4" /> {t.podcasts.addSpeaker} <Plus className="mr-2 h-4 w-4" /> {t('podcasts.addSpeaker')}
</Button> </Button>
</div> </div>
<Separator /> <Separator />
@ -240,7 +240,7 @@ export function SpeakerProfileFormDialog({
<div key={field.id} className="rounded-lg border p-4 space-y-4"> <div key={field.id} className="rounded-lg border p-4 space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm font-semibold"> <p className="text-sm font-semibold">
{t.podcasts.speakerNumber.replace('{number}', (index + 1).toString())} {t('podcasts.speakerNumber').replace('{number}', (index + 1).toString())}
</p> </p>
<Button <Button
type="button" type="button"
@ -250,16 +250,16 @@ export function SpeakerProfileFormDialog({
disabled={fields.length <= 1} disabled={fields.length <= 1}
className="text-destructive" className="text-destructive"
> >
<Trash2 className="mr-2 h-4 w-4" /> {t.common.remove} <Trash2 className="mr-2 h-4 w-4" /> {t('common.remove')}
</Button> </Button>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`speaker-name-${index}`}>{t.common.name} *</Label> <Label htmlFor={`speaker-name-${index}`}>{t('common.name')} *</Label>
<Input <Input
id={`speaker-name-${index}`} id={`speaker-name-${index}`}
{...register(`speakers.${index}.name` as const)} {...register(`speakers.${index}.name` as const)}
placeholder={t.podcasts.hostPlaceholder.replace('{number}', (index + 1).toString())} placeholder={t('podcasts.hostPlaceholder').replace('{number}', (index + 1).toString())}
autoComplete="off" autoComplete="off"
/> />
{errors.speakers?.[index]?.name ? ( {errors.speakers?.[index]?.name ? (
@ -269,7 +269,7 @@ export function SpeakerProfileFormDialog({
) : null} ) : null}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`speaker-voice-${index}`}>{t.podcasts.voiceId} *</Label> <Label htmlFor={`speaker-voice-${index}`}>{t('podcasts.voiceId')} *</Label>
<Input <Input
id={`speaker-voice-${index}`} id={`speaker-voice-${index}`}
{...register(`speakers.${index}.voice_id` as const)} {...register(`speakers.${index}.voice_id` as const)}
@ -284,11 +284,11 @@ export function SpeakerProfileFormDialog({
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`speaker-backstory-${index}`}>{t.podcasts.backstory} *</Label> <Label htmlFor={`speaker-backstory-${index}`}>{t('podcasts.backstory')} *</Label>
<Textarea <Textarea
id={`speaker-backstory-${index}`} id={`speaker-backstory-${index}`}
rows={3} rows={3}
placeholder={t.podcasts.backstoryPlaceholder} placeholder={t('podcasts.backstoryPlaceholder')}
{...register(`speakers.${index}.backstory` as const)} {...register(`speakers.${index}.backstory` as const)}
autoComplete="off" autoComplete="off"
/> />
@ -299,11 +299,11 @@ export function SpeakerProfileFormDialog({
) : null} ) : null}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`speaker-personality-${index}`}>{t.podcasts.personality} *</Label> <Label htmlFor={`speaker-personality-${index}`}>{t('podcasts.personality')} *</Label>
<Textarea <Textarea
id={`speaker-personality-${index}`} id={`speaker-personality-${index}`}
rows={3} rows={3}
placeholder={t.podcasts.personalityPlaceholder} placeholder={t('podcasts.personalityPlaceholder')}
{...register(`speakers.${index}.personality` as const)} {...register(`speakers.${index}.personality` as const)}
autoComplete="off" autoComplete="off"
/> />
@ -319,11 +319,11 @@ export function SpeakerProfileFormDialog({
render={({ field: vmField }) => ( render={({ field: vmField }) => (
<div> <div>
<ModelSelector <ModelSelector
label={t.podcasts.perSpeakerTtsOverride} label={t('podcasts.perSpeakerTtsOverride')}
modelType="text_to_speech" modelType="text_to_speech"
value={vmField.value ?? ''} value={vmField.value ?? ''}
onChange={(v) => vmField.onChange(v || null)} onChange={(v) => vmField.onChange(v || null)}
placeholder={t.podcasts.useProfileDefault} placeholder={t('podcasts.useProfileDefault')}
/> />
</div> </div>
)} )}
@ -342,14 +342,14 @@ export function SpeakerProfileFormDialog({
variant="outline" variant="outline"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
{t.common.cancel} {t('common.cancel')}
</Button> </Button>
<Button type="submit" disabled={disableSubmit}> <Button type="submit" disabled={disableSubmit}>
{isSubmitting {isSubmitting
? t.common.saving ? t('common.saving')
: isEdit : isEdit
? t.common.saveChanges ? t('common.saveChanges')
: t.podcasts.createProfile} : t('podcasts.createProfile')}
</Button> </Button>
</div> </div>
</form> </form>

View file

@ -59,44 +59,44 @@ export function AdvancedModelsDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]"> <DialogContent className="sm:max-w-[500px]">
<DialogHeader> <DialogHeader>
<DialogTitle>{t.searchPage.advancedModelTitle}</DialogTitle> <DialogTitle>{t('searchPage.advancedModelTitle')}</DialogTitle>
<DialogDescription> <DialogDescription>
{t.searchPage.advancedModelDesc} {t('searchPage.advancedModelDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<ModelSelector <ModelSelector
label={t.searchPage.strategyModel} label={t('searchPage.strategyModel')}
modelType="language" modelType="language"
value={strategyModel} value={strategyModel}
onChange={setStrategyModel} onChange={setStrategyModel}
placeholder={t.searchPage.selectStrategyPlaceholder} placeholder={t('searchPage.selectStrategyPlaceholder')}
/> />
<ModelSelector <ModelSelector
label={t.searchPage.answerModel} label={t('searchPage.answerModel')}
modelType="language" modelType="language"
value={answerModel} value={answerModel}
onChange={setAnswerModel} onChange={setAnswerModel}
placeholder={t.searchPage.selectAnswerPlaceholder} placeholder={t('searchPage.selectAnswerPlaceholder')}
/> />
<ModelSelector <ModelSelector
label={t.searchPage.finalAnswerModel} label={t('searchPage.finalAnswerModel')}
modelType="language" modelType="language"
value={finalAnswerModel} value={finalAnswerModel}
onChange={setFinalAnswerModel} onChange={setFinalAnswerModel}
placeholder={t.searchPage.selectFinalPlaceholder} placeholder={t('searchPage.selectFinalPlaceholder')}
/> />
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
{t.common.cancel} {t('common.cancel')}
</Button> </Button>
<Button onClick={handleSave}> <Button onClick={handleSave}>
{t.searchPage.saveChanges} {t('searchPage.saveChanges')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View file

@ -45,7 +45,7 @@ export function SaveToNotebooksDialog({
const handleSave = async () => { const handleSave = async () => {
if (selectedNotebooks.length === 0) { if (selectedNotebooks.length === 0) {
toast.error(t.searchPage.selectNotebook) toast.error(t('searchPage.selectNotebook'))
return return
} }
@ -60,11 +60,11 @@ export function SaveToNotebooksDialog({
}) })
} }
toast.success(t.searchPage.saveSuccess) toast.success(t('searchPage.saveSuccess'))
setSelectedNotebooks([]) setSelectedNotebooks([])
onOpenChange(false) onOpenChange(false)
} catch { } catch {
toast.error(t.searchPage.saveError) toast.error(t('searchPage.saveError'))
} }
} }
@ -78,9 +78,9 @@ export function SaveToNotebooksDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]"> <DialogContent className="sm:max-w-[500px]">
<DialogHeader> <DialogHeader>
<DialogTitle>{t.searchPage.saveToNotebooks}</DialogTitle> <DialogTitle>{t('searchPage.saveToNotebooks')}</DialogTitle>
<DialogDescription> <DialogDescription>
{t.searchPage.selectNotebook} {t('searchPage.selectNotebook')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -94,14 +94,14 @@ export function SaveToNotebooksDialog({
items={notebookItems} items={notebookItems}
selectedIds={selectedNotebooks} selectedIds={selectedNotebooks}
onToggle={handleToggle} onToggle={handleToggle}
emptyMessage={t.sources.noNotebooksFound} emptyMessage={t('sources.noNotebooksFound')}
/> />
)} )}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
{t.common.cancel} {t('common.cancel')}
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
@ -110,10 +110,10 @@ export function SaveToNotebooksDialog({
{createNote.isPending ? ( {createNote.isPending ? (
<> <>
<LoadingSpinner size="sm" className="mr-2" /> <LoadingSpinner size="sm" className="mr-2" />
{t.searchPage.saving} {t('searchPage.saving')}
</> </>
) : ( ) : (
t.searchPage.saveToNotebook t('searchPage.saveToNotebook')
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>

View file

@ -46,7 +46,7 @@ export function StreamingResponse({
// This try-catch is here for future enhancements or unexpected errors. // This try-catch is here for future enhancements or unexpected errors.
} catch { } catch {
const typeLabel = type === 'source_insight' ? 'insight' : type const typeLabel = type === 'source_insight' ? 'insight' : type
toast.error(t.common.itemNotFound.replace('{type}', typeLabel)) toast.error(t('common.itemNotFound').replace('{type}', typeLabel))
} }
} }
@ -58,7 +58,7 @@ export function StreamingResponse({
<div <div
className="space-y-4 mt-6 max-h-[60vh] overflow-y-auto pr-2" className="space-y-4 mt-6 max-h-[60vh] overflow-y-auto pr-2"
role="region" role="region"
aria-label={t.common.accessibility.askResponse} aria-label={t('common.accessibility.askResponse')}
aria-live="polite" aria-live="polite"
aria-busy={isStreaming} aria-busy={isStreaming}
> >
@ -70,7 +70,7 @@ export function StreamingResponse({
<CollapsibleTrigger className="flex items-center justify-between w-full hover:opacity-80"> <CollapsibleTrigger className="flex items-center justify-between w-full hover:opacity-80">
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" /> <Sparkles className="h-4 w-4 text-primary" />
{t.common.strategy} {t('common.strategy')}
</CardTitle> </CardTitle>
<ChevronDown className={`h-4 w-4 transition-transform ${strategyOpen ? 'rotate-180' : ''}`} /> <ChevronDown className={`h-4 w-4 transition-transform ${strategyOpen ? 'rotate-180' : ''}`} />
</CollapsibleTrigger> </CollapsibleTrigger>
@ -78,12 +78,12 @@ export function StreamingResponse({
<CollapsibleContent> <CollapsibleContent>
<CardContent className="space-y-3 pt-0"> <CardContent className="space-y-3 pt-0">
<div> <div>
<p className="text-sm text-muted-foreground mb-2">{t.common.reasoning}:</p> <p className="text-sm text-muted-foreground mb-2">{t('common.reasoning')}:</p>
<p className="text-sm">{strategy.reasoning}</p> <p className="text-sm">{strategy.reasoning}</p>
</div> </div>
{strategy.searches.length > 0 && ( {strategy.searches.length > 0 && (
<div> <div>
<p className="text-sm text-muted-foreground mb-2">{t.common.searchTerms}:</p> <p className="text-sm text-muted-foreground mb-2">{t('common.searchTerms')}:</p>
<div className="space-y-2"> <div className="space-y-2">
{strategy.searches.map((search, i) => ( {strategy.searches.map((search, i) => (
<div key={i} className="flex items-start gap-2"> <div key={i} className="flex items-start gap-2">
@ -111,7 +111,7 @@ export function StreamingResponse({
<CollapsibleTrigger className="flex items-center justify-between w-full hover:opacity-80"> <CollapsibleTrigger className="flex items-center justify-between w-full hover:opacity-80">
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
<Lightbulb className="h-4 w-4 text-primary" /> <Lightbulb className="h-4 w-4 text-primary" />
{t.common.individualAnswers.replace('{count}', answers.length.toString())} {t('common.individualAnswers').replace('{count}', answers.length.toString())}
</CardTitle> </CardTitle>
<ChevronDown className={`h-4 w-4 transition-transform ${answersOpen ? 'rotate-180' : ''}`} /> <ChevronDown className={`h-4 w-4 transition-transform ${answersOpen ? 'rotate-180' : ''}`} />
</CollapsibleTrigger> </CollapsibleTrigger>
@ -135,7 +135,7 @@ export function StreamingResponse({
<CardHeader> <CardHeader>
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-primary" /> <CheckCircle className="h-4 w-4 text-primary" />
{t.common.finalAnswer} {t('common.finalAnswer')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -151,7 +151,7 @@ export function StreamingResponse({
{isStreaming && !finalAnswer && ( {isStreaming && !finalAnswer && (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoadingSpinner size="sm" /> <LoadingSpinner size="sm" />
<span>{t.searchPage.processingQuestion}</span> <span>{t('searchPage.processingQuestion')}</span>
</div> </div>
)} )}
</div> </div>

View file

@ -57,49 +57,49 @@ export function EmbeddingModelChangeDialog({
<AlertDialogHeader> <AlertDialogHeader>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" /> <AlertTriangle className="h-5 w-5 text-yellow-500" />
<AlertDialogTitle>{t.models.embeddingChangeTitle}</AlertDialogTitle> <AlertDialogTitle>{t('models.embeddingChangeTitle')}</AlertDialogTitle>
</div> </div>
<AlertDialogDescription asChild> <AlertDialogDescription asChild>
<div className="space-y-3 text-base text-muted-foreground"> <div className="space-y-3 text-base text-muted-foreground">
<p> <p>
{t.models.embeddingChangeConfirm {t('models.embeddingChangeConfirm')
.replace('{from}', oldModelName || '...') .replace('{from}', oldModelName || '...')
.replace('{to}', newModelName || '...')} .replace('{to}', newModelName || '...')}
</p> </p>
<div className="bg-muted p-4 rounded-md space-y-2"> <div className="bg-muted p-4 rounded-md space-y-2">
<p className="font-semibold text-foreground">{t.models.rebuildRequired}</p> <p className="font-semibold text-foreground">{t('models.rebuildRequired')}</p>
<p className="text-sm"> <p className="text-sm">
{t.models.rebuildReason} {t('models.rebuildReason')}
</p> </p>
</div> </div>
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<p className="font-medium text-foreground">{t.models.whatHappensNext}</p> <p className="font-medium text-foreground">{t('models.whatHappensNext')}</p>
<ul className="list-disc list-inside space-y-1 ml-2"> <ul className="list-disc list-inside space-y-1 ml-2">
<li>{t.models.step1}</li> <li>{t('models.step1')}</li>
<li>{t.models.step2}</li> <li>{t('models.step2')}</li>
<li>{t.models.step3}</li> <li>{t('models.step3')}</li>
<li>{t.models.step4}</li> <li>{t('models.step4')}</li>
</ul> </ul>
</div> </div>
<p className="text-sm font-medium text-foreground"> <p className="text-sm font-medium text-foreground">
{t.models.proceedToRebuildPrompt} {t('models.proceedToRebuildPrompt')}
</p> </p>
</div> </div>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter className="flex-col sm:flex-row gap-2"> <AlertDialogFooter className="flex-col sm:flex-row gap-2">
<AlertDialogCancel disabled={isConfirming}> <AlertDialogCancel disabled={isConfirming}>
{t.common.cancel} {t('common.cancel')}
</AlertDialogCancel> </AlertDialogCancel>
<Button <Button
variant="outline" variant="outline"
onClick={handleConfirmOnly} onClick={handleConfirmOnly}
disabled={isConfirming} disabled={isConfirming}
> >
{t.models.changeModelOnly} {t('models.changeModelOnly')}
</Button> </Button>
<AlertDialogAction <AlertDialogAction
onClick={handleConfirmAndRebuild} onClick={handleConfirmAndRebuild}
@ -107,7 +107,7 @@ export function EmbeddingModelChangeDialog({
className="bg-primary" className="bg-primary"
> >
<ExternalLink className="mr-2 h-4 w-4" /> <ExternalLink className="mr-2 h-4 w-4" />
{t.models.changeAndRebuild} {t('models.changeAndRebuild')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View file

@ -22,11 +22,11 @@ export function MigrationBanner({ providersToMigrate }: MigrationBannerProps) {
<Alert className="border-amber-500/50 bg-amber-50 dark:bg-amber-950/20"> <Alert className="border-amber-500/50 bg-amber-50 dark:bg-amber-950/20">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" /> <AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<AlertTitle className="text-amber-800 dark:text-amber-200"> <AlertTitle className="text-amber-800 dark:text-amber-200">
{t.apiKeys.migrationAvailable} {t('apiKeys.migrationAvailable')}
</AlertTitle> </AlertTitle>
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span className="text-amber-700 dark:text-amber-300"> <span className="text-amber-700 dark:text-amber-300">
{t.apiKeys.migrationDescription.replace('{count}', providersToMigrate.length.toString())} {t('apiKeys.migrationDescription').replace('{count}', providersToMigrate.length.toString())}
</span> </span>
<Button <Button
variant="outline" variant="outline"
@ -38,11 +38,11 @@ export function MigrationBanner({ providersToMigrate }: MigrationBannerProps) {
{migrate.isPending ? ( {migrate.isPending ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t.apiKeys.migrating} {t('apiKeys.migrating')}
</> </>
) : ( ) : (
<> <>
{t.apiKeys.migrateToDatabase} {t('apiKeys.migrateToDatabase')}
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
</> </>
)} )}

View file

@ -37,7 +37,7 @@ export function ModelTestResultDialog({
) : ( ) : (
<X className="h-5 w-5 text-destructive" /> <X className="h-5 w-5 text-destructive" />
)} )}
{result.success ? t.models.testModelSuccess : t.models.testModelFailed} {result.success ? t('models.testModelSuccess') : t('models.testModelFailed')}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
@ -54,7 +54,7 @@ export function ModelTestResultDialog({
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
{t.common.done} {t('common.done')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View file

@ -92,7 +92,7 @@ export function ChatPanel({
// The modal component itself will handle displaying "not found" states. // The modal component itself will handle displaying "not found" states.
// This try-catch is here for future enhancements or unexpected errors. // This try-catch is here for future enhancements or unexpected errors.
} catch { } catch {
toast.error(t.common.noResults) toast.error(t('common.noResults'))
} }
} }
@ -130,7 +130,7 @@ export function ChatPanel({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Bot className="h-5 w-5" /> <Bot className="h-5 w-5" />
{title || (contextType === 'source' ? t.chat.chatWith.replace('{name}', t.navigation.sources) : t.chat.chatWith.replace('{name}', t.common.notebook))} {title || (contextType === 'source' ? t('chat.chatWith').replace('{name}', t('navigation.sources')) : t('chat.chatWith').replace('{name}', t('common.notebook')))}
</CardTitle> </CardTitle>
{onSelectSession && onCreateSession && onDeleteSession && ( {onSelectSession && onCreateSession && onDeleteSession && (
<Dialog open={sessionManagerOpen} onOpenChange={setSessionManagerOpen}> <Dialog open={sessionManagerOpen} onOpenChange={setSessionManagerOpen}>
@ -142,10 +142,10 @@ export function ChatPanel({
disabled={loadingSessions} disabled={loadingSessions}
> >
<Clock className="h-4 w-4" /> <Clock className="h-4 w-4" />
<span className="text-xs">{t.chat.sessions}</span> <span className="text-xs">{t('chat.sessions')}</span>
</Button> </Button>
<DialogContent className="sm:max-w-[420px] p-0 overflow-hidden"> <DialogContent className="sm:max-w-[420px] p-0 overflow-hidden">
<DialogTitle className="sr-only">{t.chat.sessionsTitle}</DialogTitle> <DialogTitle className="sr-only">{t('chat.sessionsTitle')}</DialogTitle>
<SessionManager <SessionManager
sessions={sessions} sessions={sessions}
currentSessionId={currentSessionId ?? null} currentSessionId={currentSessionId ?? null}
@ -170,9 +170,9 @@ export function ChatPanel({
<div className="text-center text-muted-foreground py-8"> <div className="text-center text-muted-foreground py-8">
<Bot className="h-12 w-12 mx-auto mb-4 opacity-50" /> <Bot className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-sm"> <p className="text-sm">
{t.chat.startConversation.replace('{type}', contextType === 'source' ? t.navigation.sources : t.common.notebook)} {t('chat.startConversation').replace('{type}', contextType === 'source' ? t('navigation.sources') : t('common.notebook'))}
</p> </p>
<p className="text-xs mt-2">{t.chat.askQuestions}</p> <p className="text-xs mt-2">{t('chat.askQuestions')}</p>
</div> </div>
) : ( ) : (
messages.map((message) => ( messages.map((message) => (
@ -246,19 +246,19 @@ export function ChatPanel({
{contextIndicators.sources?.length > 0 && ( {contextIndicators.sources?.length > 0 && (
<Badge variant="outline" className="gap-1"> <Badge variant="outline" className="gap-1">
<FileText className="h-3 w-3" /> <FileText className="h-3 w-3" />
{contextIndicators.sources.length} {t.navigation.sources} {contextIndicators.sources.length} {t('navigation.sources')}
</Badge> </Badge>
)} )}
{contextIndicators.insights?.length > 0 && ( {contextIndicators.insights?.length > 0 && (
<Badge variant="outline" className="gap-1"> <Badge variant="outline" className="gap-1">
<Lightbulb className="h-3 w-3" /> <Lightbulb className="h-3 w-3" />
{contextIndicators.insights.length} {contextIndicators.insights.length === 1 ? t.common.insight : t.common.insights} {contextIndicators.insights.length} {contextIndicators.insights.length === 1 ? t('common.insight') : t('common.insights')}
</Badge> </Badge>
)} )}
{contextIndicators.notes?.length > 0 && ( {contextIndicators.notes?.length > 0 && (
<Badge variant="outline" className="gap-1"> <Badge variant="outline" className="gap-1">
<StickyNote className="h-3 w-3" /> <StickyNote className="h-3 w-3" />
{contextIndicators.notes.length} {contextIndicators.notes.length === 1 ? t.common.note : t.common.notes} {contextIndicators.notes.length} {contextIndicators.notes.length === 1 ? t('common.note') : t('common.notes')}
</Badge> </Badge>
)} )}
</div> </div>
@ -281,7 +281,7 @@ export function ChatPanel({
{/* Model selector */} {/* Model selector */}
{onModelChange && ( {onModelChange && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{t.chat.model}</span> <span className="text-xs text-muted-foreground">{t('chat.model')}</span>
<ModelSelector <ModelSelector
currentModel={modelOverride} currentModel={modelOverride}
onModelChange={onModelChange} onModelChange={onModelChange}
@ -298,7 +298,7 @@ export function ChatPanel({
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={`${t.chat.sendPlaceholder} (${t.chat.pressToSend.replace('{key}', keyHint)})`} placeholder={`${t('chat.sendPlaceholder')} (${t('chat.pressToSend').replace('{key}', keyHint)})`}
disabled={isStreaming} disabled={isStreaming}
className="flex-1 min-h-[40px] max-h-[100px] resize-none py-2 px-3 min-w-0" className="flex-1 min-h-[40px] max-h-[100px] resize-none py-2 px-3 min-w-0"
rows={1} rows={1}
@ -334,7 +334,7 @@ function AIMessageContent({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
// Convert references to compact markdown with numbered citations // Convert references to compact markdown with numbered citations
const markdownWithCompactRefs = convertReferencesToCompactMarkdown(content, t.common.references) const markdownWithCompactRefs = convertReferencesToCompactMarkdown(content, t('common.references'))
// Create custom link component for compact references // Create custom link component for compact references
const LinkComponent = createCompactReferenceLinkComponent(onReferenceClick) const LinkComponent = createCompactReferenceLinkComponent(onReferenceClick)

View file

@ -20,7 +20,7 @@ export function MessageActions({ content, notebookId }: MessageActionsProps) {
const handleSaveToNote = () => { const handleSaveToNote = () => {
if (!notebookId) { if (!notebookId) {
toast.error(t.sources.cannotSaveNoteNoNotebook) toast.error(t('sources.cannotSaveNoteNoNotebook'))
return return
} }
@ -37,7 +37,7 @@ export function MessageActions({ content, notebookId }: MessageActionsProps) {
// Try modern clipboard API first // Try modern clipboard API first
if (navigator.clipboard && navigator.clipboard.writeText) { if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(content) await navigator.clipboard.writeText(content)
toast.success(t.common.copyToClipboard) toast.success(t('common.copyToClipboard'))
setCopySuccess(true) setCopySuccess(true)
setTimeout(() => setCopySuccess(false), 2000) setTimeout(() => setCopySuccess(false), 2000)
} else { } else {
@ -53,18 +53,18 @@ export function MessageActions({ content, notebookId }: MessageActionsProps) {
try { try {
document.execCommand('copy') document.execCommand('copy')
toast.success(t.common.copyToClipboard) toast.success(t('common.copyToClipboard'))
setCopySuccess(true) setCopySuccess(true)
setTimeout(() => setCopySuccess(false), 2000) setTimeout(() => setCopySuccess(false), 2000)
} catch { } catch {
toast.error(t.common.error) toast.error(t('common.error'))
} }
document.body.removeChild(textArea) document.body.removeChild(textArea)
} }
} catch (err) { } catch (err) {
console.error('Failed to copy to clipboard:', err) console.error('Failed to copy to clipboard:', err)
toast.error(t.common.error) toast.error(t('common.error'))
} }
} }
@ -89,7 +89,7 @@ export function MessageActions({ content, notebookId }: MessageActionsProps) {
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{t.common.saveToNote}</p> <p>{t('common.saveToNote')}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
@ -110,7 +110,7 @@ export function MessageActions({ content, notebookId }: MessageActionsProps) {
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>{t.common.copyToClipboard}</p> <p>{t('common.copyToClipboard')}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>

View file

@ -67,8 +67,8 @@ export function ModelSelector({
if (defaultModel) { if (defaultModel) {
return defaultModel.name return defaultModel.name
} }
return t.common.default return t('common.default')
}, [currentModel, languageModels, defaultModel, t.common.default]) }, [currentModel, languageModels, defaultModel, t('common.default')])
const handleSave = () => { const handleSave = () => {
onModelChange(selectedModel === 'default' ? undefined : selectedModel) onModelChange(selectedModel === 'default' ? undefined : selectedModel)
@ -100,26 +100,26 @@ export function ModelSelector({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5" /> <Sparkles className="h-5 w-5" />
{t.common.modelConfiguration} {t('common.modelConfiguration')}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{t.transformations.overrideModelDesc} {t('transformations.overrideModelDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="model">{t.common.model}</Label> <Label htmlFor="model">{t('common.model')}</Label>
<Select value={selectedModel} onValueChange={setSelectedModel}> <Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger id="model"> <SelectTrigger id="model">
<SelectValue placeholder={t.models.selectModelPlaceholder} /> <SelectValue placeholder={t('models.selectModelPlaceholder')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="default"> <SelectItem value="default">
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<span> <span>
{defaultModel {defaultModel
? `${t.common.default} (${defaultModel.name})` ? `${t('common.default')} (${defaultModel.name})`
: t.transformations.systemDefault} : t('transformations.systemDefault')}
</span> </span>
{defaultModel?.provider && ( {defaultModel?.provider && (
<span className="text-xs text-muted-foreground ml-2"> <span className="text-xs text-muted-foreground ml-2">
@ -150,7 +150,7 @@ export function ModelSelector({
{selectedModel && selectedModel !== 'default' && ( {selectedModel && selectedModel !== 'default' && (
<div className="rounded-lg bg-muted p-3"> <div className="rounded-lg bg-muted p-3">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t.transformations.sessionUseReplacement.replace( {t('transformations.sessionUseReplacement').replace(
'{name}', '{name}',
languageModels.find(m => m.id === selectedModel)?.name || selectedModel languageModels.find(m => m.id === selectedModel)?.name || selectedModel
)} )}
@ -160,10 +160,10 @@ export function ModelSelector({
</div> </div>
<DialogFooter className="flex justify-between"> <DialogFooter className="flex justify-between">
<Button variant="outline" onClick={handleReset}> <Button variant="outline" onClick={handleReset}>
{t.common.resetToDefault} {t('common.resetToDefault')}
</Button> </Button>
<Button onClick={handleSave}> <Button onClick={handleSave}>
{t.common.saveChanges} {t('common.saveChanges')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View file

@ -110,10 +110,10 @@ export function NotebookAssociations({
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<BookOpen className="h-5 w-5" /> <BookOpen className="h-5 w-5" />
{t.sources.manageNotebooks} {t('sources.manageNotebooks')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{t.sources.manageNotebooksDesc} {t('sources.manageNotebooksDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -131,14 +131,14 @@ export function NotebookAssociations({
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<BookOpen className="h-5 w-5" /> <BookOpen className="h-5 w-5" />
{t.sources.manageNotebooks} {t('sources.manageNotebooks')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{t.sources.manageNotebooksDesc} {t('sources.manageNotebooksDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground">{t.sources.noNotebooksAvailable}</p> <p className="text-sm text-muted-foreground">{t('sources.noNotebooksAvailable')}</p>
</CardContent> </CardContent>
</Card> </Card>
) )
@ -149,10 +149,10 @@ export function NotebookAssociations({
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<BookOpen className="h-5 w-5" /> <BookOpen className="h-5 w-5" />
{t.sources.manageNotebooks} {t('sources.manageNotebooks')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{t.sources.manageNotebooksDesc} {t('sources.manageNotebooksDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@ -205,7 +205,7 @@ export function NotebookAssociations({
onClick={handleCancel} onClick={handleCancel}
disabled={isSaving} disabled={isSaving}
> >
{t.common.cancel} {t('common.cancel')}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@ -215,10 +215,10 @@ export function NotebookAssociations({
{isSaving ? ( {isSaving ? (
<> <>
<LoaderIcon className="mr-2 h-4 w-4 animate-spin" /> <LoaderIcon className="mr-2 h-4 w-4 animate-spin" />
{t.common.saving}... {t('common.saving')}...
</> </>
) : ( ) : (
t.common.saveChanges t('common.saveChanges')
)} )}
</Button> </Button>
</div> </div>

View file

@ -60,7 +60,7 @@ export function SessionManager({
const { data: models } = useModels() const { data: models } = useModels()
// Helper to get model name from ID // Helper to get model name from ID
const customModelLabel = t.common.customModel const customModelLabel = t('common.customModel')
const getModelName = useMemo(() => { const getModelName = useMemo(() => {
return (modelId: string) => { return (modelId: string) => {
const model = models?.find(m => m.id === modelId) const model = models?.find(m => m.id === modelId)
@ -108,7 +108,7 @@ export function SessionManager({
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" /> <MessageSquare className="h-5 w-5" />
{t.chat.sessions} {t('chat.sessions')}
</span> </span>
<Button <Button
size="sm" size="sm"
@ -126,7 +126,7 @@ export function SessionManager({
<Input <Input
value={newSessionTitle} value={newSessionTitle}
onChange={(e) => setNewSessionTitle(e.target.value)} onChange={(e) => setNewSessionTitle(e.target.value)}
placeholder={t.chat.sessionTitlePlaceholder} placeholder={t('chat.sessionTitlePlaceholder')}
className="mb-2" className="mb-2"
autoFocus autoFocus
onKeyPress={(e) => { onKeyPress={(e) => {
@ -135,7 +135,7 @@ export function SessionManager({
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="sm" onClick={handleCreateSession}> <Button size="sm" onClick={handleCreateSession}>
{t.common.create} {t('common.create')}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@ -145,7 +145,7 @@ export function SessionManager({
setNewSessionTitle('') setNewSessionTitle('')
}} }}
> >
{t.common.cancel} {t('common.cancel')}
</Button> </Button>
</div> </div>
</div> </div>
@ -153,13 +153,13 @@ export function SessionManager({
{loadingSessions ? ( {loadingSessions ? (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
{t.common.loading} {t('common.loading')}
</div> </div>
) : sessions.length === 0 ? ( ) : sessions.length === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-50" /> <MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">{t.chat.noSessions}</p> <p className="text-sm">{t('chat.noSessions')}</p>
<p className="text-xs mt-2">{t.chat.createToStart}</p> <p className="text-xs mt-2">{t('chat.createToStart')}</p>
</div> </div>
) : ( ) : (
<div className="space-y-2 pb-4"> <div className="space-y-2 pb-4">
@ -231,7 +231,7 @@ export function SessionManager({
</div> </div>
{session.message_count != null && session.message_count > 0 && ( {session.message_count != null && session.message_count > 0 && (
<Badge variant="secondary" className="mt-2 text-xs"> <Badge variant="secondary" className="mt-2 text-xs">
{t.chat.messagesCount.replace('{count}', session.message_count.toString())} {t('chat.messagesCount').replace('{count}', session.message_count.toString())}
</Badge> </Badge>
)} )}
{session.model_override && ( {session.model_override && (
@ -252,15 +252,15 @@ export function SessionManager({
<AlertDialog open={!!deleteConfirmId} onOpenChange={() => setDeleteConfirmId(null)}> <AlertDialog open={!!deleteConfirmId} onOpenChange={() => setDeleteConfirmId(null)}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{t.chat.deleteSession}</AlertDialogTitle> <AlertDialogTitle>{t('chat.deleteSession')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{t.chat.deleteSessionDesc} {t('chat.deleteSessionDesc')}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>{t.common.cancel}</AlertDialogCancel> <AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}> <AlertDialogAction onClick={handleDeleteConfirm}>
{t.common.delete} {t('common.delete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View file

@ -113,7 +113,7 @@ export function SourceDetailContent({
} }
} catch (err) { } catch (err) {
console.error('Failed to fetch source:', err) console.error('Failed to fetch source:', err)
setError(t.sources.loadFailed) setError(t('sources.loadFailed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -150,7 +150,7 @@ export function SourceDetailContent({
const createInsight = async () => { const createInsight = async () => {
if (!selectedTransformation) { if (!selectedTransformation) {
toast.error(t.sources.selectTransformation) toast.error(t('sources.selectTransformation'))
return return
} }
@ -160,7 +160,7 @@ export function SourceDetailContent({
transformation_id: selectedTransformation transformation_id: selectedTransformation
}) })
// Show toast for async operation // Show toast for async operation
toast.success(t.sources.insightGenerationStarted) toast.success(t('sources.insightGenerationStarted'))
setSelectedTransformation('') setSelectedTransformation('')
// Poll for command completion if we have a command_id // Poll for command completion if we have a command_id
@ -188,7 +188,7 @@ export function SourceDetailContent({
} }
} catch (err) { } catch (err) {
console.error('Failed to create insight:', err) console.error('Failed to create insight:', err)
toast.error(t.common.error) toast.error(t('common.error'))
} finally { } finally {
setCreatingInsight(false) setCreatingInsight(false)
} }
@ -201,12 +201,12 @@ export function SourceDetailContent({
try { try {
setDeletingInsight(true) setDeletingInsight(true)
await insightsApi.delete(insightToDelete) await insightsApi.delete(insightToDelete)
toast.success(t.common.success) toast.success(t('common.success'))
setInsightToDelete(null) setInsightToDelete(null)
await fetchInsights() await fetchInsights()
} catch (err) { } catch (err) {
console.error('Failed to delete insight:', err) console.error('Failed to delete insight:', err)
toast.error(t.common.error) toast.error(t('common.error'))
} finally { } finally {
setDeletingInsight(false) setDeletingInsight(false)
} }
@ -217,11 +217,11 @@ export function SourceDetailContent({
try { try {
await sourcesApi.update(sourceId, { title }) await sourcesApi.update(sourceId, { title })
toast.success(t.common.success) toast.success(t('common.success'))
setSource({ ...source, title }) setSource({ ...source, title })
} catch (err) { } catch (err) {
console.error('Failed to update source title:', err) console.error('Failed to update source title:', err)
toast.error(t.common.error) toast.error(t('common.error'))
await fetchSource() await fetchSource()
} }
} }
@ -232,11 +232,11 @@ export function SourceDetailContent({
try { try {
setIsEmbedding(true) setIsEmbedding(true)
const response = await embeddingApi.embedContent(sourceId, 'source') const response = await embeddingApi.embedContent(sourceId, 'source')
toast.success(response.message || t.common.success) toast.success(response.message || t('common.success'))
await fetchSource() await fetchSource()
} catch (err) { } catch (err) {
console.error('Failed to embed content:', err) console.error('Failed to embed content:', err)
toast.error(t.common.error) toast.error(t('common.error'))
} finally { } finally {
setIsEmbedding(false) setIsEmbedding(false)
} }
@ -288,14 +288,14 @@ export function SourceDetailContent({
document.body.removeChild(link) document.body.removeChild(link)
window.URL.revokeObjectURL(blobUrl) window.URL.revokeObjectURL(blobUrl)
setFileAvailable(true) setFileAvailable(true)
toast.success(t.common.success) toast.success(t('common.success'))
} catch (err) { } catch (err) {
console.error('Failed to download file:', err) console.error('Failed to download file:', err)
if (isAxiosError(err) && err.response?.status === 404) { if (isAxiosError(err) && err.response?.status === 404) {
setFileAvailable(false) setFileAvailable(false)
toast.error(t.sources.fileUnavailable) toast.error(t('sources.fileUnavailable'))
} else { } else {
toast.error(t.common.error) toast.error(t('common.error'))
} }
} finally { } finally {
setIsDownloadingFile(false) setIsDownloadingFile(false)
@ -320,7 +320,7 @@ export function SourceDetailContent({
if (source?.asset?.url) { if (source?.asset?.url) {
navigator.clipboard.writeText(source.asset.url) navigator.clipboard.writeText(source.asset.url)
setCopied(true) setCopied(true)
toast.success(t.sources.urlCopied) toast.success(t('sources.urlCopied'))
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
} }
}, [source, t]) }, [source, t])
@ -357,14 +357,14 @@ export function SourceDetailContent({
const handleDelete = async () => { const handleDelete = async () => {
if (!source) return if (!source) return
if (confirm(t.sources.deleteSourceConfirm || t.common.confirm)) { if (confirm(t('sources.deleteSourceConfirm') || t('common.confirm'))) {
try { try {
await sourcesApi.delete(source.id) await sourcesApi.delete(source.id)
toast.success(t.common.success) toast.success(t('common.success'))
onClose?.() onClose?.()
} catch (error) { } catch (error) {
console.error('Failed to delete source:', error) console.error('Failed to delete source:', error)
toast.error(t.common.error) toast.error(t('common.error'))
} }
} }
} }
@ -380,7 +380,7 @@ export function SourceDetailContent({
if (error || !source) { if (error || !source) {
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8"> <div className="flex h-full flex-col items-center justify-center gap-4 p-8">
<p className="text-red-500">{error || t.sources.notFound}</p> <p className="text-red-500">{error || t('sources.notFound')}</p>
</div> </div>
) )
} }
@ -396,11 +396,11 @@ export function SourceDetailContent({
onSave={handleUpdateTitle} onSave={handleUpdateTitle}
className="text-2xl font-bold" className="text-2xl font-bold"
inputClassName="text-2xl font-bold" inputClassName="text-2xl font-bold"
placeholder={t.sources.titlePlaceholder} placeholder={t('sources.titlePlaceholder')}
emptyText={t.sources.untitledSource} emptyText={t('sources.untitledSource')}
/> />
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{t.sources.id}: {source.id} {t('sources.id')}: {source.id}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -413,7 +413,7 @@ export function SourceDetailContent({
{showChatButton && onChatClick && ( {showChatButton && onChatClick && (
<Button variant="outline" size="sm" onClick={onChatClick}> <Button variant="outline" size="sm" onClick={onChatClick}>
<MessageSquare className="h-4 w-4 mr-2" /> <MessageSquare className="h-4 w-4 mr-2" />
{t.chat.chatWith.replace('{name}', t.navigation.sources)} {t('chat.chatWith').replace('{name}', t('navigation.sources'))}
</Button> </Button>
)} )}
@ -432,10 +432,10 @@ export function SourceDetailContent({
> >
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
{fileAvailable === false {fileAvailable === false
? t.sources.fileUnavailable ? t('sources.fileUnavailable')
: isDownloadingFile : isDownloadingFile
? t.sources.preparing ? t('sources.preparing')
: t.sources.downloadFile} : t('sources.downloadFile')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</> </>
@ -445,7 +445,7 @@ export function SourceDetailContent({
disabled={isEmbedding || source.embedded} disabled={isEmbedding || source.embedded}
> >
<Database className="mr-2 h-4 w-4" /> <Database className="mr-2 h-4 w-4" />
{isEmbedding ? t.sources.embedding : source.embedded ? t.sources.alreadyEmbedded : t.sources.embedContent} {isEmbedding ? t('sources.embedding') : source.embedded ? t('sources.alreadyEmbedded') : t('sources.embedContent')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
@ -453,7 +453,7 @@ export function SourceDetailContent({
onClick={handleDelete} onClick={handleDelete}
> >
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
{t.sources.deleteSource} {t('sources.deleteSource')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -465,11 +465,11 @@ export function SourceDetailContent({
<div className="flex-1 overflow-y-auto px-2"> <div className="flex-1 overflow-y-auto px-2">
<Tabs defaultValue="content" className="w-full"> <Tabs defaultValue="content" className="w-full">
<TabsList className="grid w-full grid-cols-3 sticky top-0 z-10"> <TabsList className="grid w-full grid-cols-3 sticky top-0 z-10">
<TabsTrigger value="content">{t.sources.content}</TabsTrigger> <TabsTrigger value="content">{t('sources.content')}</TabsTrigger>
<TabsTrigger value="insights"> <TabsTrigger value="insights">
{t.common.insights} {insights.length > 0 && `(${insights.length})`} {t('common.insights')} {insights.length > 0 && `(${insights.length})`}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="details">{t.sources.details}</TabsTrigger> <TabsTrigger value="details">{t('sources.details')}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="content" className="mt-6"> <TabsContent value="content" className="mt-6">
@ -477,7 +477,7 @@ export function SourceDetailContent({
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
{isYouTubeUrl && <Youtube className="h-5 w-5" />} {isYouTubeUrl && <Youtube className="h-5 w-5" />}
{t.sources.content} {t('sources.content')}
</CardTitle> </CardTitle>
{source.asset?.url && !isYouTubeUrl && ( {source.asset?.url && !isYouTubeUrl && (
<CardDescription className="flex items-center gap-2"> <CardDescription className="flex items-center gap-2">
@ -499,7 +499,7 @@ export function SourceDetailContent({
<div className="aspect-video rounded-lg overflow-hidden bg-black"> <div className="aspect-video rounded-lg overflow-hidden bg-black">
<iframe <iframe
src={`https://www.youtube.com/embed/${youTubeVideoId}`} src={`https://www.youtube.com/embed/${youTubeVideoId}`}
title={t.common.accessibility.ytVideo} title={t('common.accessibility.ytVideo')}
className="w-full h-full" className="w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen allowFullScreen
@ -514,7 +514,7 @@ export function SourceDetailContent({
className="text-sm text-muted-foreground hover:underline inline-flex items-center gap-1" className="text-sm text-muted-foreground hover:underline inline-flex items-center gap-1"
> >
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
{t.sources.openOnYoutube} {t('sources.openOnYoutube')}
</a> </a>
</div> </div>
)} )}
@ -543,7 +543,7 @@ export function SourceDetailContent({
td: ({ children }) => <td className="border border-border px-3 py-2">{children}</td>, td: ({ children }) => <td className="border border-border px-3 py-2">{children}</td>,
}} }}
> >
{source.full_text || t.sources.noContent} {source.full_text || t('sources.noContent')}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
</CardContent> </CardContent>
@ -556,12 +556,12 @@ export function SourceDetailContent({
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Lightbulb className="h-5 w-5" /> <Lightbulb className="h-5 w-5" />
{t.common.insights} {t('common.insights')}
</span> </span>
<Badge variant="secondary">{insights.length}</Badge> <Badge variant="secondary">{insights.length}</Badge>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{t.sources.insightsDesc} {t('sources.insightsDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@ -572,7 +572,7 @@ export function SourceDetailContent({
className="mb-3 text-sm font-semibold flex items-center gap-2" className="mb-3 text-sm font-semibold flex items-center gap-2"
> >
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
{t.sources.generateNewInsight} {t('sources.generateNewInsight')}
</Label> </Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Select <Select
@ -582,7 +582,7 @@ export function SourceDetailContent({
disabled={creatingInsight} disabled={creatingInsight}
> >
<SelectTrigger id="transformation-select" className="flex-1"> <SelectTrigger id="transformation-select" className="flex-1">
<SelectValue placeholder={t.sources.selectTransformation} /> <SelectValue placeholder={t('sources.selectTransformation')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{transformations.map((trans) => ( {transformations.map((trans) => (
@ -600,12 +600,12 @@ export function SourceDetailContent({
{creatingInsight ? ( {creatingInsight ? (
<> <>
<LoadingSpinner className="mr-2 h-3 w-3" /> <LoadingSpinner className="mr-2 h-3 w-3" />
{t.common.creating} {t('common.creating')}
</> </>
) : ( ) : (
<> <>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
{t.common.create} {t('common.create')}
</> </>
)} )}
</Button> </Button>
@ -620,8 +620,8 @@ export function SourceDetailContent({
) : insights.length === 0 ? ( ) : insights.length === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
<Lightbulb className="h-12 w-12 mx-auto mb-3 opacity-50" /> <Lightbulb className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">{t.sources.noInsightsYet}</p> <p className="text-sm">{t('sources.noInsightsYet')}</p>
<p className="text-xs mt-1">{t.sources.createFirstInsight}</p> <p className="text-xs mt-1">{t('sources.createFirstInsight')}</p>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@ -639,7 +639,7 @@ export function SourceDetailContent({
</p> </p>
<div className="mt-3 flex justify-end gap-2"> <div className="mt-3 flex justify-end gap-2">
<Button size="sm" variant="outline" onClick={() => setSelectedInsight(insight)}> <Button size="sm" variant="outline" onClick={() => setSelectedInsight(insight)}>
{t.sources.viewInsight} {t('sources.viewInsight')}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@ -661,7 +661,7 @@ export function SourceDetailContent({
<TabsContent value="details" className="mt-6"> <TabsContent value="details" className="mt-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t.sources.details}</CardTitle> <CardTitle>{t('sources.details')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Embedding Alert */} {/* Embedding Alert */}
@ -669,10 +669,10 @@ export function SourceDetailContent({
<Alert> <Alert>
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertTitle> <AlertTitle>
{t.sources.notEmbeddedAlert} {t('sources.notEmbeddedAlert')}
</AlertTitle> </AlertTitle>
<AlertDescription> <AlertDescription>
{t.sources.notEmbeddedDesc} {t('sources.notEmbeddedDesc')}
<div className="mt-3"> <div className="mt-3">
<Button <Button
onClick={handleEmbedContent} onClick={handleEmbedContent}
@ -680,7 +680,7 @@ export function SourceDetailContent({
size="sm" size="sm"
> >
<Database className="mr-2 h-4 w-4" /> <Database className="mr-2 h-4 w-4" />
{isEmbedding ? t.sources.embedding : t.sources.embedContent} {isEmbedding ? t('sources.embedding') : t('sources.embedContent')}
</Button> </Button>
</div> </div>
</AlertDescription> </AlertDescription>
@ -691,7 +691,7 @@ export function SourceDetailContent({
<div className="space-y-4"> <div className="space-y-4">
{source.asset?.url && ( {source.asset?.url && (
<div> <div>
<h3 className="mb-2 text-sm font-semibold">{t.common.url}</h3> <h3 className="mb-2 text-sm font-semibold">{t('common.url')}</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="flex-1 rounded bg-muted px-2 py-1 text-sm"> <code className="flex-1 rounded bg-muted px-2 py-1 text-sm">
{source.asset.url} {source.asset.url}
@ -720,7 +720,7 @@ export function SourceDetailContent({
{source.asset?.file_path && ( {source.asset?.file_path && (
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-sm font-semibold">{t.sources.uploadedFile}</h3> <h3 className="text-sm font-semibold">{t('sources.uploadedFile')}</h3>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<code className="rounded bg-muted px-2 py-1 text-sm"> <code className="rounded bg-muted px-2 py-1 text-sm">
{source.asset.file_path} {source.asset.file_path}
@ -733,15 +733,15 @@ export function SourceDetailContent({
> >
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
{fileAvailable === false {fileAvailable === false
? t.sources.fileUnavailable ? t('sources.fileUnavailable')
: isDownloadingFile : isDownloadingFile
? t.sources.preparing ? t('sources.preparing')
: t.common.download} : t('common.download')}
</Button> </Button>
</div> </div>
{fileAvailable === false ? ( {fileAvailable === false ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t.sources.fileUnavailableDesc} {t('sources.fileUnavailableDesc')}
</p> </p>
) : null} ) : null}
</div> </div>
@ -749,7 +749,7 @@ export function SourceDetailContent({
{source.topics && source.topics.length > 0 && ( {source.topics && source.topics.length > 0 && (
<div> <div>
<h3 className="mb-2 text-sm font-semibold">{t.sources.topics}</h3> <h3 className="mb-2 text-sm font-semibold">{t('sources.topics')}</h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{source.topics.map((topic, idx) => ( {source.topics.map((topic, idx) => (
<Badge key={idx} variant="outline"> <Badge key={idx} variant="outline">
@ -764,17 +764,17 @@ export function SourceDetailContent({
{/* Metadata */} {/* Metadata */}
<div> <div>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold">{t.sources.metadata}</h3> <h3 className="text-sm font-semibold">{t('sources.metadata')}</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Database className="h-3.5 w-3.5 text-muted-foreground" /> <Database className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant={source.embedded ? "default" : "secondary"} className="text-xs"> <Badge variant={source.embedded ? "default" : "secondary"} className="text-xs">
{source.embedded ? t.sources.embedded : t.sources.notEmbedded} {source.embedded ? t('sources.embedded') : t('sources.notEmbedded')}
</Badge> </Badge>
</div> </div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div> <div>
<p className="text-xs font-medium text-muted-foreground">{t.common.created_label}</p> <p className="text-xs font-medium text-muted-foreground">{t('common.created_label')}</p>
<p className="text-sm"> <p className="text-sm">
{formatDistanceToNow(new Date(source.created), { {formatDistanceToNow(new Date(source.created), {
addSuffix: true, addSuffix: true,
@ -786,7 +786,7 @@ export function SourceDetailContent({
</p> </p>
</div> </div>
<div> <div>
<p className="text-xs font-medium text-muted-foreground">{t.common.updated_label}</p> <p className="text-xs font-medium text-muted-foreground">{t('common.updated_label')}</p>
<p className="text-sm"> <p className="text-sm">
{formatDistanceToNow(new Date(source.updated), { {formatDistanceToNow(new Date(source.updated), {
addSuffix: true, addSuffix: true,
@ -823,12 +823,12 @@ export function SourceDetailContent({
onDelete={async (insightId) => { onDelete={async (insightId) => {
try { try {
await insightsApi.delete(insightId) await insightsApi.delete(insightId)
toast.success(t.common.success) toast.success(t('common.success'))
setSelectedInsight(null) setSelectedInsight(null)
await fetchInsights() await fetchInsights()
} catch (err) { } catch (err) {
console.error('Failed to delete insight:', err) console.error('Failed to delete insight:', err)
toast.error(t.common.error) toast.error(t('common.error'))
} }
}} }}
/> />
@ -836,20 +836,20 @@ export function SourceDetailContent({
<AlertDialog open={!!insightToDelete} onOpenChange={() => setInsightToDelete(null)}> <AlertDialog open={!!insightToDelete} onOpenChange={() => setInsightToDelete(null)}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{t.sources.deleteInsight}</AlertDialogTitle> <AlertDialogTitle>{t('sources.deleteInsight')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{t.sources.deleteInsightConfirm} {t('sources.deleteInsightConfirm')}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={deletingInsight}>{t.common.cancel}</AlertDialogCancel> <AlertDialogCancel disabled={deletingInsight}>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction asChild> <AlertDialogAction asChild>
<Button <Button
onClick={handleDeleteInsight} onClick={handleDeleteInsight}
disabled={deletingInsight} disabled={deletingInsight}
variant="destructive" variant="destructive"
> >
{deletingInsight ? t.common.deleting : t.common.delete} {deletingInsight ? t('common.deleting') : t('common.delete')}
</Button> </Button>
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View file

@ -42,7 +42,7 @@ export function SourceDialog({ open, onOpenChange, sourceId }: SourceDialogProps
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl max-h-[90vh] flex flex-col p-0"> <DialogContent className="max-w-5xl max-h-[90vh] flex flex-col p-0">
{/* Accessibility title (hidden visually but read by screen readers) */} {/* Accessibility title (hidden visually but read by screen readers) */}
<DialogTitle className="sr-only">{t.sources.detailsTitle}</DialogTitle> <DialogTitle className="sr-only">{t('sources.detailsTitle')}</DialogTitle>
{/* Source detail content */} {/* Source detail content */}
<div className="flex-1 overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0">

View file

@ -73,7 +73,7 @@ export function SourceInsightDialog({ open, onOpenChange, insight, onDelete }: S
<DialogContent className="sm:max-w-3xl max-h-[90vh] flex flex-col"> <DialogContent className="sm:max-w-3xl max-h-[90vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center justify-between gap-2"> <DialogTitle className="flex items-center justify-between gap-2">
<span>{t.sources.sourceInsight}</span> <span>{t('sources.sourceInsight')}</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{displayInsight?.insight_type && ( {displayInsight?.insight_type && (
<Badge variant="outline" className="text-xs uppercase"> <Badge variant="outline" className="text-xs uppercase">
@ -88,7 +88,7 @@ export function SourceInsightDialog({ open, onOpenChange, insight, onDelete }: S
className="gap-1" className="gap-1"
> >
<FileText className="h-3 w-3" /> <FileText className="h-3 w-3" />
{t.sources.viewSource} {t('sources.viewSource')}
</Button> </Button>
)} )}
</div> </div>
@ -98,8 +98,8 @@ export function SourceInsightDialog({ open, onOpenChange, insight, onDelete }: S
{showDeleteConfirm ? ( {showDeleteConfirm ? (
<div className="flex flex-col items-center justify-center py-8 gap-4"> <div className="flex flex-col items-center justify-center py-8 gap-4">
<p className="text-center text-muted-foreground"> <p className="text-center text-muted-foreground">
{t.sources.deleteInsightConfirm.split(/[?]/)[0]}?<br /> {t('sources.deleteInsightConfirm').split(/[?]/)[0]}?<br />
<span className="text-sm">{t.sources.deleteInsightConfirm.split(/[?]/)[1]?.trim() || t.common.deleteForever}</span> <span className="text-sm">{t('sources.deleteInsightConfirm').split(/[?]/)[1]?.trim() || t('common.deleteForever')}</span>
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@ -107,14 +107,14 @@ export function SourceInsightDialog({ open, onOpenChange, insight, onDelete }: S
onClick={() => setShowDeleteConfirm(false)} onClick={() => setShowDeleteConfirm(false)}
disabled={isDeleting} disabled={isDeleting}
> >
{t.common.cancel} {t('common.cancel')}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={handleDelete} onClick={handleDelete}
disabled={isDeleting} disabled={isDeleting}
> >
{isDeleting ? t.common.deleting : t.common.delete} {isDeleting ? t('common.deleting') : t('common.delete')}
</Button> </Button>
</div> </div>
</div> </div>
@ -122,7 +122,7 @@ export function SourceInsightDialog({ open, onOpenChange, insight, onDelete }: S
<div className="flex-1 overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0">
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-10"> <div className="flex items-center justify-center py-10">
<span className="text-sm text-muted-foreground">{t.common.loading}</span> <span className="text-sm text-muted-foreground">{t('common.loading')}</span>
</div> </div>
) : displayInsight ? ( ) : displayInsight ? (
<div className="prose prose-sm prose-neutral dark:prose-invert max-w-none"> <div className="prose prose-sm prose-neutral dark:prose-invert max-w-none">
@ -145,7 +145,7 @@ export function SourceInsightDialog({ open, onOpenChange, insight, onDelete }: S
</ReactMarkdown> </ReactMarkdown>
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground">{t.sources.noInsightSelected}</p> <p className="text-sm text-muted-foreground">{t('sources.noInsightSelected')}</p>
)} )}
</div> </div>
)} )}

View file

@ -186,10 +186,10 @@ export function AddExistingSourceDialog({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Link2 className="h-5 w-5" /> <Link2 className="h-5 w-5" />
{t.sources.addExistingTitle} {t('sources.addExistingTitle')}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{t.sources.addExistingDesc} {t('sources.addExistingDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -198,7 +198,7 @@ export function AddExistingSourceDialog({
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder={t.sources.searchPlaceholder} placeholder={t('sources.searchPlaceholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10" className="pl-10"
@ -213,12 +213,12 @@ export function AddExistingSourceDialog({
{isSearching && filteredSources.length === 0 ? ( {isSearching && filteredSources.length === 0 ? (
<div className="flex flex-col items-center justify-center h-[200px] text-muted-foreground"> <div className="flex flex-col items-center justify-center h-[200px] text-muted-foreground">
<LoaderIcon className="h-12 w-12 mb-2 animate-spin" /> <LoaderIcon className="h-12 w-12 mb-2 animate-spin" />
<p>{t.common.loading}</p> <p>{t('common.loading')}</p>
</div> </div>
) : filteredSources.length === 0 ? ( ) : filteredSources.length === 0 ? (
<div className="flex flex-col items-center justify-center h-[200px] text-muted-foreground"> <div className="flex flex-col items-center justify-center h-[200px] text-muted-foreground">
<FileText className="h-12 w-12 mb-2 opacity-50" /> <FileText className="h-12 w-12 mb-2 opacity-50" />
<p>{t.sources.noNotebooksFound}</p> <p>{t('sources.noNotebooksFound')}</p>
</div> </div>
) : ( ) : (
<div className="space-y-2 p-4"> <div className="space-y-2 p-4">
@ -249,12 +249,12 @@ export function AddExistingSourceDialog({
</h4> </h4>
{isAlreadyLinked && ( {isAlreadyLinked && (
<Badge variant="secondary" className="text-xs shrink-0"> <Badge variant="secondary" className="text-xs shrink-0">
{t.common.linked} {t('common.linked')}
</Badge> </Badge>
)} )}
</div> </div>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{t.sources.added.replace('{date}', formatDate(source.created))} {t('sources.added').replace('{date}', formatDate(source.created))}
</p> </p>
</div> </div>
</div> </div>
@ -267,14 +267,14 @@ export function AddExistingSourceDialog({
{/* Truncation Warning */} {/* Truncation Warning */}
{allSources.length >= 100 && !debouncedSearchQuery && ( {allSources.length >= 100 && !debouncedSearchQuery && (
<div className="text-xs text-muted-foreground bg-muted/50 p-2 rounded-md"> <div className="text-xs text-muted-foreground bg-muted/50 p-2 rounded-md">
{t.sources.showingFirst100} {t('sources.showingFirst100')}
</div> </div>
)} )}
{/* Selection Summary */} {/* Selection Summary */}
{selectedSourceIds.length > 0 && ( {selectedSourceIds.length > 0 && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{t.sources.selectedCount.replace('{count}', selectedSourceIds.length.toString())} {t('sources.selectedCount').replace('{count}', selectedSourceIds.length.toString())}
</div> </div>
)} )}
</div> </div>
@ -285,7 +285,7 @@ export function AddExistingSourceDialog({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={addSources.isPending} disabled={addSources.isPending}
> >
{t.common.cancel} {t('common.cancel')}
</Button> </Button>
<Button <Button
onClick={handleAddSelected} onClick={handleAddSelected}
@ -294,10 +294,10 @@ export function AddExistingSourceDialog({
{addSources.isPending ? ( {addSources.isPending ? (
<> <>
<LoaderIcon className="mr-2 h-4 w-4 animate-spin" /> <LoaderIcon className="mr-2 h-4 w-4 animate-spin" />
{t.common.adding} {t('common.adding')}
</> </>
) : ( ) : (
<>{t.common.addSelected}</> <>{t('common.addSelected')}</>
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>

View file

@ -93,9 +93,9 @@ export function AddSourceDialog({
const { t } = useTranslation() const { t } = useTranslation()
const WIZARD_STEPS: readonly WizardStep[] = [ const WIZARD_STEPS: readonly WizardStep[] = [
{ number: 1, title: t.sources.addSource, description: t.sources.processDescription }, { number: 1, title: t('sources.addSource'), description: t('sources.processDescription') },
{ number: 2, title: t.navigation.notebooks, description: t.notebooks.searchPlaceholder }, { number: 2, title: t('navigation.notebooks'), description: t('notebooks.searchPlaceholder') },
{ number: 3, title: t.navigation.process, description: t.sources.processDescription }, { number: 3, title: t('navigation.process'), description: t('sources.processDescription') },
] ]
// Simplified state management // Simplified state management
@ -389,29 +389,29 @@ export function AddSourceDialog({
if (isBatchMode) { if (isBatchMode) {
// Batch submission // Batch submission
setProcessingStatus({ message: t.sources.processingFiles }) setProcessingStatus({ message: t('sources.processingFiles') })
const results = await submitBatch(data) const results = await submitBatch(data)
// Show summary toast // Show summary toast
if (results.failed === 0) { if (results.failed === 0) {
toast.success(t.sources.batchSuccess.replace('{count}', results.success.toString())) toast.success(t('sources.batchSuccess').replace('{count}', results.success.toString()))
} else if (results.success === 0) { } else if (results.success === 0) {
toast.error(t.sources.batchFailed.replace('{count}', results.failed.toString())) toast.error(t('sources.batchFailed').replace('{count}', results.failed.toString()))
} else { } else {
toast.warning(t.sources.batchPartial.replace('{success}', results.success.toString()).replace('{failed}', results.failed.toString())) toast.warning(t('sources.batchPartial').replace('{success}', results.success.toString()).replace('{failed}', results.failed.toString()))
} }
handleClose() handleClose()
} else { } else {
// Single source submission // Single source submission
setProcessingStatus({ message: t.sources.submittingSource }) setProcessingStatus({ message: t('sources.submittingSource') })
await submitSingleSource(data) await submitSingleSource(data)
handleClose() handleClose()
} }
} catch (error) { } catch (error) {
console.error('Error creating source:', error) console.error('Error creating source:', error)
setProcessingStatus({ setProcessingStatus({
message: t.common.error, message: t('common.error'),
}) })
timeoutRef.current = setTimeout(() => { timeoutRef.current = setTimeout(() => {
setProcessing(false) setProcessing(false)
@ -461,12 +461,12 @@ export function AddSourceDialog({
<DialogContent className="sm:max-w-[500px]" showCloseButton={true}> <DialogContent className="sm:max-w-[500px]" showCloseButton={true}>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{batchProgress ? t.sources.processingFiles : t.sources.statusProcessing} {batchProgress ? t('sources.processingFiles') : t('sources.statusProcessing')}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{batchProgress {batchProgress
? t.sources.processingBatchSources.replace('{count}', batchProgress.total.toString()) ? t('sources.processingBatchSources').replace('{count}', batchProgress.total.toString())
: t.sources.processingSource : t('sources.processingSource')
} }
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -475,7 +475,7 @@ export function AddSourceDialog({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<LoaderIcon className="h-5 w-5 animate-spin text-primary" /> <LoaderIcon className="h-5 w-5 animate-spin text-primary" />
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{processingStatus?.message || t.common.processing} {processingStatus?.message || t('common.processing')}
</span> </span>
</div> </div>
@ -493,12 +493,12 @@ export function AddSourceDialog({
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="flex items-center gap-1.5 text-green-600"> <span className="flex items-center gap-1.5 text-green-600">
<CheckCircleIcon className="h-4 w-4" /> <CheckCircleIcon className="h-4 w-4" />
{batchProgress.completed} {t.common.completed} {batchProgress.completed} {t('common.completed')}
</span> </span>
{batchProgress.failed > 0 && ( {batchProgress.failed > 0 && (
<span className="flex items-center gap-1.5 text-destructive"> <span className="flex items-center gap-1.5 text-destructive">
<XCircleIcon className="h-4 w-4" /> <XCircleIcon className="h-4 w-4" />
{batchProgress.failed} {t.common.failed} {batchProgress.failed} {t('common.failed')}
</span> </span>
)} )}
</div> </div>
@ -509,7 +509,7 @@ export function AddSourceDialog({
{batchProgress.currentItem && ( {batchProgress.currentItem && (
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{t.common.current}: {batchProgress.currentItem} {t('common.current')}: {batchProgress.currentItem}
</p> </p>
)} )}
</> </>
@ -536,9 +536,9 @@ export function AddSourceDialog({
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[700px] p-0"> <DialogContent className="sm:max-w-[700px] p-0">
<DialogHeader className="px-6 pt-6 pb-0"> <DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle>{t.sources.addNew}</DialogTitle> <DialogTitle>{t('sources.addNew')}</DialogTitle>
<DialogDescription> <DialogDescription>
{t.sources.processDescription} {t('sources.processDescription')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -591,7 +591,7 @@ export function AddSourceDialog({
variant="outline" variant="outline"
onClick={handleClose} onClick={handleClose}
> >
{t.common.cancel} {t('common.cancel')}
</Button> </Button>
<div className="flex gap-2"> <div className="flex gap-2">
@ -601,7 +601,7 @@ export function AddSourceDialog({
variant="outline" variant="outline"
onClick={handlePrevStep} onClick={handlePrevStep}
> >
{t.common.back} {t('common.back')}
</Button> </Button>
)} )}
@ -613,7 +613,7 @@ export function AddSourceDialog({
onClick={(e) => handleNextStep(e)} onClick={(e) => handleNextStep(e)}
disabled={!currentStepValid} disabled={!currentStepValid}
> >
{t.common.next} {t('common.next')}
</Button> </Button>
)} )}
@ -623,7 +623,7 @@ export function AddSourceDialog({
disabled={!currentStepValid || createSource.isPending} disabled={!currentStepValid || createSource.isPending}
className="min-w-[120px]" className="min-w-[120px]"
> >
{createSource.isPending ? t.common.adding : t.common.done} {createSource.isPending ? t('common.adding') : t('common.done')}
</Button> </Button>
</div> </div>
</div> </div>

View file

@ -27,7 +27,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useSourceStatus } from '@/lib/hooks/use-sources' import { useSourceStatus } from '@/lib/hooks/use-sources'
import { useTranslation } from '@/lib/hooks/use-translation' import { useTranslation } from '@/lib/hooks/use-translation'
import { TranslationKeys } from '@/lib/locales' import type { TFunction } from 'i18next'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { ContextToggle } from '@/components/common/ContextToggle' import { ContextToggle } from '@/components/common/ContextToggle'
import { ContextMode } from '@/app/(dashboard)/notebooks/[id]/page' import { ContextMode } from '@/app/(dashboard)/notebooks/[id]/page'
@ -51,46 +51,46 @@ const SOURCE_TYPE_ICONS = {
text: FileText, text: FileText,
} as const } as const
const getStatusConfig = (t: TranslationKeys) => ({ const getStatusConfig = (t: TFunction) => ({
new: { new: {
icon: Clock, icon: Clock,
color: 'text-blue-600', color: 'text-blue-600',
bgColor: 'bg-blue-50', bgColor: 'bg-blue-50',
borderColor: 'border-blue-200', borderColor: 'border-blue-200',
label: t.sources.statusProcessing, label: t('sources.statusProcessing'),
description: t.sources.statusPreparingDesc description: t('sources.statusPreparingDesc')
}, },
queued: { queued: {
icon: Clock, icon: Clock,
color: 'text-blue-600', color: 'text-blue-600',
bgColor: 'bg-blue-50', bgColor: 'bg-blue-50',
borderColor: 'border-blue-200', borderColor: 'border-blue-200',
label: t.sources.statusQueued, label: t('sources.statusQueued'),
description: t.sources.statusQueuedDesc description: t('sources.statusQueuedDesc')
}, },
running: { running: {
icon: Loader2, icon: Loader2,
color: 'text-blue-600', color: 'text-blue-600',
bgColor: 'bg-blue-50', bgColor: 'bg-blue-50',
borderColor: 'border-blue-200', borderColor: 'border-blue-200',
label: t.sources.statusProcessing, label: t('sources.statusProcessing'),
description: t.sources.statusProcessingDesc description: t('sources.statusProcessingDesc')
}, },
completed: { completed: {
icon: CheckCircle, icon: CheckCircle,
color: 'text-green-600', color: 'text-green-600',
bgColor: 'bg-green-50', bgColor: 'bg-green-50',
borderColor: 'border-green-200', borderColor: 'border-green-200',
label: t.sources.statusCompleted, label: t('sources.statusCompleted'),
description: t.sources.statusCompletedDesc description: t('sources.statusCompletedDesc')
}, },
failed: { failed: {
icon: AlertTriangle, icon: AlertTriangle,
color: 'text-red-600', color: 'text-red-600',
bgColor: 'bg-red-50', bgColor: 'bg-red-50',
borderColor: 'border-red-200', borderColor: 'border-red-200',
label: t.sources.statusFailed, label: t('sources.statusFailed'),
description: t.sources.statusFailedDesc description: t('sources.statusFailedDesc')
} }
} as const) } as const)
@ -172,7 +172,7 @@ export function SourceCard({
const sourceType = getSourceType(source) const sourceType = getSourceType(source)
const SourceTypeIcon = SOURCE_TYPE_ICONS[sourceType] const SourceTypeIcon = SOURCE_TYPE_ICONS[sourceType]
const title = source.title || t.sources.untitledSource const title = source.title || t('sources.untitledSource')
const handleRetry = () => { const handleRetry = () => {
if (onRetry) { if (onRetry) {
@ -226,13 +226,13 @@ export function SourceCard({
'h-3 w-3', 'h-3 w-3',
isProcessing && 'animate-spin' isProcessing && 'animate-spin'
)} /> )} />
{statusLoading && shouldFetchStatus ? t.sources.checking : statusConfig.label} {statusLoading && shouldFetchStatus ? t('sources.checking') : statusConfig.label}
</div> </div>
{/* Source type indicator */} {/* Source type indicator */}
<div className="flex items-center gap-1 text-gray-500"> <div className="flex items-center gap-1 text-gray-500">
<SourceTypeIcon className="h-3 w-3" /> <SourceTypeIcon className="h-3 w-3" />
<span className="text-xs capitalize">{t.common.source}</span> <span className="text-xs capitalize">{t('common.source')}</span>
</div> </div>
</div> </div>
)} )}
@ -259,12 +259,12 @@ export function SourceCard({
{/* Source type badge */} {/* Source type badge */}
<Badge variant="secondary" className="text-xs flex items-center gap-1"> <Badge variant="secondary" className="text-xs flex items-center gap-1">
<SourceTypeIcon className="h-3 w-3" /> <SourceTypeIcon className="h-3 w-3" />
{sourceType === 'link' ? t.sources.addUrl : sourceType === 'upload' ? t.sources.uploadFile : t.sources.enterText} {sourceType === 'link' ? t('sources.addUrl') : sourceType === 'upload' ? t('sources.uploadFile') : t('sources.enterText')}
</Badge> </Badge>
{isCompleted && source.insights_count > 0 && ( {isCompleted && source.insights_count > 0 && (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{t.sources.insightsCount.replace('{count}', source.insights_count.toString())} {t('sources.insightsCount').replace('{count}', source.insights_count.toString())}
</Badge> </Badge>
)} )}
{source.topics && source.topics.length > 0 && isCompleted && ( {source.topics && source.topics.length > 0 && isCompleted && (
@ -318,7 +318,7 @@ export function SourceCard({
disabled={!onRemoveFromNotebook} disabled={!onRemoveFromNotebook}
> >
<Unlink className="h-4 w-4 mr-2" /> <Unlink className="h-4 w-4 mr-2" />
{t.sources.removeFromNotebook} {t('sources.removeFromNotebook')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</> </>
@ -334,7 +334,7 @@ export function SourceCard({
disabled={!onRetry} disabled={!onRetry}
> >
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
{t.sources.retryProcessing} {t('sources.retryProcessing')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</> </>
@ -349,7 +349,7 @@ export function SourceCard({
className="text-red-600 focus:text-red-600" className="text-red-600 focus:text-red-600"
> >
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
{t.sources.deleteSource} {t('sources.deleteSource')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -366,7 +366,7 @@ export function SourceCard({
className="h-7 text-xs" className="h-7 text-xs"
> >
<RefreshCw className="h-3 w-3 mr-1" /> <RefreshCw className="h-3 w-3 mr-1" />
{t.sources.retry} {t('sources.retry')}
</Button> </Button>
</div> </div>
)} )}
@ -375,7 +375,7 @@ export function SourceCard({
{isProcessing && statusData?.processing_info?.progress && ( {isProcessing && statusData?.processing_info?.progress && (
<div className="mt-3 pt-2 border-t"> <div className="mt-3 pt-2 border-t">
<div className="flex justify-between items-center mb-1"> <div className="flex justify-between items-center mb-1">
<span className="text-xs text-gray-600">{t.common.progress}</span> <span className="text-xs text-gray-600">{t('common.progress')}</span>
<span className="text-xs text-gray-600"> <span className="text-xs text-gray-600">
{Math.round(statusData.processing_info.progress as number)}% {Math.round(statusData.processing_info.progress as number)}%
</span> </span>

View file

@ -28,15 +28,15 @@ export function NotebooksStep({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<FormSection <FormSection
title={`${t.notebooks.title} (${t.common.optional})`} title={`${t('notebooks.title')} (${t('common.optional')})`}
description={t.sources.addExistingDesc} description={t('sources.addExistingDesc')}
> >
<CheckboxList <CheckboxList
items={notebookItems} items={notebookItems}
selectedIds={selectedNotebooks} selectedIds={selectedNotebooks}
onToggle={onToggleNotebook} onToggle={onToggleNotebook}
loading={loading} loading={loading}
emptyMessage={t.sources.noNotebooksFound} emptyMessage={t('sources.noNotebooksFound')}
/> />
</FormSection> </FormSection>
</div> </div>

View file

@ -47,21 +47,21 @@ export function ProcessingStep({
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<FormSection <FormSection
title={`${t.navigation.transformations} (${t.common.optional})`} title={`${t('navigation.transformations')} (${t('common.optional')})`}
description={t.sources.processDescription} description={t('sources.processDescription')}
> >
<CheckboxList <CheckboxList
items={transformationItems} items={transformationItems}
selectedIds={selectedTransformations} selectedIds={selectedTransformations}
onToggle={onToggleTransformation} onToggle={onToggleTransformation}
loading={loading} loading={loading}
emptyMessage={t.common.noMatches} emptyMessage={t('common.noMatches')}
/> />
</FormSection> </FormSection>
<FormSection <FormSection
title={t.navigation.settings} title={t('navigation.settings')}
description={t.sources.processDescription} description={t('sources.processDescription')}
> >
<div className="space-y-4"> <div className="space-y-4">
{settings?.default_embedding_option === 'ask' && ( {settings?.default_embedding_option === 'ask' && (
@ -80,9 +80,9 @@ export function ProcessingStep({
className="mt-0.5" className="mt-0.5"
/> />
<div className="flex-1"> <div className="flex-1">
<span className="text-sm font-medium block">{t.sources.enableEmbedding}</span> <span className="text-sm font-medium block">{t('sources.enableEmbedding')}</span>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{t.sources.embeddingDesc} {t('sources.embeddingDesc')}
</p> </p>
</div> </div>
</label> </label>
@ -95,10 +95,10 @@ export function ProcessingStep({
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-4 h-4 bg-primary rounded-full mt-0.5 flex-shrink-0"></div> <div className="w-4 h-4 bg-primary rounded-full mt-0.5 flex-shrink-0"></div>
<div className="flex-1"> <div className="flex-1">
<span className="text-sm font-medium block text-primary">{t.sources.embeddingAlways}</span> <span className="text-sm font-medium block text-primary">{t('sources.embeddingAlways')}</span>
<p className="text-xs text-primary mt-1"> <p className="text-xs text-primary mt-1">
{t.sources.embeddingAlwaysDesc} {t('sources.embeddingAlwaysDesc')}
{t.sources.changeInSettings} <span className="font-medium">{t.navigation.settings}</span>. {t('sources.changeInSettings')} <span className="font-medium">{t('navigation.settings')}</span>.
</p> </p>
</div> </div>
</div> </div>
@ -110,10 +110,10 @@ export function ProcessingStep({
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-4 h-4 bg-muted-foreground rounded-full mt-0.5 flex-shrink-0"></div> <div className="w-4 h-4 bg-muted-foreground rounded-full mt-0.5 flex-shrink-0"></div>
<div className="flex-1"> <div className="flex-1">
<span className="text-sm font-medium block text-foreground">{t.sources.embeddingNever}</span> <span className="text-sm font-medium block text-foreground">{t('sources.embeddingNever')}</span>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{t.sources.embeddingNeverDesc} {t('sources.embeddingNeverDesc')}
{t.sources.changeInSettings} <span className="font-medium">{t.navigation.settings}</span>. {t('sources.changeInSettings')} <span className="font-medium">{t('navigation.settings')}</span>.
</p> </p>
</div> </div>
</div> </div>

View file

@ -63,26 +63,26 @@ export function parseAndValidateUrls(text: string): {
return { valid, invalid } return { valid, invalid }
} }
import { TranslationKeys } from '@/lib/locales' import type { TFunction } from 'i18next'
const getSourceTypes = (t: TranslationKeys) => [ const getSourceTypes = (t: TFunction) => [
{ {
value: 'link' as const, value: 'link' as const,
label: t.sources.addUrl, label: t('sources.addUrl'),
icon: LinkIcon, icon: LinkIcon,
description: t.sources.processDescription, description: t('sources.processDescription'),
}, },
{ {
value: 'upload' as const, value: 'upload' as const,
label: t.sources.uploadFile, label: t('sources.uploadFile'),
icon: FileIcon, icon: FileIcon,
description: t.sources.processDescription, description: t('sources.processDescription'),
}, },
{ {
value: 'text' as const, value: 'text' as const,
label: t.sources.enterText, label: t('sources.enterText'),
icon: FileTextIcon, icon: FileTextIcon,
description: t.sources.processDescription, description: t('sources.processDescription'),
}, },
] ]
@ -156,8 +156,8 @@ export function SourceTypeStep({ control, register, setValue, errors, urlValidat
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<FormSection <FormSection
title={t.sources.title} title={t('sources.title')}
description={t.sources.processDescription} description={t('sources.processDescription')}
> >
<Controller <Controller
control={control} control={control}
@ -188,11 +188,11 @@ export function SourceTypeStep({ control, register, setValue, errors, urlValidat
{type.value === 'link' && ( {type.value === 'link' && (
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<Label htmlFor="url">{t.sources.urlLabel}</Label> <Label htmlFor="url">{t('sources.urlLabel')}</Label>
{urlCount > 0 && ( {urlCount > 0 && (
<Badge variant={isOverLimit ? "destructive" : "secondary"}> <Badge variant={isOverLimit ? "destructive" : "secondary"}>
{t.sources.urlsCount.replace('{count}', urlCount.toString())} {t('sources.urlsCount').replace('{count}', urlCount.toString())}
{isOverLimit && ` (${t.sources.maxItems.replace('{count}', MAX_BATCH_SIZE.toString())})`} {isOverLimit && ` (${t('sources.maxItems').replace('{count}', MAX_BATCH_SIZE.toString())})`}
</Badge> </Badge>
)} )}
</div> </div>
@ -201,12 +201,12 @@ export function SourceTypeStep({ control, register, setValue, errors, urlValidat
{...register('url', { {...register('url', {
onChange: () => onClearUrlErrors?.() onChange: () => onClearUrlErrors?.()
})} })}
placeholder={t.sources.enterUrlsPlaceholder} placeholder={t('sources.enterUrlsPlaceholder')}
rows={urlCount > 1 ? 6 : 2} rows={urlCount > 1 ? 6 : 2}
className="font-mono text-sm" className="font-mono text-sm"
/> />
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{t.sources.batchUrlHint} {t('sources.batchUrlHint')}
</p> </p>
{errors.url && ( {errors.url && (
<p className="text-sm text-destructive mt-1">{errors.url.message}</p> <p className="text-sm text-destructive mt-1">{errors.url.message}</p>
@ -214,20 +214,20 @@ export function SourceTypeStep({ control, register, setValue, errors, urlValidat
{urlValidationErrors && urlValidationErrors.length > 0 && ( {urlValidationErrors && urlValidationErrors.length > 0 && (
<div className="mt-2 p-3 bg-destructive/10 rounded-md border border-destructive/20"> <div className="mt-2 p-3 bg-destructive/10 rounded-md border border-destructive/20">
<p className="text-sm font-medium text-destructive mb-2"> <p className="text-sm font-medium text-destructive mb-2">
{t.sources.invalidUrlsDetected} {t('sources.invalidUrlsDetected')}
</p> </p>
<ul className="space-y-1"> <ul className="space-y-1">
{urlValidationErrors.map((error, idx) => ( {urlValidationErrors.map((error, idx) => (
<li key={idx} className="text-xs text-destructive flex items-start gap-2"> <li key={idx} className="text-xs text-destructive flex items-start gap-2">
<span className="font-mono bg-destructive/20 px-1 rounded"> <span className="font-mono bg-destructive/20 px-1 rounded">
{t.sources.lineLabel.replace('{line}', error.line.toString())} {t('sources.lineLabel').replace('{line}', error.line.toString())}
</span> </span>
<span className="truncate">{error.url}</span> <span className="truncate">{error.url}</span>
</li> </li>
))} ))}
</ul> </ul>
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">
{t.sources.fixInvalidUrls} {t('sources.fixInvalidUrls')}
</p> </p>
</div> </div>
)} )}
@ -237,11 +237,11 @@ export function SourceTypeStep({ control, register, setValue, errors, urlValidat
{type.value === 'upload' && ( {type.value === 'upload' && (
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<Label htmlFor="file">{t.sources.fileLabel}</Label> <Label htmlFor="file">{t('sources.fileLabel')}</Label>
{fileCount > 0 && ( {fileCount > 0 && (
<Badge variant={isOverLimit ? "destructive" : "secondary"}> <Badge variant={isOverLimit ? "destructive" : "secondary"}>
{t.sources.filesCount.replace('{count}', fileCount.toString())} {t('sources.filesCount').replace('{count}', fileCount.toString())}
{isOverLimit && ` (${t.sources.maxItems.replace('{count}', MAX_BATCH_SIZE.toString())})`} {isOverLimit && ` (${t('sources.maxItems').replace('{count}', MAX_BATCH_SIZE.toString())})`}
</Badge> </Badge>
)} )}
</div> </div>
@ -253,11 +253,11 @@ export function SourceTypeStep({ control, register, setValue, errors, urlValidat
accept=".pdf,.doc,.docx,.pptx,.ppt,.xlsx,.xls,.txt,.md,.epub,.mp4,.avi,.mov,.wmv,.mp3,.wav,.m4a,.aac,.jpg,.jpeg,.png,.tiff,.zip,.tar,.gz,.html" accept=".pdf,.doc,.docx,.pptx,.ppt,.xlsx,.xls,.txt,.md,.epub,.mp4,.avi,.mov,.wmv,.mp3,.wav,.m4a,.aac,.jpg,.jpeg,.png,.tiff,.zip,.tar,.gz,.html"
/> />
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{t.sources.selectMultipleFilesHint} {t('sources.selectMultipleFilesHint')}
</p> </p>
{fileCount > 1 && fileInput instanceof FileList && ( {fileCount > 1 && fileInput instanceof FileList && (
<div className="mt-2 p-3 bg-muted rounded-md"> <div className="mt-2 p-3 bg-muted rounded-md">
<p className="text-xs font-medium mb-2">{t.sources.selectedFiles}</p> <p className="text-xs font-medium mb-2">{t('sources.selectedFiles')}</p>
<ul className="space-y-1 max-h-32 overflow-y-auto"> <ul className="space-y-1 max-h-32 overflow-y-auto">
{Array.from(fileInput).map((file, idx) => ( {Array.from(fileInput).map((file, idx) => (
<li key={idx} className="text-xs text-muted-foreground flex items-center gap-2"> <li key={idx} className="text-xs text-muted-foreground flex items-center gap-2">
@ -276,7 +276,7 @@ export function SourceTypeStep({ control, register, setValue, errors, urlValidat
)} )}
{isOverLimit && selectedType === 'upload' && ( {isOverLimit && selectedType === 'upload' && (
<p className="text-sm text-destructive mt-1"> <p className="text-sm text-destructive mt-1">
{t.sources.maxFilesAllowed.replace('{count}', MAX_BATCH_SIZE.toString())} {t('sources.maxFilesAllowed').replace('{count}', MAX_BATCH_SIZE.toString())}
</p> </p>
)} )}
</div> </div>
@ -284,18 +284,18 @@ export function SourceTypeStep({ control, register, setValue, errors, urlValidat
{type.value === 'text' && ( {type.value === 'text' && (
<div> <div>
<Label htmlFor="content" className="mb-2 block">{t.sources.textContentLabel}</Label> <Label htmlFor="content" className="mb-2 block">{t('sources.textContentLabel')}</Label>
{hasHtmlContent && ( {hasHtmlContent && (
<div className="mb-2 p-2 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md"> <div className="mb-2 p-2 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md">
<p className="text-sm text-blue-700 dark:text-blue-300"> <p className="text-sm text-blue-700 dark:text-blue-300">
{t.sources.htmlDetected} {t('sources.htmlDetected')}
</p> </p>
</div> </div>
)} )}
<Textarea <Textarea
id="content" id="content"
{...register('content')} {...register('content')}
placeholder={t.sources.textPlaceholder} placeholder={t('sources.textPlaceholder')}
rows={6} rows={6}
onPaste={handleTextPaste} onPaste={handleTextPaste}
/> />
@ -318,16 +318,16 @@ export function SourceTypeStep({ control, register, setValue, errors, urlValidat
{!isBatchMode && ( {!isBatchMode && (
<FormSection <FormSection
htmlFor="source-title" htmlFor="source-title"
title={selectedType === 'text' ? `${t.common.title} *` : `${t.common.title} (${t.common.optional})`} title={selectedType === 'text' ? `${t('common.title')} *` : `${t('common.title')} (${t('common.optional')})`}
description={selectedType === 'text' description={selectedType === 'text'
? t.sources.titleRequired ? t('sources.titleRequired')
: t.sources.titleGenerated : t('sources.titleGenerated')
} }
> >
<Input <Input
id="source-title" id="source-title"
{...register('title')} {...register('title')}
placeholder={t.sources.titlePlaceholder} placeholder={t('sources.titlePlaceholder')}
autoComplete="off" autoComplete="off"
/> />
{errors.title && ( {errors.title && (
@ -340,14 +340,14 @@ export function SourceTypeStep({ control, register, setValue, errors, urlValidat
{isBatchMode && ( {isBatchMode && (
<div className="p-4 bg-primary/5 border border-primary/20 rounded-lg"> <div className="p-4 bg-primary/5 border border-primary/20 rounded-lg">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Badge variant="default">{t.common.batchMode}</Badge> <Badge variant="default">{t('common.batchMode')}</Badge>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{t.sources.batchCount.replace('{count}', itemCount.toString()).replace('{type}', selectedType === 'link' ? t.sources.addUrl : t.sources.uploadFile)} {t('sources.batchCount').replace('{count}', itemCount.toString()).replace('{type}', selectedType === 'link' ? t('sources.addUrl') : t('sources.uploadFile'))}
</span> </span>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t.sources.batchTitlesAuto} {t('sources.batchTitlesAuto')}
{t.sources.batchCommonSettings} {t('sources.batchCommonSettings')}
</p> </p>
</div> </div>
)} )}

View file

@ -72,7 +72,7 @@ const DialogContent = ({
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">{t?.common?.close || 'Close'}</span> <span className="sr-only">{t('common.close')}</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>

View file

@ -10,7 +10,7 @@ React hooks for API data fetching, state management, and complex workflows (chat
- **Streaming hooks** (`useAsk`): SSE parsing for multi-stage Ask workflows (strategy → answers → final answer) - **Streaming hooks** (`useAsk`): SSE parsing for multi-stage Ask workflows (strategy → answers → final answer)
- **Model/config hooks** (`useModels`, `useSettings`, `useTransformations`): Application-level settings and model management - **Model/config hooks** (`useModels`, `useSettings`, `useTransformations`): Application-level settings and model management
- **Utility hooks** (`useMediaQuery`, `useToast`, `useNavigation`, `useAuth`): UI state and auth checking - **Utility hooks** (`useMediaQuery`, `useToast`, `useNavigation`, `useAuth`): UI state and auth checking
- **i18n hook** (`useTranslation`): Proxy-based translation access with `t.section.key` pattern and language switching - **i18n hook** (`useTranslation`): Thin wrapper around react-i18next with `t('section.key')` pattern and language switching
## Important Patterns ## Important Patterns
@ -22,7 +22,7 @@ React hooks for API data fetching, state management, and complex workflows (chat
- **SSE streaming pattern**: `useAsk` manually parses newline-delimited JSON from `/api/search/ask`; handles incomplete buffers - **SSE streaming pattern**: `useAsk` manually parses newline-delimited JSON from `/api/search/ask`; handles incomplete buffers
- **Status polling**: `useSourceStatus` auto-refetches every 2s while `status === 'running' | 'queued' | 'new'` - **Status polling**: `useSourceStatus` auto-refetches every 2s while `status === 'running' | 'queued' | 'new'`
- **Context building**: `useNotebookChat.buildContext()` assembles selected sources + notes with token/char counts - **Context building**: `useNotebookChat.buildContext()` assembles selected sources + notes with token/char counts
- **i18n Proxy pattern**: `useTranslation` returns `t` object with Proxy; access `t.section.key` instead of `t('section.key')` - **i18n pattern**: `useTranslation` returns standard react-i18next `t` function; access translations via `t('section.key')`
## Key Dependencies ## Key Dependencies
@ -49,8 +49,7 @@ React hooks for API data fetching, state management, and complex workflows (chat
- **Status polling race**: `useSourceStatus` may refetch stale data before server catches up; retry logic has 3-attempt limit - **Status polling race**: `useSourceStatus` may refetch stale data before server catches up; retry logic has 3-attempt limit
- **Keyboard trap in dialogs**: Some hooks manage modal state; ensure Dialog/Modal components handle escape key properly - **Keyboard trap in dialogs**: Some hooks manage modal state; ensure Dialog/Modal components handle escape key properly
- **Form data handling**: `useFileUpload` and source creation convert JSON fields to strings in FormData - **Form data handling**: `useFileUpload` and source creation convert JSON fields to strings in FormData
- **useTranslation depth limit**: Proxy limits nesting to 4 levels; deeper access returns path string as fallback - **useTranslation**: Thin wrapper preserving `setLanguage` with language change events for `LanguageLoadingOverlay`
- **useTranslation loop detection**: >1000 accesses to same key in 1s triggers error and breaks recursion
## Testing Patterns ## Testing Patterns
@ -187,7 +186,7 @@ function CredentialSettings() {
### Important Notes ### Important Notes
- **Toast notifications**: All mutations show success/error toasts automatically - **Toast notifications**: All mutations show success/error toasts automatically
- **i18n integration**: Toast messages use translation keys from `t.apiKeys.*` and `t.common.*` - **i18n integration**: Toast messages use translation keys from `t('apiKeys.*')` and `t('common.*')`
- **Error handling**: Uses `getApiErrorKey()` utility to extract error messages from API responses - **Error handling**: Uses `getApiErrorKey()` utility to extract error messages from API responses
- **Local test results**: `useTestCredential` stores results in local state (not cached in TanStack Query) - **Local test results**: `useTestCredential` stores results in local state (not cached in TanStack Query)
- **Migration feedback**: Migration hooks show different toasts based on migrated/skipped/error counts - **Migration feedback**: Migration hooks show different toasts based on migrated/skipped/error counts

View file

@ -87,14 +87,14 @@ export function useCreateCredential() {
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all }) queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all })
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.providers }) queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.providers })
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.apiKeys.configSaveSuccess, description: t('apiKeys.configSaveSuccess'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorKey(error, t.common.error), description: getApiErrorKey(error, t('common.error')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -121,14 +121,14 @@ export function useUpdateCredential() {
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all }) queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all })
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.providers }) queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.providers })
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.apiKeys.configUpdateSuccess, description: t('apiKeys.configUpdateSuccess'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorKey(error, t.common.error), description: getApiErrorKey(error, t('common.error')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -156,14 +156,14 @@ export function useDeleteCredential() {
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.models }) queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.models })
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.providers }) queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.providers })
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.apiKeys.configDeleteSuccess, description: t('apiKeys.configDeleteSuccess'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorKey(error, t.common.error), description: getApiErrorKey(error, t('common.error')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -184,21 +184,21 @@ export function useTestCredential() {
setTestResults(prev => ({ ...prev, [credentialId]: result })) setTestResults(prev => ({ ...prev, [credentialId]: result }))
if (result.success) { if (result.success) {
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.apiKeys.testSuccess, description: t('apiKeys.testSuccess'),
}) })
} else { } else {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: result.message || t.apiKeys.testFailed, description: result.message || t('apiKeys.testFailed'),
variant: 'destructive', variant: 'destructive',
}) })
} }
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorKey(error, t.apiKeys.testFailed), description: getApiErrorKey(error, t('apiKeys.testFailed')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -229,8 +229,8 @@ export function useDiscoverModels() {
mutationFn: (credentialId: string) => credentialsApi.discover(credentialId), mutationFn: (credentialId: string) => credentialsApi.discover(credentialId),
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorKey(error, t.apiKeys.syncFailed), description: getApiErrorKey(error, t('apiKeys.syncFailed')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -259,22 +259,22 @@ export function useRegisterModels() {
if (result.created > 0) { if (result.created > 0) {
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.apiKeys.syncSuccess description: t('apiKeys.syncSuccess')
.replace('{discovered}', (result.created + result.existing).toString()) .replace('{discovered}', (result.created + result.existing).toString())
.replace('{new}', result.created.toString()), .replace('{new}', result.created.toString()),
}) })
} else { } else {
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.apiKeys.syncNoNew.replace('{count}', result.existing.toString()), description: t('apiKeys.syncNoNew').replace('{count}', result.existing.toString()),
}) })
} }
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorKey(error, t.apiKeys.syncFailed), description: getApiErrorKey(error, t('apiKeys.syncFailed')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -303,31 +303,31 @@ export function useMigrateFromEnv() {
if (errorCount > 0 && migratedCount === 0) { if (errorCount > 0 && migratedCount === 0) {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: t.apiKeys.migrationErrors.replace('{count}', errorCount.toString()), description: t('apiKeys.migrationErrors').replace('{count}', errorCount.toString()),
variant: 'destructive', variant: 'destructive',
}) })
} else if (migratedCount > 0 && errorCount > 0) { } else if (migratedCount > 0 && errorCount > 0) {
toast({ toast({
title: t.common.success, title: t('common.success'),
description: `${t.apiKeys.migrationSuccess.replace('{count}', migratedCount.toString())}. ${t.apiKeys.migrationErrors.replace('{count}', errorCount.toString())}`, description: `${t('apiKeys.migrationSuccess').replace('{count}', migratedCount.toString())}. ${t('apiKeys.migrationErrors').replace('{count}', errorCount.toString())}`,
}) })
} else if (migratedCount > 0) { } else if (migratedCount > 0) {
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.apiKeys.migrationSuccess.replace('{count}', migratedCount.toString()), description: t('apiKeys.migrationSuccess').replace('{count}', migratedCount.toString()),
}) })
} else { } else {
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.apiKeys.migrationNothingToMigrate, description: t('apiKeys.migrationNothingToMigrate'),
}) })
} }
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorKey(error, t.common.error), description: getApiErrorKey(error, t('common.error')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -356,31 +356,31 @@ export function useMigrateFromProviderConfig() {
if (errorCount > 0 && migratedCount === 0) { if (errorCount > 0 && migratedCount === 0) {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: t.apiKeys.migrationErrors.replace('{count}', errorCount.toString()), description: t('apiKeys.migrationErrors').replace('{count}', errorCount.toString()),
variant: 'destructive', variant: 'destructive',
}) })
} else if (migratedCount > 0 && errorCount > 0) { } else if (migratedCount > 0 && errorCount > 0) {
toast({ toast({
title: t.common.success, title: t('common.success'),
description: `${t.apiKeys.migrationSuccess.replace('{count}', migratedCount.toString())}. ${t.apiKeys.migrationErrors.replace('{count}', errorCount.toString())}`, description: `${t('apiKeys.migrationSuccess').replace('{count}', migratedCount.toString())}. ${t('apiKeys.migrationErrors').replace('{count}', errorCount.toString())}`,
}) })
} else if (migratedCount > 0) { } else if (migratedCount > 0) {
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.apiKeys.migrationSuccess.replace('{count}', migratedCount.toString()), description: t('apiKeys.migrationSuccess').replace('{count}', migratedCount.toString()),
}) })
} else { } else {
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.apiKeys.migrationNothingToMigrate, description: t('apiKeys.migrationNothingToMigrate'),
}) })
} }
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorKey(error, t.common.error), description: getApiErrorKey(error, t('common.error')),
variant: 'destructive', variant: 'destructive',
}) })
}, },

View file

@ -38,14 +38,14 @@ export function useCreateModel() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.models }) queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.models })
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.models.saveSuccess, description: t('models.saveSuccess'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorKey(error, t.common.error), description: getApiErrorKey(error, t('common.error')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -64,14 +64,14 @@ export function useDeleteModel() {
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.defaults }) queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.defaults })
queryClient.invalidateQueries({ queryKey: ['credentials'] }) queryClient.invalidateQueries({ queryKey: ['credentials'] })
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.models.deleteSuccess, description: t('models.deleteSuccess'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorKey(error, t.common.error), description: getApiErrorKey(error, t('common.error')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -95,14 +95,14 @@ export function useUpdateModelDefaults() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.defaults }) queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.defaults })
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.models.saveSuccess, description: t('models.saveSuccess'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorKey(error, t.common.error), description: getApiErrorKey(error, t('common.error')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -131,26 +131,26 @@ export function useAutoAssignDefaults() {
if (assignedCount > 0) { if (assignedCount > 0) {
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.models.autoAssignSuccess.replace('{count}', assignedCount.toString()), description: t('models.autoAssignSuccess').replace('{count}', assignedCount.toString()),
}) })
} else if (missingCount > 0) { } else if (missingCount > 0) {
toast({ toast({
title: t.common.warning, title: t('common.warning'),
description: t.models.autoAssignNoModels, description: t('models.autoAssignNoModels'),
variant: 'destructive', variant: 'destructive',
}) })
} else { } else {
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.models.autoAssignAlreadySet, description: t('models.autoAssignAlreadySet'),
}) })
} }
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorKey(error, t.common.error), description: getApiErrorKey(error, t('common.error')),
variant: 'destructive', variant: 'destructive',
}) })
}, },

View file

@ -31,14 +31,14 @@ export function useCreateNotebook() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.notebooks }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.notebooks })
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.notebooks.createSuccess, description: t('notebooks.createSuccess'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: t(getApiErrorKey(error, t.common.error)), description: t(getApiErrorKey(error, t('common.error'))),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -57,14 +57,14 @@ export function useUpdateNotebook() {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.notebooks }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.notebooks })
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.notebook(id) }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.notebook(id) })
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.notebooks.updateSuccess, description: t('notebooks.updateSuccess'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: t(getApiErrorKey(error, t.common.error)), description: t(getApiErrorKey(error, t('common.error'))),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -97,14 +97,14 @@ export function useDeleteNotebook() {
// Also invalidate sources since some may have been deleted // Also invalidate sources since some may have been deleted
queryClient.invalidateQueries({ queryKey: ['sources'] }) queryClient.invalidateQueries({ queryKey: ['sources'] })
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.notebooks.deleteSuccess, description: t('notebooks.deleteSuccess'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: t(getApiErrorKey(error, t.common.error)), description: t(getApiErrorKey(error, t('common.error'))),
variant: 'destructive', variant: 'destructive',
}) })
}, },

View file

@ -35,14 +35,14 @@ export function useCreateNote() {
queryKey: QUERY_KEYS.notes(variables.notebook_id) queryKey: QUERY_KEYS.notes(variables.notebook_id)
}) })
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.notebooks.noteCreatedSuccess, description: t('notebooks.noteCreatedSuccess'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorKey(error, t.notebooks.failedToCreateNote), description: getApiErrorKey(error, t('notebooks.failedToCreateNote')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -61,14 +61,14 @@ export function useUpdateNote() {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.notes() }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.notes() })
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.note(id) }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.note(id) })
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.notebooks.noteUpdatedSuccess, description: t('notebooks.noteUpdatedSuccess'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorKey(error, t.notebooks.failedToUpdateNote), description: getApiErrorKey(error, t('notebooks.failedToUpdateNote')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -86,14 +86,14 @@ export function useDeleteNote() {
// Invalidate all notes queries (with and without notebook IDs) // Invalidate all notes queries (with and without notebook IDs)
queryClient.invalidateQueries({ queryKey: ['notes'] }) queryClient.invalidateQueries({ queryKey: ['notes'] })
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.notebooks.noteDeletedSuccess, description: t('notebooks.noteDeletedSuccess'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorKey(error, t.notebooks.failedToDeleteNote), description: getApiErrorKey(error, t('notebooks.failedToDeleteNote')),
variant: 'destructive', variant: 'destructive',
}) })
}, },

View file

@ -98,14 +98,14 @@ export function useRetryPodcastEpisode() {
onSuccess: async () => { onSuccess: async () => {
await queryClient.refetchQueries({ queryKey: QUERY_KEYS.podcastEpisodes }) await queryClient.refetchQueries({ queryKey: QUERY_KEYS.podcastEpisodes })
toast({ toast({
title: t.podcasts.retryStarted, title: t('podcasts.retryStarted'),
description: t.podcasts.retryStartedDesc, description: t('podcasts.retryStartedDesc'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.podcasts.failedToRetry, title: t('podcasts.failedToRetry'),
description: getApiErrorKey(error, t.common.error), description: getApiErrorKey(error, t('common.error')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -122,14 +122,14 @@ export function useDeletePodcastEpisode() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes })
toast({ toast({
title: t.podcasts.episodeDeleted, title: t('podcasts.episodeDeleted'),
description: t.podcasts.episodeDeletedDesc, description: t('podcasts.episodeDeletedDesc'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.podcasts.failedToDeleteEpisode, title: t('podcasts.failedToDeleteEpisode'),
description: getApiErrorKey(error, t.common.error), description: getApiErrorKey(error, t('common.error')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -160,14 +160,14 @@ export function useCreateEpisodeProfile() {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.episodeProfiles }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.episodeProfiles })
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes })
toast({ toast({
title: t.podcasts.profileCreated, title: t('podcasts.profileCreated'),
description: t.podcasts.profileCreatedDesc, description: t('podcasts.profileCreatedDesc'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.podcasts.failedToCreateProfile, title: t('podcasts.failedToCreateProfile'),
description: getApiErrorKey(error, t.common.error), description: getApiErrorKey(error, t('common.error')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -191,14 +191,14 @@ export function useUpdateEpisodeProfile() {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.episodeProfiles }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.episodeProfiles })
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes })
toast({ toast({
title: t.podcasts.profileUpdated, title: t('podcasts.profileUpdated'),
description: t.podcasts.profileUpdatedDesc, description: t('podcasts.profileUpdatedDesc'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.podcasts.failedToUpdateProfile, title: t('podcasts.failedToUpdateProfile'),
description: getApiErrorKey(error, t.common.error), description: getApiErrorKey(error, t('common.error')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -216,14 +216,14 @@ export function useDeleteEpisodeProfile() {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.episodeProfiles }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.episodeProfiles })
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes })
toast({ toast({
title: t.podcasts.profileDeleted, title: t('podcasts.profileDeleted'),
description: t.podcasts.profileDeletedDesc, description: t('podcasts.profileDeletedDesc'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.podcasts.failedToDeleteProfile, title: t('podcasts.failedToDeleteProfile'),
description: getApiErrorKey(error, t.podcasts.failedToDeleteProfileDesc), description: getApiErrorKey(error, t('podcasts.failedToDeleteProfileDesc')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -242,14 +242,14 @@ export function useDuplicateEpisodeProfile() {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.episodeProfiles }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.episodeProfiles })
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes })
toast({ toast({
title: t.podcasts.profileDuplicated, title: t('podcasts.profileDuplicated'),
description: t.podcasts.profileDuplicatedDesc, description: t('podcasts.profileDuplicatedDesc'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.podcasts.failedToDuplicateProfile, title: t('podcasts.failedToDuplicateProfile'),
description: getApiErrorKey(error, t.common.error), description: getApiErrorKey(error, t('common.error')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -289,14 +289,14 @@ export function useCreateSpeakerProfile() {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.episodeProfiles }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.episodeProfiles })
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes })
toast({ toast({
title: t.podcasts.speakerCreated, title: t('podcasts.speakerCreated'),
description: t.podcasts.speakerCreatedDesc, description: t('podcasts.speakerCreatedDesc'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.podcasts.failedToCreateSpeaker, title: t('podcasts.failedToCreateSpeaker'),
description: getApiErrorKey(error, t.common.error), description: getApiErrorKey(error, t('common.error')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -321,14 +321,14 @@ export function useUpdateSpeakerProfile() {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.episodeProfiles }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.episodeProfiles })
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes })
toast({ toast({
title: t.podcasts.speakerUpdated, title: t('podcasts.speakerUpdated'),
description: t.podcasts.speakerUpdatedDesc, description: t('podcasts.speakerUpdatedDesc'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.podcasts.failedToUpdateSpeaker, title: t('podcasts.failedToUpdateSpeaker'),
description: getApiErrorKey(error, t.common.error), description: getApiErrorKey(error, t('common.error')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -347,14 +347,14 @@ export function useDeleteSpeakerProfile() {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.episodeProfiles }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.episodeProfiles })
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes })
toast({ toast({
title: t.podcasts.speakerDeleted, title: t('podcasts.speakerDeleted'),
description: t.podcasts.speakerDeletedDesc, description: t('podcasts.speakerDeletedDesc'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.podcasts.failedToDeleteSpeaker, title: t('podcasts.failedToDeleteSpeaker'),
description: getApiErrorKey(error, t.podcasts.failedToDeleteSpeakerDesc), description: getApiErrorKey(error, t('podcasts.failedToDeleteSpeakerDesc')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -372,14 +372,14 @@ export function useDuplicateSpeakerProfile() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.speakerProfiles }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.speakerProfiles })
toast({ toast({
title: t.podcasts.speakerDuplicated, title: t('podcasts.speakerDuplicated'),
description: t.podcasts.speakerDuplicatedDesc, description: t('podcasts.speakerDuplicatedDesc'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.podcasts.failedToDuplicateSpeaker, title: t('podcasts.failedToDuplicateSpeaker'),
description: getApiErrorKey(error, t.common.error), description: getApiErrorKey(error, t('common.error')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -398,14 +398,14 @@ export function useGeneratePodcast() {
// Immediately refetch to show the new episode // Immediately refetch to show the new episode
await queryClient.refetchQueries({ queryKey: QUERY_KEYS.podcastEpisodes }) await queryClient.refetchQueries({ queryKey: QUERY_KEYS.podcastEpisodes })
toast({ toast({
title: t.podcasts.generationStarted, title: t('podcasts.generationStarted'),
description: t.podcasts.generationStartedDesc.replace('{name}', response.episode_name), description: t('podcasts.generationStartedDesc').replace('{name}', response.episode_name),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.podcasts.failedToStartGeneration, title: t('podcasts.failedToStartGeneration'),
description: getApiErrorKey(error, t.podcasts.tryAgainMoment), description: getApiErrorKey(error, t('podcasts.tryAgainMoment')),
variant: 'destructive', variant: 'destructive',
}) })
}, },

View file

@ -3,7 +3,7 @@ import { settingsApi } from '@/lib/api/settings'
import { QUERY_KEYS } from '@/lib/api/query-client' import { QUERY_KEYS } from '@/lib/api/query-client'
import { useToast } from '@/lib/hooks/use-toast' import { useToast } from '@/lib/hooks/use-toast'
import { useTranslation } from '@/lib/hooks/use-translation' import { useTranslation } from '@/lib/hooks/use-translation'
import { getApiErrorKey } from '@/lib/utils/error-handler' import { getApiErrorMessage } from '@/lib/utils/error-handler'
import { SettingsResponse } from '@/lib/types/api' import { SettingsResponse } from '@/lib/types/api'
export function useSettings() { export function useSettings() {
@ -23,14 +23,14 @@ export function useUpdateSettings() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.settings }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.settings })
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.common.saveSuccess, description: t('common.saveSuccess'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorKey(error, t.common.error), description: getApiErrorMessage(error, (key) => t(key), 'common.error'),
variant: 'destructive', variant: 'destructive',
}) })
}, },

View file

@ -126,20 +126,20 @@ export function useCreateSource() {
// Show different messages based on processing mode // Show different messages based on processing mode
if (variables.async_processing) { if (variables.async_processing) {
toast({ toast({
title: t.sources.sourceQueued, title: t('sources.sourceQueued'),
description: t.sources.sourceQueuedDesc, description: t('sources.sourceQueuedDesc'),
}) })
} else { } else {
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.sources.sourceAddedSuccess, description: t('sources.sourceAddedSuccess'),
}) })
} }
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToAddSource), description: getApiErrorMessage(error, (key) => t(key), t('sources.failedToAddSource')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -159,14 +159,14 @@ export function useUpdateSource() {
queryClient.invalidateQueries({ queryKey: ['sources'] }) queryClient.invalidateQueries({ queryKey: ['sources'] })
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.source(id) }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.source(id) })
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.sources.sourceUpdatedSuccess, description: t('sources.sourceUpdatedSuccess'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToUpdateSource), description: getApiErrorMessage(error, (key) => t(key), t('sources.failedToUpdateSource')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -186,14 +186,14 @@ export function useDeleteSource() {
// Also invalidate the specific source // Also invalidate the specific source
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.source(id) }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.source(id) })
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.sources.sourceDeletedSuccess, description: t('sources.sourceDeletedSuccess'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToDeleteSource), description: getApiErrorMessage(error, (key) => t(key), t('sources.failedToDeleteSource')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -217,14 +217,14 @@ export function useFileUpload() {
refetchType: 'active' refetchType: 'active'
}) })
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.sources.fileUploadedSuccess, description: t('sources.fileUploadedSuccess'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToUploadFile), description: getApiErrorMessage(error, (key) => t(key), t('sources.failedToUploadFile')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -275,14 +275,14 @@ export function useRetrySource() {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.source(sourceId) }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.source(sourceId) })
toast({ toast({
title: t.sources.sourceRequeued, title: t('sources.sourceRequeued'),
description: t.sources.sourceRequeuedDesc, description: t('sources.sourceRequeuedDesc'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToRetry), description: getApiErrorMessage(error, (key) => t(key), t('sources.failedToRetry')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -322,19 +322,19 @@ export function useAddSourcesToNotebook() {
// Show appropriate toast based on results // Show appropriate toast based on results
if (result.failures === 0) { if (result.failures === 0) {
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.sources.sourcesAddedToNotebook.replace('{count}', result.successes.toString()), description: t('sources.sourcesAddedToNotebook').replace('{count}', result.successes.toString()),
}) })
} else if (result.successes === 0) { } else if (result.successes === 0) {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: t.sources.failedToAddSourcesToNotebook, description: t('sources.failedToAddSourcesToNotebook'),
variant: 'destructive', variant: 'destructive',
}) })
} else { } else {
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.sources.partialAddSuccess description: t('sources.partialAddSuccess')
.replace('{success}', result.successes.toString()) .replace('{success}', result.successes.toString())
.replace('{failed}', result.failures.toString()), .replace('{failed}', result.failures.toString()),
variant: 'default', variant: 'default',
@ -343,8 +343,8 @@ export function useAddSourcesToNotebook() {
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToAddSourcesToNotebook), description: getApiErrorMessage(error, (key) => t(key), t('sources.failedToAddSourcesToNotebook')),
variant: 'destructive', variant: 'destructive',
}) })
}, },
@ -371,14 +371,14 @@ export function useRemoveSourceFromNotebook() {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.source(sourceId) }) queryClient.invalidateQueries({ queryKey: QUERY_KEYS.source(sourceId) })
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.sources.sourceRemovedFromNotebook, description: t('sources.sourceRemovedFromNotebook'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToRemoveSourceFromNotebook), description: getApiErrorMessage(error, (key) => t(key), t('sources.failedToRemoveSourceFromNotebook')),
variant: 'destructive', variant: 'destructive',
}) })
}, },

View file

@ -13,11 +13,11 @@ export function useToast() {
return { return {
toast: ({ title, description, variant = 'default' }: ToastProps) => { toast: ({ title, description, variant = 'default' }: ToastProps) => {
if (variant === 'destructive') { if (variant === 'destructive') {
sonnerToast.error(title || t.common.error, { sonnerToast.error(title || t('common.error'), {
description, description,
}) })
} else { } else {
sonnerToast.success(title || t.common.success, { sonnerToast.success(title || t('common.success'), {
description, description,
}) })
} }

View file

@ -42,13 +42,13 @@ export function useCreateTransformation() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: TRANSFORMATION_QUERY_KEYS.transformations }) queryClient.invalidateQueries({ queryKey: TRANSFORMATION_QUERY_KEYS.transformations })
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.transformations.createSuccess, description: t('transformations.createSuccess'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorMessage(error, (key) => t(key)), description: getApiErrorMessage(error, (key) => t(key)),
variant: 'destructive', variant: 'destructive',
}) })
@ -68,13 +68,13 @@ export function useUpdateTransformation() {
queryClient.invalidateQueries({ queryKey: TRANSFORMATION_QUERY_KEYS.transformations }) queryClient.invalidateQueries({ queryKey: TRANSFORMATION_QUERY_KEYS.transformations })
queryClient.invalidateQueries({ queryKey: TRANSFORMATION_QUERY_KEYS.transformation(id) }) queryClient.invalidateQueries({ queryKey: TRANSFORMATION_QUERY_KEYS.transformation(id) })
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.transformations.updateSuccess, description: t('transformations.updateSuccess'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorMessage(error, (key) => t(key)), description: getApiErrorMessage(error, (key) => t(key)),
variant: 'destructive', variant: 'destructive',
}) })
@ -92,13 +92,13 @@ export function useDeleteTransformation() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: TRANSFORMATION_QUERY_KEYS.transformations }) queryClient.invalidateQueries({ queryKey: TRANSFORMATION_QUERY_KEYS.transformations })
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.transformations.deleteSuccess, description: t('transformations.deleteSuccess'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorMessage(error, (key) => t(key)), description: getApiErrorMessage(error, (key) => t(key)),
variant: 'destructive', variant: 'destructive',
}) })
@ -114,7 +114,7 @@ export function useExecuteTransformation() {
mutationFn: (data: ExecuteTransformationRequest) => transformationsApi.execute(data), mutationFn: (data: ExecuteTransformationRequest) => transformationsApi.execute(data),
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorMessage(error, (key) => t(key)), description: getApiErrorMessage(error, (key) => t(key)),
variant: 'destructive', variant: 'destructive',
}) })
@ -139,13 +139,13 @@ export function useUpdateDefaultPrompt() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: TRANSFORMATION_QUERY_KEYS.defaultPrompt }) queryClient.invalidateQueries({ queryKey: TRANSFORMATION_QUERY_KEYS.defaultPrompt })
toast({ toast({
title: t.common.success, title: t('common.success'),
description: t.transformations.updateSuccess, description: t('transformations.updateSuccess'),
}) })
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
toast({ toast({
title: t.common.error, title: t('common.error'),
description: getApiErrorMessage(error, (key) => t(key)), description: getApiErrorMessage(error, (key) => t(key)),
variant: 'destructive', variant: 'destructive',
}) })

View file

@ -5,8 +5,7 @@ vi.unmock('@/lib/hooks/use-translation')
import { useTranslation } from './use-translation' import { useTranslation } from './use-translation'
import { useTranslation as useI18nTranslation } from 'react-i18next' import { useTranslation as useI18nTranslation } from 'react-i18next'
// Mock react-i18next is already done in setup.ts, // Mock react-i18next
// but we might need to control it per test
vi.mock('react-i18next', () => ({ vi.mock('react-i18next', () => ({
useTranslation: vi.fn() useTranslation: vi.fn()
})) }))
@ -18,7 +17,6 @@ describe('useTranslation Hook', () => {
vi.clearAllMocks() vi.clearAllMocks()
;(useI18nTranslation as unknown as { mockReturnValue: (v: unknown) => void }).mockReturnValue({ ;(useI18nTranslation as unknown as { mockReturnValue: (v: unknown) => void }).mockReturnValue({
t: (key: string) => { t: (key: string) => {
if (key === 'common') return { appName: 'Open Notebook' }
if (key === 'common.appName') return 'Open Notebook' if (key === 'common.appName') return 'Open Notebook'
return key return key
}, },
@ -29,14 +27,13 @@ describe('useTranslation Hook', () => {
}) })
}) })
it('should return initial translations via proxy', () => { it('should return standard t() function for translations', () => {
const { result } = renderHook(() => useTranslation()) const { result } = renderHook(() => useTranslation())
expect(result.current.language).toBe('en-US') expect(result.current.language).toBe('en-US')
// Test the proxy behavior t.common.appName -> t("common.appName") expect(result.current.t('common.appName')).toBe('Open Notebook')
expect(result.current.t.common.appName).toBe('Open Notebook')
}) })
it('should allow changing language via i18n.changeLanguage', () => { it('should allow changing language via setLanguage', () => {
const { result } = renderHook(() => useTranslation()) const { result } = renderHook(() => useTranslation())
act(() => { act(() => {

View file

@ -1,132 +1,13 @@
import { useTranslation as useI18nTranslation } from 'react-i18next' import { useTranslation as useI18nTranslation } from 'react-i18next'
import { useMemo, useCallback, useRef } from 'react' import { useMemo, useCallback } from 'react'
import { emitLanguageChangeEnd, emitLanguageChangeStart } from '@/lib/i18n-events' import { emitLanguageChangeEnd, emitLanguageChangeStart } from '@/lib/i18n-events'
/** /**
* Custom useTranslation hook that provides a Proxy-based API for accessing translations. * Thin wrapper around react-i18next's useTranslation hook.
* * Returns the standard t() function along with language utilities.
* CRITICAL: The Proxy implementation must be carefully designed to avoid infinite loops
* during language switching. Key safeguards:
* 1. Strict depth limit (max 4 levels)
* 2. Blocked properties list to prevent React/JS internals from triggering recursion
* 3. Early return for missing keys
* 4. Memoization with stable dependencies
*/ */
export function useTranslation() { export function useTranslation() {
const { t: i18nTranslate, i18n } = useI18nTranslation() const { t, i18n } = useI18nTranslation()
// Use a ref to track the current language to avoid unnecessary Proxy recreation
const languageRef = useRef(i18n.language)
languageRef.current = i18n.language
// Loop detection
const accessCounts = useRef<Record<string, number>>({})
const lastResetTime = useRef(Date.now())
// High-performance Recursive Proxy with strict safety limits
const t = useMemo(() => {
const i18nTranslateCopy = i18nTranslate;
// Set of properties to completely block from Proxy traversal
const BLOCKED_PROPS = new Set([
'__proto__', '__esModule', '$$typeof', 'toJSON', 'constructor',
'valueOf', 'toString', 'inspect', 'nodeType', 'tagName',
'then', 'catch', 'finally', // Promise methods
'prototype', 'caller', 'callee', 'arguments', // Function props
'Symbol(Symbol.toStringTag)', 'Symbol(Symbol.iterator)',
]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createProxy = (path: string, depth: number = 0): any => {
// SAFETY: Strict depth limit to prevent stack overflow
if (depth > 3) {
return path; // Return the path string as fallback
}
// Base function for t('key') or t.path({ options })
const proxyTarget = (keyOrOptions?: string | unknown, options?: unknown) => {
if (typeof keyOrOptions === 'string') {
const fullPath = path ? `${path}.${keyOrOptions}` : keyOrOptions;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return i18nTranslateCopy(fullPath, options as any);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return i18nTranslateCopy(path, keyOrOptions as any);
};
return new Proxy(proxyTarget, {
get(target, prop) {
// Reset counters every 1s
const now = Date.now()
if (now - lastResetTime.current > 1000) {
accessCounts.current = {}
lastResetTime.current = now
}
if (typeof prop === 'string') {
const key = path ? `${path}.${prop}` : prop;
accessCounts.current[key] = (accessCounts.current[key] || 0) + 1;
if (accessCounts.current[key] > 1000) {
console.error(`[useTranslation] INFINITE LOOP DETECTED on key: "${key}". Breaking recursion.`);
return key; // Force break
}
}
// Handle Symbol properties immediately
if (typeof prop === 'symbol') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (target as any)[prop];
}
if (typeof prop !== 'string') return undefined;
// Block React internals and JS built-ins
if (prop.startsWith('__') || prop.startsWith('@@') || BLOCKED_PROPS.has(prop)) {
return undefined;
}
const currentPath = path ? `${path}.${prop}` : prop;
// Try to get the translation first (before checking target properties,
// since target is a function and has built-in properties like 'name'
// that would shadow translation keys)
const result = i18nTranslateCopy(currentPath, { returnObjects: true });
// If it's a leaf string, return it directly
if (typeof result === 'string') {
return result;
}
// Handle String.prototype methods on the current path
if (prop === 'replace' || prop === 'split' || prop === 'length' ||
prop === 'trim' || prop === 'toLowerCase' || prop === 'toUpperCase') {
const translated = i18nTranslateCopy(path);
if (typeof translated === 'string') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const val = (translated as any)[prop];
return typeof val === 'function' ? val.bind(translated) : val;
}
}
// If i18n returned the key itself (meaning not found), stop recursion
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((result as any) === currentPath || result === undefined || result === null) {
return currentPath; // Return path as fallback instead of continuing
}
// If it's an object (nested structure), continue with depth limit
if (typeof result === 'object') {
return createProxy(currentPath, depth + 1);
}
return result;
}
});
};
return createProxy('', 0);
}, [i18nTranslate])
const setLanguage = useCallback(async (lang: string) => { const setLanguage = useCallback(async (lang: string) => {
if (lang === i18n.language) { if (lang === i18n.language) {
@ -144,8 +25,7 @@ export function useTranslation() {
}, [i18n]) }, [i18n])
return useMemo(() => ({ return useMemo(() => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any t,
t: t as any,
i18n, i18n,
language: i18n.language, language: i18n.language,
setLanguage setLanguage

View file

@ -26,12 +26,12 @@ export function useVersionCheck() {
const dismissKey = `version_notification_dismissed_${config.latestVersion}` const dismissKey = `version_notification_dismissed_${config.latestVersion}`
if (sessionStorage.getItem(dismissKey)) return if (sessionStorage.getItem(dismissKey)) return
toast.info(t.advanced.updateAvailable.replace('{version}', config.latestVersion), { toast.info(t('advanced.updateAvailable').replace('{version}', config.latestVersion), {
description: t.advanced.updateAvailableDesc, description: t('advanced.updateAvailableDesc'),
duration: Infinity, duration: Infinity,
closeButton: true, closeButton: true,
action: { action: {
label: t.advanced.viewOnGithub, label: t('advanced.viewOnGithub'),
onClick: () => window.open('https://github.com/lfnovo/open-notebook', '_blank'), onClick: () => window.open('https://github.com/lfnovo/open-notebook', '_blank'),
}, },
onDismiss: () => sessionStorage.setItem(dismissKey, 'true'), onDismiss: () => sessionStorage.setItem(dismissKey, 'true'),

View file

@ -80,7 +80,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
queryKey: QUERY_KEYS.notebookChatSessions(notebookId) queryKey: QUERY_KEYS.notebookChatSessions(notebookId)
}) })
setCurrentSessionId(newSession.id) setCurrentSessionId(newSession.id)
toast.success(t.chat.sessionCreated) toast.success(t('chat.sessionCreated'))
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
const error = err as { response?: { data?: { detail?: string } }, message?: string }; const error = err as { response?: { data?: { detail?: string } }, message?: string };
@ -101,7 +101,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: QUERY_KEYS.notebookChatSession(currentSessionId!) queryKey: QUERY_KEYS.notebookChatSession(currentSessionId!)
}) })
toast.success(t.chat.sessionUpdated) toast.success(t('chat.sessionUpdated'))
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
const error = err as { response?: { data?: { detail?: string } }, message?: string }; const error = err as { response?: { data?: { detail?: string } }, message?: string };
@ -121,7 +121,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
setCurrentSessionId(null) setCurrentSessionId(null)
setMessages([]) setMessages([])
} }
toast.success(t.chat.sessionDeleted) toast.success(t('chat.sessionDeleted'))
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
const error = err as { response?: { data?: { detail?: string } }, message?: string }; const error = err as { response?: { data?: { detail?: string } }, message?: string };

View file

@ -60,7 +60,7 @@ export function useSourceChat(sourceId: string) {
onSuccess: (newSession) => { onSuccess: (newSession) => {
queryClient.invalidateQueries({ queryKey: ['sourceChatSessions', sourceId] }) queryClient.invalidateQueries({ queryKey: ['sourceChatSessions', sourceId] })
setCurrentSessionId(newSession.id) setCurrentSessionId(newSession.id)
toast.success(t.chat.sessionCreated) toast.success(t('chat.sessionCreated'))
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
const error = err as { response?: { data?: { detail?: string } }, message?: string }; const error = err as { response?: { data?: { detail?: string } }, message?: string };
@ -75,7 +75,7 @@ export function useSourceChat(sourceId: string) {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sourceChatSessions', sourceId] }) queryClient.invalidateQueries({ queryKey: ['sourceChatSessions', sourceId] })
queryClient.invalidateQueries({ queryKey: ['sourceChatSession', sourceId, currentSessionId] }) queryClient.invalidateQueries({ queryKey: ['sourceChatSession', sourceId, currentSessionId] })
toast.success(t.chat.sessionUpdated) toast.success(t('chat.sessionUpdated'))
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
const error = err as { response?: { data?: { detail?: string } }, message?: string }; const error = err as { response?: { data?: { detail?: string } }, message?: string };
@ -93,7 +93,7 @@ export function useSourceChat(sourceId: string) {
setCurrentSessionId(null) setCurrentSessionId(null)
setMessages([]) setMessages([])
} }
toast.success(t.chat.sessionDeleted) toast.success(t('chat.sessionDeleted'))
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
const error = err as { response?: { data?: { detail?: string } }, message?: string }; const error = err as { response?: { data?: { detail?: string } }, message?: string };

View file

@ -1,6 +1,6 @@
# Locales Module (i18n) # Locales Module (i18n)
Internationalization system providing multi-language UI support using i18next with type-safe translation access. Internationalization system providing multi-language UI support using i18next with standard `t()` function calls.
## Architecture ## Architecture
@ -9,7 +9,7 @@ lib/
├── i18n.ts # i18next initialization and configuration ├── i18n.ts # i18next initialization and configuration
├── i18n-events.ts # Language change event emitters ├── i18n-events.ts # Language change event emitters
├── hooks/ ├── hooks/
│ └── use-translation.ts # Custom hook with Proxy-based API │ └── use-translation.ts # Thin wrapper around react-i18next with language change events
├── utils/ ├── utils/
│ └── date-locale.ts # date-fns locale mapping │ └── date-locale.ts # date-fns locale mapping
└── locales/ └── locales/
@ -28,7 +28,7 @@ lib/
- **`i18n.ts`**: i18next initialization with language detection (localStorage → browser) - **`i18n.ts`**: i18next initialization with language detection (localStorage → browser)
- **`i18n-events.ts`**: Event emitters for language change start/end (used by loading overlay) - **`i18n-events.ts`**: Event emitters for language change start/end (used by loading overlay)
- **`locales/index.ts`**: Central registry exporting all locales and `LanguageCode` type - **`locales/index.ts`**: Central registry exporting all locales and `LanguageCode` type
- **`use-translation.ts`**: Custom hook providing `t` object with nested property access - **`use-translation.ts`**: Thin wrapper around react-i18next returning `{ t, i18n, language, setLanguage }`
## Translation Structure ## Translation Structure
@ -67,24 +67,36 @@ import { useTranslation } from '@/lib/hooks/use-translation'
function MyComponent() { function MyComponent() {
const { t, language, setLanguage } = useTranslation() const { t, language, setLanguage } = useTranslation()
// Nested property access (Proxy-based) // Standard t() function call
return <h1>{t.notebooks.title}</h1> return <h1>{t('notebooks.title')}</h1>
// With interpolation // With string interpolation
return <p>{t.common.updated.replace('{time}', timeAgo)}</p> return <p>{t('common.updated').replace('{time}', timeAgo)}</p>
// Change language // Change language
await setLanguage('zh-CN') await setLanguage('zh-CN')
} }
``` ```
### Functions that accept t as a parameter
Use `TFunction` from i18next:
```typescript
import type { TFunction } from 'i18next'
const getNavigation = (t: TFunction) => [
{ name: t('navigation.sources'), href: '/sources' },
]
```
## Important Patterns ## Important Patterns
- **Proxy-based access**: `t.section.key` instead of `t('section.key')` for better DX - **Standard t() calls**: `t('section.key')` — standard react-i18next pattern
- **Type safety**: `TranslationKeys` type derived from `enUS` locale
- **Language persistence**: Saved to localStorage, auto-detected on load - **Language persistence**: Saved to localStorage, auto-detected on load
- **Fallback**: Falls back to `en-US` if key missing in current locale - **Fallback**: Falls back to `en-US` if key missing in current locale
- **Date localization**: Use `getDateLocale(language)` from `utils/date-locale.ts` - **Date localization**: Use `getDateLocale(language)` from `utils/date-locale.ts`
- **Language change events**: `setLanguage` emits start/end events for `LanguageLoadingOverlay`
## Key Dependencies ## Key Dependencies
@ -117,13 +129,10 @@ function MyComponent() {
## Important Quirks & Gotchas ## Important Quirks & Gotchas
- **Proxy depth limit**: `useTranslation` limits nesting to 4 levels to prevent infinite loops
- **Blocked properties**: React internals (`__proto__`, `$$typeof`, etc.) are blocked from Proxy traversal
- **Loop detection**: Access counts reset every 1s; >1000 accesses triggers error and breaks recursion
- **String methods**: `.replace()`, `.split()` work on translated strings via Proxy magic
- **Language change events**: `emitLanguageChangeStart/End` used by `LanguageLoadingOverlay` for UX - **Language change events**: `emitLanguageChangeStart/End` used by `LanguageLoadingOverlay` for UX
- **No SSR**: `useSuspense: false` disables React Suspense for i18next (avoids hydration issues) - **No SSR**: `useSuspense: false` disables React Suspense for i18next (avoids hydration issues)
- **All keys required**: Missing keys in non-English locales fall back to English; keep locales in sync - **All keys required**: Missing keys in non-English locales fall back to English; keep locales in sync
- **ErrorBoundary**: Uses raw `enUS` locale object directly (class component, can't use hooks)
## Testing Patterns ## Testing Patterns
@ -131,7 +140,7 @@ function MyComponent() {
// Mock useTranslation in tests (see test/setup.ts) // Mock useTranslation in tests (see test/setup.ts)
vi.mock('@/lib/hooks/use-translation', () => ({ vi.mock('@/lib/hooks/use-translation', () => ({
useTranslation: () => ({ useTranslation: () => ({
t: enUS, // Use English locale directly t: (key: string) => key, // Identity function returns the key
language: 'en-US', language: 'en-US',
setLanguage: vi.fn(), setLanguage: vi.fn(),
}), }),

View file

@ -1,6 +1,5 @@
import '@testing-library/jest-dom' import '@testing-library/jest-dom'
import { vi } from 'vitest' import { vi } from 'vitest'
import { enUS } from '../lib/locales/en-US'
// Mock next/navigation // Mock next/navigation
vi.mock('next/navigation', () => ({ vi.mock('next/navigation', () => ({
@ -28,14 +27,11 @@ Object.defineProperty(window, 'matchMedia', {
})), })),
}) })
// Mock @/lib/hooks/use-translation with full locale structure // Mock @/lib/hooks/use-translation with standard t() function
vi.mock('../lib/hooks/use-translation', () => { vi.mock('../lib/hooks/use-translation', () => {
const t = (key: string) => key
Object.assign(t, enUS)
return { return {
useTranslation: () => ({ useTranslation: () => ({
t, t: (key: string) => key,
language: 'en-US', language: 'en-US',
setLanguage: vi.fn(), setLanguage: vi.fn(),
}), }),