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); // Abort Controllers for Refresh Actions const localAbortRef = useRef(null); const cloudAbortRef = useRef(null); // 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 () => { 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; }, []); const cancelLocalRefresh = () => { if (localAbortRef.current) { localAbortRef.current.abort(); localAbortRef.current = null; setLoadingLocal(false); addToast("Local refresh cancelled."); } }; // Fetch Cloud Playlists and Info const refreshCloud = useCallback(async () => { 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; } }, []); const cancelCloudRefresh = () => { if (cloudAbortRef.current) { cloudAbortRef.current.abort(); cloudAbortRef.current = null; setLoadingCloud(false); addToast("Cloud refresh cancelled."); } }; // Initial Load useEffect(() => { refreshLocal(); refreshCloud(); return () => { // Cleanup on unmount if (localAbortRef.current) localAbortRef.current.abort(); if (cloudAbortRef.current) cloudAbortRef.current.abort(); } }, [refreshLocal, refreshCloud]); // Handle Strategy Change const handleStrategyChange = (strategy: SyncStrategy, label: string) => { setCurrentStrategy(strategy); addToast(`Selected strategy "${label}" has been saved.`); }; // Handle Regex Save const handleSaveRegex = (replacements: RegexReplacement[]) => { setRegexReplacements(replacements); addToast('Regex preprocessing rules have been saved.'); }; const handleConnectSuccess = (serverInfo: PlexServerConnection) => { setCloudServerInfo(serverInfo); // Refresh playlists after new connection refreshCloud(); }; 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 */}
{/* Reduced gap from gap-3/gap-6 to gap-2/gap-3 for tighter layout */}
{/* Left Column - Local */}
{/* Strategy Selector - Positioned specifically between headers */}
{/* Right Column - Cloud */}
{/* Footer */}

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

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