9 Commits

19 changed files with 1159 additions and 176 deletions
+8
View File
@@ -0,0 +1,8 @@
# Normalize line endings to avoid CRLF issues in Linux containers
* text=auto
# Shell scripts must be LF for correct shebang parsing
*.sh text eol=lf
# PowerShell scripts are typically CRLF on Windows
*.ps1 text eol=crlf
+7 -1
View File
@@ -6,9 +6,13 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app WORKDIR /app
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \ && apt-get install -y --no-install-recommends build-essential tzdata \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
&& chmod +x /usr/local/bin/docker-entrypoint.sh
COPY requirements.txt ./ COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
@@ -17,4 +21,6 @@ COPY frontend ./frontend
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
+16
View File
@@ -26,6 +26,22 @@ PlexPlaylistSync 是一个用于同步 Plex 播放列表和本地 `.m3u`/`.m3u8`
docker compose up --build docker compose up --build
``` ```
### 配置容器时区
本项目支持通过 `docker-compose.yml` 的环境变量 `TZ` 配置容器运行时区(需要使用有效的 IANA 时区名,例如 `Asia/Shanghai``UTC``America/New_York`)。
- 临时指定(当前终端会话生效):
```bash
TZ=UTC docker compose up --build
```
- 或在项目根目录创建 `.env`
```env
TZ=Asia/Shanghai
```
- 默认会以 `--reload` 模式启动,监听本地 8080 端口,可在浏览器访问 `http://localhost:8080` - 默认会以 `--reload` 模式启动,监听本地 8080 端口,可在浏览器访问 `http://localhost:8080`
- 通过 `./app/config.json` 保存的 Plex 配置信息会在主机和容器间共享,便于调试时保留登录 token 等数据。 - 通过 `./app/config.json` 保存的 Plex 配置信息会在主机和容器间共享,便于调试时保留登录 token 等数据。
- 如需自定义端口或其他参数,可在 `docker-compose.yml` 中调整。 - 如需自定义端口或其他参数,可在 `docker-compose.yml` 中调整。
+1
View File
@@ -11,4 +11,5 @@ services:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- PYTHONDONTWRITEBYTECODE=1 - PYTHONDONTWRITEBYTECODE=1
- LOG_LEVEL=INFO - LOG_LEVEL=INFO
- TZ=${TZ:-Asia/Tokyo}
restart: unless-stopped restart: unless-stopped
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env sh
set -eu
# Configure timezone inside the container if TZ is provided.
# This avoids relying on host mounts like /etc/localtime, which are awkward on Windows.
if [ "${TZ:-}" != "" ]; then
ZONEINFO="/usr/share/zoneinfo/${TZ}"
if [ -e "$ZONEINFO" ]; then
ln -snf "$ZONEINFO" /etc/localtime
echo "$TZ" > /etc/timezone
else
echo "[entrypoint] Warning: TZ='$TZ' not found at $ZONEINFO; keeping existing timezone." >&2
fi
fi
exec "$@"
+114 -71
View File
@@ -15,7 +15,9 @@ import { SYNC_BANNER_PADDING_X, SYNC_BANNER_PADDING_Y, SYNC_BANNER_MIN_WIDTH } f
import ServerPanel from './components/ServerPanel'; import ServerPanel from './components/ServerPanel';
import StrategySelector from './components/StrategySelector'; import StrategySelector from './components/StrategySelector';
import ConnectionModal from './components/ConnectionModal'; import ConnectionModal from './components/ConnectionModal';
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive } from 'lucide-react'; import OverflowMarquee from './components/OverflowMarquee';
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive, Languages } from 'lucide-react';
import { useLanguage } from './LanguageContext';
interface Toast { interface Toast {
id: number; id: number;
@@ -112,6 +114,7 @@ const useStripeAnimation = (syncState: SyncState) => {
}; };
const App: React.FC = () => { const App: React.FC = () => {
const { t, language, setLanguage } = useLanguage();
const [localPlaylists, setLocalPlaylists] = useState<Playlist[]>([]); const [localPlaylists, setLocalPlaylists] = useState<Playlist[]>([]);
const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]); const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined); const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined);
@@ -134,6 +137,7 @@ const App: React.FC = () => {
// Connection Modal State // Connection Modal State
const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false); const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false);
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
// Strategy State // Strategy State
const [currentStrategy, setCurrentStrategy] = useState<SyncStrategy>(SyncStrategy.LOCAL_OVERWRITE); const [currentStrategy, setCurrentStrategy] = useState<SyncStrategy>(SyncStrategy.LOCAL_OVERWRITE);
@@ -269,13 +273,15 @@ const App: React.FC = () => {
loadSchedule(); loadSchedule();
if (settings.mode === ScheduleMode.DISABLED) { 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 { } else {
addToast("Scheduled task updated successfully."); addToast(t('toasts.scheduleStarted'));
} }
return true; return true;
} else { } else {
addToast(result.message || "Failed to update schedule."); addToast(result.message || t('toasts.scheduleFailed'));
return false; return false;
} }
}; };
@@ -285,9 +291,9 @@ const App: React.FC = () => {
const result = await apiService.saveBackupSettings(settings); const result = await apiService.saveBackupSettings(settings);
if (result.status === 'success') { if (result.status === 'success') {
setBackupSettings(settings); setBackupSettings(settings);
addToast('Backup settings have been saved.'); addToast(t('toasts.backupSaved'));
} else { } else {
addToast(result.message || 'Failed to save backup settings.'); addToast(result.message || t('toasts.backupFailed'));
} }
}; };
@@ -311,7 +317,7 @@ const App: React.FC = () => {
localAbortRef.current.abort(); localAbortRef.current.abort();
localAbortRef.current = null; localAbortRef.current = null;
setLoadingLocal(false); setLoadingLocal(false);
addToast("Local refresh cancelled."); addToast(t('toasts.localRefreshCancelled'));
} }
}; };
@@ -345,7 +351,7 @@ const App: React.FC = () => {
cloudAbortRef.current.abort(); cloudAbortRef.current.abort();
cloudAbortRef.current = null; cloudAbortRef.current = null;
setLoadingCloud(false); setLoadingCloud(false);
addToast("Cloud refresh cancelled."); addToast(t('toasts.cloudRefreshCancelled'));
} }
}; };
@@ -372,9 +378,9 @@ const App: React.FC = () => {
setCurrentStrategy(strategy); setCurrentStrategy(strategy);
const result = await apiService.updateSyncStrategy(strategy); const result = await apiService.updateSyncStrategy(strategy);
if (result.status === 'success') { if (result.status === 'success') {
addToast(`Selected strategy "${label}" has been saved.`); addToast(t('toasts.strategySaved', { strategy: label }));
} else { } else {
addToast(result.message || 'Failed to save sync strategy.'); addToast(result.message || t('toasts.strategySaveFailed'));
} }
}; };
@@ -383,9 +389,9 @@ const App: React.FC = () => {
setPathMappingConfig(config); setPathMappingConfig(config);
const result = await apiService.savePathMapping(config); const result = await apiService.savePathMapping(config);
if (result.status === 'success') { if (result.status === 'success') {
addToast('Path mapping rules have been saved.'); addToast(t('toasts.mappingSaved'));
} else { } else {
addToast(result.message || 'Failed to save path mapping rules.'); addToast(result.message || t('toasts.mappingSaveFailed'));
} }
}; };
@@ -410,7 +416,7 @@ const App: React.FC = () => {
}, SYNC_SUCCESS_TOTAL_MS); }, SYNC_SUCCESS_TOTAL_MS);
} else { } else {
setSyncState(SyncState.ERROR); 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); setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS);
} }
}; };
@@ -457,11 +463,11 @@ const App: React.FC = () => {
setSyncState(SyncState.SUCCESS); setSyncState(SyncState.SUCCESS);
refreshLocal(); refreshLocal();
refreshCloud(); refreshCloud();
addToast("Background sync completed successfully."); addToast(t('toasts.backgroundSyncSuccess'));
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_SUCCESS_TOTAL_MS); setTimeout(() => setSyncState(SyncState.IDLE), SYNC_SUCCESS_TOTAL_MS);
} else if (status === 'error') { } else if (status === 'error') {
setSyncState(SyncState.ERROR); setSyncState(SyncState.ERROR);
addToast(`Background sync failed: ${error}`); addToast(t('toasts.backgroundSyncFailed', { error: error || '' }));
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS); setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS);
} }
} else { } else {
@@ -518,30 +524,22 @@ const App: React.FC = () => {
const isConnected = cloudServerInfo?.isConnected; const isConnected = cloudServerInfo?.isConnected;
const getScheduleDisplayInfo = () => { const getScheduleDisplayInfo = () => {
const result = { const result = {
label: 'Auto-Sync', label: t('dashboard.autoSync'),
value: 'Not configured', value: t('schedule.notConfigured'),
active: false, active: false,
autoWatch: scheduleSettings.autoWatch autoWatch: scheduleSettings.autoWatch,
}; };
if (scheduleSettings.mode === ScheduleMode.DISABLED) { if (scheduleSettings.mode === ScheduleMode.DISABLED) {
result.label = 'Auto-Sync'; result.value = t('common.disabled');
result.value = 'Disabled';
return result; 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.trim() === '') {
result.value = t('dashboard.notSet');
if (scheduleSettings.mode === ScheduleMode.CRON && !scheduleSettings.cronExpression) {
result.value = 'Not Set';
} else { } else {
result.value = nextRunTime ? `${nextRunTime}` : 'Calculating...'; result.value = nextRunTime ? `${nextRunTime}` : t('common.loading');
} }
result.active = true; result.active = true;
@@ -553,39 +551,36 @@ const App: React.FC = () => {
// Helper: Calculate Path Mapping Info // Helper: Calculate Path Mapping Info
const getPathMappingDisplayInfo = (config: PathMappingConfig) => { const getPathMappingDisplayInfo = (config: PathMappingConfig) => {
let count = 0; let count = 0;
let modeLabel = '';
let Icon = Type; let Icon = Type;
let label = 'Mapping';
if (config.mode === PathMappingMode.SIMPLE) { if (config.mode === PathMappingMode.SIMPLE) {
modeLabel = 'Simple'; count = config.simple.length;
label = 'Simple-Mapping'; Icon = Type;
count = config.simple.length;
Icon = Type;
} else { } else {
modeLabel = 'Regex'; count =
label = 'Regex-Mapping'; config.regex.localPre.length +
count = config.regex.localPre.length + config.regex.localPost.length +
config.regex.localPost.length + config.regex.remotePre.length +
config.regex.remotePre.length + config.regex.remotePost.length;
config.regex.remotePost.length; Icon = Code2;
Icon = Code2;
} }
if (count === 0) { if (count === 0) {
return { return {
label: 'Mapping', label: t('dashboard.mapping'),
value: 'Not Set', value: t('dashboard.notSet'),
active: false, active: false,
Icon: Icon Icon,
}; };
} }
const modeLabel = config.mode === PathMappingMode.SIMPLE ? t('mapping.simple') : t('mapping.regex');
return { return {
label: label, label: t('dashboard.mapping'),
value: `${modeLabel} (${count})`, value: `${modeLabel} (${count})`,
active: true, active: true,
Icon: Icon Icon,
}; };
}; };
@@ -594,16 +589,16 @@ const App: React.FC = () => {
// Helper: Calculate Backup Info // Helper: Calculate Backup Info
const getBackupDisplayInfo = (settings: BackupSettings) => { const getBackupDisplayInfo = (settings: BackupSettings) => {
if (!settings.enabled) { if (!settings.enabled) {
return { return {
label: 'Backups', label: t('dashboard.backup'),
value: 'Disabled', value: t('common.disabled'),
active: false active: false,
}; };
} }
return { return {
label: 'Backups', label: t('dashboard.backup'),
value: `Keep ${settings.retentionCount}`, value: t('dashboard.keep', { count: settings.retentionCount }),
active: true active: true,
}; };
}; };
@@ -691,7 +686,9 @@ 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> <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'}`}> <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" /> <pathMappingInfo.Icon size={12} strokeWidth={2.5} className="flex-shrink-0" />
<span className="truncate">{pathMappingInfo.value === 'Not Set' ? 'None' : pathMappingInfo.value}</span> <OverflowMarquee>
{pathMappingInfo.active ? pathMappingInfo.value : t('common.none')}
</OverflowMarquee>
</div> </div>
</div> </div>
@@ -700,7 +697,9 @@ 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> <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'}`}> <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" /> <Archive size={12} strokeWidth={2.5} className="flex-shrink-0" />
<span className="truncate">{backupInfo.active ? backupInfo.value.replace('Keep ', 'Retain: ') : 'Disabled'}</span> <OverflowMarquee>
{backupInfo.active ? t('dashboard.retain', { count: backupSettings.retentionCount }) : backupInfo.value}
</OverflowMarquee>
</div> </div>
</div> </div>
@@ -711,19 +710,63 @@ const App: React.FC = () => {
{/* Watch Indicator Badge */} {/* Watch Indicator Badge */}
<div <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'}`} 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} />} {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> </div>
<div className={`flex items-center gap-1.5 text-xs font-medium mt-0.5 ${scheduleInfo.active ? 'text-green-400' : 'text-gray-600'}`}> <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" /> <Clock size={12} strokeWidth={2.5} className="flex-shrink-0" />
<span className="truncate">{scheduleInfo.active ? scheduleInfo.value : 'Disabled'}</span> <OverflowMarquee>
{scheduleInfo.active ? scheduleInfo.value : t('common.disabled')}
</OverflowMarquee>
</div> </div>
</div> </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>
<button
onClick={() => { setLanguage('chs'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'chs' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
</button>
<button
onClick={() => { setLanguage('cht'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'cht' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
</button>
</div>
</>
)}
</div>
{/* Connection Status Button */} {/* Connection Status Button */}
<button <button
onClick={() => setIsConnectionModalOpen(true)} onClick={() => setIsConnectionModalOpen(true)}
@@ -732,7 +775,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-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" : "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} />} {isConnected ? <Server size={18} /> : <ServerOff size={18} />}
</button> </button>
@@ -749,7 +792,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]'}`}> <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> </h1>
</div> </div>
</div> </div>
@@ -834,7 +877,7 @@ const App: React.FC = () => {
{/* Footer */} {/* 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"> <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> </footer>
{/* Modals */} {/* Modals */}
+63
View File
@@ -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;
};
+23 -21
View File
@@ -3,6 +3,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { PlexConnectionSettings, PlexServerConnection, PlexLibrary } from '../types'; import { PlexConnectionSettings, PlexServerConnection, PlexLibrary } from '../types';
import { apiService } from '../services/api'; import { apiService } from '../services/api';
import { X, Server, Lock, User, Key, Globe, Eye, EyeOff, CheckCircle, Library, ChevronDown, ChevronRight, Settings, Loader2 } from 'lucide-react'; import { X, Server, Lock, User, Key, Globe, Eye, EyeOff, CheckCircle, Library, ChevronDown, ChevronRight, Settings, Loader2 } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
interface ConnectionModalProps { interface ConnectionModalProps {
isOpen: boolean; isOpen: boolean;
@@ -13,6 +14,7 @@ interface ConnectionModalProps {
} }
const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onConnectSuccess, onShowMessage, initialSettings }) => { const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onConnectSuccess, onShowMessage, initialSettings }) => {
const { t } = useLanguage();
const [formData, setFormData] = useState<PlexConnectionSettings>({ const [formData, setFormData] = useState<PlexConnectionSettings>({
protocol: 'http', protocol: 'http',
address: '', address: '',
@@ -90,9 +92,9 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
onConnectSuccess(updatedInfo); onConnectSuccess(updatedInfo);
const saveResult = await apiService.updateLibrary(lib.title); const saveResult = await apiService.updateLibrary(lib.title);
if (saveResult.status !== 'success') { if (saveResult.status !== 'success') {
onShowMessage(saveResult.message || 'Failed to save library selection'); onShowMessage(saveResult.message || t('toasts.librarySaveFailed'));
} else { } 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.abort();
abortControllerRef.current = null; abortControllerRef.current = null;
setIsConnecting(false); setIsConnecting(false);
setError("Connection cancelled by user."); setError(t('toasts.connectionCancelled'));
} }
return; return;
} }
@@ -141,7 +143,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
const info = result.data.serverInfo; const info = result.data.serverInfo;
setConnectedServerInfo(info); setConnectedServerInfo(info);
onShowMessage(`Successfully connected to ${info.name || 'Plex Server'}`); onShowMessage(t('toasts.connectedTo', { name: info.name || 'Plex Server' }));
const libs = info.libraries || []; const libs = info.libraries || [];
const musicLibraries = libs.filter((lib) => lib.type === 'artist').sort((a, b) => a.title.localeCompare(b.title)); 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); const saveResult = await apiService.updateLibrary(defaultLib.title);
if (saveResult.status !== 'success') { if (saveResult.status !== 'success') {
setError(saveResult.message || 'Failed to save library selection'); setError(saveResult.message || t('toasts.librarySaveFailed'));
} }
} else { } else {
onConnectSuccess(info); onConnectSuccess(info);
} }
} else { } 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"> <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"> <h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Server size={18} className={isConnected ? "text-green-400" : "text-plex-orange"} /> <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> </h3>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors"> <button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<X size={20} /> <X size={20} />
@@ -202,7 +204,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{/* Server Connection */} {/* Server Connection */}
<div className="space-y-3"> <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="grid grid-cols-4 gap-3">
<div className="col-span-1"> <div className="col-span-1">
<select <select
@@ -226,7 +228,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
name="address" name="address"
required required
disabled={isConnected || isConnecting} disabled={isConnected || isConnecting}
placeholder="IP Address or Domain" placeholder={t('connection.address')}
value={formData.address} value={formData.address}
onChange={handleChange} 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' : ''}`} 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" type="text"
name="port" name="port"
disabled={isConnected || isConnecting} disabled={isConnected || isConnecting}
placeholder="Port (e.g. 32400)" placeholder={t('connection.port')}
value={formData.port} value={formData.port}
onChange={handleChange} 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' : ''}`} 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 */} {/* Authentication */}
<div className="space-y-3"> <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 */} {/* Token */}
<div className="relative"> <div className="relative">
@@ -263,7 +265,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
type="text" type="text"
name="token" name="token"
disabled={isConnected || isConnecting} disabled={isConnected || isConnecting}
placeholder="X-Plex-Token (Optional)" placeholder={t('connection.token')}
value={formData.token} value={formData.token}
onChange={handleChange} 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' : ''}`} 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" type="text"
name="username" name="username"
disabled={isTokenProvided || isConnecting} disabled={isTokenProvided || isConnecting}
placeholder="Username / Email" placeholder={t('connection.username')}
value={formData.username} value={formData.username}
onChange={handleChange} onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`} 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"} type={showPassword ? "text" : "password"}
name="password" name="password"
disabled={isTokenProvided || isConnecting} disabled={isTokenProvided || isConnecting}
placeholder="Password" placeholder={t('connection.password')}
value={formData.password} value={formData.password}
onChange={handleChange} onChange={handleChange}
className={`w-full h-10 pl-9 pr-10 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`} 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"> <div className="flex items-center gap-2">
<Settings size={14} /> <Settings size={14} />
<span>Advanced Options</span> <span>{t('connection.advanced')}</span>
</div> </div>
{showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />} {showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button> </button>
@@ -337,7 +339,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{showAdvanced && ( {showAdvanced && (
<div className="p-3 bg-gray-900/50 space-y-3 animate-in slide-in-from-top-2"> <div className="p-3 bg-gray-900/50 space-y-3 animate-in slide-in-from-top-2">
<div> <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 <input
type="number" type="number"
min="1" min="1"
@@ -366,15 +368,15 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{isConnecting ? ( {isConnecting ? (
<> <>
<Loader2 size={16} className="animate-spin" /> <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> </button>
) : ( ) : (
<div className="mt-2 p-2 bg-green-500/10 border border-green-500/20 rounded-lg text-center"> <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"> <p className="text-green-400 text-sm font-semibold flex items-center justify-center gap-2">
<CheckCircle size={16} /> <CheckCircle size={16} />
Connected Successfully {t('connection.connectedSuccess')}
</p> </p>
</div> </div>
)} )}
@@ -383,7 +385,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
{/* Library Selection - Appears after connection */} {/* Library Selection - Appears after connection */}
{isConnected && libraries.length > 0 && ( {isConnected && libraries.length > 0 && (
<div className="mt-6 pt-5 border-t border-gray-700 animate-in slide-in-from-top-2 fade-in"> <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="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Library size={14} className="text-plex-orange" /> <Library size={14} className="text-plex-orange" />
@@ -407,7 +409,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
onClick={onClose} 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" 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> </button>
</div> </div>
</div> </div>
+117
View File
@@ -0,0 +1,117 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
type OverflowMarqueeProps = {
children: React.ReactNode;
className?: string;
textClassName?: string;
title?: string;
speedPxPerSec?: number;
minDurationSec?: number;
};
type MarqueeMetrics = {
isOverflowing: boolean;
overflowPx: number;
durationSec: number;
};
const DEFAULT_SPEED_PX_PER_SEC = 24;
const DEFAULT_MIN_DURATION_SEC = 4;
const OverflowMarquee: React.FC<OverflowMarqueeProps> = ({
children,
className,
textClassName,
title,
speedPxPerSec = DEFAULT_SPEED_PX_PER_SEC,
minDurationSec = DEFAULT_MIN_DURATION_SEC,
}) => {
const containerRef = useRef<HTMLSpanElement>(null);
const textRef = useRef<HTMLSpanElement>(null);
const [metrics, setMetrics] = useState<MarqueeMetrics>({
isOverflowing: false,
overflowPx: 0,
durationSec: minDurationSec,
});
const fallbackTitle = useMemo(() => {
if (title) return title;
return typeof children === 'string' ? children : undefined;
}, [children, title]);
const recompute = () => {
const container = containerRef.current;
const text = textRef.current;
if (!container || !text) return;
// Ensure we measure with current layout.
const available = container.clientWidth;
const content = text.scrollWidth;
const overflowPx = Math.ceil(content - available);
if (overflowPx > 1) {
const durationSec = Math.max(minDurationSec, overflowPx / Math.max(1, speedPxPerSec));
setMetrics({ isOverflowing: true, overflowPx, durationSec });
} else {
// Avoid re-render loops if already not overflowing.
setMetrics((prev) => (prev.isOverflowing ? { isOverflowing: false, overflowPx: 0, durationSec: minDurationSec } : prev));
}
};
useLayoutEffect(() => {
recompute();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [children]);
useEffect(() => {
recompute();
const container = containerRef.current;
const text = textRef.current;
if (!container || !text) return;
const ro = new ResizeObserver(() => recompute());
ro.observe(container);
ro.observe(text);
const onResize = () => recompute();
window.addEventListener('resize', onResize);
return () => {
ro.disconnect();
window.removeEventListener('resize', onResize);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const textStyle: React.CSSProperties | undefined = metrics.isOverflowing
? ({
['--marquee-distance' as any]: `${metrics.overflowPx}px`,
['--marquee-duration' as any]: `${metrics.durationSec}s`,
} satisfies React.CSSProperties)
: undefined;
return (
<span
ref={containerRef}
className={['overflow-marquee', className].filter(Boolean).join(' ')}
title={fallbackTitle}
>
<span
ref={textRef}
className={[
'overflow-marquee__text',
metrics.isOverflowing ? 'overflow-marquee__text--animate' : '',
textClassName,
]
.filter(Boolean)
.join(' ')}
style={textStyle}
>
{children}
</span>
</span>
);
};
export default OverflowMarquee;
+9 -4
View File
@@ -1,26 +1,31 @@
import React from 'react'; import React from 'react';
import { Playlist } from '../types'; import { Playlist } from '../types';
import { Disc3, Clock } from 'lucide-react'; import { Disc3, Clock } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
import OverflowMarquee from './OverflowMarquee';
interface PlaylistCardProps { interface PlaylistCardProps {
playlist: Playlist; playlist: Playlist;
} }
const PlaylistCard: React.FC<PlaylistCardProps> = ({ playlist }) => { const PlaylistCard: React.FC<PlaylistCardProps> = ({ playlist }) => {
const { t } = useLanguage();
return ( 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="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"> <div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-200 truncate flex-1 mr-2 group-hover:text-white transition-colors"> <h4 className="text-sm font-medium text-gray-200 flex-1 mr-2 group-hover:text-white transition-colors min-w-0">
{playlist.title} <OverflowMarquee>
{playlist.title}
</OverflowMarquee>
</h4> </h4>
</div> </div>
<div className="flex items-center mt-1.5 space-x-4 text-xs text-gray-500 group-hover:text-gray-400"> <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" /> <Disc3 size={12} className="mr-1.5 opacity-70" />
{playlist.trackCount} {playlist.trackCount}
</span> </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" /> <Clock size={12} className="mr-1.5 opacity-70" />
{new Date(playlist.lastUpdated).toLocaleDateString()} {new Date(playlist.lastUpdated).toLocaleDateString()}
</span> </span>
+19 -11
View File
@@ -3,6 +3,8 @@ import React from 'react';
import { Playlist, ServerType, PlexServerConnection } from '../types'; import { Playlist, ServerType, PlexServerConnection } from '../types';
import PlaylistCard from './PlaylistCard'; import PlaylistCard from './PlaylistCard';
import { RefreshCw, Server, Cloud, WifiOff, X } from 'lucide-react'; import { RefreshCw, Server, Cloud, WifiOff, X } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
import OverflowMarquee from './OverflowMarquee';
interface ServerPanelProps { interface ServerPanelProps {
type: ServerType; type: ServerType;
@@ -14,6 +16,7 @@ interface ServerPanelProps {
} }
const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, onRefresh, onCancel, serverInfo }) => { const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, onRefresh, onCancel, serverInfo }) => {
const { t } = useLanguage();
const isLocal = type === ServerType.LOCAL; const isLocal = type === ServerType.LOCAL;
let Icon = isLocal ? Server : Cloud; let Icon = isLocal ? Server : Cloud;
@@ -28,39 +31,44 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
let displaySubtitle: React.ReactNode = null; let displaySubtitle: React.ReactNode = null;
if (isLocal) { if (isLocal) {
displayTitle = 'Local Server'; displayTitle = t('server.local');
displaySubtitle = ( displaySubtitle = (
<p className="text-xs text-gray-400 font-medium mt-0.5 md:mt-0 md:ml-0"> <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> </p>
); );
} else { } else {
// Cloud Logic // Cloud Logic
if (serverInfo) { if (serverInfo) {
if (serverInfo.isConnected) { if (serverInfo.isConnected) {
displayTitle = serverInfo.name || 'Cloud Server'; displayTitle = serverInfo.name || t('server.cloud');
displaySubtitle = ( displaySubtitle = (
<div className="flex items-center text-xs text-gray-300 font-medium space-x-1.5 truncate mt-0.5 md:mt-0"> <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> <span className="text-plex-orange font-semibold min-w-0 max-w-full">
<span className="block md:hidden truncate">{serverInfo.libraryName}</span>
<OverflowMarquee className="hidden md:inline-block">
{serverInfo.libraryName}
</OverflowMarquee>
</span>
<span className="text-gray-600 hidden md:inline"></span> <span className="text-gray-600 hidden md:inline"></span>
<span className="text-gray-500 font-mono text-[10px] hidden md:inline">{serverInfo.ip}:{serverInfo.port}</span> <span className="text-gray-500 font-mono text-[10px] hidden md:inline">{serverInfo.ip}:{serverInfo.port}</span>
</div> </div>
); );
} else { } else {
displayTitle = 'Not Connected'; displayTitle = t('server.notConnected');
Icon = WifiOff; Icon = WifiOff;
headerColor = 'text-red-400'; headerColor = 'text-red-400';
displaySubtitle = ( displaySubtitle = (
<p className="text-xs text-gray-500 font-medium mt-0.5"> <p className="text-xs text-gray-500 font-medium mt-0.5">
Connection failed {t('server.connectionFailed')}
</p> </p>
); );
} }
} else { } else {
displayTitle = 'Cloud Server'; displayTitle = t('server.cloud');
displaySubtitle = ( displaySubtitle = (
<p className="text-xs text-gray-500 font-medium mt-0.5"> <p className="text-xs text-gray-500 font-medium mt-0.5">
{isLoading ? 'Connecting...' : 'Waiting...'} {isLoading ? t('server.connecting') : t('server.waiting')}
</p> </p>
); );
} }
@@ -121,7 +129,7 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
: 'text-gray-400 hover:text-white hover:bg-white/10' : '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 ? ( {isLoading ? (
<div className="relative flex items-center justify-center"> <div className="relative flex items-center justify-center">
@@ -141,11 +149,11 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
{isLoading && playlists.length === 0 ? ( {isLoading && playlists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500 space-y-3"> <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" /> <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> </div>
) : playlists.length === 0 ? ( ) : playlists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500"> <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>
) : ( ) : (
<div className="space-y-2.5 md:space-y-3"> <div className="space-y-2.5 md:space-y-3">
+69 -67
View File
@@ -23,6 +23,7 @@ import {
History, History,
Eye Eye
} from 'lucide-react'; } from 'lucide-react';
import { useLanguage } from '../LanguageContext';
// Generate a UUID for mapping rules // Generate a UUID for mapping rules
const generateUUID = (): string => { const generateUUID = (): string => {
@@ -40,8 +41,8 @@ const generateUUID = (): string => {
interface StrategyOption { interface StrategyOption {
value: SyncStrategy; value: SyncStrategy;
label: string; labelKey: string;
description: string; descKey: string;
icon: React.ElementType; icon: React.ElementType;
color: string; color: string;
} }
@@ -49,35 +50,35 @@ interface StrategyOption {
const STRATEGIES: StrategyOption[] = [ const STRATEGIES: StrategyOption[] = [
{ {
value: SyncStrategy.LOCAL_OVERWRITE, value: SyncStrategy.LOCAL_OVERWRITE,
label: 'Local Overwrite', labelKey: 'strategies.localOverwrite.label',
description: 'Local playlist completely overwrites Cloud. (No Diff)', descKey: 'strategies.localOverwrite.desc',
icon: ArrowRightCircle, icon: ArrowRightCircle,
color: 'text-blue-400' color: 'text-blue-400'
}, },
{ {
value: SyncStrategy.CLOUD_OVERWRITE, value: SyncStrategy.CLOUD_OVERWRITE,
label: 'Cloud Overwrite', labelKey: 'strategies.cloudOverwrite.label',
description: 'Cloud playlist completely overwrites Local. (No Diff)', descKey: 'strategies.cloudOverwrite.desc',
icon: ArrowLeftCircle, icon: ArrowLeftCircle,
color: 'text-green-400' color: 'text-green-400'
}, },
{ {
value: SyncStrategy.MERGE_LOCAL, value: SyncStrategy.MERGE_LOCAL,
label: 'Two-way Merge (Local Priority)', labelKey: 'strategies.mergeLocal.label',
description: 'Merge both. Conflicts resolve to Local version.', descKey: 'strategies.mergeLocal.desc',
icon: GitMerge, icon: GitMerge,
color: 'text-blue-300' color: 'text-blue-300'
}, },
{ {
value: SyncStrategy.MERGE_CLOUD, value: SyncStrategy.MERGE_CLOUD,
label: 'Two-way Merge (Cloud Priority)', labelKey: 'strategies.mergeCloud.label',
description: 'Merge both. Conflicts resolve to Cloud version.', descKey: 'strategies.mergeCloud.desc',
icon: GitMerge, icon: GitMerge,
color: 'text-green-300' color: 'text-green-300'
} }
]; ];
const WEEK_DAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; const WEEK_DAY_INDEXES = [0, 1, 2, 3, 4, 5, 6];
// Color Theme Variables for Mapping Editors // Color Theme Variables for Mapping Editors
const MAPPING_THEME = { const MAPPING_THEME = {
@@ -145,6 +146,7 @@ const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
leftInputClass, leftInputClass,
rightInputClass rightInputClass
}) => { }) => {
const { t } = useLanguage();
const handleAdd = () => { const handleAdd = () => {
if (isLocked) return; if (isLocked) return;
@@ -176,7 +178,7 @@ const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
onClick={handleAdd} onClick={handleAdd}
disabled={isLocked} disabled={isLocked}
className="p-1 rounded bg-gray-700/50 hover:bg-gray-600 text-gray-400 hover:text-white transition-colors" 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} /> <Plus size={12} />
</button> </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"> <div className="flex-1 space-y-2 overflow-y-auto max-h-32 custom-scrollbar pr-1">
{rules.length === 0 ? ( {rules.length === 0 ? (
<div className="text-xs text-gray-600 italic text-center py-2 border border-dashed border-gray-700/50 rounded-lg"> <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> </div>
) : ( ) : (
rules.map((rule) => ( rules.map((rule) => (
<div key={rule.id} className="flex items-center space-x-1 animate-in slide-in-from-left-1 duration-200"> <div key={rule.id} className="flex items-center space-x-1 animate-in slide-in-from-left-1 duration-200">
<input <input
type="text" type="text"
placeholder={leftPlaceholder} placeholder={leftPlaceholder || t('mapping.pattern')}
value={rule.search} value={rule.search}
onChange={(e) => handleUpdate(rule.id, 'search', e.target.value)} 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}`} 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" /> <Link size={12} className="text-gray-600 flex-none opacity-50" />
<input <input
type="text" type="text"
placeholder={rightPlaceholder} placeholder={rightPlaceholder || t('mapping.replace')}
value={rule.replace} value={rule.replace}
onChange={(e) => handleUpdate(rule.id, 'replace', e.target.value)} 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}`} 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, syncState,
onSync onSync
}) => { }) => {
const { t } = useLanguage();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@@ -321,7 +324,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
const handleSelect = (strategy: StrategyOption) => { const handleSelect = (strategy: StrategyOption) => {
if (isLocked) return; if (isLocked) return;
onSelect(strategy.value, strategy.label); onSelect(strategy.value, t(strategy.labelKey));
}; };
// --- Path Mapping Handlers --- // --- Path Mapping Handlers ---
@@ -453,7 +456,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<button <button
onClick={() => setIsOpen(!isOpen)} 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" 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} /> <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"> <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 */} {/* Section 1: Sync Strategy */}
<div className="px-4 py-3 bg-black/20 border-b border-white/5 flex-none"> <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"> <div className="space-y-1">
{STRATEGIES.map((strategy) => ( {STRATEGIES.map((strategy) => (
<div <div
@@ -494,7 +497,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="flex items-center space-x-3 overflow-hidden"> <div className="flex items-center space-x-3 overflow-hidden">
<strategy.icon size={18} className={strategy.color} /> <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'}`}> <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> </span>
</div> </div>
@@ -502,7 +505,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="relative group/tooltip"> <div className="relative group/tooltip">
<HelpCircle size={14} className="text-gray-600 hover:text-gray-400 transition-colors" /> <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"> <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>
</div> </div>
@@ -518,7 +521,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Section 1.5: Backup Retention */} {/* Section 1.5: Backup Retention */}
<div className="px-4 py-3 bg-gray-900/40 border-b border-white/5 flex-none"> <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"> <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>
<div className="flex flex-col space-y-3"> <div className="flex flex-col space-y-3">
@@ -528,8 +531,8 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<Archive size={16} /> <Archive size={16} />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-sm font-medium text-gray-200">Enable Backups</span> <span className="text-sm font-medium text-gray-200">{t('backup.enable')}</span>
<span className="text-[10px] text-gray-500">Create a copy before changes</span> <span className="text-[10px] text-gray-500">{t('backup.enableDesc')}</span>
</div> </div>
</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 justify-between p-2.5 rounded-lg bg-black/20 border border-white/5">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<History size={14} className="text-gray-500" /> <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>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<input <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" 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> </div>
</div> </div>
@@ -575,7 +578,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`} : 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
> >
<RotateCcw size={12} /> <RotateCcw size={12} />
<span>Revert</span> <span>{t('common.revert')}</span>
</button> </button>
<button <button
onClick={handleSaveBackupClick} 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'}`} : 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
> >
<Save size={12} /> <Save size={12} />
<span>Save</span> <span>{t('common.save')}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -595,14 +598,14 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Section 2: Path Mapping (Tabs + Grid) */} {/* Section 2: Path Mapping (Tabs + Grid) */}
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none"> <div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3"> <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> </div>
{/* Tabs for Path Mapping Mode */} {/* Tabs for Path Mapping Mode */}
<div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4"> <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.SIMPLE, label: t('mapping.simple'), icon: Type },
{ id: PathMappingMode.REGEX, label: 'Regex Rules', icon: Code2 }, { id: PathMappingMode.REGEX, label: t('mapping.regex'), icon: Code2 },
].map((tab) => ( ].map((tab) => (
<button <button
key={tab.id} key={tab.id}
@@ -613,8 +616,8 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5' : 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
}`} }`}
> >
<tab.icon size={12} /> <tab.icon size={12} />
<span>{tab.label}</span> <span>{tab.label}</span>
</button> </button>
))} ))}
</div> </div>
@@ -625,15 +628,15 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
// Simple Mode: Single Editor // Simple Mode: Single Editor
<div className="animate-in fade-in duration-200"> <div className="animate-in fade-in duration-200">
<MappingGroupEditor <MappingGroupEditor
title="Path Mapping" title={t('mapping.simpleTitle')}
subtitle="Map Local paths to Cloud paths using simple string matching" subtitle={t('mapping.simpleSubtitle')}
rules={simpleRules} rules={simpleRules}
onChange={updateSimpleGroup} onChange={updateSimpleGroup}
isLocked={isLocked} isLocked={isLocked}
borderColor={MAPPING_THEME.simple.borderColor} borderColor={MAPPING_THEME.simple.borderColor}
bgColor={MAPPING_THEME.simple.bgColor} bgColor={MAPPING_THEME.simple.bgColor}
leftPlaceholder="Local Path" leftPlaceholder={t('mapping.localPath')}
rightPlaceholder="Cloud Path" rightPlaceholder={t('mapping.cloudPath')}
leftInputClass={MAPPING_THEME.inputs.local} leftInputClass={MAPPING_THEME.inputs.local}
rightInputClass={MAPPING_THEME.inputs.cloud} 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"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-in fade-in duration-200">
{/* Row 1: Pre-Processing */} {/* Row 1: Pre-Processing */}
<MappingGroupEditor <MappingGroupEditor
title="Local Playlist" title={t('server.local')}
subtitle="Pre-Processing (Before Sync)" subtitle={t('mapping.regexPre')}
rules={regexRules.localPre} rules={regexRules.localPre}
onChange={(rules) => updateRegexGroup('localPre', rules)} onChange={(rules) => updateRegexGroup('localPre', rules)}
isLocked={isLocked} isLocked={isLocked}
@@ -653,8 +656,8 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
/> />
<MappingGroupEditor <MappingGroupEditor
title="Remote Playlist" title={t('server.cloud')}
subtitle="Pre-Processing (Before Sync)" subtitle={t('mapping.regexPre')}
rules={regexRules.remotePre} rules={regexRules.remotePre}
onChange={(rules) => updateRegexGroup('remotePre', rules)} onChange={(rules) => updateRegexGroup('remotePre', rules)}
isLocked={isLocked} isLocked={isLocked}
@@ -664,8 +667,8 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Row 2: Post-Processing */} {/* Row 2: Post-Processing */}
<MappingGroupEditor <MappingGroupEditor
title="Local Playlist" title={t('server.local')}
subtitle="Post-Processing (After Sync / Result)" subtitle={t('mapping.regexPost')}
rules={regexRules.localPost} rules={regexRules.localPost}
onChange={(rules) => updateRegexGroup('localPost', rules)} onChange={(rules) => updateRegexGroup('localPost', rules)}
isLocked={isLocked} isLocked={isLocked}
@@ -674,8 +677,8 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
/> />
<MappingGroupEditor <MappingGroupEditor
title="Remote Playlist" title={t('server.cloud')}
subtitle="Post-Processing (After Sync / Result)" subtitle={t('mapping.regexPost')}
rules={regexRules.remotePost} rules={regexRules.remotePost}
onChange={(rules) => updateRegexGroup('remotePost', rules)} onChange={(rules) => updateRegexGroup('remotePost', rules)}
isLocked={isLocked} isLocked={isLocked}
@@ -696,7 +699,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`} : 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
> >
<RotateCcw size={12} /> <RotateCcw size={12} />
<span>Revert</span> <span>{t('common.revert')}</span>
</button> </button>
<button <button
onClick={handleSaveMappingClick} 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'}`} : 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
> >
<Save size={12} /> <Save size={12} />
<span>Save Rules</span> <span>{t('mapping.saveRules')}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -715,15 +718,14 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Section 3: Scheduled Tasks */} {/* Section 3: Scheduled Tasks */}
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none"> <div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3"> <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> </div>
{/* Tabs */}
<div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4"> <div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4">
{[ {[
{ id: ScheduleMode.CRON, label: 'Cron', icon: Repeat }, { id: ScheduleMode.CRON, label: t('schedule.cron'), icon: Repeat },
{ id: ScheduleMode.DAILY, label: 'Daily', icon: Clock }, { id: ScheduleMode.DAILY, label: t('schedule.daily'), icon: Clock },
{ id: ScheduleMode.WEEKLY, label: 'Weekly', icon: Calendar }, { id: ScheduleMode.WEEKLY, label: t('schedule.weekly'), icon: Calendar },
].map((tab) => ( ].map((tab) => (
<button <button
key={tab.id} key={tab.id}
@@ -746,7 +748,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="flex flex-col animate-in fade-in duration-200"> <div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Label + Switch */} {/* Top Row: Label + Switch */}
<div className="flex items-center justify-between mb-3 px-1"> <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 <button
onClick={() => toggleScheduleEnable(ScheduleMode.CRON)} 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'}`} 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"> <div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Label + Switch */} {/* Top Row: Label + Switch */}
<div className="flex items-center justify-between mb-3 px-1"> <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 <button
onClick={() => toggleScheduleEnable(ScheduleMode.DAILY)} 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'}`} 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"> <div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Label + Switch */} {/* Top Row: Label + Switch */}
<div className="flex items-center justify-between mb-3 px-1"> <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 <button
onClick={() => toggleScheduleEnable(ScheduleMode.WEEKLY)} 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'}`} 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'}`}
@@ -816,12 +818,12 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Middle Row: Full Width Capsules */} {/* Middle Row: Full Width Capsules */}
<div className={`grid grid-cols-7 w-full transition-opacity duration-200 mb-3 ${localSchedule.mode !== ScheduleMode.WEEKLY ? 'opacity-50 pointer-events-none' : ''}`}> <div className={`grid grid-cols-7 w-full transition-opacity duration-200 mb-3 ${localSchedule.mode !== ScheduleMode.WEEKLY ? 'opacity-50 pointer-events-none' : ''}`}>
{WEEK_DAYS.map((day, index) => { {WEEK_DAY_INDEXES.map((dayIndex) => {
const isSelected = localSchedule.weeklyDays.includes(index); const isSelected = localSchedule.weeklyDays.includes(dayIndex);
return ( return (
<button <button
key={index} key={dayIndex}
onClick={() => toggleWeekDay(index)} onClick={() => toggleWeekDay(dayIndex)}
className={`h-9 text-xs font-bold border-y border-l last:border-r border-gray-600/50 transition-colors className={`h-9 text-xs font-bold border-y border-l last:border-r border-gray-600/50 transition-colors
first:rounded-l-lg last:rounded-r-lg first:rounded-l-lg last:rounded-r-lg
${isSelected ${isSelected
@@ -830,7 +832,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
} }
`} `}
> >
{day} {t(`schedule.weekdaysNarrow.${dayIndex}`)}
</button> </button>
) )
})} })}
@@ -857,8 +859,8 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<Eye size={16} /> <Eye size={16} />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-sm font-medium text-gray-200">Watch Local Changes</span> <span className="text-sm font-medium text-gray-200">{t('schedule.watchLocal')}</span>
<span className="text-[10px] text-gray-500">Auto-sync when local playlist updates</span> <span className="text-[10px] text-gray-500">{t('schedule.watchDesc')}</span>
</div> </div>
</div> </div>
<button <button
@@ -880,7 +882,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`} : 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
> >
<RotateCcw size={12} /> <RotateCcw size={12} />
<span>Revert</span> <span>{t('common.revert')}</span>
</button> </button>
<button <button
onClick={handleSaveScheduleClick} 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'}`} : 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
> >
<Save size={12} /> <Save size={12} />
<span>Save</span> <span>{t('common.save')}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -913,18 +915,18 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{isSyncing ? ( {isSyncing ? (
<> <>
<Loader2 size={16} className="animate-spin" /> <Loader2 size={16} className="animate-spin" />
<span>Sync in Progress...</span> <span>{t('strategies.syncing')}</span>
</> </>
) : ( ) : (
<> <>
<Zap size={16} fill="currentColor" /> <Zap size={16} fill="currentColor" />
<span>Sync Now</span> <span>{t('strategies.syncNow')}</span>
</> </>
)} )}
</button> </button>
{(isMappingDirty || isBackupDirty) && ( {(isMappingDirty || isBackupDirty) && (
<p className="text-[10px] text-plex-orange text-center mt-2"> <p className="text-[10px] text-plex-orange text-center mt-2">
Please save pending changes (Backups/Path Mapping) before syncing. {t('strategies.saveWarning')}
</p> </p>
)} )}
</div> </div>
+35
View File
@@ -36,6 +36,41 @@
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #6b7280; background: #6b7280;
} }
/* Overflow marquee (auto-scroll when truncated) */
.overflow-marquee {
display: inline-block;
overflow: hidden;
white-space: nowrap;
max-width: 100%;
}
.overflow-marquee__text {
display: inline-block;
will-change: transform;
}
.overflow-marquee__text--animate {
animation: overflow-marquee-scroll var(--marquee-duration, 6s) linear infinite;
}
@keyframes overflow-marquee-scroll {
0%, 12% {
transform: translateX(0);
}
70%, 86% {
transform: translateX(calc(var(--marquee-distance, 0px) * -1));
}
100% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.overflow-marquee__text--animate {
animation: none;
}
}
</style> </style>
<script type="importmap"> <script type="importmap">
{ {
+4 -1
View File
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import { LanguageProvider } from './LanguageContext';
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
if (!rootElement) { if (!rootElement) {
@@ -10,6 +11,8 @@ if (!rootElement) {
const root = ReactDOM.createRoot(rootElement); const root = ReactDOM.createRoot(rootElement);
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<App /> <LanguageProvider>
<App />
</LanguageProvider>
</React.StrictMode> </React.StrictMode>
); );
+161
View File
@@ -0,0 +1,161 @@
export const cht = {
app: {
title: 'PlexSync',
manager: '管理',
footer: '© {year} PMS Playlist Sync。已連線至 Docker 後端。',
},
common: {
save: '儲存',
cancel: '取消',
revert: '還原',
delete: '刪除',
done: '完成',
loading: '載入中…',
refresh: '重新整理',
close: '關閉',
none: '無',
disabled: '已停用',
add: '新增',
switchLanguage: '切換語言',
},
server: {
local: '本機伺服器',
cloud: '雲端伺服器',
playlists: '{count} 個播放清單',
notConnected: '未連線',
connectionFailed: '連線失敗',
connecting: '連線中…',
waiting: '等待中…',
syncing: '同步中…',
noPlaylists: '找不到播放清單。',
cancelRefresh: '取消重新整理',
refreshPlaylists: '重新整理播放清單',
},
playlist: {
trackCount: '曲目數',
lastUpdated: '上次更新',
},
dashboard: {
mapping: '路徑對應',
backup: '備份',
autoSync: '自動同步',
watch: '監看',
watchModeActive: '監看模式:啟用',
watchModeDisabled: '監看模式:停用',
notSet: '未設定',
retain: '保留:{count}',
keep: '保留 {count}',
connected: '已連線至 Plex',
disconnected: '未連線',
synchronizing: 'SYNCHRONIZING',
syncComplete: 'SYNC COMPLETE',
},
strategies: {
title: '同步策略',
localOverwrite: {
label: '本機覆寫',
desc: '本機播放清單完全覆寫雲端。(無 Diff)',
},
cloudOverwrite: {
label: '雲端覆寫',
desc: '雲端播放清單完全覆寫本機。(無 Diff)',
},
mergeLocal: {
label: '雙向合併(本機優先)',
desc: '合併兩端。衝突以本機版本為準。',
},
mergeCloud: {
label: '雙向合併(雲端優先)',
desc: '合併兩端。衝突以雲端版本為準。',
},
syncNow: '立即同步',
syncing: '同步進行中…',
saveWarning: '同步前請先儲存待處理的變更(備份/路徑對應)。',
},
mapping: {
title: '路徑對應',
simple: '簡易對應',
regex: 'Regex 規則',
simpleTitle: '路徑對應',
simpleSubtitle: '使用簡單字串比對將本機路徑對應到雲端路徑',
regexPre: '前處理(同步前)',
regexPost: '後處理(同步後 / 結果)',
localPath: '本機路徑',
cloudPath: '雲端路徑',
pattern: '模式',
replace: '取代',
saveRules: '儲存規則',
noRules: '尚未定義規則。',
},
backup: {
title: '備份保留',
enable: '啟用備份',
enableDesc: '變更前建立副本',
maxVersions: '保留的最大版本數:',
noAutoDelete: '不自動刪除',
autoDelete: '自動刪除最舊版本',
},
schedule: {
title: '排程任務',
cron: 'Cron',
daily: '每日',
weekly: '每週',
weekdaysNarrow: {
0: '日',
1: '一',
2: '二',
3: '三',
4: '四',
5: '五',
6: '六',
},
enableCron: '啟用 Cron 排程',
enableDaily: '啟用每日執行',
enableWeekly: '啟用每週執行',
watchLocal: '監看本機變更',
watchDesc: '本機播放清單更新時自動同步',
schedule: '排程',
notConfigured: '尚未設定',
today: '今天',
tomorrow: '明天',
},
connection: {
titleConnected: '伺服器已連線',
titleConnect: '連線 Plex 伺服器',
serverDetails: '伺服器詳細資訊',
authentication: '驗證',
protocol: '通訊協定',
address: 'IP 位址或網域',
port: '連接埠',
token: 'X-Plex-Token(選填)',
username: '使用者名稱 / 電子郵件',
password: '密碼',
advanced: '進階選項',
timeout: '連線逾時(秒)',
connectBtn: '連線伺服器',
connecting: '連線中…',
connectedSuccess: '連線成功',
selectLibrary: '選擇要同步的媒體庫',
},
toasts: {
localRefreshCancelled: '已取消本機重新整理。',
cloudRefreshCancelled: '已取消雲端重新整理。',
strategySaved: '已儲存選擇的策略「{strategy}」。',
strategySaveFailed: '儲存同步策略失敗。',
mappingSaved: '已儲存路徑對應規則。',
mappingSaveFailed: '儲存路徑對應規則失敗。',
backupSaved: '已儲存備份設定。',
backupFailed: '儲存備份設定失敗。',
scheduleDisabled: '已停用排程任務。',
scheduleEmpty: '已停用排程任務(Cron 為空)。',
scheduleStarted: '排程任務更新成功。',
scheduleFailed: '更新排程失敗。',
syncFailed: '同步失敗。請檢查連線。',
backgroundSyncSuccess: '背景同步已成功完成。',
backgroundSyncFailed: '背景同步失敗:{error}',
librarySwitched: '媒體庫已切換為 {library}',
connectedTo: '已成功連線到 {name}',
connectionCancelled: '使用者已取消連線。',
librarySaveFailed: '儲存媒體庫選擇失敗。',
},
};
+161
View File
@@ -0,0 +1,161 @@
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',
weekdaysNarrow: {
0: 'S',
1: 'M',
2: 'T',
3: 'W',
4: 'T',
5: 'F',
6: 'S',
},
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.',
},
};
+161
View File
@@ -0,0 +1,161 @@
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',
weekdaysNarrow: {
0: 'D',
1: 'L',
2: 'M',
3: 'X',
4: 'J',
5: 'V',
6: 'S',
},
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.',
},
};
+161
View File
@@ -0,0 +1,161 @@
export const zh = {
app: {
title: 'PlexSync',
manager: '管理',
footer: '© {year} PMS Playlist Sync。已连接到 Docker 后端。',
},
common: {
save: '保存',
cancel: '取消',
revert: '恢复',
delete: '删除',
done: '完成',
loading: '加载中...',
refresh: '刷新',
close: '关闭',
none: '无',
disabled: '已禁用',
add: '添加',
switchLanguage: '切换语言',
},
server: {
local: '本地服务器',
cloud: '云端服务器',
playlists: '{count} 个播放列表',
notConnected: '未连接',
connectionFailed: '连接失败',
connecting: '正在连接...',
waiting: '等待中...',
syncing: '同步中...',
noPlaylists: '未找到播放列表。',
cancelRefresh: '取消刷新',
refreshPlaylists: '刷新播放列表',
},
playlist: {
trackCount: '曲目数',
lastUpdated: '最近更新',
},
dashboard: {
mapping: '路径映射',
backup: '备份',
autoSync: '自动同步',
watch: '监听',
watchModeActive: '监听模式:启用',
watchModeDisabled: '监听模式:禁用',
notSet: '未设置',
retain: '保留:{count}',
keep: '保留 {count}',
connected: '已连接 Plex',
disconnected: '未连接',
synchronizing: 'SYNCHRONIZING',
syncComplete: 'SYNC COMPLETE',
},
strategies: {
title: '同步策略',
localOverwrite: {
label: '本地覆盖',
desc: '本地播放列表完全覆盖云端。(无 Diff)',
},
cloudOverwrite: {
label: '云端覆盖',
desc: '云端播放列表完全覆盖本地。(无 Diff)',
},
mergeLocal: {
label: '双向合并(本地优先)',
desc: '合并两端。冲突以本地版本为准。',
},
mergeCloud: {
label: '双向合并(云端优先)',
desc: '合并两端。冲突以云端版本为准。',
},
syncNow: '立即同步',
syncing: '同步进行中...',
saveWarning: '同步前请先保存待处理的更改(备份/路径映射)。',
},
mapping: {
title: '路径映射',
simple: '简单映射',
regex: '正则规则',
simpleTitle: '路径映射',
simpleSubtitle: '使用简单字符串匹配将本地路径映射到云端路径',
regexPre: '预处理(同步前)',
regexPost: '后处理(同步后 / 结果)',
localPath: '本地路径',
cloudPath: '云端路径',
pattern: '模式',
replace: '替换',
saveRules: '保存规则',
noRules: '尚未定义规则。',
},
backup: {
title: '备份保留',
enable: '启用备份',
enableDesc: '在更改前创建副本',
maxVersions: '保留的最大版本数:',
noAutoDelete: '不自动删除',
autoDelete: '自动删除最旧版本',
},
schedule: {
title: '定时任务',
cron: 'Cron',
daily: '每日',
weekly: '每周',
weekdaysNarrow: {
0: '日',
1: '一',
2: '二',
3: '三',
4: '四',
5: '五',
6: '六',
},
enableCron: '启用 Cron 计划',
enableDaily: '启用每日运行',
enableWeekly: '启用每周运行',
watchLocal: '监听本地更改',
watchDesc: '本地播放列表更新时自动同步',
schedule: '计划',
notConfigured: '未配置',
today: '今天',
tomorrow: '明天',
},
connection: {
titleConnected: '服务器已连接',
titleConnect: '连接 Plex 服务器',
serverDetails: '服务器详情',
authentication: '认证',
protocol: '协议',
address: 'IP 地址或域名',
port: '端口',
token: 'X-Plex-Token(可选)',
username: '用户名 / 邮箱',
password: '密码',
advanced: '高级选项',
timeout: '连接超时(秒)',
connectBtn: '连接服务器',
connecting: '连接中...',
connectedSuccess: '连接成功',
selectLibrary: '选择要同步的媒体库',
},
toasts: {
localRefreshCancelled: '本地刷新已取消。',
cloudRefreshCancelled: '云端刷新已取消。',
strategySaved: '已保存选择的策略“{strategy}”。',
strategySaveFailed: '保存同步策略失败。',
mappingSaved: '已保存路径映射规则。',
mappingSaveFailed: '保存路径映射规则失败。',
backupSaved: '已保存备份设置。',
backupFailed: '保存备份设置失败。',
scheduleDisabled: '已禁用定时任务。',
scheduleEmpty: '已禁用定时任务(Cron 为空)。',
scheduleStarted: '定时任务更新成功。',
scheduleFailed: '更新定时任务失败。',
syncFailed: '同步失败。请检查连接。',
backgroundSyncSuccess: '后台同步已成功完成。',
backgroundSyncFailed: '后台同步失败:{error}',
librarySwitched: '媒体库已切换为 {library}',
connectedTo: '已成功连接到 {name}',
connectionCancelled: '用户已取消连接。',
librarySaveFailed: '保存媒体库选择失败。',
},
};
+14
View File
@@ -0,0 +1,14 @@
import { en } from './locales/en';
import { es } from './locales/es';
import { zh as chs } from './locales/zh';
import { cht } from './locales/cht';
export const translations = {
en,
es,
chs,
cht,
};
export type Language = keyof typeof translations;
export type TranslationStructure = typeof en;