import React, { useEffect, useState, useCallback, useRef } from 'react'; import { Playlist, ServerType, SyncStrategy, PlexServerConnection, RegexReplacement } from './types'; import { apiService } from './services/api'; import ServerPanel from './components/ServerPanel'; import StrategySelector from './components/StrategySelector'; import ConnectionModal from './components/ConnectionModal'; import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff } from 'lucide-react'; interface Toast { id: number; message: string; exiting: boolean; entering: boolean; } const App: React.FC = () => { const [localPlaylists, setLocalPlaylists] = useState([]); const [cloudPlaylists, setCloudPlaylists] = useState([]); const [cloudServerInfo, setCloudServerInfo] = useState(undefined); const [loadingLocal, setLoadingLocal] = useState(false); const [loadingCloud, setLoadingCloud] = useState(false); const [statusIntervalMs, setStatusIntervalMs] = useState(60000); // Connection Modal State const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false); // Strategy State const [currentStrategy, setCurrentStrategy] = useState(SyncStrategy.LOCAL_OVERWRITE); // Regex State const [regexReplacements, setRegexReplacements] = useState([]); // Toast Notification System const [toasts, setToasts] = useState([]); const timeoutsRef = useRef<{[key: number]: ReturnType}>({}); 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)); }, 3000); timeoutsRef.current[id] = dismissTimer; }; // 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}`]; }, 300); } }); }, [toasts]); // Fetch Local Playlists const refreshLocal = useCallback(async () => { setLoadingLocal(true); const result = await apiService.getPlaylists(ServerType.LOCAL); if (result.status === 'success') { setLocalPlaylists(result.data); } setLoadingLocal(false); }, []); // Fetch Cloud Playlists and Info const refreshCloud = useCallback(async () => { setLoadingCloud(true); // Fetch playlists const playlistResult = await apiService.getPlaylists(ServerType.CLOUD); if (playlistResult.status === 'success') { setCloudPlaylists(playlistResult.data); } else { setCloudPlaylists([]); } // Fetch server info const infoResult = await apiService.getServerStatus(); if (infoResult.status === 'success') { setCloudServerInfo(infoResult.data); } else { setCloudServerInfo({ isConnected: false }); } setLoadingCloud(false); }, []); // Initial Load useEffect(() => { const loadSettings = async () => { const [settings, uiConfig] = await Promise.all([ apiService.getSettings(), apiService.getUiConfig() ]); if (settings.status === 'success') { setCurrentStrategy(normalizeStrategy(settings.data.syncStrategy)); if (settings.data.regexRules) { setRegexReplacements(settings.data.regexRules); } } if (uiConfig.status === 'success') { const ms = Math.max(10000, (uiConfig.data.statusCheckIntervalSeconds || 60) * 1000); setStatusIntervalMs(ms); } refreshLocal(); refreshCloud(); }; loadSettings(); }, [refreshLocal, refreshCloud]); // Periodically check cloud connection status to avoid stale UI loops useEffect(() => { const timer = window.setInterval(() => { refreshCloud(); }, statusIntervalMs); return () => window.clearInterval(timer); }, [refreshCloud, statusIntervalMs]); const normalizeStrategy = (value?: string): SyncStrategy => { if (!value) return SyncStrategy.LOCAL_OVERWRITE; const values = Object.values(SyncStrategy); return values.includes(value as SyncStrategy) ? (value as SyncStrategy) : SyncStrategy.LOCAL_OVERWRITE; }; // Handle Strategy Change const handleStrategyChange = async (strategy: SyncStrategy, label: string) => { setCurrentStrategy(strategy); const result = await apiService.saveStrategy(strategy); if (result.status === 'success') { addToast(`Selected strategy "${label}" has been saved.`); } else { addToast('Failed to save strategy to server.'); } }; // Handle Regex Save const handleSaveRegex = async (replacements: RegexReplacement[]) => { const result = await apiService.saveRegexRules(replacements); if (result.status === 'success') { setRegexReplacements(result.data); addToast('Regex preprocessing rules have been saved.'); } else { addToast(result.message || 'Failed to save regex rules.'); } }; const handleConnectSuccess = (serverInfo: PlexServerConnection) => { setCloudServerInfo(serverInfo); // Removed implicit toast here to allow the caller (ConnectionModal) to handle specific messaging refreshCloud(); // Refresh playlists after new connection }; 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; return (
{/* App Header */}

PlexSync

{/* Connection Status Button */}
{/* Notification Toasts Container */}
{toasts.map((toast) => (
{toast.message}
))}
{/* Main Content Area */}
{/* Left Column - Local */}
{/* Strategy Selector - Positioned specifically between headers */} {/* Desktop: Centered Horizontally, Top aligned with Headers (Padding Top 24px + Header Half Height ~40px = ~64px) */} {/* Mobile: Centered Vertically, Right aligned with Headers (Padding Right 16px + Header Half Width ~36px = ~52px) */}
{/* Right Column - Cloud */}
{/* Footer */}

© {new Date().getFullYear()} PlexSync Manager. Connected to Docker backend.

{/* Modals */} setIsConnectionModalOpen(false)} onConnectSuccess={handleConnectSuccess} onShowMessage={addToast} />
); }; export default App;