PlexPlaylist_UI subtree merge

feat: Implement internationalization and rename project

Merge commit 'a745adc1ab02adbd17ed19574f47070f87eba50b'
This commit is contained in:
2025-12-09 05:19:21 +09:00
13 changed files with 569 additions and 143 deletions
+79 -44
View File
@@ -1,3 +1,4 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, PathMappingConfig, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from './types';
import { apiService } from './services/api';
@@ -15,7 +16,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 +114,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);
@@ -131,6 +134,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);
@@ -250,7 +254,7 @@ const App: React.FC = () => {
localAbortRef.current.abort();
localAbortRef.current = null;
setLoadingLocal(false);
addToast("Local refresh cancelled.");
addToast(t('toasts.localRefreshCancelled'));
}
};
@@ -284,7 +288,7 @@ const App: React.FC = () => {
cloudAbortRef.current.abort();
cloudAbortRef.current = null;
setLoadingCloud(false);
addToast("Cloud refresh cancelled.");
addToast(t('toasts.cloudRefreshCancelled'));
}
};
@@ -302,13 +306,13 @@ const App: React.FC = () => {
// Handle Strategy Change
const handleStrategyChange = (strategy: SyncStrategy, label: string) => {
setCurrentStrategy(strategy);
addToast(`Selected strategy "${label}" has been saved.`);
addToast(t('toasts.strategySaved', { strategy: label }));
};
// Handle Path Mapping Save
const handleSavePathMapping = (config: PathMappingConfig) => {
setPathMappingConfig(config);
addToast('Path mapping rules have been saved.');
addToast(t('toasts.mappingSaved'));
};
// Handle Backup Settings Save
@@ -316,9 +320,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('Failed to save backup settings.');
addToast(t('toasts.backupFailed'));
}
};
@@ -332,15 +336,15 @@ const App: React.FC = () => {
setScheduleSettings(settings);
if (settings.mode === ScheduleMode.DISABLED) {
addToast("Scheduled tasks disabled.");
addToast(t('toasts.scheduleDisabled'));
} else if (settings.mode === ScheduleMode.CRON && settings.cronExpression.trim() === '') {
addToast("Scheduled tasks disabled (Empty Cron).");
addToast(t('toasts.scheduleEmpty'));
} else {
addToast("Scheduled task started successfully.");
addToast(t('toasts.scheduleStarted'));
}
return true;
} else {
addToast(result.message || "Failed to update schedule.");
addToast(result.message || t('toasts.scheduleFailed'));
return false;
}
};
@@ -375,7 +379,7 @@ const App: React.FC = () => {
} else {
setSyncState(SyncState.ERROR);
addToast("Sync failed. Please check connection.");
addToast(t('toasts.syncFailed'));
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS);
}
};
@@ -408,21 +412,21 @@ const App: React.FC = () => {
// Helper: Calculate Next Run Info
const getScheduleDisplayInfo = (settings: ScheduleSettings) => {
const result = {
label: 'Schedule',
value: 'Not configured',
label: t('schedule.schedule'),
value: t('schedule.notConfigured'),
active: false,
autoWatch: settings.autoWatch
};
if (settings.mode === ScheduleMode.DISABLED) {
result.label = 'Auto-Sync';
result.value = 'Disabled';
result.label = t('dashboard.autoSync');
result.value = t('common.disabled');
return result;
}
if (settings.mode === ScheduleMode.CRON) {
result.label = 'Cron Schedule';
result.value = settings.cronExpression || 'Pending...';
result.label = t('schedule.cron');
result.value = settings.cronExpression || t('server.waiting');
result.active = true;
return result;
}
@@ -452,8 +456,8 @@ const App: React.FC = () => {
const activeDays = [...settings.weeklyDays].sort();
if (activeDays.length === 0) {
result.label = 'Weekly Schedule';
result.value = 'No days selected';
result.label = t('schedule.weekly');
result.value = t('common.none');
return result;
}
@@ -487,12 +491,12 @@ const App: React.FC = () => {
const isTomorrow = new Date(now.getTime() + 86400000).getDate() === nextRun.getDate();
let dateStr = '';
if (isToday) dateStr = 'Today';
else if (isTomorrow) dateStr = 'Tomorrow';
if (isToday) dateStr = t('schedule.today');
else if (isTomorrow) dateStr = t('schedule.tomorrow');
else dateStr = days[nextRun.getDay()];
result.label = `${settings.mode === ScheduleMode.DAILY ? 'Daily' : 'Weekly'} Schedule`;
result.value = `${dateStr} at ${timeStr}`;
result.label = settings.mode === ScheduleMode.DAILY ? t('schedule.daily') : t('schedule.weekly');
result.value = `${dateStr} @ ${timeStr}`;
result.active = true;
return result;
}
@@ -509,6 +513,7 @@ const App: React.FC = () => {
let Icon = Type;
if (config.mode === PathMappingMode.SIMPLE) {
modeLabel = t('common.none').replace('None', 'Simple'); // Fallback hack if simple not in dict, but it is in mapping
modeLabel = 'Simple';
count = config.simple.length;
Icon = Type;
@@ -523,15 +528,15 @@ const App: React.FC = () => {
if (count === 0) {
return {
label: 'Path Mapping',
value: 'Not Set',
label: t('dashboard.mapping'),
value: t('dashboard.notSet'),
active: false,
Icon: Icon
};
}
return {
label: 'Path Mapping',
label: t('dashboard.mapping'),
value: `${modeLabel} (${count})`,
active: true,
Icon: Icon
@@ -544,14 +549,14 @@ const App: React.FC = () => {
const getBackupDisplayInfo = (settings: BackupSettings) => {
if (!settings.enabled) {
return {
label: 'Backups',
value: 'Disabled',
label: t('dashboard.backup'),
value: t('common.disabled'),
active: false
};
}
return {
label: 'Backups',
value: `Keep ${settings.retentionCount}`,
label: t('dashboard.backup'),
value: t('dashboard.keep', { count: settings.retentionCount }),
active: true
};
};
@@ -629,7 +634,7 @@ const App: React.FC = () => {
<ArrowLeftRight size={24} strokeWidth={2.5} />
</div>
<h1 className="text-xl font-bold tracking-tight text-white">
Plex<span className="text-plex-orange">Sync</span>
<span className="text-plex-orange">PMS</span> Playlist Sync
</h1>
</div>
@@ -641,42 +646,72 @@ const App: React.FC = () => {
{/* Path Mapping Section */}
<div className="flex flex-col px-3 py-0.5 border-r border-gray-700/30 min-w-[90px] group/item">
<span className="text-[9px] font-bold text-gray-500 uppercase tracking-widest group-hover/item:text-gray-400 transition-colors">Mapping</span>
<span className="text-[9px] font-bold text-gray-500 uppercase tracking-widest group-hover/item:text-gray-400 transition-colors">{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} />
<span className="truncate">{pathMappingInfo.value === 'Not Set' ? 'None' : pathMappingInfo.value}</span>
<span className="truncate">{pathMappingInfo.value}</span>
</div>
</div>
{/* Backup Section */}
<div className="flex flex-col px-3 py-0.5 border-r border-gray-700/30 min-w-[90px] group/item">
<span className="text-[9px] font-bold text-gray-500 uppercase tracking-widest group-hover/item:text-gray-400 transition-colors">Backup</span>
<span className="text-[9px] font-bold text-gray-500 uppercase tracking-widest group-hover/item:text-gray-400 transition-colors">{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} />
<span>{backupInfo.active ? backupInfo.value.replace('Keep ', 'Retain: ') : 'Disabled'}</span>
<span>{backupInfo.active ? t('dashboard.retain', { count: backupSettings.retentionCount }) : backupInfo.value}</span>
</div>
</div>
{/* Schedule Section */}
<div className="flex flex-col px-3 py-0.5 min-w-[140px] group/item">
<div className="flex items-center justify-between">
<span className="text-[9px] font-bold text-gray-500 uppercase tracking-widest group-hover/item:text-gray-400 transition-colors">Auto-Sync</span>
<span className="text-[9px] font-bold text-gray-500 uppercase tracking-widest group-hover/item:text-gray-400 transition-colors">{t('dashboard.autoSync')}</span>
{/* 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} />
<span className="truncate max-w-[120px]">{scheduleInfo.active ? scheduleInfo.value : 'Disabled'}</span>
<span className="truncate max-w-[120px]">{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="Switch Language"
>
<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)}
@@ -685,7 +720,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>
@@ -702,7 +737,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>
@@ -787,7 +822,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 */}
@@ -801,4 +836,4 @@ const App: React.FC = () => {
);
};
export default App;
export default App;