feat(i18n): localize Voices tab (table header, search, inspector)

VoicesTab now translates the "Voices" title, search placeholder,
"New Voice" button, all six table column headers (Name, Language,
Generations, Samples, Effects, Channels), the avatar alt text, and
the per-row channel MultiSelect (placeholder + "(Default)" suffix).

VoiceInspector routes its form labels through the existing
`profileForm.fields.*` keys, has its own "Default Effects" hint and
avatar/save toasts, and reuses the ProfileForm Zod validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
James Pine
2026-04-20 03:33:52 -07:00
co-authored by Claude Opus 4.7
parent 6e13a30a59
commit 4f384ca64f
4 changed files with 125 additions and 45 deletions
+51 -32
View File
@@ -2,6 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Edit2, Mic, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import * as z from 'zod';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { Button } from '@/components/ui/button';
@@ -38,19 +39,26 @@ import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore';
import { useServerStore } from '@/stores/serverStore';
const profileSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
description: z.string().max(500).optional(),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
});
function makeProfileSchema(t: (key: string) => string) {
return z.object({
name: z.string().min(1, t('profileForm.validation.nameRequired')).max(100),
description: z.string().max(500).optional(),
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
});
}
type ProfileFormValues = z.infer<typeof profileSchema>;
type ProfileFormValues = {
name: string;
description?: string;
language: LanguageCode;
};
interface VoiceInspectorProps {
profileId: string;
}
export function VoiceInspector({ profileId }: VoiceInspectorProps) {
const { t } = useTranslation();
const { data: profile } = useProfile(profileId);
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
@@ -68,7 +76,7 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
const [effectsDirty, setEffectsDirty] = useState(false);
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
resolver: zodResolver(makeProfileSchema(t)),
defaultValues: {
name: '',
description: '',
@@ -104,32 +112,31 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
if (!file) return;
if (!file.type.startsWith('image/')) {
toast({
title: 'Invalid file type',
description: 'Please select PNG, JPG, or WebP',
title: t('profileForm.toast.invalidFile'),
description: t('voiceInspector.toast.invalidImageFormat'),
variant: 'destructive',
});
return;
}
if (file.size > 5 * 1024 * 1024) {
toast({
title: 'File too large',
description: 'Image must be less than 5MB',
title: t('profileForm.toast.fileTooLarge'),
description: t('profileForm.toast.imageTooLargeDescription'),
variant: 'destructive',
});
return;
}
// Upload immediately
uploadAvatar.mutate(
{ profileId, file },
{
onSuccess: () => {
setAvatarPreview(URL.createObjectURL(file));
toast({ title: 'Avatar updated' });
toast({ title: t('voiceInspector.toast.avatarUpdated') });
},
onError: (err) => {
toast({
title: 'Avatar upload failed',
description: err instanceof Error ? err.message : 'Unknown error',
title: t('profileForm.toast.avatarUploadFailed'),
description: err instanceof Error ? err.message : t('common.unknownError'),
variant: 'destructive',
});
},
@@ -141,11 +148,11 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
if (profile?.avatar_path) {
try {
await deleteAvatar.mutateAsync(profileId);
toast({ title: 'Avatar removed' });
toast({ title: t('profileForm.toast.avatarRemoved') });
} catch (err) {
toast({
title: 'Failed to remove avatar',
description: err instanceof Error ? err.message : 'Unknown error',
title: t('profileForm.toast.avatarRemoveFailed'),
description: err instanceof Error ? err.message : t('common.unknownError'),
variant: 'destructive',
});
}
@@ -174,19 +181,25 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
setEffectsDirty(false);
} catch (fxError) {
toast({
title: 'Effects update failed',
description: fxError instanceof Error ? fxError.message : 'Failed to save effects',
title: t('profileForm.toast.effectsUpdateFailed'),
description:
fxError instanceof Error
? fxError.message
: t('profileForm.toast.effectsUpdateFailedFallback'),
variant: 'destructive',
});
return;
}
}
toast({ title: 'Voice updated', description: `"${data.name}" saved.` });
toast({
title: t('profileForm.toast.voiceUpdated'),
description: t('voiceInspector.toast.savedDescription', { name: data.name }),
});
} catch (error) {
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to save profile',
title: t('common.error'),
description: error instanceof Error ? error.message : t('profileForm.toast.saveFailed'),
variant: 'destructive',
});
}
@@ -195,7 +208,7 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
if (!profile) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Loading...
{t('voiceInspector.loading')}
</div>
);
}
@@ -256,9 +269,9 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>{t('profileForm.fields.name')}</FormLabel>
<FormControl>
<Input placeholder="My Voice" {...field} />
<Input placeholder={t('profileForm.fields.namePlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -270,9 +283,13 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormLabel>{t('voiceInspector.fields.description')}</FormLabel>
<FormControl>
<Textarea placeholder="Describe this voice..." rows={2} {...field} />
<Textarea
placeholder={t('profileForm.fields.descriptionPlaceholder')}
rows={2}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -284,7 +301,7 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>Language</FormLabel>
<FormLabel>{t('profileForm.fields.language')}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
@@ -306,9 +323,9 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
{/* Effects */}
<div className="space-y-2">
<FormLabel>Default Effects</FormLabel>
<FormLabel>{t('profileForm.fields.defaultEffects')}</FormLabel>
<p className="text-xs text-muted-foreground">
Applied automatically to new generations with this voice.
{t('voiceInspector.defaultEffectsHint')}
</p>
<EffectsChainEditor
value={effectsChain}
@@ -323,7 +340,9 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) {
{/* Save */}
{isDirty && (
<Button type="submit" className="w-full" disabled={updateProfile.isPending}>
{updateProfile.isPending ? 'Saving...' : 'Save Changes'}
{updateProfile.isPending
? t('profileForm.actions.saving')
: t('profileForm.actions.saveChanges')}
</Button>
)}
</div>
+16 -13
View File
@@ -1,6 +1,7 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Mic, Plus, Search, Sparkles } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -25,6 +26,7 @@ import { useUIStore } from '@/stores/uiStore';
import { VoiceInspector } from './VoiceInspector';
export function VoicesTab() {
const { t } = useTranslation();
const { data: profiles, isLoading } = useProfiles();
const queryClient = useQueryClient();
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
@@ -95,7 +97,7 @@ export function VoicesTab() {
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-muted-foreground">Loading voices...</div>
<div className="text-muted-foreground">{t('voicesTab.loading')}</div>
</div>
);
}
@@ -110,12 +112,12 @@ export function VoicesTab() {
{/* Fixed Header */}
<div className="absolute top-0 left-0 right-0 z-20 pl-8 pr-8">
<div className="flex items-center gap-3 mb-6">
<h1 className="text-2xl font-bold">Voices</h1>
<h1 className="text-2xl font-bold">{t('voicesTab.title')}</h1>
<div className="flex-1" />
<div className="relative w-[240px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search voices..."
placeholder={t('voicesTab.searchPlaceholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-10 pl-8 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
@@ -123,7 +125,7 @@ export function VoicesTab() {
</div>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
New Voice
{t('voicesTab.newVoice')}
</Button>
</div>
</div>
@@ -139,12 +141,12 @@ export function VoicesTab() {
<Table className="table-fixed [&_td:first-child]:pl-8 [&_th:first-child]:pl-8">
<TableHeader>
<TableRow>
<TableHead className="w-[30%]">Name</TableHead>
<TableHead className="w-[10%]">Language</TableHead>
<TableHead className="w-[10%]">Generations</TableHead>
<TableHead className="w-[8%]">Samples</TableHead>
<TableHead className="w-[8%]">Effects</TableHead>
<TableHead className="w-[24%]">Channels</TableHead>
<TableHead className="w-[30%]">{t('voicesTab.columns.name')}</TableHead>
<TableHead className="w-[10%]">{t('voicesTab.columns.language')}</TableHead>
<TableHead className="w-[10%]">{t('voicesTab.columns.generations')}</TableHead>
<TableHead className="w-[8%]">{t('voicesTab.columns.samples')}</TableHead>
<TableHead className="w-[8%]">{t('voicesTab.columns.effects')}</TableHead>
<TableHead className="w-[24%]">{t('voicesTab.columns.channels')}</TableHead>
<TableHead className="w-6"></TableHead>
</TableRow>
</TableHeader>
@@ -194,6 +196,7 @@ function VoiceRow({
channels,
onChannelChange,
}: VoiceRowProps) {
const { t } = useTranslation();
const serverUrl = useServerStore((state) => state.serverUrl);
const [avatarError, setAvatarError] = useState(false);
const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
@@ -212,7 +215,7 @@ function VoiceRow({
{avatarUrl && !avatarError ? (
<img
src={avatarUrl}
alt={`${profile.name} avatar`}
alt={t('voicesTab.avatarAlt', { name: profile.name })}
className="h-full w-full object-cover"
onError={() => setAvatarError(true)}
/>
@@ -248,11 +251,11 @@ function VoiceRow({
<MultiSelect
options={channels.map((ch) => ({
value: ch.id,
label: `${ch.name}${ch.is_default ? ' (Default)' : ''}`,
label: ch.is_default ? t('voicesTab.channelDefaultLabel', { name: ch.name }) : ch.name,
}))}
value={channelIds}
onChange={onChannelChange}
placeholder="Select channels..."
placeholder={t('voicesTab.selectChannels')}
className="w-full"
/>
</TableCell>
+29
View File
@@ -21,6 +21,35 @@
"settings": "Settings",
"updateBadge": "Update"
},
"voicesTab": {
"title": "Voices",
"loading": "Loading voices…",
"searchPlaceholder": "Search voices…",
"newVoice": "New Voice",
"avatarAlt": "{{name}} avatar",
"selectChannels": "Select channels…",
"channelDefaultLabel": "{{name}} (Default)",
"columns": {
"name": "Name",
"language": "Language",
"generations": "Generations",
"samples": "Samples",
"effects": "Effects",
"channels": "Channels"
}
},
"voiceInspector": {
"loading": "Loading…",
"defaultEffectsHint": "Applied automatically to new generations with this voice.",
"fields": {
"description": "Description"
},
"toast": {
"invalidImageFormat": "Please select PNG, JPG, or WebP",
"avatarUpdated": "Avatar updated",
"savedDescription": "\"{{name}}\" saved."
}
},
"audioChannels": {
"title": "Audio Channels",
"newChannel": "New Channel",
@@ -21,6 +21,35 @@
"settings": "设置",
"updateBadge": "更新"
},
"voicesTab": {
"title": "声音",
"loading": "加载声音中…",
"searchPlaceholder": "搜索声音……",
"newVoice": "新建声音",
"avatarAlt": "{{name}} 的头像",
"selectChannels": "选择通道……",
"channelDefaultLabel": "{{name}}(默认)",
"columns": {
"name": "名称",
"language": "语言",
"generations": "生成次数",
"samples": "样本",
"effects": "效果",
"channels": "通道"
}
},
"voiceInspector": {
"loading": "加载中…",
"defaultEffectsHint": "自动应用于使用此声音的新生成。",
"fields": {
"description": "描述"
},
"toast": {
"invalidImageFormat": "请选择 PNG、JPG 或 WebP 格式",
"avatarUpdated": "头像已更新",
"savedDescription": "\"{{name}}\" 已保存。"
}
},
"audioChannels": {
"title": "音频通道",
"newChannel": "新建通道",