Add i18n system with 4-language support (en/zh/ja/ko)

- Add I18nContext provider with useI18n hook and localStorage persistence
- Add translations.ts with complete strings for English, Chinese, Japanese, Korean
- Wrap App in I18nProvider, replace hardcoded toast messages with t() calls
- Add language selector to SettingsModal with Globe icon
- Add localization credits in About section
- Default language: English
- Add I18N_USAGE.md documentation
This commit is contained in:
fspecii
2026-02-08 18:19:08 +02:00
parent af5343f9db
commit c219edb037
5 changed files with 2591 additions and 60 deletions
+30 -18
View File
@@ -15,13 +15,17 @@ import { Song, GenerationParams, View, Playlist } from './types';
import { generateApi, songsApi, playlistsApi, getAudioUrl } from './services/api';
import { useAuth } from './context/AuthContext';
import { useResponsive } from './context/ResponsiveContext';
import { I18nProvider, useI18n } from './context/I18nContext';
import { List } from 'lucide-react';
import { PlaylistDetail } from './components/PlaylistDetail';
import { Toast, ToastType } from './components/Toast';
import { SearchPage } from './components/SearchPage';
export default function App() {
function AppContent() {
// i18n
const { t } = useI18n();
// Responsive
const { isMobile, isDesktop } = useResponsive();
@@ -464,9 +468,9 @@ export default function App() {
if (audio.error && audio.error.code !== 1) {
console.error("Audio playback error:", audio.error);
if (audio.error.code === 4) {
showToast('This song is no longer available.', 'error');
showToast(t('songNotAvailable'), 'error');
} else {
showToast('Unable to play this song.', 'error');
showToast(t('unableToPlay'), 'error');
}
}
setIsPlaying(false);
@@ -502,7 +506,7 @@ export default function App() {
if (err instanceof Error && err.name !== 'AbortError') {
console.error("Playback failed:", err);
if (err.name === 'NotSupportedError') {
showToast('This song is no longer available.', 'error');
showToast(t('songNotAvailable'), 'error');
}
setIsPlaying(false);
}
@@ -631,7 +635,7 @@ export default function App() {
} else if (status.status === 'failed') {
cleanupJob(jobId, tempId);
console.error(`Job ${jobId} failed:`, status.error);
showToast(`Generation failed: ${status.error || 'Unknown error'}`, 'error');
showToast(`${t('generationFailed')}: ${status.error || 'Unknown error'}`, 'error');
}
} catch (pollError) {
console.error(`Polling error for job ${jobId}:`, pollError);
@@ -646,7 +650,7 @@ export default function App() {
if (activeJobsRef.current.has(jobId)) {
console.warn(`Job ${jobId} timed out`);
cleanupJob(jobId, tempId);
showToast('Generation timed out', 'error');
showToast(t('generationTimedOut'), 'error');
}
}, 600000);
}, [token, cleanupJob, refreshSongsList]);
@@ -762,7 +766,7 @@ export default function App() {
if (activeJobsRef.current.size === 0) {
setIsGenerating(false);
}
showToast('Generation failed. Please try again.', 'error');
showToast(t('generationFailed'), 'error');
}
};
@@ -946,10 +950,10 @@ export default function App() {
// Remove from play queue if present
setPlayQueue(prev => prev.filter(s => s.id !== song.id));
showToast('Song deleted successfully');
showToast(t('songDeleted'));
} catch (error) {
console.error('Failed to delete song:', error);
showToast('Failed to delete song', 'error');
showToast(t('failedToDeleteSong'), 'error');
}
};
@@ -1001,9 +1005,9 @@ export default function App() {
}
if (failed.length > 0) {
showToast(`Deleted ${succeeded.length}/${songsToDelete.length} songs`, 'error');
showToast(t('songsDeletedPartial').replace('{succeeded}', String(succeeded.length)).replace('{total}', String(songsToDelete.length)), 'error');
} else {
showToast('Songs deleted successfully');
showToast(t('songsDeletedSuccess'));
}
};
@@ -1020,10 +1024,10 @@ export default function App() {
throw new Error('Failed to delete upload');
}
setReferenceTracks(prev => prev.filter(track => track.id !== trackId));
showToast('Upload deleted successfully');
showToast(t('songDeleted'));
} catch (error) {
console.error('Failed to delete upload:', error);
showToast('Failed to delete upload', 'error');
showToast(t('failedToDeleteSong'), 'error');
}
};
@@ -1038,10 +1042,10 @@ export default function App() {
setSongToAddToPlaylist(null);
playlistsApi.getMyPlaylists(token).then(r => setPlaylists(r.playlists));
}
showToast('Playlist created successfully!');
showToast(t('playlistCreated'));
} catch (error) {
console.error('Create playlist error:', error);
showToast('Failed to create playlist', 'error');
showToast(t('failedToCreatePlaylist'), 'error');
}
};
@@ -1055,11 +1059,11 @@ export default function App() {
try {
await playlistsApi.addSong(playlistId, songToAddToPlaylist.id, token);
setSongToAddToPlaylist(null);
showToast('Song added to playlist');
showToast(t('songAddedToPlaylist'));
playlistsApi.getMyPlaylists(token).then(r => setPlaylists(r.playlists));
} catch (error) {
console.error('Add song error:', error);
showToast('Failed to add song to playlist', 'error');
showToast(t('failedToAddSong'), 'error');
}
};
@@ -1286,7 +1290,7 @@ export default function App() {
onClick={() => setMobileShowList(!mobileShowList)}
className="bg-zinc-800 text-white px-4 py-2 rounded-full shadow-lg border border-white/10 flex items-center gap-2 text-sm font-bold"
>
{mobileShowList ? 'Create Song' : 'View List'}
{mobileShowList ? t('createSong') : t('viewList')}
<List size={16} />
</button>
</div>
@@ -1415,3 +1419,11 @@ export default function App() {
</div>
);
}
export default function App() {
return (
<I18nProvider>
<AppContent />
</I18nProvider>
);
}
+61
View File
@@ -0,0 +1,61 @@
# ACE-Step UI — Internationalization Guide
## Overview
The project supports 4 languages: English (default), Chinese, Japanese, and Korean.
## Architecture
```
ace-step-ui/
├── i18n/
│ └── translations.ts # All translation strings
├── context/
│ └── I18nContext.tsx # React context + useI18n hook
└── components/ # i18n-enabled components
```
## Usage
### 1. Use translations in a component
```tsx
import { useI18n } from '../context/I18nContext';
function YourComponent() {
const { t } = useI18n();
return <div>{t('yourTranslationKey')}</div>;
}
```
### 2. Switch language
Users can switch language in Settings. Programmatically:
```tsx
const { language, setLanguage } = useI18n();
setLanguage('en'); // 'en' | 'zh' | 'ja' | 'ko'
```
### 3. Add a new translation key
Add the key to all 4 languages in `i18n/translations.ts`:
```typescript
export const translations = {
en: { yourNewKey: 'English text' },
zh: { yourNewKey: '中文文本' },
ja: { yourNewKey: '日本語テキスト' },
ko: { yourNewKey: '한국어 텍스트' },
};
```
## Language Persistence
The selected language is stored in `localStorage` and restored on next visit. Default is English.
## Notes
- All keys must exist in every language object
- TypeScript's `TranslationKey` type enforces key safety
- If a key is missing, the raw key name is returned as fallback
+99 -42
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { X, User as UserIcon, Palette, Info, Edit3, ExternalLink, Github } from 'lucide-react';
import { X, User as UserIcon, Palette, Info, Edit3, ExternalLink, Globe, ChevronDown, Github } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { useI18n } from '../context/I18nContext';
import { EditProfileModal } from './EditProfileModal';
interface SettingsModalProps {
@@ -13,6 +14,7 @@ interface SettingsModalProps {
export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, theme, onToggleTheme, onNavigateToProfile }) => {
const { user } = useAuth();
const { t, language, setLanguage } = useI18n();
const [isEditProfileOpen, setIsEditProfileOpen] = useState(false);
if (!isOpen || !user) {
@@ -29,14 +31,14 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
}
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4" onClick={onClose}>
<div
className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-zinc-200 dark:border-white/5">
<h2 className="text-2xl font-bold text-zinc-900 dark:text-white">Settings</h2>
<h2 className="text-2xl font-bold text-zinc-900 dark:text-white">{t('settings')}</h2>
<button
onClick={onClose}
className="p-2 hover:bg-zinc-100 dark:hover:bg-white/5 rounded-full transition-colors"
@@ -59,7 +61,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
<div className="flex-1">
<h3 className="text-xl font-bold text-zinc-900 dark:text-white">@{user.username}</h3>
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-1">
Member since {new Date(user.createdAt).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
{t('memberSince')} {new Date(user.createdAt).toLocaleDateString(language === 'zh' ? 'zh-CN' : 'en-US', { month: 'long', year: 'numeric' })}
</p>
</div>
<div className="flex gap-2">
@@ -71,7 +73,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition-colors"
>
<Edit3 size={16} />
Edit Profile
{t('editProfile')}
</button>
<button
onClick={() => {
@@ -81,7 +83,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
className="flex items-center gap-2 px-4 py-2 bg-zinc-200 dark:bg-zinc-700 text-zinc-900 dark:text-white rounded-lg text-sm font-medium hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors"
>
<ExternalLink size={16} />
View Profile
{t('viewProfile')}
</button>
</div>
</div>
@@ -91,21 +93,47 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
<div className="space-y-4">
<div className="flex items-center gap-2 text-zinc-900 dark:text-white">
<UserIcon size={20} />
<h3 className="font-semibold">Account</h3>
<h3 className="font-semibold">{t('account')}</h3>
</div>
<div className="pl-7 space-y-3">
<div>
<label className="text-sm text-zinc-500 dark:text-zinc-400">Username</label>
<label className="text-sm text-zinc-500 dark:text-zinc-400">{t('username')}</label>
<p className="text-zinc-900 dark:text-white font-medium">@{user.username}</p>
</div>
</div>
</div>
{/* Language Section */}
<div className="space-y-4">
<div className="flex items-center gap-2 text-zinc-900 dark:text-white">
<Globe size={20} />
<h3 className="font-semibold">{t('language')}</h3>
</div>
<div className="pl-7 space-y-3">
<div className="relative">
<select
value={language}
onChange={(e) => setLanguage(e.target.value as 'en' | 'zh' | 'ja' | 'ko')}
className="w-full appearance-none py-3 px-4 pr-10 rounded-lg border-2 border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white font-medium transition-colors hover:border-zinc-400 dark:hover:border-zinc-600 focus:outline-none focus:border-indigo-500 dark:focus:border-indigo-500 cursor-pointer"
>
<option value="en">{t('english')}</option>
<option value="zh">{t('chinese')}</option>
<option value="ja">{t('japaneseLanguage')}</option>
<option value="ko">{t('koreanLanguage')}</option>
</select>
<ChevronDown
size={20}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 pointer-events-none"
/>
</div>
</div>
</div>
{/* Theme Section */}
<div className="space-y-4">
<div className="flex items-center gap-2 text-zinc-900 dark:text-white">
<Palette size={20} />
<h3 className="font-semibold">Appearance</h3>
<h3 className="font-semibold">{t('appearance')}</h3>
</div>
<div className="pl-7 space-y-3">
<div className="flex gap-3">
@@ -116,7 +144,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
: 'border-zinc-300 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-600'
}`}
>
Light
{t('light')}
</button>
<button
onClick={theme === 'light' ? onToggleTheme : undefined}
@@ -125,7 +153,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
: 'border-zinc-300 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-600'
}`}
>
Dark
{t('dark')}
</button>
</div>
</div>
@@ -135,41 +163,70 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
<div className="space-y-4">
<div className="flex items-center gap-2 text-zinc-900 dark:text-white">
<Info size={20} />
<h3 className="font-semibold">About</h3>
<h3 className="font-semibold">{t('about')}</h3>
</div>
<div className="pl-7 space-y-3 text-sm text-zinc-600 dark:text-zinc-400">
<p>Version 1.0.0</p>
<p>ACE-Step UI - Local AI Music Generator</p>
<p>{t('version')} 2.0.0</p>
<p>ACE-Step UI - {t('localAIMusicGenerator')}</p>
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-2">
Powered by ACE-Step 1.5. Open source and free to use.
{t('poweredBy')}
</p>
<div className="pt-3 border-t border-zinc-200 dark:border-zinc-700/50 mt-4">
<p className="text-zinc-900 dark:text-white font-medium mb-3">Created by Ambsd</p>
<div className="flex flex-wrap gap-2">
<a
href="https://x.com/AmbsdOP"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-black dark:bg-white text-white dark:text-black rounded-lg text-sm font-medium hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
Follow @AmbsdOP
</a>
<a
href="https://github.com/fspecii/ace-step-ui"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-zinc-800 dark:bg-zinc-700 text-white rounded-lg text-sm font-medium hover:bg-zinc-700 dark:hover:bg-zinc-600 transition-colors"
>
<Github size={16} />
GitHub Repo
</a>
<div className="pt-3 border-t border-zinc-200 dark:border-zinc-700/50 mt-4 space-y-4">
<div>
<p className="text-zinc-900 dark:text-white font-medium mb-2">{t('createdBy')}</p>
<div className="flex flex-wrap gap-2">
<a
href="https://x.com/AmbsdOP"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-black dark:bg-white text-white dark:text-black rounded-lg text-sm font-medium hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
{t('follow')} @AmbsdOP
</a>
<a
href="https://github.com/fspecii/ace-step-ui"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-zinc-800 dark:bg-zinc-700 text-white rounded-lg text-sm font-medium hover:bg-zinc-700 dark:hover:bg-zinc-600 transition-colors"
>
<Github size={16} />
GitHub Repo
</a>
</div>
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-2">
Report issues or request features on GitHub
</p>
</div>
<div>
<p className="text-zinc-900 dark:text-white font-medium mb-2">{t('localizedBy')}</p>
<div className="flex flex-wrap gap-2">
<a
href="https://x.com/bdsqlsz"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-black dark:bg-white text-white dark:text-black rounded-lg text-sm font-medium hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
{t('follow')} @bdsqlsz
</a>
<a
href="https://space.bilibili.com/219296"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-[#00A1D6] text-white rounded-lg text-sm font-medium hover:bg-[#0090C0] transition-colors"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/>
</svg>
{t('follow')}
</a>
</div>
</div>
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-3">
Report issues or request features on GitHub
</p>
</div>
</div>
</div>
@@ -181,7 +238,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
onClick={onClose}
className="px-6 py-2 bg-zinc-900 dark:bg-white text-white dark:text-black font-semibold rounded-lg hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors"
>
Done
{t('done')}
</button>
</div>
</div>
+40
View File
@@ -0,0 +1,40 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import { translations, Language, TranslationKey } from '../i18n/translations';
interface I18nContextType {
language: Language;
setLanguage: (lang: Language) => void;
t: (key: TranslationKey) => string;
}
const I18nContext = createContext<I18nContextType | undefined>(undefined);
export const I18nProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [language, setLanguage] = useState<Language>(() => {
const stored = localStorage.getItem('language') as Language;
return stored === 'zh' || stored === 'en' || stored === 'ja' || stored === 'ko' ? stored : 'en';
});
const handleSetLanguage = (lang: Language) => {
setLanguage(lang);
localStorage.setItem('language', lang);
};
const t = (key: TranslationKey): string => {
return translations[language][key] || key;
};
return (
<I18nContext.Provider value={{ language, setLanguage: handleSetLanguage, t }}>
{children}
</I18nContext.Provider>
);
};
export const useI18n = () => {
const context = useContext(I18nContext);
if (!context) {
throw new Error('useI18n must be used within I18nProvider');
}
return context;
};
+2361
View File
File diff suppressed because it is too large Load Diff