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:
commit
d7967a0fcf
86 changed files with 1295 additions and 1425 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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} →
|
||||
{t('apiKeys.getApiKey')} →
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
|
|
|
|||
Loading…
Reference in a new issue