feat(i18n): localize ProfileCard, HistoryTable, and relative dates

- ProfileCard: "No description", "designed" badge, aria-labels, and
  delete dialog.
- HistoryTable: delete / clear-failed / import / effects dialogs.
- formatDate: switch date-fns `formatDistance` locale based on
  `i18n.language` so "5 minutes ago" becomes "5 分钟前" under zh-CN.
  HistoryTable now subscribes via useTranslation so the table
  re-renders when language flips.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
James Pine
2026-04-20 02:57:29 -07:00
co-authored by Claude Opus 4.7
parent 302e28e313
commit dd4119e0e6
5 changed files with 130 additions and 39 deletions
+23 -21
View File
@@ -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<HistoryResponse[]>([]);
const [total, setTotal] = useState(0);
@@ -678,7 +680,9 @@ export function HistoryTable() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}>
<DropdownMenuItem
onClick={() => handlePlay(gen.id, gen.text, gen.profile_id)}
>
<Play className="mr-2 h-4 w-4" />
Play
</DropdownMenuItem>
@@ -802,10 +806,9 @@ export function HistoryTable() {
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Generation</DialogTitle>
<DialogTitle>{t('history.deleteDialog.title')}</DialogTitle>
<DialogDescription>
Are you sure you want to delete this generation from "{generationToDelete?.name}"?
This action cannot be undone.
{t('history.deleteDialog.body', { name: generationToDelete?.name })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -816,14 +819,14 @@ export function HistoryTable() {
setGenerationToDelete(null);
}}
>
Cancel
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={deleteGeneration.isPending}
>
{deleteGeneration.isPending ? 'Deleting...' : 'Delete'}
{deleteGeneration.isPending ? t('history.deleteDialog.deleting') : t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
@@ -832,23 +835,23 @@ export function HistoryTable() {
<Dialog open={clearFailedDialogOpen} onOpenChange={setClearFailedDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Clear failed generations</DialogTitle>
<DialogTitle>{t('history.clearFailedDialog.title')}</DialogTitle>
<DialogDescription>
This will permanently delete {failedCount} failed{' '}
{failedCount === 1 ? 'generation' : 'generations'} from your history. This cannot be
undone.
{t('history.clearFailedDialog.body', { count: failedCount })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setClearFailedDialogOpen(false)}>
Cancel
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={handleClearFailedConfirm}
disabled={clearFailed.isPending}
>
{clearFailed.isPending ? 'Clearing...' : 'Clear all'}
{clearFailed.isPending
? t('history.clearFailedDialog.clearing')
: t('history.clearFailedDialog.clearAll')}
</Button>
</DialogFooter>
</DialogContent>
@@ -857,9 +860,9 @@ export function HistoryTable() {
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Import Generation</DialogTitle>
<DialogTitle>{t('history.importDialog.title')}</DialogTitle>
<DialogDescription>
Import the generation from "{selectedFile?.name}". This will add it to your history.
{t('history.importDialog.body', { name: selectedFile?.name })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -873,13 +876,15 @@ export function HistoryTable() {
}
}}
>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={handleImportConfirm}
disabled={importGeneration.isPending || !selectedFile}
>
{importGeneration.isPending ? 'Importing...' : 'Import'}
{importGeneration.isPending
? t('history.importDialog.importing')
: t('history.importDialog.action')}
</Button>
</DialogFooter>
</DialogContent>
@@ -888,11 +893,8 @@ export function HistoryTable() {
<Dialog open={effectsDialogOpen} onOpenChange={setEffectsDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Apply Effects</DialogTitle>
<DialogDescription>
Configure post-processing effects to apply to this generation. A new version will be
created.
</DialogDescription>
<DialogTitle>{t('history.effectsDialog.title')}</DialogTitle>
<DialogDescription>{t('history.effectsDialog.body')}</DialogDescription>
</DialogHeader>
{effectsTargetVersions.length > 1 && (
<div className="space-y-1.5">
@@ -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) {
</CardHeader>
<CardContent className="p-3 pt-0 flex flex-col flex-1">
<p className="text-xs text-muted-foreground mb-1.5 line-clamp-2 leading-relaxed">
{profile.description || 'No description'}
{profile.description || t('profiles.card.noDescription')}
</p>
<div className="mb-2 flex items-center gap-1.5">
<Badge variant="outline" className="text-xs h-5 px-1.5 text-muted-foreground">
@@ -118,7 +120,7 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
)}
{profile.voice_type === 'designed' && (
<Badge variant="secondary" className="text-xs h-5 px-1.5">
designed
{t('profiles.card.designed')}
</Badge>
)}
{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')}
/>
<CircleButton
icon={Edit}
@@ -138,13 +140,13 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
e.stopPropagation();
handleEdit();
}}
aria-label="Edit profile"
aria-label={t('profiles.card.edit')}
/>
<CircleButton
icon={Trash2}
onClick={handleDeleteClick}
disabled={deleteProfile.isPending}
aria-label="Delete profile"
aria-label={t('profiles.card.delete')}
/>
</div>
</CardContent>
@@ -153,21 +155,21 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Profile</DialogTitle>
<DialogTitle>{t('profiles.deleteDialog.title')}</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{profile.name}"? This action cannot be undone.
{t('profiles.deleteDialog.body', { name: profile.name })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={deleteProfile.isPending}
>
{deleteProfile.isPending ? 'Deleting...' : 'Delete'}
{deleteProfile.isPending ? t('profiles.deleteDialog.deleting') : t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
+40
View File
@@ -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)",
@@ -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}}\" 生成语音… (输入 / 使用效果)",
+12 -5
View File
@@ -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<string, string> = {