feat: Implement i18n infrastructure

This commit is contained in:
2025-12-13 17:29:27 +09:00
parent 2fc8a32b5f
commit cae08acab3
10 changed files with 578 additions and 166 deletions
+95 -71
View File
@@ -15,7 +15,8 @@ import { SYNC_BANNER_PADDING_X, SYNC_BANNER_PADDING_Y, SYNC_BANNER_MIN_WIDTH } f
import ServerPanel from './components/ServerPanel';
import StrategySelector from './components/StrategySelector';
import ConnectionModal from './components/ConnectionModal';
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive } from 'lucide-react';
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive, Languages } from 'lucide-react';
import { useLanguage } from './LanguageContext';
interface Toast {
id: number;
@@ -112,6 +113,7 @@ const useStripeAnimation = (syncState: SyncState) => {
};
const App: React.FC = () => {
const { t, language, setLanguage } = useLanguage();
const [localPlaylists, setLocalPlaylists] = useState<Playlist[]>([]);
const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined);
@@ -134,6 +136,7 @@ const App: React.FC = () => {
// Connection Modal State
const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false);
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
// Strategy State
const [currentStrategy, setCurrentStrategy] = useState<SyncStrategy>(SyncStrategy.LOCAL_OVERWRITE);
@@ -269,13 +272,15 @@ const App: React.FC = () => {
loadSchedule();
if (settings.mode === ScheduleMode.DISABLED) {
addToast("Scheduled tasks disabled.");
addToast(t('toasts.scheduleDisabled'));
} else if (settings.mode === ScheduleMode.CRON && settings.cronExpression.trim() === '') {
addToast(t('toasts.scheduleEmpty'));
} else {
addToast("Scheduled task updated successfully.");
addToast(t('toasts.scheduleStarted'));
}
return true;
} else {
addToast(result.message || "Failed to update schedule.");
addToast(result.message || t('toasts.scheduleFailed'));
return false;
}
};
@@ -285,9 +290,9 @@ const App: React.FC = () => {
const result = await apiService.saveBackupSettings(settings);
if (result.status === 'success') {
setBackupSettings(settings);
addToast('Backup settings have been saved.');
addToast(t('toasts.backupSaved'));
} else {
addToast(result.message || 'Failed to save backup settings.');
addToast(result.message || t('toasts.backupFailed'));
}
};
@@ -311,7 +316,7 @@ const App: React.FC = () => {
localAbortRef.current.abort();
localAbortRef.current = null;
setLoadingLocal(false);
addToast("Local refresh cancelled.");
addToast(t('toasts.localRefreshCancelled'));
}
};
@@ -345,7 +350,7 @@ const App: React.FC = () => {
cloudAbortRef.current.abort();
cloudAbortRef.current = null;
setLoadingCloud(false);
addToast("Cloud refresh cancelled.");
addToast(t('toasts.cloudRefreshCancelled'));
}
};
@@ -372,9 +377,9 @@ const App: React.FC = () => {
setCurrentStrategy(strategy);
const result = await apiService.updateSyncStrategy(strategy);
if (result.status === 'success') {
addToast(`Selected strategy "${label}" has been saved.`);
addToast(t('toasts.strategySaved', { strategy: label }));
} else {
addToast(result.message || 'Failed to save sync strategy.');
addToast(result.message || t('toasts.strategySaveFailed'));
}
};
@@ -383,9 +388,9 @@ const App: React.FC = () => {
setPathMappingConfig(config);
const result = await apiService.savePathMapping(config);
if (result.status === 'success') {
addToast('Path mapping rules have been saved.');
addToast(t('toasts.mappingSaved'));
} else {
addToast(result.message || 'Failed to save path mapping rules.');
addToast(result.message || t('toasts.mappingSaveFailed'));
}
};
@@ -410,7 +415,7 @@ const App: React.FC = () => {
}, SYNC_SUCCESS_TOTAL_MS);
} else {
setSyncState(SyncState.ERROR);
addToast(result.message || 'Sync failed. Please check connection.');
addToast(result.message || t('toasts.syncFailed'));
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS);
}
};
@@ -457,11 +462,11 @@ const App: React.FC = () => {
setSyncState(SyncState.SUCCESS);
refreshLocal();
refreshCloud();
addToast("Background sync completed successfully.");
addToast(t('toasts.backgroundSyncSuccess'));
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_SUCCESS_TOTAL_MS);
} else if (status === 'error') {
setSyncState(SyncState.ERROR);
addToast(`Background sync failed: ${error}`);
addToast(t('toasts.backgroundSyncFailed', { error: error || '' }));
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS);
}
} else {
@@ -518,30 +523,22 @@ const App: React.FC = () => {
const isConnected = cloudServerInfo?.isConnected;
const getScheduleDisplayInfo = () => {
const result = {
label: 'Auto-Sync',
value: 'Not configured',
active: false,
autoWatch: scheduleSettings.autoWatch
const result = {
label: t('dashboard.autoSync'),
value: t('schedule.notConfigured'),
active: false,
autoWatch: scheduleSettings.autoWatch,
};
if (scheduleSettings.mode === ScheduleMode.DISABLED) {
result.label = 'Auto-Sync';
result.value = 'Disabled';
result.value = t('common.disabled');
return result;
}
let label = 'Auto-Sync';
if (scheduleSettings.mode === ScheduleMode.CRON) label = 'Cron-Sync';
else if (scheduleSettings.mode === ScheduleMode.DAILY) label = 'Daily-Sync';
else if (scheduleSettings.mode === ScheduleMode.WEEKLY) label = 'Weekly-Sync';
result.label = label;
if (scheduleSettings.mode === ScheduleMode.CRON && !scheduleSettings.cronExpression) {
result.value = 'Not Set';
if (scheduleSettings.mode === ScheduleMode.CRON && scheduleSettings.cronExpression.trim() === '') {
result.value = t('dashboard.notSet');
} else {
result.value = nextRunTime ? `${nextRunTime}` : 'Calculating...';
result.value = nextRunTime ? `${nextRunTime}` : t('common.loading');
}
result.active = true;
@@ -553,39 +550,36 @@ const App: React.FC = () => {
// Helper: Calculate Path Mapping Info
const getPathMappingDisplayInfo = (config: PathMappingConfig) => {
let count = 0;
let modeLabel = '';
let Icon = Type;
let label = 'Mapping';
if (config.mode === PathMappingMode.SIMPLE) {
modeLabel = 'Simple';
label = 'Simple-Mapping';
count = config.simple.length;
Icon = Type;
count = config.simple.length;
Icon = Type;
} else {
modeLabel = 'Regex';
label = 'Regex-Mapping';
count = config.regex.localPre.length +
config.regex.localPost.length +
config.regex.remotePre.length +
config.regex.remotePost.length;
Icon = Code2;
count =
config.regex.localPre.length +
config.regex.localPost.length +
config.regex.remotePre.length +
config.regex.remotePost.length;
Icon = Code2;
}
if (count === 0) {
return {
label: 'Mapping',
value: 'Not Set',
active: false,
Icon: Icon
};
return {
label: t('dashboard.mapping'),
value: t('dashboard.notSet'),
active: false,
Icon,
};
}
const modeLabel = config.mode === PathMappingMode.SIMPLE ? t('mapping.simple') : t('mapping.regex');
return {
label: label,
value: `${modeLabel} (${count})`,
active: true,
Icon: Icon
label: t('dashboard.mapping'),
value: `${modeLabel} (${count})`,
active: true,
Icon,
};
};
@@ -594,16 +588,16 @@ const App: React.FC = () => {
// Helper: Calculate Backup Info
const getBackupDisplayInfo = (settings: BackupSettings) => {
if (!settings.enabled) {
return {
label: 'Backups',
value: 'Disabled',
active: false
};
return {
label: t('dashboard.backup'),
value: t('common.disabled'),
active: false,
};
}
return {
label: 'Backups',
value: `Keep ${settings.retentionCount}`,
active: true
label: t('dashboard.backup'),
value: t('dashboard.keep', { count: settings.retentionCount }),
active: true,
};
};
@@ -691,7 +685,7 @@ const App: React.FC = () => {
<span className={`text-[9px] font-bold uppercase tracking-widest transition-colors ${pathMappingInfo.active ? 'text-plex-orange' : 'text-gray-500 group-hover/item:text-gray-400'}`}>{pathMappingInfo.label}</span>
<div className={`flex items-center gap-1.5 text-xs font-medium ${pathMappingInfo.active ? 'text-blue-400' : 'text-gray-600'}`}>
<pathMappingInfo.Icon size={12} strokeWidth={2.5} className="flex-shrink-0" />
<span className="truncate">{pathMappingInfo.value === 'Not Set' ? 'None' : pathMappingInfo.value}</span>
<span className="truncate">{pathMappingInfo.active ? pathMappingInfo.value : t('common.none')}</span>
</div>
</div>
@@ -700,7 +694,7 @@ const App: React.FC = () => {
<span className={`text-[9px] font-bold uppercase tracking-widest transition-colors ${backupInfo.active ? 'text-plex-orange' : 'text-gray-500 group-hover/item:text-gray-400'}`}>{backupInfo.label}</span>
<div className={`flex items-center gap-1.5 text-xs font-medium ${backupInfo.active ? 'text-indigo-400' : 'text-gray-600'}`}>
<Archive size={12} strokeWidth={2.5} className="flex-shrink-0" />
<span className="truncate">{backupInfo.active ? backupInfo.value.replace('Keep ', 'Retain: ') : 'Disabled'}</span>
<span className="truncate">{backupInfo.active ? t('dashboard.retain', { count: backupSettings.retentionCount }) : backupInfo.value}</span>
</div>
</div>
@@ -711,19 +705,49 @@ const App: React.FC = () => {
{/* Watch Indicator Badge */}
<div
className={`flex items-center gap-1 px-1 rounded-[2px] transition-colors ${scheduleInfo.autoWatch ? 'text-plex-orange bg-plex-orange/10' : 'text-gray-700 bg-gray-800'}`}
title={scheduleInfo.autoWatch ? "Watch Mode: Active" : "Watch Mode: Disabled"}
title={scheduleInfo.autoWatch ? t('dashboard.watchModeActive') : t('dashboard.watchModeDisabled')}
>
{scheduleInfo.autoWatch ? <Eye size={9} /> : <EyeOff size={9} />}
<span className="text-[8px] font-bold uppercase">Watch</span>
<span className="text-[8px] font-bold uppercase">{t('dashboard.watch')}</span>
</div>
</div>
<div className={`flex items-center gap-1.5 text-xs font-medium mt-0.5 ${scheduleInfo.active ? 'text-green-400' : 'text-gray-600'}`}>
<Clock size={12} strokeWidth={2.5} className="flex-shrink-0" />
<span className="truncate">{scheduleInfo.active ? scheduleInfo.value : 'Disabled'}</span>
<span className="truncate">{scheduleInfo.active ? scheduleInfo.value : t('common.disabled')}</span>
</div>
</div>
</div>
{/* Language Switcher */}
<div className="relative">
<button
onClick={() => setIsLangMenuOpen(!isLangMenuOpen)}
className="flex items-center justify-center w-9 h-9 rounded-full border border-gray-700 bg-gray-800/50 text-gray-400 hover:text-white hover:bg-gray-700 transition-all"
title={t('common.switchLanguage')}
>
<Languages size={18} />
</button>
{isLangMenuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsLangMenuOpen(false)}></div>
<div className="absolute right-0 top-full mt-2 w-32 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-50 overflow-hidden">
<button
onClick={() => { setLanguage('en'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'en' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
English
</button>
<button
onClick={() => { setLanguage('es'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'es' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
Español
</button>
</div>
</>
)}
</div>
{/* Connection Status Button */}
<button
onClick={() => setIsConnectionModalOpen(true)}
@@ -732,7 +756,7 @@ const App: React.FC = () => {
? "bg-green-500/10 border-green-500/50 text-green-400 hover:bg-green-500/20 hover:shadow-green-500/20"
: "bg-red-500/10 border-red-500/50 text-red-400 hover:bg-red-500/20 hover:shadow-red-500/20"
}`}
title={isConnected ? "Connected to Plex" : "Disconnected"}
title={isConnected ? t('dashboard.connected') : t('dashboard.disconnected')}
>
{isConnected ? <Server size={18} /> : <ServerOff size={18} />}
</button>
@@ -749,7 +773,7 @@ const App: React.FC = () => {
}}
>
<h1 className={`text-xl md:text-2xl font-black tracking-[0.2em] uppercase whitespace-nowrap transition-colors duration-300 ${syncState === SyncState.SUCCESS ? 'text-[#22c55e]' : 'text-[#F59E0B]'}`}>
{syncState === SyncState.SYNCING ? 'SYNCHRONIZING' : 'SYNC COMPLETE'}
{syncState === SyncState.SYNCING ? t('dashboard.synchronizing') : t('dashboard.syncComplete')}
</h1>
</div>
</div>
@@ -834,7 +858,7 @@ const App: React.FC = () => {
{/* Footer */}
<footer className="flex-none py-4 text-center text-xs text-gray-600 border-t border-white/5 bg-gray-900/50 backdrop-blur">
<p>&copy; {new Date().getFullYear()} PlexSync Manager. Connected to Docker backend.</p>
<p>{t('app.footer', { year: new Date().getFullYear() })}</p>
</footer>
{/* Modals */}