Files
PlexPlaylistSync/sample-front-end/App.tsx
T
2025-11-28 03:00:02 +09:00

339 lines
12 KiB
TypeScript

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<Playlist[]>([]);
const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined);
const [loadingLocal, setLoadingLocal] = useState(false);
const [loadingCloud, setLoadingCloud] = useState(false);
const [statusIntervalMs, setStatusIntervalMs] = useState<number>(60000);
// Connection Modal State
const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false);
// Strategy State
const [currentStrategy, setCurrentStrategy] = useState<SyncStrategy>(SyncStrategy.LOCAL_OVERWRITE);
// Regex State
const [regexReplacements, setRegexReplacements] = useState<RegexReplacement[]>([]);
// 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));
}, 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 (
<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 bg-gray-800/80 border-b border-white/5 shadow-md z-20 relative backdrop-blur-md">
<div className="max-w-7xl mx-auto px-4 md:px-6 h-16 flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="bg-gradient-to-br from-plex-orange to-yellow-600 p-1.5 rounded-lg text-gray-900 shadow-lg shadow-plex-orange/20">
<ArrowLeftRight size={24} strokeWidth={2.5} />
</div>
<h1 className="text-xl font-bold tracking-tight text-white">
Plex<span className="text-plex-orange">Sync</span>
</h1>
</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 ? "Connected to Plex" : "Disconnected"}
>
{isConnected ? <Server size={18} /> : <ServerOff size={18} />}
</button>
</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">
<div className="absolute inset-0 flex flex-col md:flex-row max-w-7xl mx-auto p-4 md:p-6 gap-3 md:gap-6">
{/* Left Column - Local */}
<div className="flex-1 min-h-0 h-full w-full">
<ServerPanel
type={ServerType.LOCAL}
playlists={localPlaylists}
isLoading={loadingLocal}
onRefresh={refreshLocal}
/>
</div>
{/* 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) */}
<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}
savedRegexReplacements={regexReplacements}
onSaveRegex={handleSaveRegex}
/>
</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}
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>&copy; {new Date().getFullYear()} PlexSync Manager. Connected to Docker backend.</p>
</footer>
{/* Modals */}
<ConnectionModal
isOpen={isConnectionModalOpen}
onClose={() => setIsConnectionModalOpen(false)}
onConnectSuccess={handleConnectSuccess}
onShowMessage={addToast}
/>
</div>
);
};
export default App;