From 4f384ca64f07f14342f788272a8d4c1a2c650a26 Mon Sep 17 00:00:00 2001 From: James Pine Date: Mon, 20 Apr 2026 03:33:52 -0700 Subject: [PATCH] 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) --- .../components/VoicesTab/VoiceInspector.tsx | 83 ++++++++++++------- app/src/components/VoicesTab/VoicesTab.tsx | 29 ++++--- app/src/i18n/locales/en/translation.json | 29 +++++++ app/src/i18n/locales/zh-CN/translation.json | 29 +++++++ 4 files changed, 125 insertions(+), 45 deletions(-) diff --git a/app/src/components/VoicesTab/VoiceInspector.tsx b/app/src/components/VoicesTab/VoiceInspector.tsx index 062726be..b479a6c5 100644 --- a/app/src/components/VoicesTab/VoiceInspector.tsx +++ b/app/src/components/VoicesTab/VoiceInspector.tsx @@ -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; +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({ - 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 (
- Loading... + {t('voiceInspector.loading')}
); } @@ -256,9 +269,9 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) { name="name" render={({ field }) => ( - Name + {t('profileForm.fields.name')} - + @@ -270,9 +283,13 @@ export function VoiceInspector({ profileId }: VoiceInspectorProps) { name="description" render={({ field }) => ( - Description + {t('voiceInspector.fields.description')} -