3 Commits

Author SHA1 Message Date
Koha9 2fc8a32b5f PlexPlaylist_UI subtree merge
feat: Implement internationalization and rename project

Merge commit 'a745adc1ab02adbd17ed19574f47070f87eba50b'
2025-12-09 05:19:21 +09:00
Koha9 a745adc1ab Squashed 'sample-front-end/' changes from 9a32272..32f6ed7
32f6ed7 feat: Implement internationalization and rename project

git-subtree-dir: sample-front-end
git-subtree-split: 32f6ed743b43e001e6d1170cc370521f6b4173a2
2025-12-09 05:19:21 +09:00
Koha9 aa95c6bb3b feat: Improved status display. 2025-12-09 04:53:44 +09:00
17 changed files with 655 additions and 193 deletions
+5 -5
View File
@@ -2,12 +2,12 @@
"theme": "auto",
"token": "",
"server_url": "",
"server_scheme": "https",
"server_scheme": "http",
"server_port": "32400",
"timeout": 9,
"library_name": "",
"sync_mode": "merge_local_primary",
"local_path": "playlist",
"sync_mode": "local_force",
"local_path": "playlists",
"path_rules": [],
"path_mapping": {
"mode": "SIMPLE",
@@ -21,8 +21,8 @@
},
"schedule_mode": "DISABLED",
"schedule_cron": "",
"schedule_daily_time": "02:00",
"schedule_daily_time": "00:00",
"schedule_weekly_days": [0],
"schedule_weekly_time": "03:00",
"schedule_weekly_time": "00:00",
"schedule_auto_watch": false
}
+2 -1
View File
@@ -120,7 +120,8 @@ class ServerConfig:
}
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4, ensure_ascii=False)
logger.info(f"Server config saved: {config}")
logger.info(f"Server config saved.")
logger.debug(f"Saved server config: {config}")
def set_url(self, url: str) -> None:
self.url = url
+2 -1
View File
@@ -5,7 +5,8 @@ services:
ports:
- "8888:8080"
volumes:
- path_to_your_playlist:/app/playlist
- PATH_TO_YOUR_PLAYLISTS:/app/playlists
- PATH_TO_YOUR_BACKUP:/app/backup
environment:
- PYTHONUNBUFFERED=1
- PYTHONDONTWRITEBYTECODE=1
+77 -43
View File
@@ -15,7 +15,7 @@ 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 } from 'lucide-react';
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive } from 'lucide-react';
interface Toast {
id: number;
@@ -519,7 +519,7 @@ const App: React.FC = () => {
const getScheduleDisplayInfo = () => {
const result = {
label: 'Schedule',
label: 'Auto-Sync',
value: 'Not configured',
active: false,
autoWatch: scheduleSettings.autoWatch
@@ -531,13 +531,19 @@ const App: React.FC = () => {
return result;
}
let label = 'Schedule';
if (scheduleSettings.mode === ScheduleMode.CRON) label = 'Cron Schedule';
else if (scheduleSettings.mode === ScheduleMode.DAILY) label = 'Daily Schedule';
else if (scheduleSettings.mode === ScheduleMode.WEEKLY) label = 'Weekly Schedule';
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;
result.value = nextRunTime ? `Next: ${nextRunTime}` : 'Calculating...';
if (scheduleSettings.mode === ScheduleMode.CRON && !scheduleSettings.cronExpression) {
result.value = 'Not Set';
} else {
result.value = nextRunTime ? `${nextRunTime}` : 'Calculating...';
}
result.active = true;
return result;
};
@@ -549,13 +555,16 @@ const App: React.FC = () => {
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;
} else {
modeLabel = 'Regex';
label = 'Regex-Mapping';
count = config.regex.localPre.length +
config.regex.localPost.length +
config.regex.remotePre.length +
@@ -565,7 +574,7 @@ const App: React.FC = () => {
if (count === 0) {
return {
label: 'Path Mapping',
label: 'Mapping',
value: 'Not Set',
active: false,
Icon: Icon
@@ -573,7 +582,7 @@ const App: React.FC = () => {
}
return {
label: 'Path Mapping',
label: label,
value: `${modeLabel} (${count})`,
active: true,
Icon: Icon
@@ -582,6 +591,24 @@ const App: React.FC = () => {
const pathMappingInfo = getPathMappingDisplayInfo(pathMappingConfig);
// Helper: Calculate Backup Info
const getBackupDisplayInfo = (settings: BackupSettings) => {
if (!settings.enabled) {
return {
label: 'Backups',
value: 'Disabled',
active: false
};
}
return {
label: 'Backups',
value: `Keep ${settings.retentionCount}`,
active: true
};
};
const backupInfo = getBackupDisplayInfo(backupSettings);
return (
<div className="min-h-screen flex flex-col bg-gray-900 text-gray-100 font-sans overflow-hidden bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-gray-800 via-gray-900 to-black">
@@ -655,46 +682,53 @@ const App: React.FC = () => {
{/* Normal Toolbar Right */}
<div className="flex items-center gap-4">
{/* Path Mapping Info */}
<div className="flex flex-col items-end hidden md:flex border-r border-gray-700/50 pr-4 mr-1">
<span className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">
{pathMappingInfo.label}
</span>
<div className={`text-xs font-mono flex items-center gap-1.5 ${pathMappingInfo.active ? 'text-plex-orange' : 'text-gray-600'}`}>
{pathMappingInfo.active && <pathMappingInfo.Icon size={12} />}
<span>{pathMappingInfo.value}</span>
</div>
</div>
{/* Unified Status Dock */}
<div className="hidden md:flex items-center bg-gray-900/40 border border-gray-700/50 rounded-lg p-1 mr-2 backdrop-blur-sm shadow-sm transition-all hover:bg-gray-900/60 hover:border-gray-600/50">
{/* Path Mapping Section */}
<div className="flex flex-col px-3 py-0.5 border-r border-gray-700/30 w-[120px] group/item">
<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>
</div>
</div>
{/* Schedule Info */}
<div className="flex flex-col items-end mr-2 md:mr-0 hidden md:flex">
<span className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">
{scheduleInfo.label}
</span>
<div className="text-xs font-mono flex items-center gap-1.5">
{/* Schedule Part */}
<div className={`flex items-center gap-1.5 ${scheduleInfo.active ? 'text-plex-orange' : 'text-gray-600'}`}>
{scheduleInfo.active && <Clock size={12} />}
<span>{scheduleInfo.value}</span>
</div>
{/* Backup Section */}
<div className="flex flex-col px-3 py-0.5 border-r border-gray-700/30 w-[100px] group/item">
<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>
</div>
</div>
{/* Watch Part */}
<span className="text-gray-700 mx-0.5">|</span>
<div
className={`flex items-center gap-1 ${scheduleInfo.autoWatch ? 'text-plex-orange' : 'text-gray-600'}`}
title={scheduleInfo.autoWatch ? "Local Playlist Monitoring Enabled" : "Local Playlist Monitoring Disabled"}
>
{scheduleInfo.autoWatch ? <Eye size={12} /> : <EyeOff size={12} />}
<span className="text-[10px] font-sans font-bold">WATCH</span>
</div>
</div>
{/* Schedule Section */}
<div className="flex flex-col px-3 py-0.5 w-[180px] group/item">
<div className="flex items-center justify-between">
<span className={`text-[9px] font-bold uppercase tracking-widest transition-colors ${scheduleInfo.active ? 'text-plex-orange' : 'text-gray-500 group-hover/item:text-gray-400'}`}>{scheduleInfo.label}</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"}
>
{scheduleInfo.autoWatch ? <Eye size={9} /> : <EyeOff size={9} />}
<span className="text-[8px] font-bold uppercase">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>
</div>
</div>
</div>
{/* Connection Status Button */}
<button
<button
onClick={() => setIsConnectionModalOpen(true)}
className={`flex items-center justify-center w-9 h-9 rounded-full border transition-all duration-300 hover:scale-105 active:scale-95 shadow-md ${
isConnected
className={`flex items-center justify-center w-9 h-9 rounded-full border transition-all duration-300 hover:scale-105 active:scale-95 shadow-md
${isConnected
? "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"
}`}
+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;
+64
View File
@@ -0,0 +1,64 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { translations, Language, TranslationStructure } from './translations';
interface LanguageContextProps {
language: Language;
setLanguage: (lang: Language) => void;
t: (path: string, params?: Record<string, string | number>) => string;
}
const LanguageContext = createContext<LanguageContextProps | undefined>(undefined);
export const LanguageProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [language, setLanguageState] = useState<Language>('en');
useEffect(() => {
const savedLang = localStorage.getItem('plexsync-language') as Language;
if (savedLang && translations[savedLang]) {
setLanguageState(savedLang);
}
}, []);
const setLanguage = (lang: Language) => {
setLanguageState(lang);
localStorage.setItem('plexsync-language', lang);
};
const t = (path: string, params?: Record<string, string | number>): string => {
const keys = path.split('.');
let current: any = translations[language];
for (const key of keys) {
if (current[key] === undefined) {
console.warn(`Missing translation for key: ${path} in language: ${language}`);
return path;
}
current = current[key];
}
let text = current as string;
if (params) {
Object.entries(params).forEach(([key, value]) => {
text = text.replace(`{${key}}`, String(value));
});
}
return text;
};
return (
<LanguageContext.Provider value={{ language, setLanguage, t }}>
{children}
</LanguageContext.Provider>
);
};
export const useLanguage = () => {
const context = useContext(LanguageContext);
if (!context) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
};
+21 -19
View File
@@ -3,6 +3,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { PlexConnectionSettings, PlexServerConnection, PlexLibrary } from '../types';
import { apiService } from '../services/api';
import { X, Server, Lock, User, Key, Globe, Eye, EyeOff, CheckCircle, Library, ChevronDown, ChevronRight, Settings, Loader2 } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
interface ConnectionModalProps {
isOpen: boolean;
@@ -12,6 +13,7 @@ interface ConnectionModalProps {
}
const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onConnectSuccess, onShowMessage }) => {
const { t } = useLanguage();
const [formData, setFormData] = useState<PlexConnectionSettings>({
protocol: 'http',
address: '',
@@ -71,7 +73,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
const updatedInfo = { ...connectedServerInfo, libraryName: lib.title };
setConnectedServerInfo(updatedInfo);
onConnectSuccess(updatedInfo);
onShowMessage(`Library switched to ${lib.title}`);
onShowMessage(t('toasts.librarySwitched', { library: lib.title }));
}
};
@@ -90,7 +92,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
abortControllerRef.current.abort();
abortControllerRef.current = null;
setIsConnecting(false);
setError("Connection cancelled by user.");
setError(t('toasts.connectionCancelled'));
}
return;
}
@@ -119,7 +121,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
const info = result.data.serverInfo;
setConnectedServerInfo(info);
onShowMessage(`Successfully connected to ${info.name || 'Plex Server'}`);
onShowMessage(t('toasts.connectedTo', { name: info.name || 'Plex Server' }));
const libs = info.libraries || [];
setLibraries(libs);
@@ -134,7 +136,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
onConnectSuccess(info);
}
} else {
setError(result.message || "Connection failed");
setError(result.message || t('server.connectionFailed'));
}
};
@@ -154,7 +156,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
<div className="px-6 py-4 bg-gray-800 border-b border-gray-700 flex items-center justify-between flex-none">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Server size={18} className={isConnected ? "text-green-400" : "text-plex-orange"} />
{isConnected ? 'Server Connected' : 'Connect Plex Server'}
{isConnected ? t('connection.titleConnected') : t('connection.titleConnect')}
</h3>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<X size={20} />
@@ -173,7 +175,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{/* Server Connection */}
<div className="space-y-3">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Server Details</label>
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">{t('connection.serverDetails')}</label>
<div className="grid grid-cols-4 gap-3">
<div className="col-span-1">
<select
@@ -197,7 +199,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
name="address"
required
disabled={isConnected || isConnecting}
placeholder="IP Address or Domain"
placeholder={t('connection.address')}
value={formData.address}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
@@ -211,7 +213,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
type="text"
name="port"
disabled={isConnected || isConnecting}
placeholder="Port (e.g. 32400)"
placeholder={t('connection.port')}
value={formData.port}
onChange={handleChange}
className={`w-full h-10 px-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
@@ -223,7 +225,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{/* Authentication */}
<div className="space-y-3">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Authentication</label>
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">{t('connection.authentication')}</label>
{/* Token */}
<div className="relative">
@@ -234,7 +236,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
type="text"
name="token"
disabled={isConnected || isConnecting}
placeholder="X-Plex-Token (Optional)"
placeholder={t('connection.token')}
value={formData.token}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all font-mono ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
@@ -256,7 +258,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
type="text"
name="username"
disabled={isTokenProvided || isConnecting}
placeholder="Username / Email"
placeholder={t('connection.username')}
value={formData.username}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`}
@@ -272,7 +274,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
type={showPassword ? "text" : "password"}
name="password"
disabled={isTokenProvided || isConnecting}
placeholder="Password"
placeholder={t('connection.password')}
value={formData.password}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-10 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`}
@@ -300,7 +302,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
>
<div className="flex items-center gap-2">
<Settings size={14} />
<span>Advanced Options</span>
<span>{t('connection.advanced')}</span>
</div>
{showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
@@ -308,7 +310,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{showAdvanced && (
<div className="p-3 bg-gray-900/50 space-y-3 animate-in slide-in-from-top-2">
<div>
<label className="text-xs text-gray-500 mb-1 block">Connection Timeout (Seconds)</label>
<label className="text-xs text-gray-500 mb-1 block">{t('connection.timeout')}</label>
<input
type="number"
min="1"
@@ -337,15 +339,15 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{isConnecting ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>Connecting... <span className="opacity-75 font-normal ml-1">(Cancel)</span></span>
<span>{t('connection.connecting')} <span className="opacity-75 font-normal ml-1">({t('common.cancel')})</span></span>
</>
) : 'Connect Server'}
) : t('connection.connectBtn')}
</button>
) : (
<div className="mt-2 p-2 bg-green-500/10 border border-green-500/20 rounded-lg text-center">
<p className="text-green-400 text-sm font-semibold flex items-center justify-center gap-2">
<CheckCircle size={16} />
Connected Successfully
{t('connection.connectedSuccess')}
</p>
</div>
)}
@@ -354,7 +356,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{/* Library Selection - Appears after connection */}
{isConnected && libraries.length > 0 && (
<div className="mt-6 pt-5 border-t border-gray-700 animate-in slide-in-from-top-2 fade-in">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider block mb-2">Select Library to Sync</label>
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider block mb-2">{t('connection.selectLibrary')}</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Library size={14} className="text-plex-orange" />
@@ -378,7 +380,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
onClick={onClose}
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white text-sm font-medium rounded-lg transition-colors border border-gray-600 hover:border-gray-500"
>
Done
{t('common.done')}
</button>
</div>
</div>
+6 -3
View File
@@ -1,12 +1,15 @@
import React from 'react';
import { Playlist } from '../types';
import { Disc3, Clock } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
interface PlaylistCardProps {
playlist: Playlist;
}
const PlaylistCard: React.FC<PlaylistCardProps> = ({ playlist }) => {
const { t } = useLanguage();
return (
<div className="group flex flex-col w-full p-2.5 bg-gray-800/60 rounded-md border border-gray-700/50 hover:bg-gray-700 hover:border-plex-orange/50 transition-all duration-200 cursor-pointer shadow-sm">
<div className="flex items-center justify-between">
@@ -16,11 +19,11 @@ const PlaylistCard: React.FC<PlaylistCardProps> = ({ playlist }) => {
</div>
<div className="flex items-center mt-1.5 space-x-4 text-xs text-gray-500 group-hover:text-gray-400">
<span className="flex items-center" title="Track Count">
<span className="flex items-center" title={t('playlist.trackCount')}>
<Disc3 size={12} className="mr-1.5 opacity-70" />
{playlist.trackCount}
</span>
<span className="flex items-center" title="Last Updated">
<span className="flex items-center" title={t('playlist.lastUpdated')}>
<Clock size={12} className="mr-1.5 opacity-70" />
{new Date(playlist.lastUpdated).toLocaleDateString()}
</span>
@@ -29,4 +32,4 @@ const PlaylistCard: React.FC<PlaylistCardProps> = ({ playlist }) => {
);
};
export default PlaylistCard;
export default PlaylistCard;
+12 -10
View File
@@ -3,6 +3,7 @@ import React from 'react';
import { Playlist, ServerType, PlexServerConnection } from '../types';
import PlaylistCard from './PlaylistCard';
import { RefreshCw, Server, Cloud, WifiOff, X } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
interface ServerPanelProps {
type: ServerType;
@@ -14,6 +15,7 @@ interface ServerPanelProps {
}
const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, onRefresh, onCancel, serverInfo }) => {
const { t } = useLanguage();
const isLocal = type === ServerType.LOCAL;
let Icon = isLocal ? Server : Cloud;
@@ -28,17 +30,17 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
let displaySubtitle: React.ReactNode = null;
if (isLocal) {
displayTitle = 'Local Server';
displayTitle = t('server.local');
displaySubtitle = (
<p className="text-xs text-gray-400 font-medium mt-0.5 md:mt-0 md:ml-0">
{playlists.length} Playlists
{t('server.playlists', { count: playlists.length })}
</p>
);
} else {
// Cloud Logic
if (serverInfo) {
if (serverInfo.isConnected) {
displayTitle = serverInfo.name || 'Cloud Server';
displayTitle = serverInfo.name || t('server.cloud');
displaySubtitle = (
<div className="flex items-center text-xs text-gray-300 font-medium space-x-1.5 truncate mt-0.5 md:mt-0">
<span className="text-plex-orange truncate font-semibold">{serverInfo.libraryName}</span>
@@ -47,20 +49,20 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
</div>
);
} else {
displayTitle = 'Not Connected';
displayTitle = t('server.notConnected');
Icon = WifiOff;
headerColor = 'text-red-400';
displaySubtitle = (
<p className="text-xs text-gray-500 font-medium mt-0.5">
Connection failed
{t('server.connectionFailed')}
</p>
);
}
} else {
displayTitle = 'Cloud Server';
displayTitle = t('server.cloud');
displaySubtitle = (
<p className="text-xs text-gray-500 font-medium mt-0.5">
{isLoading ? 'Connecting...' : 'Waiting...'}
{isLoading ? t('server.connecting') : t('server.waiting')}
</p>
);
}
@@ -121,7 +123,7 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
: 'text-gray-400 hover:text-white hover:bg-white/10'
}
`}
title={isLoading ? "Cancel Refresh" : "Refresh Playlists"}
title={isLoading ? t('server.cancelRefresh') : t('server.refreshPlaylists')}
>
{isLoading ? (
<div className="relative flex items-center justify-center">
@@ -141,11 +143,11 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
{isLoading && playlists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500 space-y-3">
<RefreshCw size={24} className="animate-spin text-plex-orange/50" />
<p className="text-xs font-medium tracking-wide uppercase">Syncing...</p>
<p className="text-xs font-medium tracking-wide uppercase">{t('server.syncing')}</p>
</div>
) : playlists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<p className="text-sm">No playlists found.</p>
<p className="text-sm">{t('server.noPlaylists')}</p>
</div>
) : (
<div className="space-y-2.5 md:space-y-3">
@@ -1,3 +1,4 @@
import React, { useState, useRef, useEffect } from 'react';
import { SyncStrategy, ReplacementRule, PathMappingConfig, PathMappingRules, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from '../types';
import {
@@ -23,11 +24,12 @@ import {
History,
Eye
} from 'lucide-react';
import { useLanguage } from '../LanguageContext';
interface StrategyOption {
value: SyncStrategy;
label: string;
description: string;
labelKey: string;
descKey: string;
icon: React.ElementType;
color: string;
}
@@ -35,29 +37,29 @@ interface StrategyOption {
const STRATEGIES: StrategyOption[] = [
{
value: SyncStrategy.LOCAL_OVERWRITE,
label: 'Local Overwrite',
description: 'Local playlist completely overwrites Cloud. (No Diff)',
labelKey: 'strategies.localOverwrite.label',
descKey: 'strategies.localOverwrite.desc',
icon: ArrowRightCircle,
color: 'text-blue-400'
},
{
value: SyncStrategy.CLOUD_OVERWRITE,
label: 'Cloud Overwrite',
description: 'Cloud playlist completely overwrites Local. (No Diff)',
labelKey: 'strategies.cloudOverwrite.label',
descKey: 'strategies.cloudOverwrite.desc',
icon: ArrowLeftCircle,
color: 'text-green-400'
},
{
value: SyncStrategy.MERGE_LOCAL,
label: 'Two-way Merge (Local Priority)',
description: 'Merge both. Conflicts resolve to Local version.',
labelKey: 'strategies.mergeLocal.label',
descKey: 'strategies.mergeLocal.desc',
icon: GitMerge,
color: 'text-blue-300'
},
{
value: SyncStrategy.MERGE_CLOUD,
label: 'Two-way Merge (Cloud Priority)',
description: 'Merge both. Conflicts resolve to Cloud version.',
labelKey: 'strategies.mergeCloud.label',
descKey: 'strategies.mergeCloud.desc',
icon: GitMerge,
color: 'text-green-300'
}
@@ -116,6 +118,7 @@ interface MappingGroupEditorProps {
rightPlaceholder?: string;
leftInputClass?: string;
rightInputClass?: string;
t: (key: string) => string;
}
const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
@@ -126,10 +129,11 @@ const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
isLocked,
borderColor = "border-gray-700",
bgColor = "bg-gray-900/50",
leftPlaceholder = "Pattern",
rightPlaceholder = "Replace",
leftPlaceholder,
rightPlaceholder,
leftInputClass,
rightInputClass
rightInputClass,
t
}) => {
const handleAdd = () => {
@@ -162,7 +166,7 @@ const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
onClick={handleAdd}
disabled={isLocked}
className="p-1 rounded bg-gray-700/50 hover:bg-gray-600 text-gray-400 hover:text-white transition-colors"
title="Add Rule"
title={t('common.add')}
>
<Plus size={12} />
</button>
@@ -171,14 +175,14 @@ const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
<div className="flex-1 space-y-2 overflow-y-auto max-h-32 custom-scrollbar pr-1">
{rules.length === 0 ? (
<div className="text-xs text-gray-600 italic text-center py-2 border border-dashed border-gray-700/50 rounded-lg">
No rules defined.
{t('mapping.noRules')}
</div>
) : (
rules.map((rule) => (
<div key={rule.id} className="flex items-center space-x-1 animate-in slide-in-from-left-1 duration-200">
<input
type="text"
placeholder={leftPlaceholder}
placeholder={leftPlaceholder || t('mapping.pattern')}
value={rule.search}
onChange={(e) => handleUpdate(rule.id, 'search', e.target.value)}
className={`flex-1 min-w-0 border rounded px-1.5 py-1 text-xs focus:outline-none transition-colors ${leftInputClass || defaultInputStyle}`}
@@ -186,7 +190,7 @@ const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
<Link size={12} className="text-gray-600 flex-none opacity-50" />
<input
type="text"
placeholder={rightPlaceholder}
placeholder={rightPlaceholder || t('mapping.replace')}
value={rule.replace}
onChange={(e) => handleUpdate(rule.id, 'replace', e.target.value)}
className={`flex-1 min-w-0 border rounded px-1.5 py-1 text-xs focus:outline-none transition-colors ${rightInputClass || defaultInputStyle}`}
@@ -230,6 +234,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
syncState,
onSync
}) => {
const { t } = useLanguage();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@@ -307,7 +312,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
const handleSelect = (strategy: StrategyOption) => {
if (isLocked) return;
onSelect(strategy.value, strategy.label);
onSelect(strategy.value, t(strategy.labelKey));
};
// --- Path Mapping Handlers ---
@@ -439,7 +444,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center w-12 h-12 rounded-full bg-gray-800/90 border border-gray-600 hover:border-plex-orange text-gray-300 hover:text-white hover:bg-gray-700/80 transition-all shadow-2xl hover:shadow-plex-orange/30 ring-[6px] md:ring-8 ring-gray-900 backdrop-blur-sm active:scale-95"
title={`Current Strategy: ${selectedOption.label}`}
title={`Current Strategy: ${t(selectedOption.labelKey)}`}
>
<selectedOption.icon size={22} className={selectedOption.color} strokeWidth={2.5} />
<div className="absolute -bottom-1 -right-1 bg-gray-900 rounded-full border border-gray-600 p-[2px] shadow-sm">
@@ -465,7 +470,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Section 1: Sync Strategy */}
<div className="px-4 py-3 bg-black/20 border-b border-white/5 flex-none">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">Sync Strategy</h3>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">{t('strategies.title')}</h3>
<div className="space-y-1">
{STRATEGIES.map((strategy) => (
<div
@@ -480,7 +485,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="flex items-center space-x-3 overflow-hidden">
<strategy.icon size={18} className={strategy.color} />
<span className={`text-sm font-medium truncate ${currentStrategy === strategy.value ? 'text-white' : 'text-gray-300 group-hover:text-white'}`}>
{strategy.label}
{t(strategy.labelKey)}
</span>
</div>
@@ -488,7 +493,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="relative group/tooltip">
<HelpCircle size={14} className="text-gray-600 hover:text-gray-400 transition-colors" />
<div className="absolute right-0 bottom-full mb-2 w-48 p-2.5 bg-gray-900 text-xs text-gray-300 rounded-lg shadow-xl border border-gray-700 pointer-events-none opacity-0 group-hover/tooltip:opacity-100 transition-opacity z-50">
{strategy.description}
{t(strategy.descKey)}
</div>
</div>
@@ -504,7 +509,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Section 1.5: Backup Retention */}
<div className="px-4 py-3 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Backup Retention</h3>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">{t('backup.title')}</h3>
</div>
<div className="flex flex-col space-y-3">
@@ -514,8 +519,8 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<Archive size={16} />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-200">Enable Backups</span>
<span className="text-[10px] text-gray-500">Create a copy before changes</span>
<span className="text-sm font-medium text-gray-200">{t('backup.enable')}</span>
<span className="text-[10px] text-gray-500">{t('backup.enableDesc')}</span>
</div>
</div>
@@ -532,7 +537,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="flex items-center justify-between p-2.5 rounded-lg bg-black/20 border border-white/5">
<div className="flex items-center space-x-2">
<History size={14} className="text-gray-500" />
<span className="text-xs text-gray-400">Max versions to keep:</span>
<span className="text-xs text-gray-400">{t('backup.maxVersions')}</span>
</div>
<div className="flex items-center space-x-2">
<input
@@ -543,7 +548,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
onChange={(e) => handleUpdateBackup('retentionCount', parseInt(e.target.value) || 1)}
className="w-16 bg-gray-800 border border-gray-700 text-center text-sm rounded py-1 text-white focus:border-plex-orange focus:outline-none"
/>
<span className="text-[10px] text-gray-600 italic">Oldest deleted automatically</span>
<span className="text-[10px] text-gray-600 italic">{t('backup.autoDelete')}</span>
</div>
</div>
</div>
@@ -558,7 +563,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
>
<RotateCcw size={12} />
<span>Revert</span>
<span>{t('common.revert')}</span>
</button>
<button
onClick={handleSaveBackupClick}
@@ -569,7 +574,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
>
<Save size={12} />
<span>Save</span>
<span>{t('common.save')}</span>
</button>
</div>
</div>
@@ -578,14 +583,14 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Section 2: Path Mapping (Tabs + Grid) */}
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Path Mapping</h3>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">{t('mapping.title')}</h3>
</div>
{/* Tabs for Path Mapping Mode */}
<div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4">
{[
{ id: PathMappingMode.SIMPLE, label: 'Simple Mapping', icon: Type },
{ id: PathMappingMode.REGEX, label: 'Regex Rules', icon: Code2 },
{ id: PathMappingMode.SIMPLE, label: t('mapping.simple'), icon: Type },
{ id: PathMappingMode.REGEX, label: t('mapping.regex'), icon: Code2 },
].map((tab) => (
<button
key={tab.id}
@@ -608,17 +613,18 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
// Simple Mode: Single Editor
<div className="animate-in fade-in duration-200">
<MappingGroupEditor
title="Path Mapping"
subtitle="Map Local paths to Cloud paths using simple string matching"
title={t('mapping.simpleTitle')}
subtitle={t('mapping.simpleSubtitle')}
rules={simpleRules}
onChange={updateSimpleGroup}
isLocked={isLocked}
borderColor={MAPPING_THEME.simple.borderColor}
bgColor={MAPPING_THEME.simple.bgColor}
leftPlaceholder="Local Path"
rightPlaceholder="Cloud Path"
leftPlaceholder={t('mapping.localPath')}
rightPlaceholder={t('mapping.cloudPath')}
leftInputClass={MAPPING_THEME.inputs.local}
rightInputClass={MAPPING_THEME.inputs.cloud}
t={t}
/>
</div>
) : (
@@ -626,44 +632,48 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-in fade-in duration-200">
{/* Row 1: Pre-Processing */}
<MappingGroupEditor
title="Local Playlist"
subtitle="Pre-Processing (Before Sync)"
title={t('server.local')}
subtitle={t('mapping.regexPre')}
rules={regexRules.localPre}
onChange={(rules) => updateRegexGroup('localPre', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.local.borderColor}
bgColor={MAPPING_THEME.local.bgColor}
t={t}
/>
<MappingGroupEditor
title="Remote Playlist"
subtitle="Pre-Processing (Before Sync)"
title={t('server.cloud')}
subtitle={t('mapping.regexPre')}
rules={regexRules.remotePre}
onChange={(rules) => updateRegexGroup('remotePre', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.remote.borderColor}
bgColor={MAPPING_THEME.remote.bgColor}
t={t}
/>
{/* Row 2: Post-Processing */}
<MappingGroupEditor
title="Local Playlist"
subtitle="Post-Processing (After Sync / Result)"
title={t('server.local')}
subtitle={t('mapping.regexPost')}
rules={regexRules.localPost}
onChange={(rules) => updateRegexGroup('localPost', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.local.borderColor}
bgColor={MAPPING_THEME.local.bgColor}
t={t}
/>
<MappingGroupEditor
title="Remote Playlist"
subtitle="Post-Processing (After Sync / Result)"
title={t('server.cloud')}
subtitle={t('mapping.regexPost')}
rules={regexRules.remotePost}
onChange={(rules) => updateRegexGroup('remotePost', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.remote.borderColor}
bgColor={MAPPING_THEME.remote.bgColor}
t={t}
/>
</div>
)}
@@ -679,7 +689,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
>
<RotateCcw size={12} />
<span>Revert</span>
<span>{t('common.revert')}</span>
</button>
<button
onClick={handleSaveMappingClick}
@@ -690,7 +700,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
>
<Save size={12} />
<span>Save Rules</span>
<span>{t('mapping.saveRules')}</span>
</button>
</div>
</div>
@@ -698,15 +708,15 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Section 3: Scheduled Tasks */}
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Scheduled Tasks</h3>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">{t('schedule.title')}</h3>
</div>
{/* Tabs */}
<div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4">
{[
{ id: ScheduleMode.CRON, label: 'Cron', icon: Repeat },
{ id: ScheduleMode.DAILY, label: 'Daily', icon: Clock },
{ id: ScheduleMode.WEEKLY, label: 'Weekly', icon: Calendar },
{ id: ScheduleMode.CRON, label: t('schedule.cron'), icon: Repeat },
{ id: ScheduleMode.DAILY, label: t('schedule.daily'), icon: Clock },
{ id: ScheduleMode.WEEKLY, label: t('schedule.weekly'), icon: Calendar },
].map((tab) => (
<button
key={tab.id}
@@ -729,7 +739,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Label + Switch */}
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-xs text-gray-400 font-medium">Enable Cron Schedule</span>
<span className="text-xs text-gray-400 font-medium">{t('schedule.enableCron')}</span>
<button
onClick={() => toggleScheduleEnable(ScheduleMode.CRON)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.mode === ScheduleMode.CRON ? 'bg-plex-orange' : 'bg-gray-700'}`}
@@ -762,7 +772,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Label + Switch */}
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-xs text-gray-400 font-medium">Enable Daily Run</span>
<span className="text-xs text-gray-400 font-medium">{t('schedule.enableDaily')}</span>
<button
onClick={() => toggleScheduleEnable(ScheduleMode.DAILY)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.mode === ScheduleMode.DAILY ? 'bg-plex-orange' : 'bg-gray-700'}`}
@@ -788,7 +798,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Label + Switch */}
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-xs text-gray-400 font-medium">Enable Weekly Run</span>
<span className="text-xs text-gray-400 font-medium">{t('schedule.enableWeekly')}</span>
<button
onClick={() => toggleScheduleEnable(ScheduleMode.WEEKLY)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.mode === ScheduleMode.WEEKLY ? 'bg-plex-orange' : 'bg-gray-700'}`}
@@ -840,8 +850,8 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<Eye size={16} />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-200">Watch Local Changes</span>
<span className="text-[10px] text-gray-500">Auto-sync when local playlist updates</span>
<span className="text-sm font-medium text-gray-200">{t('schedule.watchLocal')}</span>
<span className="text-[10px] text-gray-500">{t('schedule.watchDesc')}</span>
</div>
</div>
<button
@@ -863,7 +873,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
>
<RotateCcw size={12} />
<span>Revert</span>
<span>{t('common.revert')}</span>
</button>
<button
onClick={handleSaveScheduleClick}
@@ -874,7 +884,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
>
<Save size={12} />
<span>Save</span>
<span>{t('common.save')}</span>
</button>
</div>
</div>
@@ -896,18 +906,18 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{isSyncing ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>Sync in Progress...</span>
<span>{t('strategies.syncing')}</span>
</>
) : (
<>
<Zap size={16} fill="currentColor" />
<span>Sync Now</span>
<span>{t('strategies.syncNow')}</span>
</>
)}
</button>
{(isMappingDirty || isBackupDirty) && (
<p className="text-[10px] text-plex-orange text-center mt-2">
Please save pending changes (Backups/Path Mapping) before syncing.
{t('strategies.saveWarning')}
</p>
)}
</div>
@@ -916,4 +926,4 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
);
};
export default StrategySelector;
export default StrategySelector;
+3 -2
View File
@@ -1,9 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PlexSync Manager</title>
<title>PMS Playlist Sync</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
@@ -73,4 +74,4 @@
<body class="bg-gray-900 text-gray-100 antialiased min-h-screen">
<div id="root"></div>
</body>
</html>
</html>
+6 -2
View File
@@ -1,6 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { LanguageProvider } from './LanguageContext';
const rootElement = document.getElementById('root');
if (!rootElement) {
@@ -10,6 +12,8 @@ if (!rootElement) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
<LanguageProvider>
<App />
</LanguageProvider>
</React.StrictMode>
);
);
+147
View File
@@ -0,0 +1,147 @@
export const en = {
app: {
// title and manager are no longer used for branding
title: 'PlexSync',
manager: 'Manager',
footer: '© {year} PMS Playlist Sync. Connected to Docker backend.',
},
common: {
save: 'Save',
cancel: 'Cancel',
revert: 'Revert',
delete: 'Delete',
done: 'Done',
loading: 'Loading...',
refresh: 'Refresh',
close: 'Close',
none: 'None',
disabled: 'Disabled',
add: 'Add',
},
server: {
local: 'Local Server',
cloud: 'Cloud Server',
playlists: '{count} Playlists',
notConnected: 'Not Connected',
connectionFailed: 'Connection failed',
connecting: 'Connecting...',
waiting: 'Waiting...',
syncing: 'Syncing...',
noPlaylists: 'No playlists found.',
cancelRefresh: 'Cancel Refresh',
refreshPlaylists: 'Refresh Playlists',
},
playlist: {
trackCount: 'Track Count',
lastUpdated: 'Last Updated',
},
dashboard: {
mapping: 'Mapping',
backup: 'Backup',
autoSync: 'Auto-Sync',
watch: 'Watch',
watchModeActive: 'Watch Mode: Active',
watchModeDisabled: 'Watch Mode: Disabled',
notSet: 'Not Set',
retain: 'Retain: {count}',
keep: 'Keep {count}',
connected: 'Connected to Plex',
disconnected: 'Disconnected',
synchronizing: 'SYNCHRONIZING',
syncComplete: 'SYNC COMPLETE',
},
strategies: {
title: 'Sync Strategy',
localOverwrite: {
label: 'Local Overwrite',
desc: 'Local playlist completely overwrites Cloud. (No Diff)',
},
cloudOverwrite: {
label: 'Cloud Overwrite',
desc: 'Cloud playlist completely overwrites Local. (No Diff)',
},
mergeLocal: {
label: 'Two-way Merge (Local Priority)',
desc: 'Merge both. Conflicts resolve to Local version.',
},
mergeCloud: {
label: 'Two-way Merge (Cloud Priority)',
desc: 'Merge both. Conflicts resolve to Cloud version.',
},
syncNow: 'Sync Now',
syncing: 'Sync in Progress...',
saveWarning: 'Please save pending changes (Backups/Path Mapping) before syncing.',
},
mapping: {
title: 'Path Mapping',
simple: 'Simple Mapping',
regex: 'Regex Rules',
simpleTitle: 'Path Mapping',
simpleSubtitle: 'Map Local paths to Cloud paths using simple string matching',
regexPre: 'Pre-Processing (Before Sync)',
regexPost: 'Post-Processing (After Sync / Result)',
localPath: 'Local Path',
cloudPath: 'Cloud Path',
pattern: 'Pattern',
replace: 'Replace',
saveRules: 'Save Rules',
noRules: 'No rules defined.',
},
backup: {
title: 'Backup Retention',
enable: 'Enable Backups',
enableDesc: 'Create a copy before changes',
maxVersions: 'Max versions to keep:',
autoDelete: 'Oldest deleted automatically',
},
schedule: {
title: 'Scheduled Tasks',
cron: 'Cron',
daily: 'Daily',
weekly: 'Weekly',
enableCron: 'Enable Cron Schedule',
enableDaily: 'Enable Daily Run',
enableWeekly: 'Enable Weekly Run',
watchLocal: 'Watch Local Changes',
watchDesc: 'Auto-sync when local playlist updates',
schedule: 'Schedule',
notConfigured: 'Not configured',
today: 'Today',
tomorrow: 'Tomorrow',
},
connection: {
titleConnected: 'Server Connected',
titleConnect: 'Connect Plex Server',
serverDetails: 'Server Details',
authentication: 'Authentication',
protocol: 'Protocol',
address: 'IP Address or Domain',
port: 'Port',
token: 'X-Plex-Token (Optional)',
username: 'Username / Email',
password: 'Password',
advanced: 'Advanced Options',
timeout: 'Connection Timeout (Seconds)',
connectBtn: 'Connect Server',
connecting: 'Connecting...',
connectedSuccess: 'Connected Successfully',
selectLibrary: 'Select Library to Sync',
},
toasts: {
localRefreshCancelled: 'Local refresh cancelled.',
cloudRefreshCancelled: 'Cloud refresh cancelled.',
strategySaved: 'Selected strategy "{strategy}" has been saved.',
mappingSaved: 'Path mapping rules have been saved.',
backupSaved: 'Backup settings have been saved.',
backupFailed: 'Failed to save backup settings.',
scheduleDisabled: 'Scheduled tasks disabled.',
scheduleEmpty: 'Scheduled tasks disabled (Empty Cron).',
scheduleStarted: 'Scheduled task started successfully.',
scheduleFailed: 'Failed to update schedule.',
syncFailed: 'Sync failed. Please check connection.',
librarySwitched: 'Library switched to {library}',
connectedTo: 'Successfully connected to {name}',
connectionCancelled: 'Connection cancelled by user.',
}
};
+147
View File
@@ -0,0 +1,147 @@
export const es = {
app: {
// title and manager are no longer used for branding
title: 'PlexSync',
manager: 'Gestor',
footer: '© {year} PMS Playlist Sync. Conectado al backend Docker.',
},
common: {
save: 'Guardar',
cancel: 'Cancelar',
revert: 'Revertir',
delete: 'Eliminar',
done: 'Hecho',
loading: 'Cargando...',
refresh: 'Actualizar',
close: 'Cerrar',
none: 'Ninguno',
disabled: 'Deshabilitado',
add: 'Añadir',
},
server: {
local: 'Servidor Local',
cloud: 'Servidor Nube',
playlists: '{count} Listas',
notConnected: 'No Conectado',
connectionFailed: 'Conexión fallida',
connecting: 'Conectando...',
waiting: 'Esperando...',
syncing: 'Sincronizando...',
noPlaylists: 'No se encontraron listas.',
cancelRefresh: 'Cancelar',
refreshPlaylists: 'Actualizar Listas',
},
playlist: {
trackCount: 'Pistas',
lastUpdated: 'Actualizado',
},
dashboard: {
mapping: 'Mapeo',
backup: 'Respaldo',
autoSync: 'Auto-Sync',
watch: 'Vigilar',
watchModeActive: 'Modo Vigía: Activo',
watchModeDisabled: 'Modo Vigía: Desactivado',
notSet: 'No Def.',
retain: 'Retener: {count}',
keep: 'Guardar {count}',
connected: 'Conectado a Plex',
disconnected: 'Desconectado',
synchronizing: 'SINCRONIZANDO',
syncComplete: 'SINCRONIZACIÓN COMPLETA',
},
strategies: {
title: 'Estrategia de Sync',
localOverwrite: {
label: 'Sobreescribir Local',
desc: 'La lista local sobreescribe la nube. (Sin Diff)',
},
cloudOverwrite: {
label: 'Sobreescribir Nube',
desc: 'La lista de la nube sobreescribe la local. (Sin Diff)',
},
mergeLocal: {
label: 'Fusión (Prioridad Local)',
desc: 'Fusionar ambas. Conflictos resueltos a versión Local.',
},
mergeCloud: {
label: 'Fusión (Prioridad Nube)',
desc: 'Fusionar ambas. Conflictos resueltos a versión Nube.',
},
syncNow: 'Sincronizar Ahora',
syncing: 'Sincronizando...',
saveWarning: 'Guarde los cambios pendientes (Respaldos/Mapeo) antes de sincronizar.',
},
mapping: {
title: 'Mapeo de Rutas',
simple: 'Mapeo Simple',
regex: 'Reglas Regex',
simpleTitle: 'Mapeo de Rutas',
simpleSubtitle: 'Mapear rutas locales a la nube usando coincidencia simple',
regexPre: 'Pre-Procesamiento (Antes de Sync)',
regexPost: 'Post-Procesamiento (Después de Sync)',
localPath: 'Ruta Local',
cloudPath: 'Ruta Nube',
pattern: 'Patrón',
replace: 'Reemplazo',
saveRules: 'Guardar Reglas',
noRules: 'No hay reglas definidas.',
},
backup: {
title: 'Retención de Respaldo',
enable: 'Habilitar Respaldos',
enableDesc: 'Crear copia antes de cambios',
maxVersions: 'Máx versiones a guardar:',
autoDelete: 'El más antiguo se borra automáticamente',
},
schedule: {
title: 'Tareas Programadas',
cron: 'Cron',
daily: 'Diario',
weekly: 'Semanal',
enableCron: 'Habilitar Cron',
enableDaily: 'Habilitar Ejecución Diaria',
enableWeekly: 'Habilitar Ejecución Semanal',
watchLocal: 'Vigilar Cambios Locales',
watchDesc: 'Auto-sync cuando la lista local se actualiza',
schedule: 'Horario',
notConfigured: 'No configurado',
today: 'Hoy',
tomorrow: 'Mañana',
},
connection: {
titleConnected: 'Servidor Conectado',
titleConnect: 'Conectar Servidor Plex',
serverDetails: 'Detalles del Servidor',
authentication: 'Autenticación',
protocol: 'Protocolo',
address: 'Dirección IP o Dominio',
port: 'Puerto',
token: 'X-Plex-Token (Opcional)',
username: 'Usuario / Email',
password: 'Password',
advanced: 'Opciones Avanzadas',
timeout: 'Tiempo de espera (Segundos)',
connectBtn: 'Conectar Servidor',
connecting: 'Conectando...',
connectedSuccess: 'Conectado Exitosamente',
selectLibrary: 'Seleccionar Librería',
},
toasts: {
localRefreshCancelled: 'Actualización local cancelada.',
cloudRefreshCancelled: 'Actualización nube cancelada.',
strategySaved: 'Estrategia seleccionada "{strategy}" guardada.',
mappingSaved: 'Reglas de mapeo guardadas.',
backupSaved: 'Configuración de respaldo guardada.',
backupFailed: 'Error al guardar configuración de respaldo.',
scheduleDisabled: 'Tareas programadas deshabilitadas.',
scheduleEmpty: 'Tareas programadas deshabilitadas (Cron Vacío).',
scheduleStarted: 'Tarea programada iniciada exitosamente.',
scheduleFailed: 'Error al actualizar horario.',
syncFailed: 'Fallo en sync. Revise conexión.',
librarySwitched: 'Librería cambiada a {library}',
connectedTo: 'Conectado exitosamente a {name}',
connectionCancelled: 'Conexión cancelada por usuario.',
}
};
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "PlexSync Manager",
"name": "PMS Playlist Sync",
"description": "A modern dashboard to synchronize and manage playlists between Local and Cloud Plex servers.",
"requestFramePermissions": []
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "plexsync-manager",
"name": "pms-playlist-sync",
"private": true,
"version": "0.0.0",
"type": "module",
+11
View File
@@ -0,0 +1,11 @@
import { en } from './locales/en';
import { es } from './locales/es';
export const translations = {
en,
es
};
export type Language = keyof typeof translations;
export type TranslationStructure = typeof en;