Files
PlexPlaylistSync/sample-front-end/App.tsx
T
Koha9 e3d3df9ecb PlexPlaylist_UI subtree merge
feat: Implement user authentication and login screen

Merge commit 'a14210c458d5f6c6a4875ca8228db63c0b73cf75'
2025-12-17 20:25:06 +09:00

914 lines
35 KiB
TypeScript

import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, PathMappingConfig, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from './types';
import { apiService } from './services/api';
import {
STRIPE_BASE_SPEED,
STRIPE_DECEL_DURATION_MS,
STRIPE_TILE_SIZE,
STRIPE_BACKGROUND_SIZE,
SYNC_SUCCESS_TOTAL_MS,
SYNC_ERROR_RESET_MS,
TOAST_AUTO_DISMISS_MS,
TOAST_EXIT_DURATION_MS
} from './Config';
import { SYNC_BANNER_PADDING_X, SYNC_BANNER_PADDING_Y, SYNC_BANNER_MIN_WIDTH } from './Config';
import ServerPanel from './components/ServerPanel';
import StrategySelector from './components/StrategySelector';
import ConnectionModal from './components/ConnectionModal';
import LoginScreen from './components/LoginScreen';
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive, Languages, LogOut, User } from 'lucide-react';
import { useLanguage } from './LanguageContext';
interface Toast {
id: number;
message: string;
exiting: boolean;
entering: boolean;
}
// Custom hook to handle the stripe animation logic
const useStripeAnimation = (syncState: SyncState) => {
const leftYellowRef = useRef<HTMLDivElement>(null);
const leftGreenRef = useRef<HTMLDivElement>(null);
const rightYellowRef = useRef<HTMLDivElement>(null);
const rightGreenRef = useRef<HTMLDivElement>(null);
const requestRef = useRef<number | undefined>(undefined);
const lastTimeRef = useRef<number>(0);
const offsetRef = useRef<number>(0);
// State tracking for deceleration
const isDeceleratingRef = useRef(false);
const decelStartTimeRef = useRef(0);
const animate = (time: number) => {
if (lastTimeRef.current === 0) lastTimeRef.current = time;
const dt = (time - lastTimeRef.current) / 1000;
lastTimeRef.current = time;
let speed = STRIPE_BASE_SPEED; // pixels per second
if (isDeceleratingRef.current) {
const t = time - decelStartTimeRef.current;
const duration = STRIPE_DECEL_DURATION_MS; // deceleration duration
if (t >= duration) {
speed = 0;
} else {
// Linear slow down
speed = speed * (1 - (t / duration));
}
}
// Update offset
offsetRef.current += speed * dt;
const modOffset = offsetRef.current % STRIPE_TILE_SIZE;
// Apply to DOM elements directly for performance
const leftPos = `right ${modOffset}px top 0`;
const rightPos = `left ${modOffset}px top 0`;
if (leftYellowRef.current) leftYellowRef.current.style.backgroundPosition = leftPos;
if (leftGreenRef.current) leftGreenRef.current.style.backgroundPosition = leftPos;
if (rightYellowRef.current) rightYellowRef.current.style.backgroundPosition = rightPos;
if (rightGreenRef.current) rightGreenRef.current.style.backgroundPosition = rightPos;
// Continue loop if moving or if we are in the middle of decelerating
if (speed > 0 || (isDeceleratingRef.current && (time - decelStartTimeRef.current) < STRIPE_DECEL_DURATION_MS)) {
requestRef.current = requestAnimationFrame(animate);
}
};
useEffect(() => {
if (syncState === SyncState.SYNCING) {
isDeceleratingRef.current = false;
lastTimeRef.current = 0;
// Start animation loop
if (!requestRef.current) {
requestRef.current = requestAnimationFrame(animate);
}
} else if (syncState === SyncState.SUCCESS) {
isDeceleratingRef.current = true;
decelStartTimeRef.current = performance.now();
// Ensure loop is running to handle deceleration phase
if (!requestRef.current) {
requestRef.current = requestAnimationFrame(animate);
}
} else {
// IDLE or ERROR: Stop animation
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
requestRef.current = undefined;
}
offsetRef.current = 0;
}
return () => {
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
requestRef.current = undefined;
}
};
}, [syncState]);
return { leftYellowRef, leftGreenRef, rightYellowRef, rightGreenRef };
};
const App: React.FC = () => {
const { t, language, setLanguage } = useLanguage();
// Auth State
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [currentUser, setCurrentUser] = useState('');
// App Data State
const [localPlaylists, setLocalPlaylists] = useState<Playlist[]>([]);
const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined);
const [loadingLocal, setLoadingLocal] = useState(false);
const [loadingCloud, setLoadingCloud] = useState(false);
// Sync State
const [syncState, setSyncState] = useState<SyncState>(SyncState.IDLE);
// Animation Refs
const { leftYellowRef, leftGreenRef, rightYellowRef, rightGreenRef } = useStripeAnimation(syncState);
// Abort Controllers for Refresh Actions
const localAbortRef = useRef<AbortController | null>(null);
const cloudAbortRef = useRef<AbortController | null>(null);
// Connection Modal State
const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false);
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
// Strategy State
const [currentStrategy, setCurrentStrategy] = useState<SyncStrategy>(SyncStrategy.LOCAL_OVERWRITE);
// Path Mapping State (Includes Simple and Regex Rules)
const [pathMappingConfig, setPathMappingConfig] = useState<PathMappingConfig>({
mode: PathMappingMode.SIMPLE,
simple: [],
regex: {
localPre: [],
localPost: [],
remotePre: [],
remotePost: []
}
});
// Schedule State
const [scheduleSettings, setScheduleSettings] = useState<ScheduleSettings>({
mode: ScheduleMode.DISABLED,
cronExpression: '',
dailyTime: '02:00',
weeklyDays: [0], // Sunday
weeklyTime: '03:00',
autoWatch: false
});
// Backup State
const [backupSettings, setBackupSettings] = useState<BackupSettings>({
enabled: false,
retentionCount: 5
});
// Toast Notification System
const [toasts, setToasts] = useState<Toast[]>([]);
const timeoutsRef = useRef<{[key: number]: ReturnType<typeof setTimeout>}>({});
const removeToast = (id: number) => {
setToasts(prev => prev.filter(t => t.id !== id));
if (timeoutsRef.current[id]) {
clearTimeout(timeoutsRef.current[id]);
delete timeoutsRef.current[id];
}
};
const addToast = (message: string) => {
const id = Date.now();
// Start with entering: true to position it above
const newToast: Toast = { id, message, exiting: false, entering: true };
setToasts(prev => {
// Mark all existing toasts as exiting immediately so they slide up
const exitingToasts = prev.map(t => ({ ...t, exiting: true, entering: false }));
return [...exitingToasts, newToast];
});
// Auto dismiss the new toast after 3 seconds
const dismissTimer = setTimeout(() => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t));
}, TOAST_AUTO_DISMISS_MS);
timeoutsRef.current[id] = dismissTimer;
};
// Check auth on mount
useEffect(() => {
const savedToken = localStorage.getItem('plexsync-token');
const savedUser = localStorage.getItem('plexsync-username');
if (savedToken && savedUser) {
setIsAuthenticated(true);
setCurrentUser(savedUser);
}
}, []);
// Effect to trigger the "slide down" animation
useEffect(() => {
const enteringIds = toasts.filter(t => t.entering).map(t => t.id);
if (enteringIds.length > 0) {
let raf1: number;
let raf2: number;
raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
setToasts(prev => prev.map(t =>
enteringIds.includes(t.id) ? { ...t, entering: false } : t
));
});
});
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}
}, [toasts]);
// Cleanup effect for exiting toasts
useEffect(() => {
const exitingToasts = toasts.filter(t => t.exiting);
exitingToasts.forEach(t => {
if (!timeoutsRef.current[`remove-${t.id}`]) {
timeoutsRef.current[`remove-${t.id}`] = setTimeout(() => {
removeToast(t.id);
delete timeoutsRef.current[`remove-${t.id}`];
}, TOAST_EXIT_DURATION_MS);
}
});
}, [toasts]);
// Fetch Local Playlists
const refreshLocal = useCallback(async () => {
if (!isAuthenticated) return;
if (localAbortRef.current) localAbortRef.current.abort();
const abortController = new AbortController();
localAbortRef.current = abortController;
setLoadingLocal(true);
const result = await apiService.getPlaylists(ServerType.LOCAL, abortController.signal);
if (result.status === 'success') {
setLocalPlaylists(result.data);
}
setLoadingLocal(false);
localAbortRef.current = null;
}, [isAuthenticated]);
const cancelLocalRefresh = () => {
if (localAbortRef.current) {
localAbortRef.current.abort();
localAbortRef.current = null;
setLoadingLocal(false);
addToast(t('toasts.localRefreshCancelled'));
}
};
// Fetch Cloud Playlists and Info
const refreshCloud = useCallback(async () => {
if (!isAuthenticated) return;
if (cloudAbortRef.current) cloudAbortRef.current.abort();
const abortController = new AbortController();
cloudAbortRef.current = abortController;
setLoadingCloud(true);
// Fetch playlists
const playlistResult = await apiService.getPlaylists(ServerType.CLOUD, abortController.signal);
if (!abortController.signal.aborted) {
if (playlistResult.status === 'success') {
setCloudPlaylists(playlistResult.data);
}
// Fetch server info
const infoResult = await apiService.getServerStatus(abortController.signal);
if (infoResult.status === 'success') {
setCloudServerInfo(infoResult.data);
}
setLoadingCloud(false);
cloudAbortRef.current = null;
}
}, [isAuthenticated]);
const cancelCloudRefresh = () => {
if (cloudAbortRef.current) {
cloudAbortRef.current.abort();
cloudAbortRef.current = null;
setLoadingCloud(false);
addToast(t('toasts.cloudRefreshCancelled'));
}
};
// Initial Load (Only if Authenticated)
useEffect(() => {
if (isAuthenticated) {
refreshLocal();
refreshCloud();
}
return () => {
// Cleanup on unmount
if (localAbortRef.current) localAbortRef.current.abort();
if (cloudAbortRef.current) cloudAbortRef.current.abort();
}
}, [isAuthenticated, refreshLocal, refreshCloud]);
// Handle Strategy Change
const handleStrategyChange = (strategy: SyncStrategy, label: string) => {
setCurrentStrategy(strategy);
addToast(t('toasts.strategySaved', { strategy: label }));
};
// Handle Path Mapping Save
const handleSavePathMapping = (config: PathMappingConfig) => {
setPathMappingConfig(config);
addToast(t('toasts.mappingSaved'));
};
// Handle Backup Settings Save
const handleSaveBackupSettings = async (settings: BackupSettings) => {
const result = await apiService.saveBackupSettings(settings);
if (result.status === 'success') {
setBackupSettings(settings);
addToast(t('toasts.backupSaved'));
} else {
addToast(t('toasts.backupFailed'));
}
};
// Handle Schedule Save
const handleSaveSchedule = async (settings: ScheduleSettings): Promise<boolean> => {
// Call API (validation happens in Mock)
const result = await apiService.saveScheduleSettings(settings);
if (result.status === 'success') {
// Only update local state if successful
setScheduleSettings(settings);
if (settings.mode === ScheduleMode.DISABLED) {
addToast(t('toasts.scheduleDisabled'));
} else if (settings.mode === ScheduleMode.CRON && settings.cronExpression.trim() === '') {
addToast(t('toasts.scheduleEmpty'));
} else {
addToast(t('toasts.scheduleStarted'));
}
return true;
} else {
addToast(result.message || t('toasts.scheduleFailed'));
return false;
}
};
// Handle Sync Trigger
const handleSyncTrigger = async () => {
if (syncState !== SyncState.IDLE) return;
setSyncState(SyncState.SYNCING);
// Note: We deliberately do not clear playlists here to keep UI populated during sync
const result = await apiService.syncPlaylists(currentStrategy, pathMappingConfig);
if (result.status === 'success') {
// Transition to Success state
setSyncState(SyncState.SUCCESS);
// Timing Breakdown:
// T+0.0s: State is SUCCESS.
setTimeout(() => {
setSyncState(SyncState.IDLE);
refreshLocal();
refreshCloud();
}, SYNC_SUCCESS_TOTAL_MS);
} else {
setSyncState(SyncState.ERROR);
addToast(t('toasts.syncFailed'));
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS);
}
};
const handleConnectSuccess = (serverInfo: PlexServerConnection) => {
setCloudServerInfo(serverInfo);
// Refresh playlists after new connection
refreshCloud();
};
const handleLoginSuccess = (token: string, username: string) => {
localStorage.setItem('plexsync-token', token);
localStorage.setItem('plexsync-username', username);
setIsAuthenticated(true);
setCurrentUser(username);
addToast(t('auth.welcome', { user: username }));
};
const handleLoginError = (msg: string) => {
// Toast handles error display, or LoginScreen internal state handles UI
addToast(msg);
};
const handleLogout = () => {
localStorage.removeItem('plexsync-token');
localStorage.removeItem('plexsync-username');
setIsAuthenticated(false);
setCurrentUser('');
addToast(t('toasts.loggedOut'));
};
const getToastStyles = (toast: Toast): React.CSSProperties => {
if (toast.exiting || toast.entering) {
return {
opacity: 0,
transform: 'translateY(-40px) scale(0.95)',
};
}
return {
opacity: 1,
transform: 'translateY(0) scale(1)',
};
};
const getToastClasses = () => {
return "absolute top-2 flex items-center space-x-2 px-4 py-2 rounded-full shadow-lg border text-sm font-medium pointer-events-auto bg-gray-800 text-plex-orange border-plex-orange/30 transition-all duration-300 ease-out origin-top z-50 backdrop-blur-md";
};
const isConnected = cloudServerInfo?.isConnected;
// Helper: Calculate Next Run Info
const getScheduleDisplayInfo = (settings: ScheduleSettings) => {
const result = {
label: t('schedule.schedule'),
value: t('schedule.notConfigured'),
active: false,
autoWatch: settings.autoWatch
};
if (settings.mode === ScheduleMode.DISABLED) {
result.label = t('dashboard.autoSync');
result.value = t('common.disabled');
return result;
}
if (settings.mode === ScheduleMode.CRON) {
result.label = t('schedule.cron');
result.value = settings.cronExpression || t('server.waiting');
result.active = true;
return result;
}
const now = new Date();
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
let nextRun: Date | null = null;
let timeStr = '';
if (settings.mode === ScheduleMode.DAILY) {
const [h, m] = settings.dailyTime.split(':').map(Number);
const target = new Date();
target.setHours(h, m, 0, 0);
timeStr = settings.dailyTime;
if (now < target) {
nextRun = target;
} else {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(h, m, 0, 0);
nextRun = tomorrow;
}
} else if (settings.mode === ScheduleMode.WEEKLY) {
timeStr = settings.weeklyTime;
const [h, m] = settings.weeklyTime.split(':').map(Number);
const activeDays = [...settings.weeklyDays].sort();
if (activeDays.length === 0) {
result.label = t('schedule.weekly');
result.value = t('common.none');
return result;
}
// Check rest of today
if (activeDays.includes(now.getDay())) {
const todayTarget = new Date();
todayTarget.setHours(h, m, 0, 0);
if (todayTarget > now) {
nextRun = todayTarget;
}
}
// Check future days
if (!nextRun) {
for (let i = 1; i <= 7; i++) {
const nextDayIndex = (now.getDay() + i) % 7;
if (activeDays.includes(nextDayIndex)) {
const d = new Date();
d.setDate(now.getDate() + i);
d.setHours(h, m, 0, 0);
nextRun = d;
break;
}
}
}
}
if (nextRun) {
// Format logic
const isToday = nextRun.getDate() === now.getDate() && nextRun.getMonth() === now.getMonth();
const isTomorrow = new Date(now.getTime() + 86400000).getDate() === nextRun.getDate();
let dateStr = '';
if (isToday) dateStr = t('schedule.today');
else if (isTomorrow) dateStr = t('schedule.tomorrow');
else dateStr = days[nextRun.getDay()];
result.label = settings.mode === ScheduleMode.DAILY ? t('schedule.daily') : t('schedule.weekly');
result.value = `${dateStr} @ ${timeStr}`;
result.active = true;
return result;
}
return result;
};
const scheduleInfo = getScheduleDisplayInfo(scheduleSettings);
// Helper: Calculate Path Mapping Info
const getPathMappingDisplayInfo = (config: PathMappingConfig) => {
let count = 0;
let modeLabel = '';
let Icon = Type;
if (config.mode === PathMappingMode.SIMPLE) {
modeLabel = t('common.none').replace('None', 'Simple'); // Fallback hack if simple not in dict, but it is in mapping
modeLabel = 'Simple';
count = config.simple.length;
Icon = Type;
} else {
modeLabel = 'Regex';
count = config.regex.localPre.length +
config.regex.localPost.length +
config.regex.remotePre.length +
config.regex.remotePost.length;
Icon = Code2;
}
if (count === 0) {
return {
label: t('dashboard.mapping'),
value: t('dashboard.notSet'),
active: false,
Icon: Icon
};
}
return {
label: t('dashboard.mapping'),
value: `${modeLabel} (${count})`,
active: true,
Icon: Icon
};
};
const pathMappingInfo = getPathMappingDisplayInfo(pathMappingConfig);
// Helper: Calculate Backup Info
const getBackupDisplayInfo = (settings: BackupSettings) => {
if (!settings.enabled) {
return {
label: t('dashboard.backup'),
value: t('common.disabled'),
active: false
};
}
return {
label: t('dashboard.backup'),
value: t('dashboard.keep', { count: settings.retentionCount }),
active: true
};
};
const backupInfo = getBackupDisplayInfo(backupSettings);
// If not authenticated, show Login Screen
if (!isAuthenticated) {
return (
<>
{/* Render toasts over login screen if needed (e.g. login errors pushed to global toast) */}
<div className="fixed top-20 left-0 right-0 flex justify-center h-0 overflow-visible z-[100] pointer-events-none">
{toasts.map((toast) => (
<div
key={toast.id}
className={getToastClasses()}
style={getToastStyles(toast)}
>
<ShieldCheck size={16} />
<span>{toast.message}</span>
<button
onClick={() => setToasts(prev => prev.map(t => t.id === toast.id ? { ...t, exiting: true } : t))}
className="ml-2 hover:text-white transition-colors"
>
<X size={14} />
</button>
</div>
))}
</div>
<LoginScreen onLoginSuccess={handleLoginSuccess} onLoginError={handleLoginError} />
</>
);
}
return (
<div className="min-h-screen flex flex-col bg-gray-900 text-gray-100 font-sans overflow-hidden bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-gray-800 via-gray-900 to-black">
{/* App Header */}
<header className={`flex-none shadow-md z-20 relative backdrop-blur-md transition-all duration-500 ease-in-out h-16 ${syncState === SyncState.IDLE ? 'bg-gray-800/80 border-b border-white/5' : 'bg-black border-none'}`}>
{/* Syncing/Success Animated Background Layer */}
{syncState !== SyncState.IDLE && (
<div className="absolute inset-0 w-full h-full overflow-hidden bg-black">
{/* Left Side: Gradient 135deg (TR -> BL /), Anchored RIGHT (Center). Moves LEFT (Right offset increases). */}
<div className="absolute left-0 top-0 bottom-0 w-1/2 overflow-hidden z-0">
{/* Layer 1: Yellow (Syncing) */}
<div
ref={leftYellowRef}
className={`absolute inset-0 transition-opacity duration-300 ease-out ${syncState === SyncState.SUCCESS ? 'opacity-0' : 'opacity-100'}`}
style={{
backgroundPosition: 'right 0 top 0',
backgroundImage: `repeating-linear-gradient(135deg, transparent, transparent 20px, #e5a00d 20px, #e5a00d 40px)`,
backgroundSize: STRIPE_BACKGROUND_SIZE,
}}
/>
{/* Layer 2: Green (Success) - Fade In */}
<div
ref={leftGreenRef}
className={`absolute inset-0 transition-opacity duration-300 ease-out ${syncState === SyncState.SUCCESS ? 'opacity-100' : 'opacity-0'}`}
style={{
backgroundPosition: 'right 0 top 0',
backgroundImage: `repeating-linear-gradient(135deg, transparent, transparent 20px, #22c55e 20px, #22c55e 40px)`,
backgroundSize: STRIPE_BACKGROUND_SIZE,
}}
/>
</div>
{/* Right Side: Gradient 225deg (TL -> BR \), Anchored LEFT (Center). Moves RIGHT (Left offset increases). */}
<div className="absolute right-0 top-0 bottom-0 w-1/2 overflow-hidden z-0">
{/* Layer 1: Yellow (Syncing) */}
<div
ref={rightYellowRef}
className={`absolute inset-0 transition-opacity duration-300 ease-out ${syncState === SyncState.SUCCESS ? 'opacity-0' : 'opacity-100'}`}
style={{
backgroundPosition: 'left 0 top 0',
backgroundImage: `repeating-linear-gradient(225deg, transparent, transparent 20px, #e5a00d 20px, #e5a00d 40px)`,
backgroundSize: STRIPE_BACKGROUND_SIZE,
}}
/>
{/* Layer 2: Green (Success) - Fade In */}
<div
ref={rightGreenRef}
className={`absolute inset-0 transition-opacity duration-300 ease-out ${syncState === SyncState.SUCCESS ? 'opacity-100' : 'opacity-0'}`}
style={{
backgroundPosition: 'left 0 top 0',
backgroundImage: `repeating-linear-gradient(225deg, transparent, transparent 20px, #22c55e 20px, #22c55e 40px)`,
backgroundSize: STRIPE_BACKGROUND_SIZE,
}}
/>
</div>
</div>
)}
{/* Content Container */}
<div className="relative max-w-7xl mx-auto px-4 md:px-6 h-full flex items-center justify-between">
{syncState === SyncState.IDLE ? (
<>
{/* Normal Toolbar Left */}
<div className="flex items-center space-x-3">
<div className="p-1.5 rounded-lg shadow-lg bg-gradient-to-br from-plex-orange to-yellow-600 text-gray-900 shadow-plex-orange/20">
<ArrowLeftRight size={24} strokeWidth={2.5} />
</div>
<h1 className="text-xl font-bold tracking-tight text-white">
<span className="text-plex-orange">PMS</span> Playlist Sync
</h1>
</div>
{/* Normal Toolbar Right */}
<div className="flex items-center gap-4">
{/* Unified Status Dock */}
<div className="hidden md:flex items-center bg-gray-900/40 border border-gray-700/50 rounded-lg p-1 mr-2 backdrop-blur-sm shadow-sm transition-all hover:bg-gray-900/60 hover:border-gray-600/50">
{/* Path Mapping Section */}
<div className="flex flex-col px-3 py-0.5 border-r border-gray-700/30 min-w-[90px] group/item">
<span className="text-[9px] font-bold text-gray-500 uppercase tracking-widest group-hover/item:text-gray-400 transition-colors">{pathMappingInfo.label}</span>
<div className={`flex items-center gap-1.5 text-xs font-medium ${pathMappingInfo.active ? 'text-blue-400' : 'text-gray-600'}`}>
<pathMappingInfo.Icon size={12} strokeWidth={2.5} />
<span className="truncate">{pathMappingInfo.value}</span>
</div>
</div>
{/* Backup Section */}
<div className="flex flex-col px-3 py-0.5 border-r border-gray-700/30 min-w-[90px] group/item">
<span className="text-[9px] font-bold text-gray-500 uppercase tracking-widest group-hover/item:text-gray-400 transition-colors">{backupInfo.label}</span>
<div className={`flex items-center gap-1.5 text-xs font-medium ${backupInfo.active ? 'text-indigo-400' : 'text-gray-600'}`}>
<Archive size={12} strokeWidth={2.5} />
<span>{backupInfo.active ? t('dashboard.retain', { count: backupSettings.retentionCount }) : backupInfo.value}</span>
</div>
</div>
{/* Schedule Section */}
<div className="flex flex-col px-3 py-0.5 min-w-[140px] group/item">
<div className="flex items-center justify-between">
<span className="text-[9px] font-bold text-gray-500 uppercase tracking-widest group-hover/item:text-gray-400 transition-colors">{t('dashboard.autoSync')}</span>
{/* Watch Indicator Badge */}
<div
className={`flex items-center gap-1 px-1 rounded-[2px] transition-colors ${scheduleInfo.autoWatch ? 'text-plex-orange bg-plex-orange/10' : 'text-gray-700 bg-gray-800'}`}
title={scheduleInfo.autoWatch ? t('dashboard.watchModeActive') : t('dashboard.watchModeDisabled')}
>
{scheduleInfo.autoWatch ? <Eye size={9} /> : <EyeOff size={9} />}
<span className="text-[8px] font-bold uppercase">{t('dashboard.watch')}</span>
</div>
</div>
<div className={`flex items-center gap-1.5 text-xs font-medium mt-0.5 ${scheduleInfo.active ? 'text-green-400' : 'text-gray-600'}`}>
<Clock size={12} strokeWidth={2.5} />
<span className="truncate max-w-[120px]">{scheduleInfo.active ? scheduleInfo.value : t('common.disabled')}</span>
</div>
</div>
</div>
{/* Language Switcher */}
<div className="relative">
<button
onClick={() => setIsLangMenuOpen(!isLangMenuOpen)}
className="flex items-center justify-center w-9 h-9 rounded-full border border-gray-700 bg-gray-800/50 text-gray-400 hover:text-white hover:bg-gray-700 transition-all"
title="Switch Language"
>
<Languages size={18} />
</button>
{isLangMenuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsLangMenuOpen(false)}></div>
<div className="absolute right-0 top-full mt-2 w-32 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-50 overflow-hidden">
<button
onClick={() => { setLanguage('en'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'en' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
English
</button>
<button
onClick={() => { setLanguage('es'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'es' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
Español
</button>
</div>
</>
)}
</div>
{/* Connection Status Button */}
<button
onClick={() => setIsConnectionModalOpen(true)}
className={`flex items-center justify-center w-9 h-9 rounded-full border transition-all duration-300 hover:scale-105 active:scale-95 shadow-md
${isConnected
? "bg-green-500/10 border-green-500/50 text-green-400 hover:bg-green-500/20 hover:shadow-green-500/20"
: "bg-red-500/10 border-red-500/50 text-red-400 hover:bg-red-500/20 hover:shadow-red-500/20"
}`}
title={isConnected ? t('dashboard.connected') : t('dashboard.disconnected')}
>
{isConnected ? <Server size={18} /> : <ServerOff size={18} />}
</button>
{/* Logout Button */}
<button
onClick={handleLogout}
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-red-400 hover:border-red-500/30 hover:bg-red-500/10 transition-all"
title={t('auth.logout') + ` (${currentUser})`}
>
<LogOut size={18} />
</button>
</div>
</>
) : (
/* Syncing / Success Text Banner */
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div
className="bg-black shadow-none rounded-none border-none"
style={{
padding: `${SYNC_BANNER_PADDING_Y}px ${SYNC_BANNER_PADDING_X}px`,
minWidth: `${SYNC_BANNER_MIN_WIDTH}px`,
}}
>
<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 ? t('dashboard.synchronizing') : t('dashboard.syncComplete')}
</h1>
</div>
</div>
)}
</div>
</header>
{/* Notification Toasts Container */}
<div className="fixed top-20 left-0 right-0 flex justify-center h-0 overflow-visible z-[100] pointer-events-none">
{toasts.map((toast) => (
<div
key={toast.id}
className={getToastClasses()}
style={getToastStyles(toast)}
>
<ShieldCheck size={16} />
<span>{toast.message}</span>
<button
onClick={() => setToasts(prev => prev.map(t => t.id === toast.id ? { ...t, exiting: true } : t))}
className="ml-2 hover:text-white transition-colors"
>
<X size={14} />
</button>
</div>
))}
</div>
{/* Main Content Area */}
<main className="flex-1 overflow-hidden relative z-10">
{/* Reduced gap from gap-3/gap-6 to gap-2/gap-3 for tighter layout */}
<div className="absolute inset-0 flex flex-col md:flex-row max-w-7xl mx-auto p-4 md:p-6 gap-2 md:gap-3">
{/* Left Column - Local */}
<div className="flex-1 min-h-0 h-full w-full">
<ServerPanel
type={ServerType.LOCAL}
playlists={localPlaylists}
isLoading={loadingLocal}
onRefresh={refreshLocal}
onCancel={cancelLocalRefresh}
/>
</div>
{/* Strategy Selector - Positioned specifically between headers */}
<div className="absolute
z-30
/* Mobile Positioning: Center Vertically, Anchored Right */
top-1/2 right-[52px] transform translate-x-1/2 -translate-y-1/2
/* Desktop Positioning: Center Horizontally, Anchored Top */
md:top-[64px] md:right-auto md:left-1/2 md:transform md:-translate-x-1/2 md:-translate-y-1/2"
>
<StrategySelector
currentStrategy={currentStrategy}
onSelect={handleStrategyChange}
savedPathMapping={pathMappingConfig}
onSavePathMapping={handleSavePathMapping}
savedBackup={backupSettings}
onSaveBackup={handleSaveBackupSettings}
savedSchedule={scheduleSettings}
onSaveSchedule={handleSaveSchedule}
syncState={syncState}
onSync={handleSyncTrigger}
/>
</div>
{/* Right Column - Cloud */}
<div className="flex-1 min-h-0 h-full w-full">
<ServerPanel
type={ServerType.CLOUD}
playlists={cloudPlaylists}
isLoading={loadingCloud}
onRefresh={refreshCloud}
onCancel={cancelCloudRefresh}
serverInfo={cloudServerInfo}
/>
</div>
</div>
</main>
{/* 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>{t('app.footer', { year: new Date().getFullYear() })}</p>
</footer>
{/* Modals */}
<ConnectionModal
isOpen={isConnectionModalOpen}
onClose={() => setIsConnectionModalOpen(false)}
onConnectSuccess={handleConnectSuccess}
onShowMessage={addToast}
/>
</div>
);
};
export default App;