feat: Implement i18n infrastructure
This commit is contained in:
+95
-71
@@ -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>© {new Date().getFullYear()} PlexSync Manager. Connected to Docker backend.</p>
|
||||
<p>{t('app.footer', { year: new Date().getFullYear() })}</p>
|
||||
</footer>
|
||||
|
||||
{/* Modals */}
|
||||
|
||||
Reference in New Issue
Block a user