Squashed 'sample-front-end/' content from commit 0881bf1
git-subtree-dir: sample-front-end git-subtree-split: 0881bf1c045118585100360b2c47594cd94b89f1
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Fetch server info
|
||||
const infoResult = await apiService.getServerStatus();
|
||||
if (infoResult.status === 'success') {
|
||||
setCloudServerInfo(infoResult.data);
|
||||
}
|
||||
|
||||
setLoadingCloud(false);
|
||||
}, []);
|
||||
|
||||
// Initial Load
|
||||
useEffect(() => {
|
||||
refreshLocal();
|
||||
refreshCloud();
|
||||
}, [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);
|
||||
// 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>© {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;
|
||||
Reference in New Issue
Block a user