Skip to content

Commit dbffa4c

Browse files
authored
refactor(frontend): use i18next interpolation instead of manual replace (#1074)
Convert every t('key').replace('{ph}', value) call site (77 t() calls across 37 files) to native i18next interpolation t('key', { ph: value }), and switch the corresponding locale placeholders from {ph} to {{ph}} in all 14 locales (842 strings). - count params are passed as numbers, enabling i18next plural resolution (podcasts.usedByCount now uses the existing _one/_other forms instead of a manual ternary) - podcasts.tokens/chars keep their pre-formatted display value (formatNumber) under a renamed {{value}} placeholder, since i18next types reserve count for numbers - escapeValue: false was already set in src/lib/i18n.ts (React escapes at render), so rendered output is unchanged - unused-key test now strips plural suffixes before matching, and a new interpolation test covers variables, plurals and non-escaping
1 parent c3716da commit dbffa4c

53 files changed

Lines changed: 972 additions & 944 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- CI now gates every PR on `ruff check` (backend lint), `npm run lint` (frontend ESLint) and `npm run build` (frontend production build), in addition to the existing test suites
1717

1818
### Fixed
19+
- Frontend translations now use i18next interpolation (`t('key', { count })`) instead of manual `.replace('{count}', ...)` string surgery across ~75 call sites — locale placeholders changed from `{name}` to `{{name}}` in all 14 locales. This restores proper pluralization (e.g. "used by N episodes" now goes through i18next plural forms) and lets translators reorder placeholders freely
1920
- Podcast generation dialog: the token/char counter no longer fires a request storm on rapid checkbox toggling (debounced, with a stale-response guard so a slow response can't overwrite a fresher count) and the dialog now closes as soon as the episode-list refetch completes instead of after a fixed 500ms timer; the 983-line component was also split (content selection panel and selection helpers extracted, duplicated context-config logic deduplicated) with no behavior changes
2021

2122
### Changed

frontend/src/app/(dashboard)/advanced/components/RebuildEmbeddings.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -256,16 +256,13 @@ export function RebuildEmbeddings() {
256256
<div className="flex justify-between text-sm">
257257
<span>{t('common.progress')}</span>
258258
<span className="font-medium">
259-
{t('advanced.rebuild.itemsProcessed')
260-
.replace('{processed}', processedItems.toString())
261-
.replace('{total}', totalItems.toString())
262-
.replace('{percent}', progressPercent.toFixed(1))}
259+
{t('advanced.rebuild.itemsProcessed', { processed: processedItems.toString(), total: totalItems.toString(), percent: progressPercent.toFixed(1) })}
263260
</span>
264261
</div>
265262
<Progress value={progressPercent} className="h-2" />
266263
{failedItems > 0 && (
267264
<p className="text-sm text-yellow-600">
268-
⚠️ {t('advanced.rebuild.failedItems').replace('{count}', failedItems.toString())}
265+
⚠️ {t('advanced.rebuild.failedItems', { count: failedItems })}
269266
</p>
270267
)}
271268
</div>
@@ -303,7 +300,7 @@ export function RebuildEmbeddings() {
303300

304301
{status.started_at && (
305302
<div className="text-sm text-muted-foreground space-y-1">
306-
<p>{t('common.created').replace('{time}', new Date(status.started_at).toLocaleString())}</p>
303+
<p>{t('common.created', { time: new Date(status.started_at).toLocaleString() })}</p>
307304
{status.completed_at && (
308305
<p>{t('notebooks.updated')}: {new Date(status.completed_at).toLocaleString()}</p>
309306
)}

frontend/src/app/(dashboard)/advanced/components/SystemInfo.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export function SystemInfo() {
6666
<span className="text-sm font-medium">{t('advanced.status')}</span>
6767
{config?.hasUpdate ? (
6868
<Badge variant="destructive">
69-
{t('advanced.updateAvailable').replace('{version}', config.latestVersion || '')}
69+
{t('advanced.updateAvailable', { version: config.latestVersion || '' })}
7070
</Badge>
7171
) : config?.latestVersion ? (
7272
<Badge variant="outline" className="text-green-600 border-green-600">

frontend/src/app/(dashboard)/notebooks/components/NotebookCard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,10 @@ export function NotebookCard({ notebook }: NotebookCardProps) {
106106
</CardDescription>
107107

108108
<div className="mt-3 text-xs text-muted-foreground">
109-
{t('common.updated').replace('{time}', formatDistanceToNow(new Date(notebook.updated), {
109+
{t('common.updated', { time: formatDistanceToNow(new Date(notebook.updated), {
110110
addSuffix: true,
111111
locale: getDateLocale(language)
112-
}))}
112+
}) })}
113113
</div>
114114

115115
{/* Item counts footer */}

frontend/src/app/(dashboard)/notebooks/components/NotebookDeleteDialog.tsx

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export function NotebookDeleteDialog({
7171
<AlertDialogHeader>
7272
<AlertDialogTitle>{t('notebooks.deleteNotebook')}</AlertDialogTitle>
7373
<AlertDialogDescription>
74-
{t('notebooks.deleteNotebookDesc').replace('{name}', notebookName)}
74+
{t('notebooks.deleteNotebookDesc', { name: notebookName })}
7575
</AlertDialogDescription>
7676
</AlertDialogHeader>
7777

@@ -91,10 +91,7 @@ export function NotebookDeleteDialog({
9191
<div className="text-sm">
9292
{preview.note_count > 0 ? (
9393
<p className="text-destructive font-medium">
94-
{t('notebooks.deleteNotebookNotes').replace(
95-
'{count}',
96-
String(preview.note_count)
97-
)}
94+
{t('notebooks.deleteNotebookNotes', { count: preview.note_count })}
9895
</p>
9996
) : (
10097
<p className="text-muted-foreground">{t('notebooks.deleteNotebookNoNotes')}</p>
@@ -105,10 +102,7 @@ export function NotebookDeleteDialog({
105102
{preview.shared_source_count > 0 && (
106103
<div className="text-sm">
107104
<p className="text-muted-foreground">
108-
{t('notebooks.deleteNotebookSharedSources').replace(
109-
'{count}',
110-
String(preview.shared_source_count)
111-
)}
105+
{t('notebooks.deleteNotebookSharedSources', { count: preview.shared_source_count })}
112106
</p>
113107
</div>
114108
)}
@@ -124,10 +118,7 @@ export function NotebookDeleteDialog({
124118
{preview.exclusive_source_count > 0 && (
125119
<div className="pt-3 border-t space-y-3">
126120
<p className="text-sm text-destructive font-medium">
127-
{t('notebooks.deleteNotebookExclusiveSources').replace(
128-
'{count}',
129-
String(preview.exclusive_source_count)
130-
)}
121+
{t('notebooks.deleteNotebookExclusiveSources', { count: preview.exclusive_source_count })}
131122
</p>
132123
<RadioGroup
133124
value={sourceAction}

frontend/src/app/(dashboard)/notebooks/components/NotebookHeader.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,8 @@ export function NotebookHeader({ notebook }: NotebookHeaderProps) {
110110
/>
111111

112112
<div className="text-sm text-muted-foreground">
113-
{t('common.created').replace('{time}', formatDistanceToNow(new Date(notebook.created), { addSuffix: true, locale: dfLocale }))}
114-
{t('common.updated').replace('{time}', formatDistanceToNow(new Date(notebook.updated), { addSuffix: true, locale: dfLocale }))}
113+
{t('common.created', { time: formatDistanceToNow(new Date(notebook.created), { addSuffix: true, locale: dfLocale }) })}
114+
{t('common.updated', { time: formatDistanceToNow(new Date(notebook.updated), { addSuffix: true, locale: dfLocale }) })}
115115
</div>
116116
</div>
117117
</div>

frontend/src/app/(dashboard)/notebooks/components/NotebookRow.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,10 @@ export function NotebookRow({ notebook }: NotebookRowProps) {
8585
</div>
8686

8787
<div className="hidden sm:block w-40 shrink-0 text-right text-xs text-muted-foreground">
88-
{t('common.updated').replace('{time}', formatDistanceToNow(new Date(notebook.updated), {
88+
{t('common.updated', { time: formatDistanceToNow(new Date(notebook.updated), {
8989
addSuffix: true,
9090
locale: getDateLocale(language)
91-
}))}
91+
}) })}
9292
</div>
9393

9494
<DropdownMenu>

frontend/src/app/(dashboard)/search/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -428,15 +428,15 @@ export default function SearchPage() {
428428
<div className="mt-6 space-y-3">
429429
<div className="flex items-center justify-between">
430430
<h3 className="text-sm font-medium">
431-
{t('searchPage.resultsFound').replace('{count}', searchMutation.data.total_count.toString())}
431+
{t('searchPage.resultsFound', { count: searchMutation.data.total_count })}
432432
</h3>
433433
<Badge variant="outline">{searchMutation.data.search_type === 'text' ? t('searchPage.textSearch') : t('searchPage.vectorSearch')}</Badge>
434434
</div>
435435

436436
{searchMutation.data.results.length === 0 ? (
437437
<Card>
438438
<CardContent className="pt-6 text-center text-muted-foreground">
439-
{t('searchPage.noResultsFor').replace('{query}', searchQuery)}
439+
{t('searchPage.noResultsFor', { query: searchQuery })}
440440
</CardContent>
441441
</Card>
442442
) : (
@@ -472,7 +472,7 @@ export default function SearchPage() {
472472
<Collapsible className="mt-3">
473473
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
474474
<ChevronDown className="h-4 w-4" />
475-
{t('searchPage.matches').replace('{count}', result.matches.length.toString())}
475+
{t('searchPage.matches', { count: result.matches.length })}
476476
</CollapsibleTrigger>
477477
<CollapsibleContent className="mt-2 space-y-1">
478478
{result.matches.map((match, i) => (

frontend/src/app/(dashboard)/sources/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,7 @@ export default function SourcesPage() {
446446
open={deleteDialog.open}
447447
onOpenChange={(open) => setDeleteDialog({ open, source: deleteDialog.source })}
448448
title={t('sources.delete')}
449-
description={t('sources.deleteConfirmWithTitle').replace('{title}', deleteDialog.source?.title || t('sources.untitledSource'))}
449+
description={t('sources.deleteConfirmWithTitle', { title: deleteDialog.source?.title || t('sources.untitledSource') })}
450450
confirmText={t('common.delete')}
451451
confirmVariant="destructive"
452452
onConfirm={handleDeleteConfirm}

frontend/src/components/common/CommandPalette.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,15 +187,15 @@ export function CommandPalette() {
187187
forceMount
188188
>
189189
<Search className="h-4 w-4" />
190-
<span>{t('searchPage.searchResultsFor').replace('{query}', query)}</span>
190+
<span>{t('searchPage.searchResultsFor', { query: query })}</span>
191191
</CommandItem>
192192
<CommandItem
193193
value={`__ask__ ${query}`}
194194
onSelect={handleAsk}
195195
forceMount
196196
>
197197
<MessageCircleQuestion className="h-4 w-4" />
198-
<span>{t('searchPage.askAbout').replace('{query}', query)}</span>
198+
<span>{t('searchPage.askAbout', { query: query })}</span>
199199
</CommandItem>
200200
</CommandGroup>
201201
)}
@@ -274,15 +274,15 @@ export function CommandPalette() {
274274
forceMount
275275
>
276276
<Search className="h-4 w-4" />
277-
<span>{t('searchPage.searchResultsFor').replace('{query}', query)}</span>
277+
<span>{t('searchPage.searchResultsFor', { query: query })}</span>
278278
</CommandItem>
279279
<CommandItem
280280
value={`__ask__ ${query}`}
281281
onSelect={handleAsk}
282282
forceMount
283283
>
284284
<MessageCircleQuestion className="h-4 w-4" />
285-
<span>{t('searchPage.askAbout').replace('{query}', query)}</span>
285+
<span>{t('searchPage.askAbout', { query: query })}</span>
286286
</CommandItem>
287287
</CommandGroup>
288288
</>

0 commit comments

Comments
 (0)