1016 lines
39 KiB
TypeScript
1016 lines
39 KiB
TypeScript
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
|
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, PathMappingConfig, PathMappingMode, PlexConnectionSettings, 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 OverflowMarquee from './components/OverflowMarquee';
|
|
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive, Languages, LogOut } 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>();
|
|
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 (optional; controlled by backend /api/auth/config)
|
|
const [authReady, setAuthReady] = useState(false);
|
|
const [authEnabled, setAuthEnabled] = useState(false);
|
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
|
const [currentUser, setCurrentUser] = useState('');
|
|
const [localPlaylists, setLocalPlaylists] = useState<Playlist[]>([]);
|
|
const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
|
|
const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined);
|
|
const [localPath, setLocalPath] = useState<string>('');
|
|
const [connectionSettings, setConnectionSettings] = useState<PlexConnectionSettings | null>(null);
|
|
|
|
const [loadingLocal, setLoadingLocal] = useState(false);
|
|
const [loadingCloud, setLoadingCloud] = useState(false);
|
|
|
|
const [syncState, setSyncState] = useState<SyncState>(SyncState.IDLE);
|
|
const manualSyncInProgress = useRef(false);
|
|
const lastKnownSyncTimeRef = useRef<string | null | undefined>(undefined);
|
|
|
|
// 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
|
|
});
|
|
const [nextRunTime, setNextRunTime] = useState<string | undefined>(undefined);
|
|
|
|
// Backup State
|
|
const [backupSettings, setBackupSettings] = useState<BackupSettings>({
|
|
enabled: false,
|
|
retentionCount: 5
|
|
});
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
const initAuth = async () => {
|
|
try {
|
|
const cfg = await apiService.getAuthConfig();
|
|
const enabled = cfg.status === 'success' ? !!cfg.data.enabled : false;
|
|
if (cancelled) return;
|
|
setAuthEnabled(enabled);
|
|
|
|
if (!enabled) {
|
|
setIsAuthenticated(true);
|
|
setAuthReady(true);
|
|
return;
|
|
}
|
|
|
|
const savedToken = localStorage.getItem('plexsync-token');
|
|
const savedUser = localStorage.getItem('plexsync-username');
|
|
if (!savedToken || !savedUser) {
|
|
setIsAuthenticated(false);
|
|
setCurrentUser('');
|
|
setAuthReady(true);
|
|
return;
|
|
}
|
|
|
|
const me = await apiService.me();
|
|
if (me.status === 'success') {
|
|
setIsAuthenticated(true);
|
|
setCurrentUser(me.data.username || savedUser);
|
|
} else {
|
|
localStorage.removeItem('plexsync-token');
|
|
localStorage.removeItem('plexsync-username');
|
|
setIsAuthenticated(false);
|
|
setCurrentUser('');
|
|
}
|
|
} catch {
|
|
// If auth discovery fails, fall back to no-auth to keep local dev workable.
|
|
setAuthEnabled(false);
|
|
setIsAuthenticated(true);
|
|
} finally {
|
|
if (!cancelled) setAuthReady(true);
|
|
}
|
|
};
|
|
|
|
initAuth();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
const handleLoginSuccess = (token: string, username: string) => {
|
|
localStorage.setItem('plexsync-token', token);
|
|
localStorage.setItem('plexsync-username', username);
|
|
setCurrentUser(username);
|
|
setIsAuthenticated(true);
|
|
};
|
|
|
|
const handleLogout = async () => {
|
|
try {
|
|
await apiService.logout();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
localStorage.removeItem('plexsync-token');
|
|
localStorage.removeItem('plexsync-username');
|
|
setIsAuthenticated(false);
|
|
setCurrentUser('');
|
|
};
|
|
|
|
// 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 = useCallback((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 configured duration
|
|
const dismissTimer = setTimeout(() => {
|
|
setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t));
|
|
}, TOAST_AUTO_DISMISS_MS);
|
|
|
|
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}`];
|
|
}, TOAST_EXIT_DURATION_MS);
|
|
}
|
|
});
|
|
}, [toasts]);
|
|
|
|
const loadSettings = useCallback(async () => {
|
|
const result = await apiService.getSettings();
|
|
if (result.status === 'success') {
|
|
setCurrentStrategy(result.data.strategy);
|
|
setPathMappingConfig(result.data.pathMapping);
|
|
setLocalPath(result.data.localPath || 'playlist');
|
|
setConnectionSettings(result.data.connection);
|
|
}
|
|
}, []);
|
|
|
|
const loadSchedule = useCallback(async () => {
|
|
const result = await apiService.getScheduleSettings();
|
|
if (result.status === 'success') {
|
|
setScheduleSettings(result.data);
|
|
setNextRunTime(result.data.nextRun);
|
|
}
|
|
}, []);
|
|
|
|
const loadBackupSettings = useCallback(async () => {
|
|
const result = await apiService.getBackupSettings();
|
|
if (result.status === 'success') {
|
|
setBackupSettings(result.data);
|
|
}
|
|
}, []);
|
|
|
|
// Handle Schedule Save
|
|
const handleSaveSchedule = async (settings: ScheduleSettings): Promise<boolean> => {
|
|
const result = await apiService.saveScheduleSettings(settings);
|
|
|
|
if (result.status === 'success') {
|
|
setScheduleSettings(settings);
|
|
// Refresh schedule info to get next run time
|
|
loadSchedule();
|
|
|
|
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 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(result.message || t('toasts.backupFailed'));
|
|
}
|
|
};
|
|
|
|
// Fetch Local Playlists
|
|
const refreshLocal = useCallback(async () => {
|
|
if (!authReady || !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, localPath || undefined);
|
|
if (result.status === 'success') {
|
|
setLocalPlaylists(result.data);
|
|
}
|
|
setLoadingLocal(false);
|
|
localAbortRef.current = null;
|
|
}, [authReady, isAuthenticated, localPath]);
|
|
|
|
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 (!authReady || !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;
|
|
}
|
|
}, [authReady, isAuthenticated]);
|
|
|
|
const cancelCloudRefresh = () => {
|
|
if (cloudAbortRef.current) {
|
|
cloudAbortRef.current.abort();
|
|
cloudAbortRef.current = null;
|
|
setLoadingCloud(false);
|
|
addToast(t('toasts.cloudRefreshCancelled'));
|
|
}
|
|
};
|
|
|
|
// Load persisted configuration
|
|
useEffect(() => {
|
|
if (!authReady || !isAuthenticated) return;
|
|
loadSettings();
|
|
loadSchedule();
|
|
loadBackupSettings();
|
|
}, [authReady, isAuthenticated, loadSettings, loadSchedule, loadBackupSettings]);
|
|
|
|
// Initial Load
|
|
useEffect(() => {
|
|
if (!authReady || !isAuthenticated) return;
|
|
refreshLocal();
|
|
refreshCloud();
|
|
return () => {
|
|
// Cleanup on unmount
|
|
if (localAbortRef.current) localAbortRef.current.abort();
|
|
if (cloudAbortRef.current) cloudAbortRef.current.abort();
|
|
}
|
|
}, [authReady, isAuthenticated, refreshLocal, refreshCloud]);
|
|
|
|
// Handle Strategy Change
|
|
const handleStrategyChange = async (strategy: SyncStrategy, label: string) => {
|
|
setCurrentStrategy(strategy);
|
|
const result = await apiService.updateSyncStrategy(strategy);
|
|
if (result.status === 'success') {
|
|
addToast(t('toasts.strategySaved', { strategy: label }));
|
|
} else {
|
|
addToast(result.message || t('toasts.strategySaveFailed'));
|
|
}
|
|
};
|
|
|
|
// Handle Path Mapping Save
|
|
const handleSavePathMapping = async (config: PathMappingConfig) => {
|
|
setPathMappingConfig(config);
|
|
const result = await apiService.savePathMapping(config);
|
|
if (result.status === 'success') {
|
|
addToast(t('toasts.mappingSaved'));
|
|
} else {
|
|
addToast(result.message || t('toasts.mappingSaveFailed'));
|
|
}
|
|
};
|
|
|
|
// Handle Sync Trigger
|
|
const handleSyncTrigger = async () => {
|
|
if (!authReady || !isAuthenticated) return;
|
|
if (syncState !== SyncState.IDLE) return;
|
|
|
|
setSyncState(SyncState.SYNCING);
|
|
manualSyncInProgress.current = true;
|
|
|
|
const result = await apiService.syncPlaylists(currentStrategy, pathMappingConfig, localPath || undefined);
|
|
|
|
manualSyncInProgress.current = false;
|
|
|
|
if (result.status === 'success') {
|
|
setSyncState(SyncState.SUCCESS);
|
|
|
|
setTimeout(() => {
|
|
setSyncState(SyncState.IDLE);
|
|
refreshLocal();
|
|
refreshCloud();
|
|
}, SYNC_SUCCESS_TOTAL_MS);
|
|
} else {
|
|
setSyncState(SyncState.ERROR);
|
|
addToast(result.message || t('toasts.syncFailed'));
|
|
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS);
|
|
}
|
|
};
|
|
|
|
// SSE for sync status
|
|
useEffect(() => {
|
|
if (!authReady || !isAuthenticated) return;
|
|
|
|
const base = `${import.meta.env.VITE_API_BASE_URL || ''}/api/sync/events`;
|
|
const url = new URL(base, window.location.origin);
|
|
if (authEnabled) {
|
|
const token = localStorage.getItem('plexsync-token');
|
|
if (!token) return;
|
|
url.searchParams.set('access_token', token);
|
|
}
|
|
const eventSource = new EventSource(url.toString());
|
|
|
|
eventSource.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
const { is_syncing, status, error, last_sync_time } = data;
|
|
|
|
// Initialize lastKnownSyncTime if it's the first event
|
|
if (lastKnownSyncTimeRef.current === undefined) {
|
|
lastKnownSyncTimeRef.current = last_sync_time;
|
|
// If we are currently syncing on load, show it
|
|
if (is_syncing && !manualSyncInProgress.current) {
|
|
setSyncState(SyncState.SYNCING);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// If manual sync is in progress, we ignore background updates to avoid state conflict
|
|
if (manualSyncInProgress.current) {
|
|
if (last_sync_time !== lastKnownSyncTimeRef.current) {
|
|
lastKnownSyncTimeRef.current = last_sync_time;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle Syncing State
|
|
if (is_syncing) {
|
|
if (syncState !== SyncState.SYNCING) {
|
|
setSyncState(SyncState.SYNCING);
|
|
}
|
|
} else {
|
|
// Check for completion by comparing timestamps
|
|
if (last_sync_time !== lastKnownSyncTimeRef.current) {
|
|
lastKnownSyncTimeRef.current = last_sync_time;
|
|
|
|
// A sync has completed since our last check
|
|
if (status === 'success') {
|
|
setSyncState(SyncState.SUCCESS);
|
|
refreshLocal();
|
|
refreshCloud();
|
|
addToast(t('toasts.backgroundSyncSuccess'));
|
|
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_SUCCESS_TOTAL_MS);
|
|
} else if (status === 'error') {
|
|
setSyncState(SyncState.ERROR);
|
|
addToast(t('toasts.backgroundSyncFailed', { error: error || '' }));
|
|
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS);
|
|
}
|
|
} else {
|
|
// Edge case: We are in SYNCING state but backend says not syncing, and time hasn't changed.
|
|
if (syncState === SyncState.SYNCING) {
|
|
setSyncState(SyncState.IDLE);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to parse SSE event", e);
|
|
}
|
|
};
|
|
|
|
eventSource.onerror = (err) => {
|
|
console.error("EventSource failed:", err);
|
|
eventSource.close();
|
|
};
|
|
|
|
return () => {
|
|
eventSource.close();
|
|
};
|
|
}, [authReady, isAuthenticated, authEnabled, syncState, refreshLocal, refreshCloud, addToast]);
|
|
|
|
if (!authReady) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-900 text-gray-200">
|
|
{t('common.loading')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (authEnabled && !isAuthenticated) {
|
|
return (
|
|
<LoginScreen
|
|
onLoginSuccess={handleLoginSuccess}
|
|
onLoginError={(msg) => addToast(msg)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const handleConnectSuccess = async (serverInfo: PlexServerConnection) => {
|
|
setCloudServerInfo(serverInfo);
|
|
if (serverInfo.libraryName) {
|
|
await apiService.updateLibrary(serverInfo.libraryName);
|
|
}
|
|
// Reload settings to ensure we have the latest connection details (protocol, etc.)
|
|
await loadSettings();
|
|
|
|
// 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;
|
|
|
|
const getScheduleDisplayInfo = () => {
|
|
const result = {
|
|
label: t('dashboard.autoSync'),
|
|
value: t('schedule.notConfigured'),
|
|
active: false,
|
|
autoWatch: scheduleSettings.autoWatch,
|
|
};
|
|
|
|
if (scheduleSettings.mode === ScheduleMode.DISABLED) {
|
|
result.value = t('common.disabled');
|
|
return result;
|
|
}
|
|
|
|
if (scheduleSettings.mode === ScheduleMode.CRON && scheduleSettings.cronExpression.trim() === '') {
|
|
result.value = t('dashboard.notSet');
|
|
} else {
|
|
result.value = nextRunTime ? `${nextRunTime}` : t('common.loading');
|
|
}
|
|
|
|
result.active = true;
|
|
return result;
|
|
};
|
|
|
|
const scheduleInfo = getScheduleDisplayInfo();
|
|
|
|
// Helper: Calculate Path Mapping Info
|
|
const getPathMappingDisplayInfo = (config: PathMappingConfig) => {
|
|
let count = 0;
|
|
let Icon = Type;
|
|
|
|
if (config.mode === PathMappingMode.SIMPLE) {
|
|
count = config.simple.length;
|
|
Icon = Type;
|
|
} else {
|
|
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,
|
|
};
|
|
}
|
|
|
|
const modeLabel = config.mode === PathMappingMode.SIMPLE ? t('mapping.simple') : t('mapping.regex');
|
|
|
|
return {
|
|
label: t('dashboard.mapping'),
|
|
value: `${modeLabel} (${count})`,
|
|
active: true,
|
|
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);
|
|
|
|
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 */}
|
|
<div className="absolute left-0 top-0 bottom-0 w-1/2 overflow-hidden z-0">
|
|
<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,
|
|
}}
|
|
/>
|
|
<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 */}
|
|
<div className="absolute right-0 top-0 bottom-0 w-1/2 overflow-hidden z-0">
|
|
<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,
|
|
}}
|
|
/>
|
|
<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 w-[120px] group/item">
|
|
<span className={`text-[9px] font-bold uppercase tracking-widest transition-colors ${pathMappingInfo.active ? 'text-plex-orange' : 'text-gray-500 group-hover/item:text-gray-400'}`}>{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} className="flex-shrink-0" />
|
|
<OverflowMarquee>
|
|
{pathMappingInfo.active ? pathMappingInfo.value : t('common.none')}
|
|
</OverflowMarquee>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Backup Section */}
|
|
<div className="flex flex-col px-3 py-0.5 border-r border-gray-700/30 w-[100px] group/item">
|
|
<span className={`text-[9px] font-bold uppercase tracking-widest transition-colors ${backupInfo.active ? 'text-plex-orange' : 'text-gray-500 group-hover/item:text-gray-400'}`}>{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} className="flex-shrink-0" />
|
|
<OverflowMarquee>
|
|
{backupInfo.active ? t('dashboard.retain', { count: backupSettings.retentionCount }) : backupInfo.value}
|
|
</OverflowMarquee>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Schedule Section */}
|
|
<div className="flex flex-col px-3 py-0.5 w-[180px] group/item">
|
|
<div className="flex items-center justify-between">
|
|
<span className={`text-[9px] font-bold uppercase tracking-widest transition-colors ${scheduleInfo.active ? 'text-plex-orange' : 'text-gray-500 group-hover/item:text-gray-400'}`}>{scheduleInfo.label}</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} className="flex-shrink-0" />
|
|
<OverflowMarquee>
|
|
{scheduleInfo.active ? scheduleInfo.value : t('common.disabled')}
|
|
</OverflowMarquee>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
{/* 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={t('common.switchLanguage')}
|
|
>
|
|
<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>
|
|
<button
|
|
onClick={() => { setLanguage('chs'); setIsLangMenuOpen(false); }}
|
|
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'chs' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
|
|
>
|
|
简体中文
|
|
</button>
|
|
<button
|
|
onClick={() => { setLanguage('cht'); setIsLangMenuOpen(false); }}
|
|
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'cht' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
|
|
>
|
|
繁體中文
|
|
</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 (rightmost) */}
|
|
{authEnabled && isAuthenticated && (
|
|
<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-white hover:bg-gray-700 transition-all"
|
|
title={t('auth.logout')}
|
|
>
|
|
<LogOut size={18} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</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}
|
|
initialSettings={connectionSettings || undefined}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default App;
|