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 */}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
import { translations, Language } 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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -13,6 +14,7 @@ interface ConnectionModalProps {
|
||||
}
|
||||
|
||||
const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onConnectSuccess, onShowMessage, initialSettings }) => {
|
||||
const { t } = useLanguage();
|
||||
const [formData, setFormData] = useState<PlexConnectionSettings>({
|
||||
protocol: 'http',
|
||||
address: '',
|
||||
@@ -90,9 +92,9 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
|
||||
onConnectSuccess(updatedInfo);
|
||||
const saveResult = await apiService.updateLibrary(lib.title);
|
||||
if (saveResult.status !== 'success') {
|
||||
onShowMessage(saveResult.message || 'Failed to save library selection');
|
||||
onShowMessage(saveResult.message || t('toasts.librarySaveFailed'));
|
||||
} else {
|
||||
onShowMessage(`Library switched to ${lib.title}`);
|
||||
onShowMessage(t('toasts.librarySwitched', { library: lib.title }));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -112,7 +114,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;
|
||||
}
|
||||
@@ -141,7 +143,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 || [];
|
||||
const musicLibraries = libs.filter((lib) => lib.type === 'artist').sort((a, b) => a.title.localeCompare(b.title));
|
||||
@@ -157,13 +159,13 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
|
||||
});
|
||||
const saveResult = await apiService.updateLibrary(defaultLib.title);
|
||||
if (saveResult.status !== 'success') {
|
||||
setError(saveResult.message || 'Failed to save library selection');
|
||||
setError(saveResult.message || t('toasts.librarySaveFailed'));
|
||||
}
|
||||
} else {
|
||||
onConnectSuccess(info);
|
||||
}
|
||||
} else {
|
||||
setError(result.message || "Connection failed");
|
||||
setError(result.message || t('server.connectionFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -183,7 +185,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} />
|
||||
@@ -202,7 +204,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
|
||||
@@ -226,7 +228,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' : ''}`}
|
||||
@@ -240,7 +242,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' : ''}`}
|
||||
@@ -252,7 +254,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">
|
||||
@@ -263,7 +265,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' : ''}`}
|
||||
@@ -285,7 +287,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}`}
|
||||
@@ -301,7 +303,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}`}
|
||||
@@ -329,7 +331,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>
|
||||
@@ -337,7 +339,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"
|
||||
@@ -366,15 +368,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>
|
||||
)}
|
||||
@@ -383,7 +385,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" />
|
||||
@@ -407,7 +409,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>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
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 +18,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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
History,
|
||||
Eye
|
||||
} from 'lucide-react';
|
||||
import { useLanguage } from '../LanguageContext';
|
||||
|
||||
// Generate a UUID for mapping rules
|
||||
const generateUUID = (): string => {
|
||||
@@ -40,8 +41,8 @@ const generateUUID = (): string => {
|
||||
|
||||
interface StrategyOption {
|
||||
value: SyncStrategy;
|
||||
label: string;
|
||||
description: string;
|
||||
labelKey: string;
|
||||
descKey: string;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
}
|
||||
@@ -49,29 +50,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'
|
||||
}
|
||||
@@ -145,6 +146,7 @@ const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
|
||||
leftInputClass,
|
||||
rightInputClass
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const handleAdd = () => {
|
||||
if (isLocked) return;
|
||||
@@ -176,7 +178,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>
|
||||
@@ -185,14 +187,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}`}
|
||||
@@ -200,7 +202,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}`}
|
||||
@@ -244,6 +246,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
syncState,
|
||||
onSync
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -321,7 +324,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 ---
|
||||
@@ -453,7 +456,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={`${t('strategies.title')}: ${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">
|
||||
@@ -479,7 +482,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
|
||||
@@ -494,7 +497,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>
|
||||
|
||||
@@ -502,7 +505,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>
|
||||
|
||||
@@ -518,7 +521,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">
|
||||
@@ -528,8 +531,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>
|
||||
|
||||
@@ -546,7 +549,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
|
||||
@@ -560,7 +563,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
}}
|
||||
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">{localBackup.retentionCount === 0 ? 'No auto-delete' : 'Oldest deleted automatically'}</span>
|
||||
<span className="text-[10px] text-gray-600 italic">{localBackup.retentionCount === 0 ? t('backup.noAutoDelete') : t('backup.autoDelete')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -575,7 +578,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}
|
||||
@@ -586,7 +589,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>
|
||||
@@ -595,14 +598,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}
|
||||
@@ -613,8 +616,8 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<tab.icon size={12} />
|
||||
<span>{tab.label}</span>
|
||||
<tab.icon size={12} />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -625,15 +628,15 @@ 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}
|
||||
/>
|
||||
@@ -643,8 +646,8 @@ 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}
|
||||
@@ -653,8 +656,8 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
/>
|
||||
|
||||
<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}
|
||||
@@ -664,8 +667,8 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
|
||||
{/* 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}
|
||||
@@ -674,8 +677,8 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
/>
|
||||
|
||||
<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}
|
||||
@@ -696,7 +699,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}
|
||||
@@ -707,7 +710,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>
|
||||
@@ -715,15 +718,14 @@ 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}
|
||||
@@ -746,7 +748,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'}`}
|
||||
@@ -779,7 +781,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'}`}
|
||||
@@ -805,7 +807,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'}`}
|
||||
@@ -857,8 +859,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
|
||||
@@ -880,7 +882,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}
|
||||
@@ -891,7 +893,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>
|
||||
@@ -913,18 +915,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>
|
||||
|
||||
+4
-1
@@ -1,6 +1,7 @@
|
||||
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 +11,8 @@ if (!rootElement) {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<LanguageProvider>
|
||||
<App />
|
||||
</LanguageProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,152 @@
|
||||
export const en = {
|
||||
app: {
|
||||
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',
|
||||
switchLanguage: 'Switch Language',
|
||||
},
|
||||
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:',
|
||||
noAutoDelete: 'No auto-delete',
|
||||
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.',
|
||||
strategySaveFailed: 'Failed to save sync strategy.',
|
||||
mappingSaved: 'Path mapping rules have been saved.',
|
||||
mappingSaveFailed: 'Failed to save path mapping rules.',
|
||||
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 updated successfully.',
|
||||
scheduleFailed: 'Failed to update schedule.',
|
||||
syncFailed: 'Sync failed. Please check connection.',
|
||||
backgroundSyncSuccess: 'Background sync completed successfully.',
|
||||
backgroundSyncFailed: 'Background sync failed: {error}',
|
||||
librarySwitched: 'Library switched to {library}',
|
||||
connectedTo: 'Successfully connected to {name}',
|
||||
connectionCancelled: 'Connection cancelled by user.',
|
||||
librarySaveFailed: 'Failed to save library selection.',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
export const es = {
|
||||
app: {
|
||||
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',
|
||||
switchLanguage: 'Cambiar idioma',
|
||||
},
|
||||
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:',
|
||||
noAutoDelete: 'Sin auto-borrado',
|
||||
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.',
|
||||
strategySaveFailed: 'Error al guardar estrategia de sync.',
|
||||
mappingSaved: 'Reglas de mapeo guardadas.',
|
||||
mappingSaveFailed: 'Error al guardar reglas de mapeo.',
|
||||
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 actualizada exitosamente.',
|
||||
scheduleFailed: 'Error al actualizar horario.',
|
||||
syncFailed: 'Fallo en sync. Revise conexión.',
|
||||
backgroundSyncSuccess: 'Sync en segundo plano completado.',
|
||||
backgroundSyncFailed: 'Sync en segundo plano falló: {error}',
|
||||
librarySwitched: 'Librería cambiada a {library}',
|
||||
connectedTo: 'Conectado exitosamente a {name}',
|
||||
connectionCancelled: 'Conexión cancelada por usuario.',
|
||||
librarySaveFailed: 'Error al guardar selección de librería.',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user