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)
- **Locale files** (`en-US/`, `pt-BR/`, `zh-CN/`, `zh-TW/`, `ja-JP/`): Translation strings organized by feature
- **`i18n.ts`**: i18next configuration with language detection
- **`use-translation.ts`**: Custom hook with Proxy-based `t.section.key` access pattern
- **Pattern**: Components call `useTranslation()` hook; access strings via `t.common.save`, `t.notebooks.title`
- **`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')`
## Data & Control Flow Walkthrough

View file

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

View file

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

View file

@ -13,9 +13,9 @@ export default function AdvancedPage() {
<div className="p-6">
<div className="max-w-4xl mx-auto space-y-6">
<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">
{t.advanced.desc}
{t('advanced.desc')}
</p>
</div>

View file

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

View file

@ -83,8 +83,8 @@ export function ChatColumn({ notebookId, contextSelections, sources, sourcesLoad
<CardContent className="flex-1 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<AlertCircle className="h-12 w-12 mx-auto mb-4 opacity-50" />
<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-sm">{t('chat.unableToLoadChat')}</p>
<p className="text-xs mt-2">{t('common.refreshPage') || 'Please try refreshing the page'}</p>
</div>
</CardContent>
</Card>
@ -93,7 +93,7 @@ export function ChatColumn({ notebookId, contextSelections, sources, sourcesLoad
return (
<ChatPanel
title={t.chat.chatWithNotebook}
title={t('chat.chatWithNotebook')}
contextType="notebook"
messages={chat.messages}
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"
)}>
<DialogTitle className="sr-only">
{isEditing ? t.sources.editNote : t.sources.createNote}
{isEditing ? t('sources.editNote') : t('sources.createNote')}
</DialogTitle>
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col min-w-0">
{isEditing && noteLoading ? (
<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>
) : (
<>
@ -139,8 +139,8 @@ export function NoteEditorDialog({ open, onOpenChange, notebookId, note }: NoteE
name="title"
value={watchTitle ?? ''}
onSave={(value) => setValue('title', value || '')}
placeholder={t.sources.addTitle}
emptyText={t.sources.untitledNote}
placeholder={t('sources.addTitle')}
emptyText={t('sources.untitledNote')}
className="text-xl font-semibold"
inputClassName="text-xl font-semibold"
/>
@ -160,7 +160,7 @@ export function NoteEditorDialog({ open, onOpenChange, notebookId, note }: NoteE
value={field.value}
onChange={field.onChange}
height={420}
placeholder={t.sources.writeNotePlaceholder}
placeholder={t('sources.writeNotePlaceholder')}
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",
!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">
<Button type="button" variant="outline" onClick={handleClose}>
{t.common.cancel}
{t('common.cancel')}
</Button>
<Button
type="submit"
disabled={isSaving || (isEditing && noteLoading)}
>
{isSaving
? isEditing ? `${t.common.saving}...` : `${t.common.creating}...`
? isEditing ? `${t('common.saving')}...` : `${t('common.creating')}...`
: isEditing
? t.sources.saveNote
: t.sources.createNoteBtn}
? t('sources.saveNote')
: t('sources.createNoteBtn')}
</Button>
</div>
</form>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -29,18 +29,18 @@ export default function PodcastsPage() {
<div className="flex-1 overflow-y-auto">
<div className="px-6 py-6 space-y-6">
<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">
{t.podcasts.listDesc}
{t('podcasts.listDesc')}
</p>
</header>
{hasUnconfiguredProfiles ? (
<Alert className="bg-amber-50 text-amber-900 border-amber-200">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t.podcasts.setupRequired}</AlertTitle>
<AlertTitle>{t('podcasts.setupRequired')}</AlertTitle>
<AlertDescription>
{t.podcasts.setupRequiredDesc}
{t('podcasts.setupRequiredDesc')}
</AlertDescription>
</Alert>
) : null}
@ -51,15 +51,15 @@ export default function PodcastsPage() {
className="space-y-6"
>
<div className="space-y-2">
<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">
<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">
<TabsTrigger value="episodes">
<Mic className="h-4 w-4" />
{t.podcasts.episodesTab}
{t('podcasts.episodesTab')}
</TabsTrigger>
<TabsTrigger value="templates">
<LayoutTemplate className="h-4 w-4" />
{t.podcasts.templatesTab}
{t('podcasts.templatesTab')}
</TabsTrigger>
</TabsList>
</div>

View file

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

View file

@ -255,14 +255,14 @@ function CredentialFormDialog({
<DialogHeader>
<DialogTitle>
{isEditing
? t.apiKeys.editConfig.replace('{provider}', PROVIDER_DISPLAY_NAMES[provider] || provider)
: t.apiKeys.addConfig.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)}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="cred-name">{t.apiKeys.configName}</Label>
<Label htmlFor="cred-name">{t('apiKeys.configName')}</Label>
<input
id="cred-name"
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`}
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>
{/* Vertex fields */}
{isVertex ? (
<>
<div className="space-y-2">
<Label htmlFor="vertex-project">{t.apiKeys.vertexProject}</Label>
<Label htmlFor="vertex-project">{t('apiKeys.vertexProject')}</Label>
<input
id="vertex-project"
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 className="space-y-2">
<Label htmlFor="vertex-location">{t.apiKeys.vertexLocation}</Label>
<Label htmlFor="vertex-location">{t('apiKeys.vertexLocation')}</Label>
<input
id="vertex-location"
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 className="space-y-2">
<Label htmlFor="vertex-creds">
{t.apiKeys.vertexCredentials}
<span className="text-muted-foreground font-normal ml-1">({t.common.optional})</span>
{t('apiKeys.vertexCredentials')}
<span className="text-muted-foreground font-normal ml-1">({t('common.optional')})</span>
</Label>
<input
id="vertex-creds"
@ -318,8 +318,8 @@ function CredentialFormDialog({
/* API Key */
<div className="space-y-2">
<Label htmlFor="api-key">
{t.models.apiKey}
{!requiresApiKey && <span className="text-muted-foreground font-normal ml-1">({t.common.optional})</span>}
{t('models.apiKey')}
{!requiresApiKey && <span className="text-muted-foreground font-normal ml-1">({t('common.optional')})</span>}
</Label>
<div className="relative">
<input
@ -341,10 +341,10 @@ function CredentialFormDialog({
{showApiKey ? 'Hide' : 'Show'}
</button>
</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 && (
<a href={docsUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline">
{t.apiKeys.getApiKey} &rarr;
{t('apiKeys.getApiKey')} &rarr;
</a>
)}
</div>
@ -353,7 +353,7 @@ function CredentialFormDialog({
{/* Base URL (non-Vertex) */}
{!isVertex && (
<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
id="base-url"
type="url"
@ -363,18 +363,18 @@ function CredentialFormDialog({
placeholder={isOllama ? 'http://localhost:11434' : 'https://api.example.com/v1'}
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>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-4 border-t">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
{t.common.cancel}
{t('common.cancel')}
</Button>
<Button type="submit" disabled={!isValid || isSubmitting}>
{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>
</div>
</form>
@ -518,7 +518,7 @@ function DiscoverModelsDialog({
<DialogContent className="sm:max-w-lg max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{t.models.discoverModels} - {PROVIDER_DISPLAY_NAMES[credential.provider] || credential.provider}
{t('models.discoverModels')} - {PROVIDER_DISPLAY_NAMES[credential.provider] || credential.provider}
</DialogTitle>
<DialogDescription>
{credential.name}
@ -538,7 +538,7 @@ function DiscoverModelsDialog({
<div className="space-y-4">
{/* Model type selector */}
<div className="space-y-2">
<Label>{t.models.modelType}</Label>
<Label>{t('models.modelType')}</Label>
<Select value={selectedType} onValueChange={(v) => setSelectedType(v as ModelType)}>
<SelectTrigger>
<SelectValue />
@ -554,14 +554,14 @@ function DiscoverModelsDialog({
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t.models.modelTypeHint}</p>
<p className="text-xs text-muted-foreground">{t('models.modelTypeHint')}</p>
</div>
{/* Search input */}
<input
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"
placeholder={t.models.searchOrAddModel}
placeholder={t('models.searchOrAddModel')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
@ -570,7 +570,7 @@ function DiscoverModelsDialog({
{filteredModels.length > 0 && (
<div className="flex items-center justify-between">
<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})
</Button>
</div>
@ -607,13 +607,13 @@ function DiscoverModelsDialog({
/>
<Plus className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="truncate">
{t.models.addCustomModel.replace('{name}', searchQuery.trim())}
{t('models.addCustomModel').replace('{name}', searchQuery.trim())}
</span>
</label>
)}
{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>
@ -621,14 +621,14 @@ function DiscoverModelsDialog({
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t.common.cancel}
{t('common.cancel')}
</Button>
<Button
onClick={handleRegister}
disabled={totalSelected === 0 || registerModels.isPending}
>
{registerModels.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
{t.common.add} ({totalSelected})
{t('common.add')} ({totalSelected})
</Button>
</DialogFooter>
</DialogContent>
@ -685,9 +685,9 @@ function DeleteCredentialDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.apiKeys.deleteConfig}</DialogTitle>
<DialogTitle>{t('apiKeys.deleteConfig')}</DialogTitle>
<DialogDescription>
{t.apiKeys.deleteConfigConfirm.replace('{name}', credential.name)}
{t('apiKeys.deleteConfigConfirm').replace('{name}', credential.name)}
</DialogDescription>
</DialogHeader>
@ -717,7 +717,7 @@ function DeleteCredentialDialog({
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t.common.cancel}
{t('common.cancel')}
</Button>
{credential.model_count > 0 && migrateToId && (
<Button onClick={handleMigrate} disabled={deleteCredential.isPending}>
@ -731,7 +731,7 @@ function DeleteCredentialDialog({
disabled={deleteCredential.isPending}
>
{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>
</DialogFooter>
</DialogContent>
@ -769,8 +769,8 @@ function CredentialItem({
const testResult = testResults[credential.id]
// Extract translations used in model badge loops to avoid excessive Proxy accesses
const testModelLabel = t.models.testModel
const deleteModelLabel = t.models.deleteModel
const testModelLabel = t('models.testModel')
const deleteModelLabel = t('models.deleteModel')
// Check which models are defaults
const defaultSlots: Record<string, string> = {}
@ -824,7 +824,7 @@ function CredentialItem({
variant="ghost" size="sm"
onClick={() => testCredential(credential.id)}
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" />}
<span className="hidden sm:inline text-xs">Test</span>
@ -833,19 +833,19 @@ function CredentialItem({
variant="ghost" size="sm"
onClick={() => setDiscoverOpen(true)}
disabled={!!credential.decryption_error}
title={t.apiKeys.syncModels}
title={t('apiKeys.syncModels')}
>
<Bot className="h-4 w-4" />
<span className="hidden sm:inline text-xs">Models</span>
</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" />
</Button>
<Button
variant="ghost" size="sm"
onClick={() => setDeleteOpen(true)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
title={t.common.delete}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
@ -856,9 +856,9 @@ function CredentialItem({
{credential.decryption_error && (
<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" />
<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">
{t.apiKeys.decryptionErrorDescription}
{t('apiKeys.decryptionErrorDescription')}
</AlertDescription>
</Alert>
)}
@ -1013,12 +1013,12 @@ function ProviderSection({
{hasCredentials ? (
<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" />
{t.apiKeys.configured}
{t('apiKeys.configured')}
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground border-dashed">
<X className="mr-1 h-3 w-3" />
{t.apiKeys.notConfigured}
{t('apiKeys.notConfigured')}
</Badge>
)}
</div>
@ -1043,7 +1043,7 @@ function ProviderSection({
disabled={!encryptionReady}
>
<Plus className="h-4 w-4" />
{t.apiKeys.addConfig}
{t('apiKeys.addConfig')}
</Button>
</CardContent>
@ -1098,16 +1098,16 @@ function DefaultModelSelectors({
}
const primaryConfigs: DefaultConfig[] = [
{ 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_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_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_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` },
]
const advancedConfigs: DefaultConfig[] = [
{ 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: 'large_context_model', label: t.models.largeContextModelLabel, description: t.models.largeContextModelDesc, modelType: 'language', id: `${generatedId}-large` },
{ 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: 'large_context_model', label: t('models.largeContextModelLabel'), description: t('models.largeContextModelDesc'), modelType: 'language', id: `${generatedId}-large` },
]
const defaultConfigs = [...primaryConfigs, ...advancedConfigs]
@ -1145,15 +1145,15 @@ function DefaultModelSelectors({
return (
<Card>
<CardHeader>
<CardTitle>{t.models.defaultAssignments}</CardTitle>
<CardDescription>{t.models.defaultAssignmentsDesc}</CardDescription>
<CardTitle>{t('models.defaultAssignments')}</CardTitle>
<CardDescription>{t('models.defaultAssignmentsDesc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{missingRequired.length > 0 && (
<Alert>
<AlertCircle className="h-4 w-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
variant="outline" size="sm"
onClick={() => autoAssign.mutate()}
@ -1161,7 +1161,7 @@ function DefaultModelSelectors({
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 ? t.models.autoAssigning : t.models.autoAssign}
{autoAssign.isPending ? t('models.autoAssigning') : t('models.autoAssign')}
</Button>
</AlertDescription>
</Alert>
@ -1191,8 +1191,8 @@ function DefaultModelSelectors({
>
<SelectValue placeholder={
config.required && !isValid && available.length > 0
? t.models.requiredModelPlaceholder
: t.models.selectModelPlaceholder
? t('models.requiredModelPlaceholder')
: t('models.selectModelPlaceholder')
} />
</SelectTrigger>
<SelectContent>
@ -1219,7 +1219,7 @@ function DefaultModelSelectors({
{/* Advanced models: Transformation, Tools, Large Context */}
<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">
{advancedConfigs.map(config => {
const available = getModelsForType(config.modelType)
@ -1243,8 +1243,8 @@ function DefaultModelSelectors({
>
<SelectValue placeholder={
config.required && !isValid && available.length > 0
? t.models.requiredModelPlaceholder
: t.models.selectModelPlaceholder
? t('models.requiredModelPlaceholder')
: t('models.selectModelPlaceholder')
} />
</SelectTrigger>
<SelectContent>
@ -1355,19 +1355,19 @@ export default function ApiKeysPage() {
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Key className="h-6 w-6" />
{t.apiKeys.title}
{t('apiKeys.title')}
</h1>
<p className="text-muted-foreground mt-1">{t.apiKeys.description}</p>
<p className="text-muted-foreground mt-1">{t('apiKeys.description')}</p>
</div>
{/* Encryption warning */}
{!encryptionReady && (
<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" />
<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">
<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>
</AlertDescription>
</Alert>
@ -1404,7 +1404,7 @@ export default function ApiKeysPage() {
rel="noopener noreferrer"
className="text-sm text-primary hover:underline"
>
{t.apiKeys.learnMore}
{t('apiKeys.learnMore')}
</a>
</div>
</div>

View file

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

View file

@ -17,7 +17,7 @@ export default function SettingsPage() {
<div className="p-6">
<div className="max-w-4xl">
<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()}>
<RefreshCw className="h-4 w-4" />
</Button>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -39,12 +39,12 @@ export function TransformationsList({ transformations, isLoading, onPlayground }
return (
<EmptyState
icon={Wand2}
title={t.transformations.noTransformations}
description={t.transformations.createOne}
title={t('transformations.noTransformations')}
description={t('transformations.createOne')}
action={
<Button onClick={() => handleOpenEditor()}>
<Plus className="h-4 w-4 mr-2" />
{t.transformations.createNew}
{t('transformations.createNew')}
</Button>
}
/>
@ -55,10 +55,10 @@ export function TransformationsList({ transformations, isLoading, onPlayground }
<>
<div className="space-y-6">
<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()}>
<Plus className="h-4 w-4 mr-2" />
{t.transformations.createNew}
{t('transformations.createNew')}
</Button>
</div>

View file

@ -29,7 +29,7 @@ export default function TransformationsPage() {
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<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()}>
<RefreshCw className="h-4 w-4" />
</Button>
@ -38,21 +38,21 @@ export default function TransformationsPage() {
<div className="max-w-5xl">
<p className="text-muted-foreground">
{t.transformations.desc}
{t('transformations.desc')}
</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<div className="space-y-2">
<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">
<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">
<TabsTrigger value="transformations" className="flex items-center gap-2">
<Wand2 className="h-4 w-4" />
{t.transformations.title}
{t('transformations.title')}
</TabsTrigger>
<TabsTrigger value="playground" className="flex items-center gap-2">
<Play className="h-4 w-4" />
{t.transformations.playground}
{t('transformations.playground')}
</TabsTrigger>
</TabsList>
</div>

View file

@ -83,9 +83,9 @@ export function LoginForm() {
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle>{t.common.connectionError}</CardTitle>
<CardTitle>{t('common.connectionError')}</CardTitle>
<CardDescription>
{t.common.unableToConnect}
{t('common.unableToConnect')}
</CardDescription>
</CardHeader>
<CardContent>
@ -93,21 +93,21 @@ export function LoginForm() {
<div className="flex items-start gap-2 text-red-600 text-sm">
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div className="flex-1">
{error || t.auth.connectErrorHint}
{error || t('auth.connectErrorHint')}
</div>
</div>
{configInfo && (
<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>{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 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>{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 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>
<div className="text-xs pt-2">
{t.common.checkConsoleLogs}
{t('common.checkConsoleLogs')}
</div>
</div>
)}
@ -116,7 +116,7 @@ export function LoginForm() {
onClick={() => window.location.reload()}
className="w-full"
>
{t.common.retryConnection}
{t('common.retryConnection')}
</Button>
</div>
</CardContent>
@ -141,9 +141,9 @@ export function LoginForm() {
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle>{t.auth.loginTitle}</CardTitle>
<CardTitle>{t('auth.loginTitle')}</CardTitle>
<CardDescription>
{t.auth.loginDesc}
{t('auth.loginDesc')}
</CardDescription>
</CardHeader>
<CardContent>
@ -151,7 +151,7 @@ export function LoginForm() {
<div>
<Input
type="password"
placeholder={t.auth.passwordPlaceholder}
placeholder={t('auth.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
@ -170,12 +170,12 @@ export function LoginForm() {
className="w-full"
disabled={isLoading || !password.trim()}
>
{isLoading ? t.auth.signingIn : t.auth.signIn}
{isLoading ? t('auth.signingIn') : t('auth.signIn')}
</Button>
{configInfo && (
<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>
)}

View file

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

View file

@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
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', () => {
const onConfirmMock = vi.fn()
@ -18,20 +18,19 @@ describe('ConfirmDialog', () => {
it('should render correct titles and descriptions', () => {
render(<ConfirmDialog {...defaultProps} />)
expect(screen.getByText('Test Title')).toBeInTheDocument()
expect(screen.getByText('Test Description')).toBeInTheDocument()
// Localized text from our setup.ts mock should be visible
expect(screen.getByText('Confirm')).toBeInTheDocument()
expect(screen.getByText('Cancel')).toBeInTheDocument()
expect(screen.getByText('common.confirm')).toBeInTheDocument()
expect(screen.getByText('common.cancel')).toBeInTheDocument()
})
it('should call onConfirm when confirm button is clicked', () => {
render(<ConfirmDialog {...defaultProps} />)
const confirmBtn = screen.getByText('Confirm')
const confirmBtn = screen.getByText('common.confirm')
fireEvent.click(confirmBtn)
expect(onConfirmMock).toHaveBeenCalledTimes(1)
})
@ -42,10 +41,10 @@ describe('ConfirmDialog', () => {
it('should show loading state and disable buttons', () => {
render(<ConfirmDialog {...defaultProps} isLoading={true} />)
const confirmBtn = screen.getByText('Confirm').closest('button')
const cancelBtn = screen.getByText('Cancel').closest('button')
const confirmBtn = screen.getByText('common.confirm').closest('button')
const cancelBtn = screen.getByText('common.cancel').closest('button')
expect(confirmBtn).toBeDisabled()
expect(cancelBtn).toBeDisabled()
})

View file

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

View file

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

View file

@ -32,7 +32,7 @@ export function InlineEdit({
const generatedId = useId()
const id = providedId || generatedId
const { t } = useTranslation()
const defaultEmptyText = emptyText || t.common.clickToEdit
const defaultEmptyText = emptyText || t('common.clickToEdit')
const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState(value)
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"}
>
<Languages className="h-[1.2rem] w-[1.2rem]" />
{!iconOnly && <span>{t.common.language}</span>}
<span className="sr-only">{t.navigation.language}</span>
{!iconOnly && <span>{t('common.language')}</span>}
<span className="sr-only">{t('navigation.language')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@ -38,49 +38,49 @@ export function LanguageToggle({ iconOnly = false }: LanguageToggleProps) {
onClick={() => setLanguage('en-US')}
className={currentLang === 'en-US' || currentLang.startsWith('en') ? 'bg-accent' : ''}
>
<span>{t.common.english}</span>
<span>{t('common.english')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('zh-CN')}
className={currentLang === 'zh-CN' || currentLang.startsWith('zh-Hans') || currentLang === 'zh' ? 'bg-accent' : ''}
>
<span>{t.common.chinese}</span>
<span>{t('common.chinese')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('zh-TW')}
className={currentLang === 'zh-TW' || currentLang.startsWith('zh-Hant') ? 'bg-accent' : ''}
>
<span>{t.common.traditionalChinese}</span>
<span>{t('common.traditionalChinese')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('pt-BR')}
className={currentLang === 'pt-BR' || currentLang.startsWith('pt') ? 'bg-accent' : ''}
>
<span>{t.common.portuguese}</span>
<span>{t('common.portuguese')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('ja-JP')}
className={currentLang === 'ja-JP' || currentLang.startsWith('ja') ? 'bg-accent' : ''}
>
<span>{t.common.japanese}</span>
<span>{t('common.japanese')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('fr-FR')}
className={currentLang === 'fr-FR' || currentLang.startsWith('fr') ? 'bg-accent' : ''}
>
<span>{t.common.french}</span>
<span>{t('common.french')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('ru-RU')}
className={currentLang === 'ru-RU' || currentLang.startsWith('ru') ? 'bg-accent' : ''}
>
<span>{t.common.russian}</span>
<span>{t('common.russian')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('bn-IN')}
className={currentLang === 'bn-IN' || currentLang.startsWith('bn') ? 'bg-accent' : ''}
>
<span>{t.common.bengali}</span>
<span>{t('common.bengali')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -38,7 +38,7 @@ export function ModelSelector({
{label && <Label htmlFor={selectId}>{label}</Label>}
<Select name={name} value={value} onValueChange={onChange} disabled={disabled || isLoading}>
<SelectTrigger id={selectId}>
<SelectValue placeholder={placeholder || t.settings.embeddingOptionPlaceholder} />
<SelectValue placeholder={placeholder || t('settings.embeddingOptionPlaceholder')} />
</SelectTrigger>
<SelectContent>
{isLoading ? (
@ -47,7 +47,7 @@ export function ModelSelector({
</div>
) : filteredModels.length === 0 ? (
<div className="text-sm text-muted-foreground py-2 px-2">
{t.common.noResults}
{t('common.noResults')}
</div>
) : (
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" />
<Moon className="absolute inset-0 h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</div>
{!iconOnly && <span>{t.common.theme}</span>}
<span className="sr-only">{t.navigation.theme}</span>
{!iconOnly && <span>{t('common.theme')}</span>}
<span className="sr-only">{t('navigation.theme')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@ -41,21 +41,21 @@ export function ThemeToggle({ iconOnly = false }: ThemeToggleProps) {
className={theme === 'light' ? 'bg-accent' : ''}
>
<Sun className="mr-2 h-4 w-4" />
<span>{t.common.light}</span>
<span>{t('common.light')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTheme('dark')}
className={theme === 'dark' ? 'bg-accent' : ''}
>
<Moon className="mr-2 h-4 w-4" />
<span>{t.common.dark}</span>
<span>{t('common.dark')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTheme('system')}
className={theme === 'system' ? 'bg-accent' : ''}
>
<Monitor className="mr-2 h-4 w-4" />
<span>{t.common.system}</span>
<span>{t('common.system')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View file

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

View file

@ -11,18 +11,15 @@ vi.mock('@/components/ui/tooltip', () => ({
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
// But setup.ts has some basic mocks, let's see.
describe('AppSidebar', () => {
it('renders correctly when expanded', () => {
render(<AppSidebar />)
// Check for logo or app name (using actual locale value)
expect(screen.getByText(/Open Notebook/i)).toBeDefined()
// Check for navigation items (using actual locale values)
expect(screen.getByText(/Sources/i)).toBeDefined()
expect(screen.getByText(/Notebooks/i)).toBeDefined()
// With mocked t() returning keys, check for translation key strings
expect(screen.getByText('common.appName')).toBeDefined()
expect(screen.getByText('navigation.sources')).toBeDefined()
expect(screen.getByText('navigation.notebooks')).toBeDefined()
})
it('toggles collapse state when clicking handle', () => {
@ -33,16 +30,9 @@ describe('AppSidebar', () => {
} as any)
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'))
expect(toggleCollapse).toHaveBeenCalled()
})
@ -53,8 +43,8 @@ describe('AppSidebar', () => {
} as any)
render(<AppSidebar />)
// 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'
import { ThemeToggle } from '@/components/common/ThemeToggle'
import { LanguageToggle } from '@/components/common/LanguageToggle'
import { TranslationKeys } from '@/lib/locales'
import type { TFunction } from 'i18next'
import { useTranslation } from '@/lib/hooks/use-translation'
import { Separator } from '@/components/ui/separator'
import {
@ -43,33 +43,33 @@ import {
Command,
} from 'lucide-react'
const getNavigation = (t: TranslationKeys) => [
const getNavigation = (t: TFunction) => [
{
title: t.navigation.collect,
title: t('navigation.collect'),
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: [
{ name: t.navigation.notebooks, href: '/notebooks', icon: Book },
{ name: t.navigation.askAndSearch, href: '/search', icon: Search },
{ name: t('navigation.notebooks'), href: '/notebooks', icon: Book },
{ name: t('navigation.askAndSearch'), href: '/search', icon: Search },
],
},
{
title: t.navigation.create,
title: t('navigation.create'),
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: [
{ name: t.navigation.models, href: '/settings/api-keys', icon: Bot },
{ name: t.navigation.transformations, href: '/transformations', icon: Shuffle },
{ name: t.navigation.settings, href: '/settings', icon: Settings },
{ name: t.navigation.advanced, href: '/advanced', icon: Wrench },
{ name: t('navigation.models'), href: '/settings/api-keys', icon: Bot },
{ name: t('navigation.transformations'), href: '/transformations', icon: Shuffle },
{ name: t('navigation.settings'), href: '/settings', icon: Settings },
{ name: t('navigation.advanced'), href: '/advanced', icon: Wrench },
],
},
] as const
@ -139,9 +139,9 @@ export function AppSidebar() {
) : (
<>
<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">
{t.common.appName}
{t('common.appName')}
</span>
</div>
<Button
@ -179,13 +179,13 @@ export function AppSidebar() {
variant="default"
size="sm"
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" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right">{t.common.create}</TooltipContent>
<TooltipContent side="right">{t('common.create')}</TooltipContent>
</Tooltip>
) : (
<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"
>
<Plus className="h-4 w-4 mr-2" />
{t.common.create}
{t('common.create')}
</Button>
</DropdownMenuTrigger>
)}
@ -214,7 +214,7 @@ export function AppSidebar() {
className="gap-2"
>
<FileText className="h-4 w-4" />
{t.common.source}
{t('common.source')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
@ -224,7 +224,7 @@ export function AppSidebar() {
className="gap-2"
>
<Book className="h-4 w-4" />
{t.common.notebook}
{t('common.notebook')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
@ -234,7 +234,7 @@ export function AppSidebar() {
className="gap-2"
>
<Mic className="h-4 w-4" />
{t.common.podcast}
{t('common.podcast')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -304,14 +304,14 @@ export function AppSidebar() {
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5">
<Command className="h-3 w-3" />
{t.common.quickActions}
{t('common.quickActions')}
</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">
{isMac ? <span className="text-xs"></span> : <span>Ctrl+</span>}K
</kbd>
</div>
<p className="mt-1 text-[10px] text-sidebar-foreground/40">
{t.common.quickActionsDesc}
{t('common.quickActionsDesc')}
</p>
</div>
)}
@ -330,7 +330,7 @@ export function AppSidebar() {
<ThemeToggle iconOnly />
</div>
</TooltipTrigger>
<TooltipContent side="right">{t.common.theme}</TooltipContent>
<TooltipContent side="right">{t('common.theme')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@ -338,7 +338,7 @@ export function AppSidebar() {
<LanguageToggle iconOnly />
</div>
</TooltipTrigger>
<TooltipContent side="right">{t.common.language}</TooltipContent>
<TooltipContent side="right">{t('common.language')}</TooltipContent>
</Tooltip>
</>
) : (
@ -356,22 +356,22 @@ export function AppSidebar() {
variant="outline"
className="w-full justify-center sidebar-menu-item"
onClick={logout}
aria-label={t.common.signOut}
aria-label={t('common.signOut')}
>
<LogOut className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">{t.common.signOut}</TooltipContent>
<TooltipContent side="right">{t('common.signOut')}</TooltipContent>
</Tooltip>
) : (
<Button
variant="outline"
className="w-full justify-start gap-3 sidebar-menu-item"
onClick={logout}
aria-label={t.common.signOut}
aria-label={t('common.signOut')}
>
<LogOut className="h-4 w-4" />
{t.common.signOut}
{t('common.signOut')}
</Button>
)}
</div>

View file

@ -36,17 +36,17 @@ export function SetupBanner() {
<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" />
<AlertTitle className="text-red-800 dark:text-red-200">
{t.setupBanner.encryptionRequired}
{t('setupBanner.encryptionRequired')}
</AlertTitle>
<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
href="https://github.com/lfnovo/open-notebook/blob/main/docs/3-USER-GUIDE/api-configuration.md#encryption-setup"
target="_blank"
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"
>
{t.setupBanner.viewDocs}
{t('setupBanner.viewDocs')}
<ExternalLink className="ml-1 h-3 w-3" />
</a>
</AlertDescription>
@ -60,11 +60,11 @@ export function SetupBanner() {
<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" />
<AlertTitle className="text-amber-800 dark:text-amber-200">
{t.setupBanner.migrationAvailable}
{t('setupBanner.migrationAvailable')}
</AlertTitle>
<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">
{t.setupBanner.migrationDescription.replace('{count}', providersToMigrate.length.toString())}
{t('setupBanner.migrationDescription').replace('{count}', providersToMigrate.length.toString())}
</span>
<Button
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"
>
<Link href="/settings/api-keys">
{t.setupBanner.goToSettings}
{t('setupBanner.goToSettings')}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -30,9 +30,9 @@ export function TemplatesTab() {
return (
<div className="space-y-6">
<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">
{t.podcasts.templatesWorkspaceDesc}
{t('podcasts.templatesWorkspaceDesc')}
</p>
</div>
@ -44,42 +44,42 @@ export function TemplatesTab() {
<AccordionTrigger className="gap-2 py-4 text-left text-sm font-semibold">
<div className="flex items-center gap-2">
<Lightbulb className="h-4 w-4 text-primary" />
{t.podcasts.howTemplatesPowerTitle}
{t('podcasts.howTemplatesPowerTitle')}
</div>
</AccordionTrigger>
<AccordionContent className="text-sm text-muted-foreground">
<div className="space-y-4">
<p className="text-muted-foreground/90">
{t.podcasts.howTemplatesPowerDesc}
{t('podcasts.howTemplatesPowerDesc')}
</p>
<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">
<li>{t.podcasts.episodeProfilesList1}</li>
<li>{t.podcasts.episodeProfilesList2}</li>
<li>{t.podcasts.episodeProfilesList3}</li>
<li>{t('podcasts.episodeProfilesList1')}</li>
<li>{t('podcasts.episodeProfilesList2')}</li>
<li>{t('podcasts.episodeProfilesList3')}</li>
</ul>
</div>
<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">
<li>{t.podcasts.speakerProfilesList1}</li>
<li>{t.podcasts.speakerProfilesList2}</li>
<li>{t.podcasts.speakerProfilesList3}</li>
<li>{t('podcasts.speakerProfilesList1')}</li>
<li>{t('podcasts.speakerProfilesList2')}</li>
<li>{t('podcasts.speakerProfilesList3')}</li>
</ul>
</div>
<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">
<li>{t.podcasts.workflowStep1}</li>
<li>{t.podcasts.workflowStep2}</li>
<li>{t.podcasts.workflowStep3}</li>
<li>{t('podcasts.workflowStep1')}</li>
<li>{t('podcasts.workflowStep2')}</li>
<li>{t('podcasts.workflowStep3')}</li>
</ol>
<p className="text-xs text-muted-foreground/80">
{t.podcasts.workflowHint}
{t('podcasts.workflowHint')}
</p>
</div>
</div>
@ -90,9 +90,9 @@ export function TemplatesTab() {
{hasError ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t.podcasts.failedToLoadTemplates}</AlertTitle>
<AlertTitle>{t('podcasts.failedToLoadTemplates')}</AlertTitle>
<AlertDescription>
{t.podcasts.failedToLoadTemplatesDesc}
{t('podcasts.failedToLoadTemplatesDesc')}
</AlertDescription>
</Alert>
) : null}
@ -100,7 +100,7 @@ export function TemplatesTab() {
{isLoading ? (
<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" />
{t.podcasts.loadingTemplates}
{t('podcasts.loadingTemplates')}
</div>
) : (
<div className="grid gap-6 lg:grid-cols-2">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -57,49 +57,49 @@ export function EmbeddingModelChangeDialog({
<AlertDialogHeader>
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
<AlertDialogTitle>{t.models.embeddingChangeTitle}</AlertDialogTitle>
<AlertDialogTitle>{t('models.embeddingChangeTitle')}</AlertDialogTitle>
</div>
<AlertDialogDescription asChild>
<div className="space-y-3 text-base text-muted-foreground">
<p>
{t.models.embeddingChangeConfirm
{t('models.embeddingChangeConfirm')
.replace('{from}', oldModelName || '...')
.replace('{to}', newModelName || '...')}
</p>
<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">
{t.models.rebuildReason}
{t('models.rebuildReason')}
</p>
</div>
<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">
<li>{t.models.step1}</li>
<li>{t.models.step2}</li>
<li>{t.models.step3}</li>
<li>{t.models.step4}</li>
<li>{t('models.step1')}</li>
<li>{t('models.step2')}</li>
<li>{t('models.step3')}</li>
<li>{t('models.step4')}</li>
</ul>
</div>
<p className="text-sm font-medium text-foreground">
{t.models.proceedToRebuildPrompt}
{t('models.proceedToRebuildPrompt')}
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-col sm:flex-row gap-2">
<AlertDialogCancel disabled={isConfirming}>
{t.common.cancel}
{t('common.cancel')}
</AlertDialogCancel>
<Button
variant="outline"
onClick={handleConfirmOnly}
disabled={isConfirming}
>
{t.models.changeModelOnly}
{t('models.changeModelOnly')}
</Button>
<AlertDialogAction
onClick={handleConfirmAndRebuild}
@ -107,7 +107,7 @@ export function EmbeddingModelChangeDialog({
className="bg-primary"
>
<ExternalLink className="mr-2 h-4 w-4" />
{t.models.changeAndRebuild}
{t('models.changeAndRebuild')}
</AlertDialogAction>
</AlertDialogFooter>
</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">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<AlertTitle className="text-amber-800 dark:text-amber-200">
{t.apiKeys.migrationAvailable}
{t('apiKeys.migrationAvailable')}
</AlertTitle>
<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">
{t.apiKeys.migrationDescription.replace('{count}', providersToMigrate.length.toString())}
{t('apiKeys.migrationDescription').replace('{count}', providersToMigrate.length.toString())}
</span>
<Button
variant="outline"
@ -38,11 +38,11 @@ export function MigrationBanner({ providersToMigrate }: MigrationBannerProps) {
{migrate.isPending ? (
<>
<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" />
</>
)}

View file

@ -37,7 +37,7 @@ export function ModelTestResultDialog({
) : (
<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>
</DialogHeader>
@ -54,7 +54,7 @@ export function ModelTestResultDialog({
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t.common.done}
{t('common.done')}
</Button>
</DialogFooter>
</DialogContent>

View file

@ -92,7 +92,7 @@ export function ChatPanel({
// The modal component itself will handle displaying "not found" states.
// This try-catch is here for future enhancements or unexpected errors.
} 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">
<CardTitle className="flex items-center gap-2">
<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>
{onSelectSession && onCreateSession && onDeleteSession && (
<Dialog open={sessionManagerOpen} onOpenChange={setSessionManagerOpen}>
@ -142,10 +142,10 @@ export function ChatPanel({
disabled={loadingSessions}
>
<Clock className="h-4 w-4" />
<span className="text-xs">{t.chat.sessions}</span>
<span className="text-xs">{t('chat.sessions')}</span>
</Button>
<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
sessions={sessions}
currentSessionId={currentSessionId ?? null}
@ -170,9 +170,9 @@ export function ChatPanel({
<div className="text-center text-muted-foreground py-8">
<Bot className="h-12 w-12 mx-auto mb-4 opacity-50" />
<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 className="text-xs mt-2">{t.chat.askQuestions}</p>
<p className="text-xs mt-2">{t('chat.askQuestions')}</p>
</div>
) : (
messages.map((message) => (
@ -246,19 +246,19 @@ export function ChatPanel({
{contextIndicators.sources?.length > 0 && (
<Badge variant="outline" className="gap-1">
<FileText className="h-3 w-3" />
{contextIndicators.sources.length} {t.navigation.sources}
{contextIndicators.sources.length} {t('navigation.sources')}
</Badge>
)}
{contextIndicators.insights?.length > 0 && (
<Badge variant="outline" className="gap-1">
<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>
)}
{contextIndicators.notes?.length > 0 && (
<Badge variant="outline" className="gap-1">
<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>
)}
</div>
@ -281,7 +281,7 @@ export function ChatPanel({
{/* Model selector */}
{onModelChange && (
<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
currentModel={modelOverride}
onModelChange={onModelChange}
@ -298,7 +298,7 @@ export function ChatPanel({
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`${t.chat.sendPlaceholder} (${t.chat.pressToSend.replace('{key}', keyHint)})`}
placeholder={`${t('chat.sendPlaceholder')} (${t('chat.pressToSend').replace('{key}', keyHint)})`}
disabled={isStreaming}
className="flex-1 min-h-[40px] max-h-[100px] resize-none py-2 px-3 min-w-0"
rows={1}
@ -334,7 +334,7 @@ function AIMessageContent({
}) {
const { t } = useTranslation()
// 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
const LinkComponent = createCompactReferenceLinkComponent(onReferenceClick)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -47,21 +47,21 @@ export function ProcessingStep({
return (
<div className="space-y-8">
<FormSection
title={`${t.navigation.transformations} (${t.common.optional})`}
description={t.sources.processDescription}
title={`${t('navigation.transformations')} (${t('common.optional')})`}
description={t('sources.processDescription')}
>
<CheckboxList
items={transformationItems}
selectedIds={selectedTransformations}
onToggle={onToggleTransformation}
loading={loading}
emptyMessage={t.common.noMatches}
emptyMessage={t('common.noMatches')}
/>
</FormSection>
<FormSection
title={t.navigation.settings}
description={t.sources.processDescription}
title={t('navigation.settings')}
description={t('sources.processDescription')}
>
<div className="space-y-4">
{settings?.default_embedding_option === 'ask' && (
@ -80,9 +80,9 @@ export function ProcessingStep({
className="mt-0.5"
/>
<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">
{t.sources.embeddingDesc}
{t('sources.embeddingDesc')}
</p>
</div>
</label>
@ -95,10 +95,10 @@ export function ProcessingStep({
<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="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">
{t.sources.embeddingAlwaysDesc}
{t.sources.changeInSettings} <span className="font-medium">{t.navigation.settings}</span>.
{t('sources.embeddingAlwaysDesc')}
{t('sources.changeInSettings')} <span className="font-medium">{t('navigation.settings')}</span>.
</p>
</div>
</div>
@ -110,10 +110,10 @@ export function ProcessingStep({
<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="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">
{t.sources.embeddingNeverDesc}
{t.sources.changeInSettings} <span className="font-medium">{t.navigation.settings}</span>.
{t('sources.embeddingNeverDesc')}
{t('sources.changeInSettings')} <span className="font-medium">{t('navigation.settings')}</span>.
</p>
</div>
</div>

View file

@ -63,26 +63,26 @@ export function parseAndValidateUrls(text: string): {
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,
label: t.sources.addUrl,
label: t('sources.addUrl'),
icon: LinkIcon,
description: t.sources.processDescription,
description: t('sources.processDescription'),
},
{
value: 'upload' as const,
label: t.sources.uploadFile,
label: t('sources.uploadFile'),
icon: FileIcon,
description: t.sources.processDescription,
description: t('sources.processDescription'),
},
{
value: 'text' as const,
label: t.sources.enterText,
label: t('sources.enterText'),
icon: FileTextIcon,
description: t.sources.processDescription,
description: t('sources.processDescription'),
},
]
@ -156,8 +156,8 @@ export function SourceTypeStep({ control, register, setValue, errors, urlValidat
return (
<div className="space-y-6">
<FormSection
title={t.sources.title}
description={t.sources.processDescription}
title={t('sources.title')}
description={t('sources.processDescription')}
>
<Controller
control={control}
@ -188,11 +188,11 @@ export function SourceTypeStep({ control, register, setValue, errors, urlValidat
{type.value === 'link' && (
<div>
<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 && (
<Badge variant={isOverLimit ? "destructive" : "secondary"}>
{t.sources.urlsCount.replace('{count}', urlCount.toString())}
{isOverLimit && ` (${t.sources.maxItems.replace('{count}', MAX_BATCH_SIZE.toString())})`}
{t('sources.urlsCount').replace('{count}', urlCount.toString())}
{isOverLimit && ` (${t('sources.maxItems').replace('{count}', MAX_BATCH_SIZE.toString())})`}
</Badge>
)}
</div>
@ -201,12 +201,12 @@ export function SourceTypeStep({ control, register, setValue, errors, urlValidat
{...register('url', {
onChange: () => onClearUrlErrors?.()
})}
placeholder={t.sources.enterUrlsPlaceholder}
placeholder={t('sources.enterUrlsPlaceholder')}
rows={urlCount > 1 ? 6 : 2}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
{t.sources.batchUrlHint}
{t('sources.batchUrlHint')}
</p>
{errors.url && (
<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 && (
<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">
{t.sources.invalidUrlsDetected}
{t('sources.invalidUrlsDetected')}
</p>
<ul className="space-y-1">
{urlValidationErrors.map((error, idx) => (
<li key={idx} className="text-xs text-destructive flex items-start gap-2">
<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 className="truncate">{error.url}</span>
</li>
))}
</ul>
<p className="text-xs text-muted-foreground mt-2">
{t.sources.fixInvalidUrls}
{t('sources.fixInvalidUrls')}
</p>
</div>
)}
@ -237,11 +237,11 @@ export function SourceTypeStep({ control, register, setValue, errors, urlValidat
{type.value === 'upload' && (
<div>
<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 && (
<Badge variant={isOverLimit ? "destructive" : "secondary"}>
{t.sources.filesCount.replace('{count}', fileCount.toString())}
{isOverLimit && ` (${t.sources.maxItems.replace('{count}', MAX_BATCH_SIZE.toString())})`}
{t('sources.filesCount').replace('{count}', fileCount.toString())}
{isOverLimit && ` (${t('sources.maxItems').replace('{count}', MAX_BATCH_SIZE.toString())})`}
</Badge>
)}
</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"
/>
<p className="text-xs text-muted-foreground mt-1">
{t.sources.selectMultipleFilesHint}
{t('sources.selectMultipleFilesHint')}
</p>
{fileCount > 1 && fileInput instanceof FileList && (
<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">
{Array.from(fileInput).map((file, idx) => (
<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' && (
<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>
)}
</div>
@ -284,18 +284,18 @@ export function SourceTypeStep({ control, register, setValue, errors, urlValidat
{type.value === 'text' && (
<div>
<Label htmlFor="content" className="mb-2 block">{t.sources.textContentLabel}</Label>
<Label htmlFor="content" className="mb-2 block">{t('sources.textContentLabel')}</Label>
{hasHtmlContent && (
<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">
{t.sources.htmlDetected}
{t('sources.htmlDetected')}
</p>
</div>
)}
<Textarea
id="content"
{...register('content')}
placeholder={t.sources.textPlaceholder}
placeholder={t('sources.textPlaceholder')}
rows={6}
onPaste={handleTextPaste}
/>
@ -318,16 +318,16 @@ export function SourceTypeStep({ control, register, setValue, errors, urlValidat
{!isBatchMode && (
<FormSection
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'
? t.sources.titleRequired
: t.sources.titleGenerated
? t('sources.titleRequired')
: t('sources.titleGenerated')
}
>
<Input
id="source-title"
{...register('title')}
placeholder={t.sources.titlePlaceholder}
placeholder={t('sources.titlePlaceholder')}
autoComplete="off"
/>
{errors.title && (
@ -340,14 +340,14 @@ export function SourceTypeStep({ control, register, setValue, errors, urlValidat
{isBatchMode && (
<div className="p-4 bg-primary/5 border border-primary/20 rounded-lg">
<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">
{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>
</div>
<p className="text-xs text-muted-foreground">
{t.sources.batchTitlesAuto}
{t.sources.batchCommonSettings}
{t('sources.batchTitlesAuto')}
{t('sources.batchCommonSettings')}
</p>
</div>
)}

View file

@ -72,7 +72,7 @@ const DialogContent = ({
{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">
<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.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)
- **Model/config hooks** (`useModels`, `useSettings`, `useTransformations`): Application-level settings and model management
- **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
@ -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
- **Status polling**: `useSourceStatus` auto-refetches every 2s while `status === 'running' | 'queued' | 'new'`
- **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
@ -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
- **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
- **useTranslation depth limit**: Proxy limits nesting to 4 levels; deeper access returns path string as fallback
- **useTranslation loop detection**: >1000 accesses to same key in 1s triggers error and breaks recursion
- **useTranslation**: Thin wrapper preserving `setLanguage` with language change events for `LanguageLoadingOverlay`
## Testing Patterns
@ -187,7 +186,7 @@ function CredentialSettings() {
### Important Notes
- **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
- **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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,24 +1,22 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
// Ensure we are testing the real implementation
vi.unmock('@/lib/hooks/use-translation')
vi.unmock('@/lib/hooks/use-translation')
import { useTranslation } from './use-translation'
import { useTranslation as useI18nTranslation } from 'react-i18next'
// Mock react-i18next is already done in setup.ts,
// but we might need to control it per test
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: vi.fn()
}))
describe('useTranslation Hook', () => {
const changeLanguageMock = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
;(useI18nTranslation as unknown as { mockReturnValue: (v: unknown) => void }).mockReturnValue({
t: (key: string) => {
if (key === 'common') return { appName: 'Open Notebook' }
if (key === 'common.appName') return 'Open Notebook'
return key
},
@ -29,20 +27,19 @@ describe('useTranslation Hook', () => {
})
})
it('should return initial translations via proxy', () => {
it('should return standard t() function for translations', () => {
const { result } = renderHook(() => useTranslation())
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())
act(() => {
result.current.setLanguage('zh-CN')
})
expect(changeLanguageMock).toHaveBeenCalledWith('zh-CN')
})
})

View file

@ -1,132 +1,13 @@
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'
/**
* Custom useTranslation hook that provides a Proxy-based API for accessing translations.
*
* 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
* Thin wrapper around react-i18next's useTranslation hook.
* Returns the standard t() function along with language utilities.
*/
export function useTranslation() {
const { t: i18nTranslate, 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 { t, i18n } = useI18nTranslation()
const setLanguage = useCallback(async (lang: string) => {
if (lang === i18n.language) {
@ -143,11 +24,10 @@ export function useTranslation() {
}
}, [i18n])
return useMemo(() => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
t: t as any,
return useMemo(() => ({
t,
i18n,
language: i18n.language,
setLanguage
language: i18n.language,
setLanguage
}), [t, i18n, setLanguage])
}

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
# 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
@ -9,7 +9,7 @@ lib/
├── i18n.ts # i18next initialization and configuration
├── i18n-events.ts # Language change event emitters
├── hooks/
│ └── use-translation.ts # Custom hook with Proxy-based API
│ └── use-translation.ts # Thin wrapper around react-i18next with language change events
├── utils/
│ └── date-locale.ts # date-fns locale mapping
└── locales/
@ -28,7 +28,7 @@ lib/
- **`i18n.ts`**: i18next initialization with language detection (localStorage → browser)
- **`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
- **`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
@ -67,24 +67,36 @@ import { useTranslation } from '@/lib/hooks/use-translation'
function MyComponent() {
const { t, language, setLanguage } = useTranslation()
// Nested property access (Proxy-based)
return <h1>{t.notebooks.title}</h1>
// Standard t() function call
return <h1>{t('notebooks.title')}</h1>
// With interpolation
return <p>{t.common.updated.replace('{time}', timeAgo)}</p>
// With string interpolation
return <p>{t('common.updated').replace('{time}', timeAgo)}</p>
// Change language
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
- **Proxy-based access**: `t.section.key` instead of `t('section.key')` for better DX
- **Type safety**: `TranslationKeys` type derived from `enUS` locale
- **Standard t() calls**: `t('section.key')` — standard react-i18next pattern
- **Language persistence**: Saved to localStorage, auto-detected on load
- **Fallback**: Falls back to `en-US` if key missing in current locale
- **Date localization**: Use `getDateLocale(language)` from `utils/date-locale.ts`
- **Language change events**: `setLanguage` emits start/end events for `LanguageLoadingOverlay`
## Key Dependencies
@ -117,13 +129,10 @@ function MyComponent() {
## 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
- **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
- **ErrorBoundary**: Uses raw `enUS` locale object directly (class component, can't use hooks)
## Testing Patterns
@ -131,7 +140,7 @@ function MyComponent() {
// Mock useTranslation in tests (see test/setup.ts)
vi.mock('@/lib/hooks/use-translation', () => ({
useTranslation: () => ({
t: enUS, // Use English locale directly
t: (key: string) => key, // Identity function returns the key
language: 'en-US',
setLanguage: vi.fn(),
}),

View file

@ -1,6 +1,5 @@
import '@testing-library/jest-dom'
import { vi } from 'vitest'
import { enUS } from '../lib/locales/en-US'
// 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', () => {
const t = (key: string) => key
Object.assign(t, enUS)
return {
useTranslation: () => ({
t,
t: (key: string) => key,
language: 'en-US',
setLanguage: vi.fn(),
}),