Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c6af7115e | |||
| 4e91c2acdf |
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -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;
|
||||
@@ -0,0 +1,20 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/drive/1HGbFKaSambWckOUfemMSKy_Vm-94xh4D
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
@@ -0,0 +1,326 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { PlexConnectionSettings, PlexServerConnection, PlexLibrary } from '../types';
|
||||
import { apiService } from '../services/api';
|
||||
import { X, Server, Lock, User, Key, Globe, Eye, EyeOff, CheckCircle, Library, ChevronDown } from 'lucide-react';
|
||||
|
||||
interface ConnectionModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConnectSuccess: (serverInfo: PlexServerConnection) => void;
|
||||
onShowMessage: (message: string) => void;
|
||||
}
|
||||
|
||||
const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onConnectSuccess, onShowMessage }) => {
|
||||
const [formData, setFormData] = useState<PlexConnectionSettings>({
|
||||
protocol: 'http',
|
||||
address: '',
|
||||
port: '32400',
|
||||
token: '',
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// Post-connection state
|
||||
const [connectedServerInfo, setConnectedServerInfo] = useState<PlexServerConnection | null>(null);
|
||||
const [libraries, setLibraries] = useState<PlexLibrary[]>([]);
|
||||
const [selectedLibraryId, setSelectedLibraryId] = useState<string>('');
|
||||
|
||||
// Reset state when opening
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setError(null);
|
||||
setConnectedServerInfo(null);
|
||||
setLibraries([]);
|
||||
setSelectedLibraryId('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleLibraryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newId = e.target.value;
|
||||
setSelectedLibraryId(newId);
|
||||
|
||||
const lib = libraries.find(l => l.id === newId);
|
||||
if (lib && connectedServerInfo) {
|
||||
const updatedInfo = { ...connectedServerInfo, libraryName: lib.title };
|
||||
setConnectedServerInfo(updatedInfo);
|
||||
// Notify parent of update
|
||||
onConnectSuccess(updatedInfo);
|
||||
// Show toast
|
||||
onShowMessage(`Library switched to ${lib.title}`);
|
||||
}
|
||||
};
|
||||
|
||||
const isTokenProvided = formData.token.trim().length > 0;
|
||||
|
||||
// Dynamic styles for disabled fields
|
||||
const disabledInputClass = isTokenProvided
|
||||
? "bg-gray-700/50 text-gray-500 line-through decoration-gray-500 cursor-not-allowed border-gray-700"
|
||||
: "bg-gray-800 text-gray-100 border-gray-600 focus:border-plex-orange focus:ring-1 focus:ring-plex-orange";
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsConnecting(true);
|
||||
|
||||
const result = await apiService.connectToPlex(formData);
|
||||
|
||||
setIsConnecting(false);
|
||||
|
||||
if (result.status === 'success' && result.data) {
|
||||
// Update Token field and clear user/pass
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
token: result.data.token,
|
||||
username: '',
|
||||
password: ''
|
||||
}));
|
||||
|
||||
const info = result.data.serverInfo;
|
||||
setConnectedServerInfo(info);
|
||||
|
||||
// Explicitly show connection message here
|
||||
onShowMessage(`Successfully connected to ${info.name || 'Plex Server'}`);
|
||||
|
||||
// Handle libraries
|
||||
const libs = info.libraries || [];
|
||||
setLibraries(libs);
|
||||
if (libs.length > 0) {
|
||||
const defaultLib = libs[0];
|
||||
setSelectedLibraryId(defaultLib.id);
|
||||
// Pass connection info back with default library name explicitly set (though mock already does it)
|
||||
onConnectSuccess({
|
||||
...info,
|
||||
libraryName: defaultLib.title
|
||||
});
|
||||
} else {
|
||||
onConnectSuccess(info);
|
||||
}
|
||||
} else {
|
||||
setError(result.message || "Connection failed");
|
||||
}
|
||||
};
|
||||
|
||||
const isConnected = !!connectedServerInfo;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 bg-gray-800 border-b border-gray-700 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Server size={18} className={isConnected ? "text-green-400" : "text-plex-orange"} />
|
||||
{isConnected ? 'Server Connected' : 'Connect Plex Server'}
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 text-red-400 text-xs rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Server Connection */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Server Details</label>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="col-span-1">
|
||||
<select
|
||||
name="protocol"
|
||||
value={formData.protocol}
|
||||
onChange={handleChange}
|
||||
disabled={isConnected}
|
||||
className={`w-full h-10 px-2 bg-gray-800 border border-gray-600 rounded-md text-sm text-white focus:border-plex-orange focus:outline-none ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Globe size={14} className="text-gray-500" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="address"
|
||||
required
|
||||
disabled={isConnected}
|
||||
placeholder="IP Address or Domain"
|
||||
value={formData.address}
|
||||
onChange={handleChange}
|
||||
className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
name="port"
|
||||
disabled={isConnected}
|
||||
placeholder="Port (e.g. 32400)"
|
||||
value={formData.port}
|
||||
onChange={handleChange}
|
||||
className={`w-full h-10 px-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-gray-800 my-4" />
|
||||
|
||||
{/* Authentication */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Authentication</label>
|
||||
|
||||
{/* Token */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Key size={14} className="text-plex-orange" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="token"
|
||||
disabled={isConnected}
|
||||
placeholder="X-Plex-Token (Optional)"
|
||||
value={formData.token}
|
||||
onChange={handleChange}
|
||||
className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all font-mono ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isConnected && (
|
||||
<>
|
||||
<div className="text-center text-[10px] text-gray-500 uppercase tracking-widest font-semibold py-1">
|
||||
— OR —
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User size={14} className={isTokenProvided ? "text-gray-600" : "text-gray-400"} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
disabled={isTokenProvided}
|
||||
placeholder="Username / Email"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
className={`w-full h-10 pl-9 pr-3 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock size={14} className={isTokenProvided ? "text-gray-600" : "text-gray-400"} />
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
disabled={isTokenProvided}
|
||||
placeholder="Password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className={`w-full h-10 pl-9 pr-10 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isTokenProvided}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className={`absolute inset-y-0 right-0 pr-3 flex items-center ${isTokenProvided ? 'cursor-not-allowed opacity-50' : 'cursor-pointer text-gray-400 hover:text-white'}`}
|
||||
>
|
||||
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
{isTokenProvided && (
|
||||
<p className="text-[10px] text-yellow-500/80 italic text-center">
|
||||
Credential login disabled when token is present.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isConnected ? (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isConnecting}
|
||||
className={`w-full mt-4 py-2.5 rounded-lg text-sm font-bold text-gray-900 transition-all shadow-lg
|
||||
${isConnecting
|
||||
? 'bg-gray-600 cursor-wait'
|
||||
: 'bg-plex-orange hover:bg-yellow-500 active:scale-[0.98] shadow-plex-orange/20'
|
||||
}`}
|
||||
>
|
||||
{isConnecting ? 'Connecting...' : 'Connect Server'}
|
||||
</button>
|
||||
) : (
|
||||
<div className="mt-2 p-2 bg-green-500/10 border border-green-500/20 rounded-lg text-center">
|
||||
<p className="text-green-400 text-sm font-semibold flex items-center justify-center gap-2">
|
||||
<CheckCircle size={16} />
|
||||
Connected Successfully
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Library Selection - Appears after connection */}
|
||||
{isConnected && libraries.length > 0 && (
|
||||
<div className="mt-6 pt-5 border-t border-gray-700 animate-in slide-in-from-top-2 fade-in">
|
||||
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider block mb-2">Select Library to Sync</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Library size={14} className="text-plex-orange" />
|
||||
</div>
|
||||
<select
|
||||
value={selectedLibraryId}
|
||||
onChange={handleLibraryChange}
|
||||
className="w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange appearance-none cursor-pointer hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
{libraries.map(lib => (
|
||||
<option key={lib.id} value={lib.id}>{lib.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<ChevronDown size={14} className="text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white text-sm font-medium rounded-lg transition-colors border border-gray-600 hover:border-gray-500"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionModal;
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Playlist } from '../types';
|
||||
import { Disc3, Clock } from 'lucide-react';
|
||||
|
||||
interface PlaylistCardProps {
|
||||
playlist: Playlist;
|
||||
}
|
||||
|
||||
const PlaylistCard: React.FC<PlaylistCardProps> = ({ playlist }) => {
|
||||
return (
|
||||
<div className="group flex flex-col w-full p-2.5 bg-gray-800/60 rounded-md border border-gray-700/50 hover:bg-gray-700 hover:border-plex-orange/50 transition-all duration-200 cursor-pointer shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-gray-200 truncate flex-1 mr-2 group-hover:text-white transition-colors">
|
||||
{playlist.title}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mt-1.5 space-x-4 text-xs text-gray-500 group-hover:text-gray-400">
|
||||
<span className="flex items-center" title="Track Count">
|
||||
<Disc3 size={12} className="mr-1.5 opacity-70" />
|
||||
{playlist.trackCount}
|
||||
</span>
|
||||
<span className="flex items-center" title="Last Updated">
|
||||
<Clock size={12} className="mr-1.5 opacity-70" />
|
||||
{new Date(playlist.lastUpdated).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaylistCard;
|
||||
@@ -0,0 +1,140 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Playlist, ServerType, PlexServerConnection } from '../types';
|
||||
import PlaylistCard from './PlaylistCard';
|
||||
import { RefreshCw, Server, Cloud, WifiOff } from 'lucide-react';
|
||||
|
||||
interface ServerPanelProps {
|
||||
type: ServerType;
|
||||
playlists: Playlist[];
|
||||
isLoading: boolean;
|
||||
onRefresh: () => void;
|
||||
serverInfo?: PlexServerConnection;
|
||||
}
|
||||
|
||||
const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, onRefresh, serverInfo }) => {
|
||||
const isLocal = type === ServerType.LOCAL;
|
||||
|
||||
let Icon = isLocal ? Server : Cloud;
|
||||
let headerColor = isLocal ? 'text-blue-400' : 'text-green-400';
|
||||
const borderColor = isLocal ? 'border-blue-500/30' : 'border-green-500/30';
|
||||
const bgGradient = isLocal
|
||||
? 'bg-gradient-to-br from-gray-800/80 to-gray-900/80'
|
||||
: 'bg-gradient-to-bl from-gray-800/80 to-gray-900/80';
|
||||
|
||||
// Resolve Title and Subtitle Logic
|
||||
let displayTitle = '';
|
||||
let displaySubtitle: React.ReactNode = null;
|
||||
|
||||
if (isLocal) {
|
||||
displayTitle = 'Local Server';
|
||||
displaySubtitle = (
|
||||
<p className="text-xs text-gray-400 font-medium mt-0.5 md:mt-0 md:ml-0">
|
||||
{playlists.length} Playlists
|
||||
</p>
|
||||
);
|
||||
} else {
|
||||
// Cloud Logic
|
||||
if (serverInfo) {
|
||||
if (serverInfo.isConnected) {
|
||||
displayTitle = serverInfo.name || 'Cloud Server';
|
||||
displaySubtitle = (
|
||||
<div className="flex items-center text-xs text-gray-300 font-medium space-x-1.5 truncate mt-0.5 md:mt-0">
|
||||
<span className="text-plex-orange truncate font-semibold">{serverInfo.libraryName}</span>
|
||||
<span className="text-gray-600 hidden md:inline">•</span>
|
||||
<span className="text-gray-500 font-mono text-[10px] hidden md:inline">{serverInfo.ip}:{serverInfo.port}</span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
displayTitle = 'Not Connected';
|
||||
Icon = WifiOff;
|
||||
headerColor = 'text-red-400';
|
||||
displaySubtitle = (
|
||||
<p className="text-xs text-gray-500 font-medium mt-0.5">
|
||||
Connection failed
|
||||
</p>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
displayTitle = 'Cloud Server';
|
||||
displaySubtitle = (
|
||||
<p className="text-xs text-gray-500 font-medium mt-0.5">
|
||||
{isLoading ? 'Connecting...' : 'Waiting...'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-row md:flex-col h-full ${bgGradient} rounded-2xl border ${borderColor} backdrop-blur-xl shadow-xl overflow-hidden transition-all duration-300`}>
|
||||
|
||||
{/* Header */}
|
||||
{/* Mobile: Order Last (Right side), Vertical Text */}
|
||||
{/* Desktop: Order First (Top side), Horizontal Text */}
|
||||
<div className={`
|
||||
relative flex-none
|
||||
order-last md:order-first
|
||||
w-[72px] md:w-full
|
||||
h-full md:h-auto md:min-h-[80px]
|
||||
flex flex-col md:flex-row items-center justify-between
|
||||
py-6 md:py-0 md:px-8
|
||||
bg-gray-800/60 border-l md:border-l-0 md:border-b border-white/5
|
||||
`}>
|
||||
|
||||
{/* Title Group */}
|
||||
<div className="flex flex-col md:flex-row items-center md:space-x-4 overflow-hidden w-full md:w-auto h-full md:h-full md:py-4">
|
||||
|
||||
{/* Icon Box */}
|
||||
<div className={`p-2.5 rounded-xl bg-gray-900/50 border border-white/5 ${headerColor} shadow-inner flex-shrink-0 mb-4 md:mb-0`}>
|
||||
<Icon size={22} strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
{/* Text Container - Vertical on Mobile, Horizontal on Desktop */}
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-center items-center md:items-start h-full md:h-auto w-full md:w-auto">
|
||||
{/* Use vertical-rl for vertical text flow, rotate-180 to flip it bottom-up */}
|
||||
<div className="flex flex-col justify-center w-full md:w-auto [writing-mode:vertical-rl] rotate-180 md:[writing-mode:horizontal-tb] md:rotate-0 items-center md:items-start gap-1 md:gap-0">
|
||||
<h2 className="text-sm md:text-lg font-bold text-gray-100 tracking-wide whitespace-nowrap" title={displayTitle}>
|
||||
{displayTitle}
|
||||
</h2>
|
||||
<div className="transform md:translate-y-0">
|
||||
{displaySubtitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Refresh Button */}
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
className="flex-shrink-0 p-2.5 text-gray-400 hover:text-white hover:bg-white/10 rounded-full transition-all active:scale-90 disabled:opacity-50 disabled:cursor-not-allowed mt-4 md:mt-0 md:ml-4"
|
||||
title="Refresh Playlists"
|
||||
>
|
||||
<RefreshCw size={20} className={isLoading ? 'animate-spin text-plex-orange' : ''} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content List */}
|
||||
<div className="flex-1 overflow-y-auto p-3 md:p-5 custom-scrollbar bg-black/20">
|
||||
{isLoading && playlists.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-500 space-y-3">
|
||||
<RefreshCw size={24} className="animate-spin text-plex-orange/50" />
|
||||
<p className="text-xs font-medium tracking-wide uppercase">Syncing...</p>
|
||||
</div>
|
||||
) : playlists.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-500">
|
||||
<p className="text-sm">No playlists found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5 md:space-y-3">
|
||||
{playlists.map((playlist) => (
|
||||
<PlaylistCard key={playlist.id} playlist={playlist} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerPanel;
|
||||
@@ -0,0 +1,297 @@
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { SyncStrategy, RegexReplacement } from '../types';
|
||||
import {
|
||||
ArrowRightCircle,
|
||||
ArrowLeftCircle,
|
||||
GitMerge,
|
||||
ChevronDown,
|
||||
Check,
|
||||
HelpCircle,
|
||||
Plus,
|
||||
Trash2,
|
||||
Save,
|
||||
RotateCcw
|
||||
} from 'lucide-react';
|
||||
|
||||
interface StrategyOption {
|
||||
value: SyncStrategy;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const STRATEGIES: StrategyOption[] = [
|
||||
{
|
||||
value: SyncStrategy.LOCAL_OVERWRITE,
|
||||
label: 'Local Overwrite',
|
||||
description: 'Local playlist completely overwrites Cloud. (No Diff)',
|
||||
icon: ArrowRightCircle,
|
||||
color: 'text-blue-400'
|
||||
},
|
||||
{
|
||||
value: SyncStrategy.CLOUD_OVERWRITE,
|
||||
label: 'Cloud Overwrite',
|
||||
description: 'Cloud playlist completely overwrites Local. (No Diff)',
|
||||
icon: ArrowLeftCircle,
|
||||
color: 'text-green-400'
|
||||
},
|
||||
{
|
||||
value: SyncStrategy.MERGE_LOCAL,
|
||||
label: 'Two-way Merge (Local Priority)',
|
||||
description: 'Merge both. Conflicts resolve to Local version.',
|
||||
icon: GitMerge,
|
||||
color: 'text-blue-300'
|
||||
},
|
||||
{
|
||||
value: SyncStrategy.MERGE_CLOUD,
|
||||
label: 'Two-way Merge (Cloud Priority)',
|
||||
description: 'Merge both. Conflicts resolve to Cloud version.',
|
||||
icon: GitMerge,
|
||||
color: 'text-green-300'
|
||||
}
|
||||
];
|
||||
|
||||
interface StrategySelectorProps {
|
||||
currentStrategy: SyncStrategy;
|
||||
onSelect: (strategy: SyncStrategy, label: string) => void;
|
||||
savedRegexReplacements: RegexReplacement[];
|
||||
onSaveRegex: (replacements: RegexReplacement[]) => void;
|
||||
}
|
||||
|
||||
const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
currentStrategy,
|
||||
onSelect,
|
||||
savedRegexReplacements,
|
||||
onSaveRegex
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Local state for regex editing
|
||||
const [localReplacements, setLocalReplacements] = useState<RegexReplacement[]>([]);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
// Initialize local state when prop updates (only if not dirty, or initially)
|
||||
useEffect(() => {
|
||||
setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements)));
|
||||
setIsDirty(false);
|
||||
}, [savedRegexReplacements]);
|
||||
|
||||
// Check dirty state whenever local changes
|
||||
useEffect(() => {
|
||||
const isDifferent = JSON.stringify(localReplacements) !== JSON.stringify(savedRegexReplacements);
|
||||
setIsDirty(isDifferent);
|
||||
}, [localReplacements, savedRegexReplacements]);
|
||||
|
||||
const selectedOption = STRATEGIES.find(s => s.value === currentStrategy) || STRATEGIES[0];
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSelect = (strategy: StrategyOption) => {
|
||||
onSelect(strategy.value, strategy.label);
|
||||
};
|
||||
|
||||
// Regex Handlers
|
||||
const handleAddRegex = () => {
|
||||
const newId = Date.now().toString();
|
||||
setLocalReplacements(prev => [...prev, { id: newId, pattern: '', replacement: '' }]);
|
||||
};
|
||||
|
||||
const handleDeleteRegex = (id: string) => {
|
||||
setLocalReplacements(prev => prev.filter(r => r.id !== id));
|
||||
};
|
||||
|
||||
const handleUpdateRegex = (id: string, field: 'pattern' | 'replacement', value: string) => {
|
||||
setLocalReplacements(prev => prev.map(r =>
|
||||
r.id === id ? { ...r, [field]: value } : r
|
||||
));
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements)));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const validReplacements = localReplacements.filter(r => r.pattern.trim() !== '');
|
||||
setLocalReplacements(validReplacements);
|
||||
onSaveRegex(validReplacements);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group" ref={dropdownRef}>
|
||||
{/* Trigger Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center justify-center w-12 h-12 rounded-full bg-gray-800/90 border border-gray-600 hover:border-plex-orange text-gray-300 hover:text-white hover:bg-gray-700/80 transition-all shadow-2xl hover:shadow-plex-orange/30 ring-1 ring-black/40 backdrop-blur-sm active:scale-95"
|
||||
title={`Current Strategy: ${selectedOption.label}`}
|
||||
>
|
||||
<selectedOption.icon size={22} className={selectedOption.color} strokeWidth={2.5} />
|
||||
<div className="absolute -bottom-1 -right-1 bg-gray-900 rounded-full border border-gray-600 p-[2px] shadow-sm">
|
||||
<ChevronDown size={10} className="text-gray-400" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu - Persistent Mount for State Preservation */}
|
||||
<div
|
||||
className={`absolute
|
||||
top-14
|
||||
/* Mobile: Open to left */
|
||||
right-0 origin-top-right
|
||||
/* Desktop: Center alignment */
|
||||
md:left-1/2 md:right-auto md:origin-top md:-translate-x-1/2
|
||||
|
||||
w-80 md:w-[30rem] bg-gray-800/95 border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden backdrop-blur-xl
|
||||
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'}`}
|
||||
>
|
||||
{/* Section 1: Sync Strategy */}
|
||||
<div className="px-4 py-3 bg-black/20 border-b border-white/5">
|
||||
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">Sync Strategy</h3>
|
||||
<div className="space-y-1">
|
||||
{STRATEGIES.map((strategy) => (
|
||||
<div
|
||||
key={strategy.value}
|
||||
onClick={() => handleSelect(strategy)}
|
||||
className={`group flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all border ${
|
||||
currentStrategy === strategy.value
|
||||
? '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} />
|
||||
<span className={`text-sm font-medium truncate ${currentStrategy === strategy.value ? 'text-white' : 'text-gray-300 group-hover:text-white'}`}>
|
||||
{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>
|
||||
|
||||
{/* Section 2: Regex Preprocessing */}
|
||||
<div className="p-4 bg-gray-900/40">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Regex Rules</h3>
|
||||
{localReplacements.length === 0 && (
|
||||
<button
|
||||
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 className="space-y-2 mb-4 max-h-52 overflow-y-auto pr-1 custom-scrollbar">
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
export default StrategySelector;
|
||||
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PlexSync Manager</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
plex: {
|
||||
orange: '#e5a00d',
|
||||
dark: '#1f2937',
|
||||
darker: '#111827',
|
||||
card: '#374151'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
/* Custom scrollbar for webkit to match dark theme */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #111827;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.555.0",
|
||||
"react/": "https://aistudiocdn.com/react@^19.2.0/",
|
||||
"react": "https://aistudiocdn.com/react@^19.2.0",
|
||||
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-100 antialiased min-h-screen">
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "PlexSync Manager",
|
||||
"description": "A modern dashboard to synchronize and manage playlists between Local and Cloud Plex servers.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "plexsync-manager",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.555.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
|
||||
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary } from '../types';
|
||||
import { MOCK_LOCAL_PLAYLISTS, MOCK_CLOUD_PLAYLISTS } from './mockData';
|
||||
|
||||
const SIMULATE_DELAY_MS = 800;
|
||||
|
||||
// Mock available libraries on a server
|
||||
const MOCK_LIBRARIES: PlexLibrary[] = [
|
||||
{ id: 'lib1', title: 'Music (Flac)', type: 'artist' },
|
||||
{ id: 'lib2', title: 'MP3 Collection', type: 'artist' },
|
||||
{ id: 'lib3', title: 'Soundtracks', type: 'artist' },
|
||||
{ id: 'lib4', title: 'Audiobooks', type: 'artist' }
|
||||
];
|
||||
|
||||
// Helper to simulate network request or call actual API
|
||||
const fetchPlaylists = async (type: ServerType): Promise<Playlist[]> => {
|
||||
// In a real Docker environment with FastAPI, you would do:
|
||||
// const response = await fetch(`/api/playlists/${type.toLowerCase()}`);
|
||||
// const data = await response.json();
|
||||
// return data;
|
||||
|
||||
// Mocking for UI demonstration
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
if (type === ServerType.LOCAL) {
|
||||
resolve([...MOCK_LOCAL_PLAYLISTS]);
|
||||
} else {
|
||||
resolve([...MOCK_CLOUD_PLAYLISTS]);
|
||||
}
|
||||
}, SIMULATE_DELAY_MS);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchServerStatus = async (): Promise<PlexServerConnection> => {
|
||||
// Mocking server status
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
// 90% chance of success for demo
|
||||
const isSuccess = Math.random() > 0.1;
|
||||
if (isSuccess) {
|
||||
resolve({
|
||||
isConnected: true,
|
||||
name: 'Home Media Server',
|
||||
ip: '192.168.1.105',
|
||||
port: 32400,
|
||||
libraryName: 'Music (Flac)'
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
isConnected: false
|
||||
});
|
||||
}
|
||||
}, SIMULATE_DELAY_MS);
|
||||
});
|
||||
};
|
||||
|
||||
const authenticatePlex = async (settings: PlexConnectionSettings): Promise<{ token: string, serverInfo: PlexServerConnection }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
// Simulate validation
|
||||
if (!settings.address) {
|
||||
reject(new Error("Server address is required"));
|
||||
return;
|
||||
}
|
||||
|
||||
// If user provided username/password, mock a token generation
|
||||
let token = settings.token;
|
||||
if (!token && settings.username && settings.password) {
|
||||
token = "MOCK_TOKEN_XYZ_999";
|
||||
} else if (!token) {
|
||||
reject(new Error("Token or Username/Password required"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Success response with libraries
|
||||
resolve({
|
||||
token: token,
|
||||
serverInfo: {
|
||||
isConnected: true,
|
||||
name: 'My Plex Server',
|
||||
ip: settings.address,
|
||||
port: parseInt(settings.port) || 32400,
|
||||
libraryName: MOCK_LIBRARIES[0].title, // Default to first library
|
||||
libraries: MOCK_LIBRARIES
|
||||
}
|
||||
});
|
||||
}, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
export const apiService = {
|
||||
getPlaylists: async (serverType: ServerType): Promise<ApiResponse<Playlist[]>> => {
|
||||
try {
|
||||
const data = await fetchPlaylists(serverType);
|
||||
return { data, status: 'success' };
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${serverType} playlists:`, error);
|
||||
return { data: [], status: 'error', message: 'Failed to fetch playlists' };
|
||||
}
|
||||
},
|
||||
|
||||
getServerStatus: async (): Promise<ApiResponse<PlexServerConnection>> => {
|
||||
try {
|
||||
const data = await fetchServerStatus();
|
||||
return { data, status: 'success' };
|
||||
} catch (error) {
|
||||
return {
|
||||
data: { isConnected: false },
|
||||
status: 'error',
|
||||
message: 'Failed to connect to server'
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
connectToPlex: async (settings: PlexConnectionSettings): Promise<ApiResponse<{ token: string, serverInfo: PlexServerConnection }>> => {
|
||||
try {
|
||||
const data = await authenticatePlex(settings);
|
||||
return { data, status: 'success', message: 'Connected successfully' };
|
||||
} catch (error: any) {
|
||||
return {
|
||||
data: { token: '', serverInfo: { isConnected: false } },
|
||||
status: 'error',
|
||||
message: error.message || 'Connection failed'
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Playlist } from '../types';
|
||||
|
||||
export const MOCK_LOCAL_PLAYLISTS: Playlist[] = [
|
||||
{ id: 'l1', title: 'Road Trip 2024', trackCount: 45, lastUpdated: '2023-10-25T10:00:00Z', thumbnail: 'https://picsum.photos/200/200?random=1' },
|
||||
{ id: 'l2', title: 'Coding Focus', trackCount: 120, lastUpdated: '2023-10-24T14:30:00Z', thumbnail: 'https://picsum.photos/200/200?random=2' },
|
||||
{ id: 'l3', title: '90s Rock', trackCount: 32, lastUpdated: '2023-10-20T09:15:00Z', thumbnail: 'https://picsum.photos/200/200?random=3' },
|
||||
{ id: 'l4', title: 'Gym Pump', trackCount: 50, lastUpdated: '2023-10-22T18:45:00Z', thumbnail: 'https://picsum.photos/200/200?random=4' },
|
||||
];
|
||||
|
||||
export const MOCK_CLOUD_PLAYLISTS: Playlist[] = [
|
||||
{ id: 'c1', title: 'Road Trip 2024', trackCount: 42, lastUpdated: '2023-10-24T10:00:00Z', thumbnail: 'https://picsum.photos/200/200?random=1' }, // Slightly out of sync
|
||||
{ id: 'c2', title: 'Coding Focus', trackCount: 120, lastUpdated: '2023-10-24T14:30:00Z', thumbnail: 'https://picsum.photos/200/200?random=2' },
|
||||
{ id: 'c5', title: 'Chill Vibes', trackCount: 88, lastUpdated: '2023-10-19T20:20:00Z', thumbnail: 'https://picsum.photos/200/200?random=5' },
|
||||
];
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
|
||||
export interface Track {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
duration: number; // in seconds
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
id: string;
|
||||
title: string;
|
||||
trackCount: number;
|
||||
thumbnail?: string;
|
||||
lastUpdated: string;
|
||||
tracks?: Track[]; // Optional detailed track list
|
||||
}
|
||||
|
||||
export enum ServerType {
|
||||
LOCAL = 'LOCAL',
|
||||
CLOUD = 'CLOUD'
|
||||
}
|
||||
|
||||
export enum SyncStrategy {
|
||||
LOCAL_OVERWRITE = 'LOCAL_OVERWRITE',
|
||||
CLOUD_OVERWRITE = 'CLOUD_OVERWRITE',
|
||||
MERGE_LOCAL = 'MERGE_LOCAL',
|
||||
MERGE_CLOUD = 'MERGE_CLOUD'
|
||||
}
|
||||
|
||||
export interface RegexReplacement {
|
||||
id: string;
|
||||
pattern: string;
|
||||
replacement: string;
|
||||
}
|
||||
|
||||
export interface PlexLibrary {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface PlexServerConnection {
|
||||
isConnected: boolean;
|
||||
name?: string;
|
||||
ip?: string;
|
||||
port?: number;
|
||||
libraryName?: string;
|
||||
libraries?: PlexLibrary[];
|
||||
}
|
||||
|
||||
export interface PlexConnectionSettings {
|
||||
protocol: 'http' | 'https';
|
||||
address: string;
|
||||
port: string;
|
||||
token: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
status: 'success' | 'error';
|
||||
message?: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import path from 'path';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
return {
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
plugins: [react()],
|
||||
define: {
|
||||
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user