mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-16 13:20:39 -07:00
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:
co-authored by
Claude Opus 4.7
parent
302e28e313
commit
dd4119e0e6
@@ -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>
|
||||
|
||||
@@ -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}}\" 生成语音… (输入 / 使用效果)",
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
Reference in New Issue
Block a user