diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index e3172e8f..605abe32 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -14,6 +14,7 @@ import { Wand2, } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor'; import { Button } from '@/components/ui/button'; @@ -88,6 +89,7 @@ function AudioBars({ mode }: { mode: 'idle' | 'generating' | 'playing' }) { // NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS WITH INFINITE SCROLL export function HistoryTable() { + const { t } = useTranslation(); const [page, setPage] = useState(0); const [allHistory, setAllHistory] = useState([]); const [total, setTotal] = useState(0); @@ -678,7 +680,9 @@ export function HistoryTable() { - handlePlay(gen.id, gen.text, gen.profile_id)}> + handlePlay(gen.id, gen.text, gen.profile_id)} + > Play @@ -802,10 +806,9 @@ export function HistoryTable() { - Delete Generation + {t('history.deleteDialog.title')} - Are you sure you want to delete this generation from "{generationToDelete?.name}"? - This action cannot be undone. + {t('history.deleteDialog.body', { name: generationToDelete?.name })} @@ -816,14 +819,14 @@ export function HistoryTable() { setGenerationToDelete(null); }} > - Cancel + {t('common.cancel')} @@ -832,23 +835,23 @@ export function HistoryTable() { - Clear failed generations + {t('history.clearFailedDialog.title')} - This will permanently delete {failedCount} failed{' '} - {failedCount === 1 ? 'generation' : 'generations'} from your history. This cannot be - undone. + {t('history.clearFailedDialog.body', { count: failedCount })} @@ -857,9 +860,9 @@ export function HistoryTable() { - Import Generation + {t('history.importDialog.title')} - Import the generation from "{selectedFile?.name}". This will add it to your history. + {t('history.importDialog.body', { name: selectedFile?.name })} @@ -873,13 +876,15 @@ export function HistoryTable() { } }} > - Cancel + {t('common.cancel')} @@ -888,11 +893,8 @@ export function HistoryTable() { - Apply Effects - - Configure post-processing effects to apply to this generation. A new version will be - created. - + {t('history.effectsDialog.title')} + {t('history.effectsDialog.body')} {effectsTargetVersions.length > 1 && (
diff --git a/app/src/components/VoiceProfiles/ProfileCard.tsx b/app/src/components/VoiceProfiles/ProfileCard.tsx index b3d5ffee..5ed74a9d 100644 --- a/app/src/components/VoiceProfiles/ProfileCard.tsx +++ b/app/src/components/VoiceProfiles/ProfileCard.tsx @@ -1,5 +1,6 @@ import { Download, Edit, Sparkles, Trash2 } from 'lucide-react'; import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -29,6 +30,7 @@ interface ProfileCardProps { } export function ProfileCard({ profile, disabled }: ProfileCardProps) { + const { t } = useTranslation(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const deleteProfile = useDeleteProfile(); @@ -41,7 +43,6 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) { const isSelected = selectedProfileId === profile.id; const handleSelect = () => { - // If disabled but already selected, bounce the selection to re-trigger engine auto-switch if (disabled && isSelected) { setSelectedProfileId(null); setTimeout(() => setSelectedProfileId(profile.id), 0); @@ -79,9 +80,10 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) { } }; - const selectLabel = isSelected - ? `${profile.name}, ${profile.language}. Selected as voice for generation.` - : `${profile.name}, ${profile.language}. Select as voice for generation.`; + const selectLabel = t( + isSelected ? 'profiles.card.selectLabelSelected' : 'profiles.card.selectLabel', + { name: profile.name, language: profile.language }, + ); return ( <> @@ -105,7 +107,7 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {

- {profile.description || 'No description'} + {profile.description || t('profiles.card.noDescription')}

@@ -118,7 +120,7 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) { )} {profile.voice_type === 'designed' && ( - designed + {t('profiles.card.designed')} )} {profile.effects_chain && profile.effects_chain.length > 0 && ( @@ -130,7 +132,7 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) { icon={Download} onClick={handleExport} disabled={exportProfile.isPending} - aria-label="Export profile" + aria-label={t('profiles.card.export')} />
@@ -153,21 +155,21 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) { - Delete Profile + {t('profiles.deleteDialog.title')} - Are you sure you want to delete "{profile.name}"? This action cannot be undone. + {t('profiles.deleteDialog.body', { name: profile.name })} diff --git a/app/src/i18n/locales/en/translation.json b/app/src/i18n/locales/en/translation.json index 86e12206..894f4dca 100644 --- a/app/src/i18n/locales/en/translation.json +++ b/app/src/i18n/locales/en/translation.json @@ -21,6 +21,46 @@ "settings": "Settings", "updateBadge": "Update" }, + "profiles": { + "card": { + "noDescription": "No description", + "designed": "designed", + "export": "Export profile", + "edit": "Edit profile", + "delete": "Delete profile", + "selectLabel": "{{name}}, {{language}}. Select as voice for generation.", + "selectLabelSelected": "{{name}}, {{language}}. Selected as voice for generation." + }, + "deleteDialog": { + "title": "Delete Profile", + "body": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", + "deleting": "Deleting…" + } + }, + "history": { + "deleteDialog": { + "title": "Delete Generation", + "body": "Are you sure you want to delete this generation from \"{{name}}\"? This action cannot be undone.", + "deleting": "Deleting…" + }, + "clearFailedDialog": { + "title": "Clear failed generations", + "body_one": "This will permanently delete {{count}} failed generation from your history. This cannot be undone.", + "body_other": "This will permanently delete {{count}} failed generations from your history. This cannot be undone.", + "clearing": "Clearing…", + "clearAll": "Clear all" + }, + "importDialog": { + "title": "Import Generation", + "body": "Import the generation from \"{{name}}\". This will add it to your history.", + "importing": "Importing…", + "action": "Import" + }, + "effectsDialog": { + "title": "Apply Effects", + "body": "Configure post-processing effects to apply to this generation. A new version will be created." + } + }, "generation": { "placeholder": { "storyWithEffects": "Generate speech for \"{{name}}\"… (type / for effects)", diff --git a/app/src/i18n/locales/zh-CN/translation.json b/app/src/i18n/locales/zh-CN/translation.json index 0937f5d1..584bbd5f 100644 --- a/app/src/i18n/locales/zh-CN/translation.json +++ b/app/src/i18n/locales/zh-CN/translation.json @@ -21,6 +21,46 @@ "settings": "设置", "updateBadge": "更新" }, + "profiles": { + "card": { + "noDescription": "无描述", + "designed": "设计", + "export": "导出声音档案", + "edit": "编辑声音档案", + "delete": "删除声音档案", + "selectLabel": "{{name}},{{language}}。选择用于生成的声音。", + "selectLabelSelected": "{{name}},{{language}}。已选为用于生成的声音。" + }, + "deleteDialog": { + "title": "删除声音档案", + "body": "确定要删除 \"{{name}}\" 吗?此操作不可撤销。", + "deleting": "删除中…" + } + }, + "history": { + "deleteDialog": { + "title": "删除生成", + "body": "确定要删除来自 \"{{name}}\" 的这次生成吗?此操作不可撤销。", + "deleting": "删除中…" + }, + "clearFailedDialog": { + "title": "清除失败的生成", + "body_one": "这将从历史记录中永久删除 {{count}} 条失败的生成。此操作不可撤销。", + "body_other": "这将从历史记录中永久删除 {{count}} 条失败的生成。此操作不可撤销。", + "clearing": "清除中…", + "clearAll": "全部清除" + }, + "importDialog": { + "title": "导入生成", + "body": "从 \"{{name}}\" 导入生成。这将添加到您的历史记录中。", + "importing": "导入中…", + "action": "导入" + }, + "effectsDialog": { + "title": "应用效果", + "body": "配置应用于此次生成的后处理效果。将会创建一个新版本。" + } + }, "generation": { "placeholder": { "storyWithEffects": "为 \"{{name}}\" 生成语音… (输入 / 使用效果)", diff --git a/app/src/lib/utils/format.ts b/app/src/lib/utils/format.ts index e1cec0e6..d1c505d9 100644 --- a/app/src/lib/utils/format.ts +++ b/app/src/lib/utils/format.ts @@ -1,4 +1,6 @@ import { formatDistance } from 'date-fns'; +import { zhCN } from 'date-fns/locale'; +import i18n from '@/i18n'; export function formatDuration(seconds: number): string { const mins = Math.floor(seconds / 60); @@ -6,15 +8,17 @@ export function formatDuration(seconds: number): string { return `${mins}:${secs.toString().padStart(2, '0')}`; } +function getDateLocale() { + if (i18n.language === 'zh-CN') return zhCN; + return undefined; +} + export function formatDate(date: string | Date): string { - // Parse the date string - if it doesn't have timezone info, treat it as UTC let dateObj: Date; if (typeof date === 'string') { - // If the string doesn't end with Z or have timezone offset, assume it's UTC const dateStr = date.trim(); if (!dateStr.includes('Z') && !dateStr.match(/[+-]\d{2}:\d{2}$/)) { - // No timezone info, treat as UTC - dateObj = new Date(dateStr + 'Z'); + dateObj = new Date(`${dateStr}Z`); } else { dateObj = new Date(dateStr); } @@ -22,7 +26,10 @@ export function formatDate(date: string | Date): string { dateObj = date; } - return formatDistance(dateObj, new Date(), { addSuffix: true }).replace(/^about /i, ''); + return formatDistance(dateObj, new Date(), { + addSuffix: true, + locale: getDateLocale(), + }).replace(/^about /i, ''); } const ENGINE_DISPLAY_NAMES: Record = {