Squashed 'sample-front-end/' content from commit 0881bf1
git-subtree-dir: sample-front-end git-subtree-split: 0881bf1c045118585100360b2c47594cd94b89f1
This commit is contained in:
+24
@@ -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;
|
||||||
+54
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+127
@@ -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