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:
@@ -15,13 +15,17 @@ import { Song, GenerationParams, View, Playlist } from './types';
|
|||||||
import { generateApi, songsApi, playlistsApi, getAudioUrl } from './services/api';
|
import { generateApi, songsApi, playlistsApi, getAudioUrl } from './services/api';
|
||||||
import { useAuth } from './context/AuthContext';
|
import { useAuth } from './context/AuthContext';
|
||||||
import { useResponsive } from './context/ResponsiveContext';
|
import { useResponsive } from './context/ResponsiveContext';
|
||||||
|
import { I18nProvider, useI18n } from './context/I18nContext';
|
||||||
import { List } from 'lucide-react';
|
import { List } from 'lucide-react';
|
||||||
import { PlaylistDetail } from './components/PlaylistDetail';
|
import { PlaylistDetail } from './components/PlaylistDetail';
|
||||||
import { Toast, ToastType } from './components/Toast';
|
import { Toast, ToastType } from './components/Toast';
|
||||||
import { SearchPage } from './components/SearchPage';
|
import { SearchPage } from './components/SearchPage';
|
||||||
|
|
||||||
|
|
||||||
export default function App() {
|
function AppContent() {
|
||||||
|
// i18n
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
// Responsive
|
// Responsive
|
||||||
const { isMobile, isDesktop } = useResponsive();
|
const { isMobile, isDesktop } = useResponsive();
|
||||||
|
|
||||||
@@ -464,9 +468,9 @@ export default function App() {
|
|||||||
if (audio.error && audio.error.code !== 1) {
|
if (audio.error && audio.error.code !== 1) {
|
||||||
console.error("Audio playback error:", audio.error);
|
console.error("Audio playback error:", audio.error);
|
||||||
if (audio.error.code === 4) {
|
if (audio.error.code === 4) {
|
||||||
showToast('This song is no longer available.', 'error');
|
showToast(t('songNotAvailable'), 'error');
|
||||||
} else {
|
} else {
|
||||||
showToast('Unable to play this song.', 'error');
|
showToast(t('unableToPlay'), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
@@ -502,7 +506,7 @@ export default function App() {
|
|||||||
if (err instanceof Error && err.name !== 'AbortError') {
|
if (err instanceof Error && err.name !== 'AbortError') {
|
||||||
console.error("Playback failed:", err);
|
console.error("Playback failed:", err);
|
||||||
if (err.name === 'NotSupportedError') {
|
if (err.name === 'NotSupportedError') {
|
||||||
showToast('This song is no longer available.', 'error');
|
showToast(t('songNotAvailable'), 'error');
|
||||||
}
|
}
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
}
|
}
|
||||||
@@ -631,7 +635,7 @@ export default function App() {
|
|||||||
} else if (status.status === 'failed') {
|
} else if (status.status === 'failed') {
|
||||||
cleanupJob(jobId, tempId);
|
cleanupJob(jobId, tempId);
|
||||||
console.error(`Job ${jobId} failed:`, status.error);
|
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) {
|
} catch (pollError) {
|
||||||
console.error(`Polling error for job ${jobId}:`, pollError);
|
console.error(`Polling error for job ${jobId}:`, pollError);
|
||||||
@@ -646,7 +650,7 @@ export default function App() {
|
|||||||
if (activeJobsRef.current.has(jobId)) {
|
if (activeJobsRef.current.has(jobId)) {
|
||||||
console.warn(`Job ${jobId} timed out`);
|
console.warn(`Job ${jobId} timed out`);
|
||||||
cleanupJob(jobId, tempId);
|
cleanupJob(jobId, tempId);
|
||||||
showToast('Generation timed out', 'error');
|
showToast(t('generationTimedOut'), 'error');
|
||||||
}
|
}
|
||||||
}, 600000);
|
}, 600000);
|
||||||
}, [token, cleanupJob, refreshSongsList]);
|
}, [token, cleanupJob, refreshSongsList]);
|
||||||
@@ -762,7 +766,7 @@ export default function App() {
|
|||||||
if (activeJobsRef.current.size === 0) {
|
if (activeJobsRef.current.size === 0) {
|
||||||
setIsGenerating(false);
|
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
|
// Remove from play queue if present
|
||||||
setPlayQueue(prev => prev.filter(s => s.id !== song.id));
|
setPlayQueue(prev => prev.filter(s => s.id !== song.id));
|
||||||
|
|
||||||
showToast('Song deleted successfully');
|
showToast(t('songDeleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete song:', 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) {
|
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 {
|
} else {
|
||||||
showToast('Songs deleted successfully');
|
showToast(t('songsDeletedSuccess'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1020,10 +1024,10 @@ export default function App() {
|
|||||||
throw new Error('Failed to delete upload');
|
throw new Error('Failed to delete upload');
|
||||||
}
|
}
|
||||||
setReferenceTracks(prev => prev.filter(track => track.id !== trackId));
|
setReferenceTracks(prev => prev.filter(track => track.id !== trackId));
|
||||||
showToast('Upload deleted successfully');
|
showToast(t('songDeleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete upload:', 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);
|
setSongToAddToPlaylist(null);
|
||||||
playlistsApi.getMyPlaylists(token).then(r => setPlaylists(r.playlists));
|
playlistsApi.getMyPlaylists(token).then(r => setPlaylists(r.playlists));
|
||||||
}
|
}
|
||||||
showToast('Playlist created successfully!');
|
showToast(t('playlistCreated'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Create playlist error:', 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 {
|
try {
|
||||||
await playlistsApi.addSong(playlistId, songToAddToPlaylist.id, token);
|
await playlistsApi.addSong(playlistId, songToAddToPlaylist.id, token);
|
||||||
setSongToAddToPlaylist(null);
|
setSongToAddToPlaylist(null);
|
||||||
showToast('Song added to playlist');
|
showToast(t('songAddedToPlaylist'));
|
||||||
playlistsApi.getMyPlaylists(token).then(r => setPlaylists(r.playlists));
|
playlistsApi.getMyPlaylists(token).then(r => setPlaylists(r.playlists));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Add song error:', 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)}
|
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"
|
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} />
|
<List size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1415,3 +1419,11 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<I18nProvider>
|
||||||
|
<AppContent />
|
||||||
|
</I18nProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
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 { useAuth } from '../context/AuthContext';
|
||||||
|
import { useI18n } from '../context/I18nContext';
|
||||||
import { EditProfileModal } from './EditProfileModal';
|
import { EditProfileModal } from './EditProfileModal';
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
@@ -13,6 +14,7 @@ interface SettingsModalProps {
|
|||||||
|
|
||||||
export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, theme, onToggleTheme, onNavigateToProfile }) => {
|
export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, theme, onToggleTheme, onNavigateToProfile }) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { t, language, setLanguage } = useI18n();
|
||||||
const [isEditProfileOpen, setIsEditProfileOpen] = useState(false);
|
const [isEditProfileOpen, setIsEditProfileOpen] = useState(false);
|
||||||
|
|
||||||
if (!isOpen || !user) {
|
if (!isOpen || !user) {
|
||||||
@@ -29,14 +31,14 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-zinc-200 dark:border-white/5">
|
<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
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 hover:bg-zinc-100 dark:hover:bg-white/5 rounded-full transition-colors"
|
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">
|
<div className="flex-1">
|
||||||
<h3 className="text-xl font-bold text-zinc-900 dark:text-white">@{user.username}</h3>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<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"
|
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} />
|
<Edit3 size={16} />
|
||||||
Edit Profile
|
{t('editProfile')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
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"
|
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} />
|
<ExternalLink size={16} />
|
||||||
View Profile
|
{t('viewProfile')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,21 +93,47 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2 text-zinc-900 dark:text-white">
|
<div className="flex items-center gap-2 text-zinc-900 dark:text-white">
|
||||||
<UserIcon size={20} />
|
<UserIcon size={20} />
|
||||||
<h3 className="font-semibold">Account</h3>
|
<h3 className="font-semibold">{t('account')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="pl-7 space-y-3">
|
<div className="pl-7 space-y-3">
|
||||||
<div>
|
<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>
|
<p className="text-zinc-900 dark:text-white font-medium">@{user.username}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 */}
|
{/* Theme Section */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2 text-zinc-900 dark:text-white">
|
<div className="flex items-center gap-2 text-zinc-900 dark:text-white">
|
||||||
<Palette size={20} />
|
<Palette size={20} />
|
||||||
<h3 className="font-semibold">Appearance</h3>
|
<h3 className="font-semibold">{t('appearance')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="pl-7 space-y-3">
|
<div className="pl-7 space-y-3">
|
||||||
<div className="flex gap-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'
|
: 'border-zinc-300 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Light
|
{t('light')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={theme === 'light' ? onToggleTheme : undefined}
|
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'
|
: 'border-zinc-300 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Dark
|
{t('dark')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,41 +163,70 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2 text-zinc-900 dark:text-white">
|
<div className="flex items-center gap-2 text-zinc-900 dark:text-white">
|
||||||
<Info size={20} />
|
<Info size={20} />
|
||||||
<h3 className="font-semibold">About</h3>
|
<h3 className="font-semibold">{t('about')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="pl-7 space-y-3 text-sm text-zinc-600 dark:text-zinc-400">
|
<div className="pl-7 space-y-3 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
<p>Version 1.0.0</p>
|
<p>{t('version')} 2.0.0</p>
|
||||||
<p>ACE-Step UI - Local AI Music Generator</p>
|
<p>ACE-Step UI - {t('localAIMusicGenerator')}</p>
|
||||||
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-2">
|
<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>
|
</p>
|
||||||
<div className="pt-3 border-t border-zinc-200 dark:border-zinc-700/50 mt-4">
|
<div className="pt-3 border-t border-zinc-200 dark:border-zinc-700/50 mt-4 space-y-4">
|
||||||
<p className="text-zinc-900 dark:text-white font-medium mb-3">Created by Ambsd</p>
|
<div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<p className="text-zinc-900 dark:text-white font-medium mb-2">{t('createdBy')}</p>
|
||||||
<a
|
<div className="flex flex-wrap gap-2">
|
||||||
href="https://x.com/AmbsdOP"
|
<a
|
||||||
target="_blank"
|
href="https://x.com/AmbsdOP"
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
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"
|
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 viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||||
</svg>
|
<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" />
|
||||||
Follow @AmbsdOP
|
</svg>
|
||||||
</a>
|
{t('follow')} @AmbsdOP
|
||||||
<a
|
</a>
|
||||||
href="https://github.com/fspecii/ace-step-ui"
|
<a
|
||||||
target="_blank"
|
href="https://github.com/fspecii/ace-step-ui"
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
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"
|
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
|
<Github size={16} />
|
||||||
</a>
|
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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,7 +238,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, t
|
|||||||
onClick={onClose}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user