Compare commits
2 Commits
80a3e373cf
...
bc04867950
| Author | SHA1 | Date | |
|---|---|---|---|
| bc04867950 | |||
| 4158d999de |
+244
-26
@@ -1,7 +1,17 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, RegexReplacement } from './types';
|
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, RegexReplacement, SyncState } from './types';
|
||||||
import { apiService } from './services/api';
|
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 ServerPanel from './components/ServerPanel';
|
||||||
import StrategySelector from './components/StrategySelector';
|
import StrategySelector from './components/StrategySelector';
|
||||||
import ConnectionModal from './components/ConnectionModal';
|
import ConnectionModal from './components/ConnectionModal';
|
||||||
@@ -14,6 +24,93 @@ interface Toast {
|
|||||||
entering: 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 App: React.FC = () => {
|
||||||
const [localPlaylists, setLocalPlaylists] = useState<Playlist[]>([]);
|
const [localPlaylists, setLocalPlaylists] = useState<Playlist[]>([]);
|
||||||
const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
|
const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
|
||||||
@@ -22,6 +119,12 @@ const App: React.FC = () => {
|
|||||||
const [loadingLocal, setLoadingLocal] = useState(false);
|
const [loadingLocal, setLoadingLocal] = useState(false);
|
||||||
const [loadingCloud, setLoadingCloud] = useState(false);
|
const [loadingCloud, setLoadingCloud] = useState(false);
|
||||||
|
|
||||||
|
// Sync State
|
||||||
|
const [syncState, setSyncState] = useState<SyncState>(SyncState.IDLE);
|
||||||
|
|
||||||
|
// Animation Refs
|
||||||
|
const { leftYellowRef, leftGreenRef, rightYellowRef, rightGreenRef } = useStripeAnimation(syncState);
|
||||||
|
|
||||||
// Abort Controllers for Refresh Actions
|
// Abort Controllers for Refresh Actions
|
||||||
const localAbortRef = useRef<AbortController | null>(null);
|
const localAbortRef = useRef<AbortController | null>(null);
|
||||||
const cloudAbortRef = useRef<AbortController | null>(null);
|
const cloudAbortRef = useRef<AbortController | null>(null);
|
||||||
@@ -59,9 +162,9 @@ const App: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Auto dismiss the new toast after 3 seconds
|
// Auto dismiss the new toast after 3 seconds
|
||||||
const dismissTimer = setTimeout(() => {
|
const dismissTimer = setTimeout(() => {
|
||||||
setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t));
|
setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t));
|
||||||
}, 3000);
|
}, TOAST_AUTO_DISMISS_MS);
|
||||||
|
|
||||||
timeoutsRef.current[id] = dismissTimer;
|
timeoutsRef.current[id] = dismissTimer;
|
||||||
};
|
};
|
||||||
@@ -97,7 +200,7 @@ const App: React.FC = () => {
|
|||||||
timeoutsRef.current[`remove-${t.id}`] = setTimeout(() => {
|
timeoutsRef.current[`remove-${t.id}`] = setTimeout(() => {
|
||||||
removeToast(t.id);
|
removeToast(t.id);
|
||||||
delete timeoutsRef.current[`remove-${t.id}`];
|
delete timeoutsRef.current[`remove-${t.id}`];
|
||||||
}, 300);
|
}, TOAST_EXIT_DURATION_MS);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [toasts]);
|
}, [toasts]);
|
||||||
@@ -183,6 +286,41 @@ const App: React.FC = () => {
|
|||||||
addToast('Regex preprocessing rules have been saved.');
|
addToast('Regex preprocessing rules have been saved.');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle Sync Trigger
|
||||||
|
const handleSyncTrigger = async () => {
|
||||||
|
if (syncState !== SyncState.IDLE) return;
|
||||||
|
|
||||||
|
setSyncState(SyncState.SYNCING);
|
||||||
|
|
||||||
|
// Note: We deliberately do not clear playlists here to keep UI populated during sync
|
||||||
|
const result = await apiService.syncPlaylists(currentStrategy, regexReplacements);
|
||||||
|
|
||||||
|
if (result.status === 'success') {
|
||||||
|
// Transition to Success state
|
||||||
|
setSyncState(SyncState.SUCCESS);
|
||||||
|
|
||||||
|
// Timing Breakdown:
|
||||||
|
// T+0.0s: State is SUCCESS.
|
||||||
|
// - JS Animation loop detects change and begins decelerating speed from 56 -> 0 over 0.5s.
|
||||||
|
// - CSS opacity transitions Yellow -> Green over 0.3s.
|
||||||
|
|
||||||
|
// T+0.5s: Deceleration complete. Speed is 0. Background is static.
|
||||||
|
// We hold this static state for another 0.5s.
|
||||||
|
|
||||||
|
// T+1.0s: Total success duration complete. Disappear.
|
||||||
|
setTimeout(() => {
|
||||||
|
setSyncState(SyncState.IDLE);
|
||||||
|
refreshLocal();
|
||||||
|
refreshCloud();
|
||||||
|
}, SYNC_SUCCESS_TOTAL_MS);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
setSyncState(SyncState.ERROR);
|
||||||
|
addToast("Sync failed. Please check connection.");
|
||||||
|
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleConnectSuccess = (serverInfo: PlexServerConnection) => {
|
const handleConnectSuccess = (serverInfo: PlexServerConnection) => {
|
||||||
setCloudServerInfo(serverInfo);
|
setCloudServerInfo(serverInfo);
|
||||||
// Refresh playlists after new connection
|
// Refresh playlists after new connection
|
||||||
@@ -212,29 +350,107 @@ const App: React.FC = () => {
|
|||||||
<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">
|
<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 */}
|
{/* App Header */}
|
||||||
<header className="flex-none bg-gray-800/80 border-b border-white/5 shadow-md z-20 relative backdrop-blur-md">
|
<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'}`}>
|
||||||
<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">
|
{/* Syncing/Success Animated Background Layer */}
|
||||||
<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">
|
{syncState !== SyncState.IDLE && (
|
||||||
<ArrowLeftRight size={24} strokeWidth={2.5} />
|
<div className="absolute inset-0 w-full h-full overflow-hidden bg-black">
|
||||||
|
|
||||||
|
{/* Left Side: Gradient 135deg (TR -> BL /), Anchored RIGHT (Center). Moves LEFT (Right offset increases). */}
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-1/2 overflow-hidden z-0">
|
||||||
|
{/* Layer 1: Yellow (Syncing) */}
|
||||||
|
<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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Layer 2: Green (Success) - Fade In */}
|
||||||
|
<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: Gradient 225deg (TL -> BR \), Anchored LEFT (Center). Moves RIGHT (Left offset increases). */}
|
||||||
|
<div className="absolute right-0 top-0 bottom-0 w-1/2 overflow-hidden z-0">
|
||||||
|
{/* Layer 1: Yellow (Syncing) */}
|
||||||
|
<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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Layer 2: Green (Success) - Fade In */}
|
||||||
|
<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>
|
||||||
<h1 className="text-xl font-bold tracking-tight text-white">
|
|
||||||
Plex<span className="text-plex-orange">Sync</span>
|
|
||||||
</h1>
|
|
||||||
</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 */}
|
||||||
|
<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">
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* 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 ? 'SYNCHRONIZING' : 'SYNC COMPLETE'}
|
||||||
|
</h1>
|
||||||
|
</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 ? "Connected to Plex" : "Disconnected"}
|
|
||||||
>
|
|
||||||
{isConnected ? <Server size={18} /> : <ServerOff size={18} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -288,6 +504,8 @@ const App: React.FC = () => {
|
|||||||
onSelect={handleStrategyChange}
|
onSelect={handleStrategyChange}
|
||||||
savedRegexReplacements={regexReplacements}
|
savedRegexReplacements={regexReplacements}
|
||||||
onSaveRegex={handleSaveRegex}
|
onSaveRegex={handleSaveRegex}
|
||||||
|
syncState={syncState}
|
||||||
|
onSync={handleSyncTrigger}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -322,4 +540,4 @@ const App: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Animation and timing configuration centralization.
|
||||||
|
// Adjust these values for debugging or tuning animation behavior.
|
||||||
|
export const STRIPE_TILE_SIZE = 56.57; // px size for repeating background pattern
|
||||||
|
export const STRIPE_BASE_SPEED = 56.57; // px per second initial scroll speed
|
||||||
|
export const STRIPE_DECEL_DURATION_MS = 500; // ms duration of deceleration phase
|
||||||
|
|
||||||
|
export const SYNC_SUCCESS_TOTAL_MS = 1000; // ms until header returns to idle after success
|
||||||
|
export const SYNC_ERROR_RESET_MS = 2000; // ms until reset after error state
|
||||||
|
|
||||||
|
export const TOAST_AUTO_DISMISS_MS = 3000; // ms before toast begins exit
|
||||||
|
export const TOAST_EXIT_DURATION_MS = 300; // ms exit animation duration
|
||||||
|
|
||||||
|
// If needed later for entrance timing tweaks
|
||||||
|
export const TOAST_ENTER_FRAME_DELAY_MS = 0; // logical placeholder (double rAF currently)
|
||||||
|
|
||||||
|
// Helper: derive CSS backgroundSize string
|
||||||
|
export const STRIPE_BACKGROUND_SIZE = `${STRIPE_TILE_SIZE}px ${STRIPE_TILE_SIZE}px`;
|
||||||
|
|
||||||
|
// Sync banner sizing (background behind SYNCHRONIZING / SYNC COMPLETE text)
|
||||||
|
// Adjust these to change the black rectangle size.
|
||||||
|
export const SYNC_BANNER_PADDING_X = 32; // horizontal padding in px
|
||||||
|
export const SYNC_BANNER_PADDING_Y = 6; // vertical padding in px
|
||||||
|
export const SYNC_BANNER_MIN_WIDTH = 260; // optional minimum width (px)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { SyncStrategy, RegexReplacement } from '../types';
|
import { SyncStrategy, RegexReplacement, SyncState } from '../types';
|
||||||
import {
|
import {
|
||||||
ArrowRightCircle,
|
ArrowRightCircle,
|
||||||
ArrowLeftCircle,
|
ArrowLeftCircle,
|
||||||
@@ -11,7 +11,9 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
Save,
|
Save,
|
||||||
RotateCcw
|
RotateCcw,
|
||||||
|
Zap,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface StrategyOption {
|
interface StrategyOption {
|
||||||
@@ -58,13 +60,17 @@ interface StrategySelectorProps {
|
|||||||
onSelect: (strategy: SyncStrategy, label: string) => void;
|
onSelect: (strategy: SyncStrategy, label: string) => void;
|
||||||
savedRegexReplacements: RegexReplacement[];
|
savedRegexReplacements: RegexReplacement[];
|
||||||
onSaveRegex: (replacements: RegexReplacement[]) => void;
|
onSaveRegex: (replacements: RegexReplacement[]) => void;
|
||||||
|
syncState: SyncState;
|
||||||
|
onSync: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StrategySelector: React.FC<StrategySelectorProps> = ({
|
const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||||
currentStrategy,
|
currentStrategy,
|
||||||
onSelect,
|
onSelect,
|
||||||
savedRegexReplacements,
|
savedRegexReplacements,
|
||||||
onSaveRegex
|
onSaveRegex,
|
||||||
|
syncState,
|
||||||
|
onSync
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -73,6 +79,9 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
|||||||
const [localReplacements, setLocalReplacements] = useState<RegexReplacement[]>([]);
|
const [localReplacements, setLocalReplacements] = useState<RegexReplacement[]>([]);
|
||||||
const [isDirty, setIsDirty] = useState(false);
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
|
||||||
|
const isSyncing = syncState === SyncState.SYNCING;
|
||||||
|
const isLocked = isSyncing || syncState === SyncState.SUCCESS;
|
||||||
|
|
||||||
// Initialize local state when prop updates (only if not dirty, or initially)
|
// Initialize local state when prop updates (only if not dirty, or initially)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements)));
|
setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements)));
|
||||||
@@ -98,35 +107,49 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSelect = (strategy: StrategyOption) => {
|
const handleSelect = (strategy: StrategyOption) => {
|
||||||
|
if (isLocked) return;
|
||||||
onSelect(strategy.value, strategy.label);
|
onSelect(strategy.value, strategy.label);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Regex Handlers
|
// Regex Handlers
|
||||||
const handleAddRegex = () => {
|
const handleAddRegex = () => {
|
||||||
|
if (isLocked) return;
|
||||||
const newId = Date.now().toString();
|
const newId = Date.now().toString();
|
||||||
setLocalReplacements(prev => [...prev, { id: newId, pattern: '', replacement: '' }]);
|
setLocalReplacements(prev => [...prev, { id: newId, pattern: '', replacement: '' }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteRegex = (id: string) => {
|
const handleDeleteRegex = (id: string) => {
|
||||||
|
if (isLocked) return;
|
||||||
setLocalReplacements(prev => prev.filter(r => r.id !== id));
|
setLocalReplacements(prev => prev.filter(r => r.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateRegex = (id: string, field: 'pattern' | 'replacement', value: string) => {
|
const handleUpdateRegex = (id: string, field: 'pattern' | 'replacement', value: string) => {
|
||||||
|
if (isLocked) return;
|
||||||
setLocalReplacements(prev => prev.map(r =>
|
setLocalReplacements(prev => prev.map(r =>
|
||||||
r.id === id ? { ...r, [field]: value } : r
|
r.id === id ? { ...r, [field]: value } : r
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
|
if (isLocked) return;
|
||||||
setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements)));
|
setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
|
if (isLocked) return;
|
||||||
const validReplacements = localReplacements.filter(r => r.pattern.trim() !== '');
|
const validReplacements = localReplacements.filter(r => r.pattern.trim() !== '');
|
||||||
setLocalReplacements(validReplacements);
|
setLocalReplacements(validReplacements);
|
||||||
onSaveRegex(validReplacements);
|
onSaveRegex(validReplacements);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSyncClick = () => {
|
||||||
|
if (isLocked) return;
|
||||||
|
onSync();
|
||||||
|
};
|
||||||
|
|
||||||
|
// If syncing or locked, apply grayscale filter to content sections
|
||||||
|
const contentClass = isLocked ? "opacity-50 pointer-events-none grayscale transition-all" : "transition-all";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative group" ref={dropdownRef}>
|
<div className="relative group" ref={dropdownRef}>
|
||||||
{/* Trigger Button - Added Ring to create visual 'cutout' over panels */}
|
{/* Trigger Button - Added Ring to create visual 'cutout' over panels */}
|
||||||
@@ -154,140 +177,174 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
|||||||
transition-all duration-200 ease-out
|
transition-all duration-200 ease-out
|
||||||
${isOpen ? 'opacity-100 scale-100 visible translate-y-0' : 'opacity-0 scale-95 invisible pointer-events-none -translate-y-2'}`}
|
${isOpen ? 'opacity-100 scale-100 visible translate-y-0' : 'opacity-0 scale-95 invisible pointer-events-none -translate-y-2'}`}
|
||||||
>
|
>
|
||||||
{/* Section 1: Sync Strategy */}
|
<div className={contentClass}>
|
||||||
<div className="px-4 py-3 bg-black/20 border-b border-white/5">
|
{/* Section 1: Sync Strategy */}
|
||||||
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">Sync Strategy</h3>
|
<div className="px-4 py-3 bg-black/20 border-b border-white/5">
|
||||||
<div className="space-y-1">
|
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">Sync Strategy</h3>
|
||||||
{STRATEGIES.map((strategy) => (
|
<div className="space-y-1">
|
||||||
<div
|
{STRATEGIES.map((strategy) => (
|
||||||
key={strategy.value}
|
<div
|
||||||
onClick={() => handleSelect(strategy)}
|
key={strategy.value}
|
||||||
className={`group flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all border ${
|
onClick={() => handleSelect(strategy)}
|
||||||
currentStrategy === strategy.value
|
className={`group flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all border ${
|
||||||
? 'bg-white/10 border-white/10 shadow-sm'
|
currentStrategy === strategy.value
|
||||||
: 'hover:bg-white/5 border-transparent'
|
? 'bg-white/10 border-white/10 shadow-sm'
|
||||||
}`}
|
: 'hover:bg-white/5 border-transparent'
|
||||||
>
|
}`}
|
||||||
<div className="flex items-center space-x-3 overflow-hidden">
|
>
|
||||||
<strategy.icon size={18} className={strategy.color} />
|
<div className="flex items-center space-x-3 overflow-hidden">
|
||||||
<span className={`text-sm font-medium truncate ${currentStrategy === strategy.value ? 'text-white' : 'text-gray-300 group-hover:text-white'}`}>
|
<strategy.icon size={18} className={strategy.color} />
|
||||||
{strategy.label}
|
<span className={`text-sm font-medium truncate ${currentStrategy === strategy.value ? 'text-white' : 'text-gray-300 group-hover:text-white'}`}>
|
||||||
</span>
|
{strategy.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="relative group/tooltip">
|
||||||
|
<HelpCircle size={14} className="text-gray-600 hover:text-gray-400 transition-colors" />
|
||||||
|
<div className="absolute right-0 bottom-full mb-2 w-48 p-2.5 bg-gray-900 text-xs text-gray-300 rounded-lg shadow-xl border border-gray-700 pointer-events-none opacity-0 group-hover/tooltip:opacity-100 transition-opacity z-50">
|
||||||
|
{strategy.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentStrategy === strategy.value && (
|
||||||
|
<Check size={14} className="text-plex-orange" strokeWidth={3} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
<div className="flex items-center space-x-2">
|
</div>
|
||||||
<div className="relative group/tooltip">
|
</div>
|
||||||
<HelpCircle size={14} className="text-gray-600 hover:text-gray-400 transition-colors" />
|
|
||||||
<div className="absolute right-0 bottom-full mb-2 w-48 p-2.5 bg-gray-900 text-xs text-gray-300 rounded-lg shadow-xl border border-gray-700 pointer-events-none opacity-0 group-hover/tooltip:opacity-100 transition-opacity z-50">
|
{/* Section 2: Regex Preprocessing */}
|
||||||
{strategy.description}
|
<div className="p-4 bg-gray-900/40">
|
||||||
</div>
|
<div className="flex items-center justify-between mb-3">
|
||||||
</div>
|
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Regex Rules</h3>
|
||||||
|
{localReplacements.length === 0 && (
|
||||||
{currentStrategy === strategy.value && (
|
<button
|
||||||
<Check size={14} className="text-plex-orange" strokeWidth={3} />
|
onClick={handleAddRegex}
|
||||||
)}
|
className="p-1 rounded bg-gray-700/50 hover:bg-gray-600 text-gray-400 hover:text-white transition-colors"
|
||||||
|
title="Add Rule"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
<div className="space-y-2 mb-4 max-h-52 overflow-y-auto pr-1 custom-scrollbar">
|
||||||
</div>
|
{localReplacements.length === 0 ? (
|
||||||
|
<div className="text-xs text-gray-600 italic text-center py-4 border border-dashed border-gray-700/50 rounded-lg">
|
||||||
|
No regex replacements configured.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
localReplacements.map((regex) => (
|
||||||
|
<div key={regex.id} className="flex items-center space-x-2 animate-in slide-in-from-left-2 duration-200">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Regex Pattern"
|
||||||
|
value={regex.pattern}
|
||||||
|
onChange={(e) => handleUpdateRegex(regex.id, 'pattern', e.target.value)}
|
||||||
|
className={`w-full bg-gray-900/80 border rounded-md px-2.5 py-1.5 text-xs text-gray-200 focus:outline-none focus:ring-1 transition-all placeholder-gray-600
|
||||||
|
${!regex.pattern && isDirty ? 'border-red-500/30 focus:border-red-500' : 'border-gray-700 focus:border-plex-orange'}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-none text-gray-600">
|
||||||
|
<ArrowRightCircle size={12} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Replacement"
|
||||||
|
value={regex.replacement}
|
||||||
|
onChange={(e) => handleUpdateRegex(regex.id, 'replacement', e.target.value)}
|
||||||
|
className="w-full bg-gray-900/80 border border-gray-700 rounded-md px-2.5 py-1.5 text-xs text-gray-200 focus:outline-none focus:border-plex-orange focus:ring-1 focus:ring-plex-orange transition-all placeholder-gray-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteRegex(regex.id)}
|
||||||
|
className="text-gray-600 hover:text-red-400 p-1.5 hover:bg-red-500/10 rounded transition-colors"
|
||||||
|
title="Delete Rule"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="space-y-3 pt-3 border-t border-white/5">
|
||||||
|
{localReplacements.length > 0 && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<button
|
||||||
|
onClick={handleAddRegex}
|
||||||
|
className="flex items-center space-x-1.5 text-xs text-plex-orange hover:text-yellow-400 transition-colors opacity-80 hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
<span className="font-medium">Add Rule</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={!isDirty}
|
||||||
|
className={`flex items-center justify-center space-x-2 py-1.5 rounded-lg text-xs font-medium border transition-all
|
||||||
|
${isDirty
|
||||||
|
? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||||
|
: 'bg-transparent border-gray-800 text-gray-600 cursor-not-allowed'}`}
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} />
|
||||||
|
<span>Revert</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!isDirty}
|
||||||
|
className={`flex items-center justify-center space-x-2 py-1.5 rounded-lg text-xs font-bold border transition-all
|
||||||
|
${isDirty
|
||||||
|
? 'bg-plex-orange border-plex-orange text-gray-900 hover:bg-yellow-500 shadow-lg shadow-plex-orange/10'
|
||||||
|
: 'bg-gray-800/50 border-gray-800 text-gray-600 cursor-not-allowed'}`}
|
||||||
|
>
|
||||||
|
<Save size={14} />
|
||||||
|
<span>Save Changes</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 2: Regex Preprocessing */}
|
{/* Section 3: Sync Now Button */}
|
||||||
<div className="p-4 bg-gray-900/40">
|
<div className="p-4 bg-gray-950/50 border-t border-white/5">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<button
|
||||||
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Regex Rules</h3>
|
onClick={handleSyncClick}
|
||||||
{localReplacements.length === 0 && (
|
disabled={isLocked || isDirty} // Disable if syncing OR if there are unsaved regex changes
|
||||||
<button
|
className={`w-full py-3 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg
|
||||||
onClick={handleAddRegex}
|
${isLocked
|
||||||
className="p-1 rounded bg-gray-700/50 hover:bg-gray-600 text-gray-400 hover:text-white transition-colors"
|
? 'bg-gray-700/30 text-gray-500 cursor-not-allowed border border-gray-700/50'
|
||||||
title="Add Rule"
|
: isDirty
|
||||||
>
|
? 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700' // Must save rules first
|
||||||
<Plus size={14} />
|
: 'bg-green-600 hover:bg-green-500 text-white border border-green-500 shadow-green-900/20 active:scale-[0.98]'
|
||||||
</button>
|
}`}
|
||||||
)}
|
>
|
||||||
</div>
|
{isSyncing ? (
|
||||||
|
<>
|
||||||
<div className="space-y-2 mb-4 max-h-52 overflow-y-auto pr-1 custom-scrollbar">
|
<Loader2 size={16} className="animate-spin" />
|
||||||
{localReplacements.length === 0 ? (
|
<span>Sync in Progress...</span>
|
||||||
<div className="text-xs text-gray-600 italic text-center py-4 border border-dashed border-gray-700/50 rounded-lg">
|
</>
|
||||||
No regex replacements configured.
|
) : (
|
||||||
</div>
|
<>
|
||||||
) : (
|
<Zap size={16} fill="currentColor" />
|
||||||
localReplacements.map((regex) => (
|
<span>Sync Now</span>
|
||||||
<div key={regex.id} className="flex items-center space-x-2 animate-in slide-in-from-left-2 duration-200">
|
</>
|
||||||
<div className="flex-1 min-w-0">
|
)}
|
||||||
<input
|
</button>
|
||||||
type="text"
|
{isDirty && (
|
||||||
placeholder="Regex Pattern"
|
<p className="text-[10px] text-plex-orange text-center mt-2">
|
||||||
value={regex.pattern}
|
Please save or revert regex rules changes before syncing.
|
||||||
onChange={(e) => handleUpdateRegex(regex.id, 'pattern', e.target.value)}
|
</p>
|
||||||
className={`w-full bg-gray-900/80 border rounded-md px-2.5 py-1.5 text-xs text-gray-200 focus:outline-none focus:ring-1 transition-all placeholder-gray-600
|
)}
|
||||||
${!regex.pattern && isDirty ? 'border-red-500/30 focus:border-red-500' : 'border-gray-700 focus:border-plex-orange'}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-none text-gray-600">
|
|
||||||
<ArrowRightCircle size={12} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Replacement"
|
|
||||||
value={regex.replacement}
|
|
||||||
onChange={(e) => handleUpdateRegex(regex.id, 'replacement', e.target.value)}
|
|
||||||
className="w-full bg-gray-900/80 border border-gray-700 rounded-md px-2.5 py-1.5 text-xs text-gray-200 focus:outline-none focus:border-plex-orange focus:ring-1 focus:ring-plex-orange transition-all placeholder-gray-600"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteRegex(regex.id)}
|
|
||||||
className="text-gray-600 hover:text-red-400 p-1.5 hover:bg-red-500/10 rounded transition-colors"
|
|
||||||
title="Delete Rule"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="space-y-3 pt-3 border-t border-white/5">
|
|
||||||
{localReplacements.length > 0 && (
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<button
|
|
||||||
onClick={handleAddRegex}
|
|
||||||
className="flex items-center space-x-1.5 text-xs text-plex-orange hover:text-yellow-400 transition-colors opacity-80 hover:opacity-100"
|
|
||||||
>
|
|
||||||
<Plus size={12} />
|
|
||||||
<span className="font-medium">Add Rule</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<button
|
|
||||||
onClick={handleReset}
|
|
||||||
disabled={!isDirty}
|
|
||||||
className={`flex items-center justify-center space-x-2 py-1.5 rounded-lg text-xs font-medium border transition-all
|
|
||||||
${isDirty
|
|
||||||
? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white'
|
|
||||||
: 'bg-transparent border-gray-800 text-gray-600 cursor-not-allowed'}`}
|
|
||||||
>
|
|
||||||
<RotateCcw size={14} />
|
|
||||||
<span>Revert</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!isDirty}
|
|
||||||
className={`flex items-center justify-center space-x-2 py-1.5 rounded-lg text-xs font-bold border transition-all
|
|
||||||
${isDirty
|
|
||||||
? 'bg-plex-orange border-plex-orange text-gray-900 hover:bg-yellow-500 shadow-lg shadow-plex-orange/10'
|
|
||||||
: 'bg-gray-800/50 border-gray-800 text-gray-600 cursor-not-allowed'}`}
|
|
||||||
>
|
|
||||||
<Save size={14} />
|
|
||||||
<span>Save Changes</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
darker: '#111827',
|
darker: '#111827',
|
||||||
card: '#374151'
|
card: '#374151'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'scroll-out-left': 'scroll-out-left 1s linear infinite',
|
||||||
|
'scroll-out-right': 'scroll-out-right 1s linear infinite',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,6 +40,23 @@
|
|||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #6b7280;
|
background: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Symmetrical Diagonal Scroll Animations
|
||||||
|
Pattern width: 40px (20px color + 20px transparent).
|
||||||
|
Diagonal length: 40 * sqrt(2) ≈ 56.57px.
|
||||||
|
|
||||||
|
Left Side: Anchored to Right (Center). Moves Left (increases right offset).
|
||||||
|
Right Side: Anchored to Left (Center). Moves Right (increases left offset).
|
||||||
|
*/
|
||||||
|
@keyframes scroll-out-left {
|
||||||
|
0% { background-position: right 0 top 0; }
|
||||||
|
100% { background-position: right 56.57px top 0; }
|
||||||
|
}
|
||||||
|
@keyframes scroll-out-right {
|
||||||
|
0% { background-position: left 0 top 0; }
|
||||||
|
100% { background-position: left 56.57px top 0; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary } from '../types';
|
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, SyncStrategy, RegexReplacement } from '../types';
|
||||||
import { MOCK_LOCAL_PLAYLISTS, MOCK_CLOUD_PLAYLISTS } from './mockData';
|
import { MOCK_LOCAL_PLAYLISTS, MOCK_CLOUD_PLAYLISTS } from './mockData';
|
||||||
|
|
||||||
const SIMULATE_DELAY_MS = 800;
|
const SIMULATE_DELAY_MS = 800;
|
||||||
@@ -126,6 +126,15 @@ const authenticatePlex = async (settings: PlexConnectionSettings, signal?: Abort
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const triggerSync = async (strategy: SyncStrategy, regexRules: RegexReplacement[]): Promise<void> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// Simulate a sync process taking 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve();
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const apiService = {
|
export const apiService = {
|
||||||
getPlaylists: async (serverType: ServerType, signal?: AbortSignal): Promise<ApiResponse<Playlist[]>> => {
|
getPlaylists: async (serverType: ServerType, signal?: AbortSignal): Promise<ApiResponse<Playlist[]>> => {
|
||||||
try {
|
try {
|
||||||
@@ -173,5 +182,14 @@ export const apiService = {
|
|||||||
message: error.message || 'Connection failed'
|
message: error.message || 'Connection failed'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
syncPlaylists: async (strategy: SyncStrategy, regexRules: RegexReplacement[]): Promise<ApiResponse<null>> => {
|
||||||
|
try {
|
||||||
|
await triggerSync(strategy, regexRules);
|
||||||
|
return { data: null, status: 'success', message: 'Sync complete' };
|
||||||
|
} catch (error) {
|
||||||
|
return { data: null, status: 'error', message: 'Sync failed' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ export enum SyncStrategy {
|
|||||||
MERGE_CLOUD = 'MERGE_CLOUD'
|
MERGE_CLOUD = 'MERGE_CLOUD'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum SyncState {
|
||||||
|
IDLE = 'IDLE',
|
||||||
|
SYNCING = 'SYNCING',
|
||||||
|
SUCCESS = 'SUCCESS',
|
||||||
|
ERROR = 'ERROR'
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegexReplacement {
|
export interface RegexReplacement {
|
||||||
id: string;
|
id: string;
|
||||||
pattern: string;
|
pattern: string;
|
||||||
@@ -62,4 +69,4 @@ export interface ApiResponse<T> {
|
|||||||
data: T;
|
data: T;
|
||||||
status: 'success' | 'error';
|
status: 'success' | 'error';
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user