feat(i18n): localize Effects tab (list, detail, dialogs, toasts)

Covers EffectsList (title, "New Preset", section headers, preset
cards) and EffectsDetail (header buttons for Save / Save as Custom /
Delete, name/description fields, preview section, Save as Custom
dialog, all toasts).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
James Pine
2026-04-20 03:07:35 -07:00
co-authored by Claude Opus 4.7
parent ffa68dc356
commit 2b7973df8b
4 changed files with 174 additions and 63 deletions
+51 -51
View File
@@ -1,6 +1,7 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Play, Save, Trash2, Wand2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { GenerationPicker } from '@/components/Effects/GenerationPicker';
@@ -25,6 +26,7 @@ import { useEffectsStore } from '@/stores/effectsStore';
import { usePlayerStore } from '@/stores/playerStore';
export function EffectsDetail() {
const { t } = useTranslation();
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
const isCreatingNew = useEffectsStore((s) => s.isCreatingNew);
const workingChain = useEffectsStore((s) => s.workingChain);
@@ -115,8 +117,8 @@ export function EffectsDetail() {
setAudioWithAutoPlay(url, `preview-${Date.now()}`, null, 'Effects Preview');
} catch (error) {
toast({
title: 'Preview failed',
description: error instanceof Error ? error.message : 'Unknown error',
title: t('effects.toast.previewFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
variant: 'destructive',
});
} finally {
@@ -130,7 +132,7 @@ export function EffectsDetail() {
async function handleSaveNew() {
if (!name.trim()) {
toast({ title: 'Name required', variant: 'destructive' });
toast({ title: t('effects.toast.nameRequired'), variant: 'destructive' });
return;
}
setSaving(true);
@@ -143,11 +145,14 @@ export function EffectsDetail() {
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
setIsCreatingNew(false);
setSelectedPresetId(created.id);
toast({ title: 'Preset saved', description: `"${created.name}" has been created.` });
toast({
title: t('effects.toast.saved'),
description: t('effects.toast.createdDescription', { name: created.name }),
});
} catch (error) {
toast({
title: 'Failed to save',
description: error instanceof Error ? error.message : 'Unknown error',
title: t('effects.toast.saveFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
variant: 'destructive',
});
} finally {
@@ -166,11 +171,11 @@ export function EffectsDetail() {
});
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
queryClient.invalidateQueries({ queryKey: ['effect-preset', selectedPresetId] });
toast({ title: 'Preset updated' });
toast({ title: t('effects.toast.updated') });
} catch (error) {
toast({
title: 'Failed to save',
description: error instanceof Error ? error.message : 'Unknown error',
title: t('effects.toast.saveFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
variant: 'destructive',
});
} finally {
@@ -179,15 +184,14 @@ export function EffectsDetail() {
}
function handleSaveAsNew() {
// Open the dialog with a suggested name based on the current preset
setSaveAsName(`${name} (Copy)`);
setSaveAsName(t('effects.saveAs.suggestedName', { name }));
setSaveAsDescription(description);
setSaveAsDialogOpen(true);
}
async function handleSaveAsConfirm() {
if (!saveAsName.trim()) {
toast({ title: 'Name required', variant: 'destructive' });
toast({ title: t('effects.toast.nameRequired'), variant: 'destructive' });
return;
}
setSaving(true);
@@ -200,11 +204,14 @@ export function EffectsDetail() {
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
setSaveAsDialogOpen(false);
setSelectedPresetId(created.id);
toast({ title: 'Preset saved', description: `"${created.name}" has been created.` });
toast({
title: t('effects.toast.saved'),
description: t('effects.toast.createdDescription', { name: created.name }),
});
} catch (error) {
toast({
title: 'Failed to save',
description: error instanceof Error ? error.message : 'Unknown error',
title: t('effects.toast.saveFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
variant: 'destructive',
});
} finally {
@@ -220,11 +227,11 @@ export function EffectsDetail() {
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
setSelectedPresetId(null);
setWorkingChain([]);
toast({ title: 'Preset deleted' });
toast({ title: t('effects.toast.deleted') });
} catch (error) {
toast({
title: 'Failed to delete',
description: error instanceof Error ? error.message : 'Unknown error',
title: t('effects.toast.deleteFailed'),
description: error instanceof Error ? error.message : t('common.unknownError'),
variant: 'destructive',
});
} finally {
@@ -237,7 +244,7 @@ export function EffectsDetail() {
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center space-y-2">
<Wand2 className="h-10 w-10 mx-auto opacity-30" />
<p className="text-sm">Select a preset or create a new one</p>
<p className="text-sm">{t('effects.placeholder')}</p>
</div>
</div>
);
@@ -245,10 +252,13 @@ export function EffectsDetail() {
return (
<div className="flex flex-col h-full min-h-0">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">
{isCreatingNew ? 'New Preset' : isBuiltIn ? preset?.name : 'Edit Preset'}
{isCreatingNew
? t('effects.detail.newTitle')
: isBuiltIn
? preset?.name
: t('effects.detail.editTitle')}
</h2>
<div className="flex items-center gap-2">
{!isBuiltIn && !isCreatingNew && (
@@ -261,7 +271,7 @@ export function EffectsDetail() {
disabled={deleting}
>
<Trash2 className="h-3.5 w-3.5" />
{deleting ? 'Deleting...' : 'Delete'}
{deleting ? t('effects.detail.deleting') : t('common.delete')}
</Button>
<Button
size="sm"
@@ -270,7 +280,7 @@ export function EffectsDetail() {
disabled={saving || workingChain.length === 0}
>
<Save className="h-3.5 w-3.5" />
{saving ? 'Saving...' : 'Save'}
{saving ? t('effects.detail.saving') : t('common.save')}
</Button>
</>
)}
@@ -282,7 +292,7 @@ export function EffectsDetail() {
disabled={saving || workingChain.length === 0}
>
<Save className="h-3.5 w-3.5" />
{saving ? 'Saving...' : 'Save Preset'}
{saving ? t('effects.detail.saving') : t('effects.detail.savePreset')}
</Button>
)}
{isBuiltIn && (
@@ -294,51 +304,46 @@ export function EffectsDetail() {
disabled={saving}
>
<Save className="h-3.5 w-3.5" />
{saving ? 'Saving...' : 'Save as Custom'}
{saving ? t('effects.detail.saving') : t('effects.detail.saveAsCustom')}
</Button>
)}
</div>
</div>
{/* Scrollable content */}
<div className="flex-1 min-h-0 overflow-y-auto space-y-5 pr-1">
{/* Name & description */}
{(isCreatingNew || !isBuiltIn) && (
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-xs">Name</Label>
<Label className="text-xs">{t('effects.fields.name')}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My preset..."
placeholder={t('effects.fields.namePlaceholder')}
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Description</Label>
<Label className="text-xs">{t('effects.fields.description')}</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe what this preset does..."
placeholder={t('effects.fields.descriptionPlaceholder')}
className="min-h-[60px] resize-none"
/>
</div>
</div>
)}
{/* Built-in description (read-only) */}
{isBuiltIn && preset?.description && (
<p className="text-sm text-muted-foreground">{preset.description}</p>
)}
{/* Effects chain editor */}
<EffectsChainEditor value={workingChain} onChange={setWorkingChain} showPresets={false} />
<Separator />
{/* Preview section */}
<div className="space-y-3">
<Label className="text-xs">Preview</Label>
<Label className="text-xs">{t('effects.preview.label')}</Label>
<div className="flex items-center gap-2">
<GenerationPicker
selectedId={previewGenId}
@@ -355,38 +360,33 @@ export function EffectsDetail() {
{previewLoading ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Processing...
{t('effects.preview.processing')}
</>
) : (
<>
<Play className="h-3.5 w-3.5" />
Preview
{t('effects.preview.button')}
</>
)}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
Preview applies effects to the clean version without saving.
</p>
<p className="text-[11px] text-muted-foreground">{t('effects.preview.hint')}</p>
</div>
</div>
{/* Save as Custom dialog */}
<Dialog open={saveAsDialogOpen} onOpenChange={setSaveAsDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Save as Custom Preset</DialogTitle>
<DialogDescription>
Create a new custom preset based on the current effects chain.
</DialogDescription>
<DialogTitle>{t('effects.saveAs.title')}</DialogTitle>
<DialogDescription>{t('effects.saveAs.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-1.5">
<Label className="text-xs">Name</Label>
<Label className="text-xs">{t('effects.fields.name')}</Label>
<Input
value={saveAsName}
onChange={(e) => setSaveAsName(e.target.value)}
placeholder="My preset..."
placeholder={t('effects.fields.namePlaceholder')}
className="h-9"
autoFocus
onKeyDown={(e) => {
@@ -397,22 +397,22 @@ export function EffectsDetail() {
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Description</Label>
<Label className="text-xs">{t('effects.fields.description')}</Label>
<Textarea
value={saveAsDescription}
onChange={(e) => setSaveAsDescription(e.target.value)}
placeholder="Describe what this preset does..."
placeholder={t('effects.fields.descriptionPlaceholder')}
className="min-h-[60px] resize-none"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSaveAsDialogOpen(false)} disabled={saving}>
Cancel
{t('common.cancel')}
</Button>
<Button onClick={handleSaveAsConfirm} disabled={saving || !saveAsName.trim()}>
<Save className="h-3.5 w-3.5 mr-1.5" />
{saving ? 'Saving...' : 'Save'}
{saving ? t('effects.detail.saving') : t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
+13 -12
View File
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { Loader2, Plus, Sparkles, Wand2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { apiClient } from '@/lib/api/client';
import type { EffectPresetResponse } from '@/lib/api/types';
@@ -7,6 +8,7 @@ import { cn } from '@/lib/utils/cn';
import { useEffectsStore } from '@/stores/effectsStore';
export function EffectsList() {
const { t } = useTranslation();
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
const setSelectedPresetId = useEffectsStore((s) => s.setSelectedPresetId);
const setWorkingChain = useEffectsStore((s) => s.setWorkingChain);
@@ -44,10 +46,10 @@ export function EffectsList() {
<div className="flex flex-col h-full min-h-0">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Effects</h2>
<h2 className="text-lg font-semibold">{t('effects.title')}</h2>
<Button variant="outline" size="sm" className="h-8 gap-1.5" onClick={handleCreateNew}>
<Plus className="h-3.5 w-3.5" />
New Preset
{t('effects.newPreset')}
</Button>
</div>
@@ -57,7 +59,7 @@ export function EffectsList() {
{builtIn.length > 0 && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
Built-in
{t('effects.sections.builtin')}
</div>
<div className="space-y-1.5">
{builtIn.map((preset) => (
@@ -76,7 +78,7 @@ export function EffectsList() {
{userPresets.length > 0 && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
Custom
{t('effects.sections.custom')}
</div>
<div className="space-y-1.5">
{userPresets.map((preset) => (
@@ -95,16 +97,14 @@ export function EffectsList() {
{isCreatingNew && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
New
{t('effects.sections.new')}
</div>
<div className="rounded-xl border-2 border-accent/40 bg-accent/5 p-3">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-accent" />
<span className="text-sm font-medium">Unsaved Preset</span>
<span className="text-sm font-medium">{t('effects.unsaved.title')}</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Configure effects in the panel on the right.
</p>
<p className="text-xs text-muted-foreground mt-1">{t('effects.unsaved.hint')}</p>
</div>
</div>
)}
@@ -122,6 +122,7 @@ function PresetCard({
isSelected: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation();
const effectCount = preset.effects_chain.length;
return (
@@ -142,16 +143,16 @@ function PresetCard({
<span className="text-sm font-medium truncate">{preset.name}</span>
{preset.is_builtin && (
<span className="text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded-full shrink-0">
built-in
{t('effects.badge.builtin')}
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-1 pl-6">
{preset.description || 'No description'}
{preset.description || t('effects.noDescription')}
</p>
<div className="flex items-center gap-2 mt-1.5 pl-6">
<span className="text-[10px] text-muted-foreground">
{effectCount} effect{effectCount !== 1 ? 's' : ''}
{t('effects.effectCount', { count: effectCount })}
</span>
<span className="text-[10px] text-muted-foreground/50">
{preset.effects_chain
+55
View File
@@ -37,6 +37,61 @@
"deleting": "Deleting…"
}
},
"effects": {
"title": "Effects",
"newPreset": "New Preset",
"noDescription": "No description",
"placeholder": "Select a preset or create a new one",
"effectCount_one": "{{count}} effect",
"effectCount_other": "{{count}} effects",
"sections": {
"builtin": "Built-in",
"custom": "Custom",
"new": "New"
},
"badge": {
"builtin": "built-in"
},
"unsaved": {
"title": "Unsaved Preset",
"hint": "Configure effects in the panel on the right."
},
"detail": {
"newTitle": "New Preset",
"editTitle": "Edit Preset",
"savePreset": "Save Preset",
"saveAsCustom": "Save as Custom",
"saving": "Saving…",
"deleting": "Deleting…"
},
"fields": {
"name": "Name",
"namePlaceholder": "My preset…",
"description": "Description",
"descriptionPlaceholder": "Describe what this preset does…"
},
"preview": {
"label": "Preview",
"button": "Preview",
"processing": "Processing…",
"hint": "Preview applies effects to the clean version without saving."
},
"saveAs": {
"title": "Save as Custom Preset",
"description": "Create a new custom preset based on the current effects chain.",
"suggestedName": "{{name}} (Copy)"
},
"toast": {
"saved": "Preset saved",
"createdDescription": "\"{{name}}\" has been created.",
"updated": "Preset updated",
"deleted": "Preset deleted",
"saveFailed": "Failed to save",
"deleteFailed": "Failed to delete",
"previewFailed": "Preview failed",
"nameRequired": "Name required"
}
},
"stories": {
"title": "Stories",
"newStory": "New Story",
@@ -37,6 +37,61 @@
"deleting": "删除中…"
}
},
"effects": {
"title": "效果",
"newPreset": "新建预设",
"noDescription": "无描述",
"placeholder": "选择一个预设或创建新的",
"effectCount_one": "{{count}} 个效果",
"effectCount_other": "{{count}} 个效果",
"sections": {
"builtin": "内置",
"custom": "自定义",
"new": "新建"
},
"badge": {
"builtin": "内置"
},
"unsaved": {
"title": "未保存的预设",
"hint": "在右侧面板配置效果。"
},
"detail": {
"newTitle": "新建预设",
"editTitle": "编辑预设",
"savePreset": "保存预设",
"saveAsCustom": "另存为自定义",
"saving": "保存中…",
"deleting": "删除中…"
},
"fields": {
"name": "名称",
"namePlaceholder": "我的预设……",
"description": "描述",
"descriptionPlaceholder": "描述此预设的作用……"
},
"preview": {
"label": "预览",
"button": "预览",
"processing": "处理中…",
"hint": "预览仅将效果应用于干净版本,不会保存。"
},
"saveAs": {
"title": "另存为自定义预设",
"description": "基于当前效果链创建一个新的自定义预设。",
"suggestedName": "{{name}}(副本)"
},
"toast": {
"saved": "预设已保存",
"createdDescription": "\"{{name}}\" 已创建。",
"updated": "预设已更新",
"deleted": "预设已删除",
"saveFailed": "保存失败",
"deleteFailed": "删除失败",
"previewFailed": "预览失败",
"nameRequired": "请输入名称"
}
},
"stories": {
"title": "故事",
"newStory": "新建故事",