Files
PlexPlaylistSync/frontend/components/ConnectionModal.tsx
T

416 lines
18 KiB
TypeScript

import React, { useState, useEffect, useRef } 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, ChevronRight, Settings, Loader2 } from 'lucide-react';
interface ConnectionModalProps {
isOpen: boolean;
onClose: () => void;
onConnectSuccess: (serverInfo: PlexServerConnection) => void | Promise<void>;
onShowMessage: (message: string) => void;
initialSettings?: Partial<PlexConnectionSettings>;
}
const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onConnectSuccess, onShowMessage, initialSettings }) => {
const [formData, setFormData] = useState<PlexConnectionSettings>({
protocol: 'http',
address: '',
port: '32400',
token: '',
username: '',
password: '',
timeout: 9,
libraryName: ''
});
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
// Post-connection state
const [connectedServerInfo, setConnectedServerInfo] = useState<PlexServerConnection | null>(null);
const [libraries, setLibraries] = useState<PlexLibrary[]>([]);
const [selectedLibraryId, setSelectedLibraryId] = useState<string>('');
const abortControllerRef = useRef<AbortController | null>(null);
// Reset state when opening
useEffect(() => {
if (isOpen) {
setError(null);
setConnectedServerInfo(null);
setLibraries([]);
setSelectedLibraryId('');
if (initialSettings) {
setFormData(prev => ({
...prev,
protocol: initialSettings.protocol || prev.protocol,
address: initialSettings.address || prev.address,
port: initialSettings.port || prev.port,
token: initialSettings.token || prev.token,
libraryName: initialSettings.libraryName || prev.libraryName,
}));
}
}
return () => {
// Cleanup any pending request if modal closes
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [isOpen, initialSettings]);
if (!isOpen) return null;
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleTimeoutChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseInt(e.target.value) || 0;
setFormData(prev => ({ ...prev, timeout: val }));
};
const handleLibraryChange = async (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);
onConnectSuccess(updatedInfo);
const saveResult = await apiService.updateLibrary(lib.title);
if (saveResult.status !== 'success') {
onShowMessage(saveResult.message || 'Failed to save library selection');
} else {
onShowMessage(`Library switched to ${lib.title}`);
}
}
};
const isTokenProvided = formData.token.trim().length > 0;
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();
// If already connecting, this acts as Cancel
if (isConnecting) {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
setIsConnecting(false);
setError("Connection cancelled by user.");
}
return;
}
setError(null);
setIsConnecting(true);
const abortController = new AbortController();
abortControllerRef.current = abortController;
const result = await apiService.connectToPlex(formData, abortController.signal);
// Only proceed if we weren't aborted/cancelled (though apiService handles error msg)
if (abortController.signal.aborted) return;
setIsConnecting(false);
abortControllerRef.current = null;
if (result.status === 'success' && result.data) {
setFormData(prev => ({
...prev,
token: result.data.token,
username: '',
password: ''
}));
const info = result.data.serverInfo;
setConnectedServerInfo(info);
onShowMessage(`Successfully connected to ${info.name || 'Plex Server'}`);
const libs = info.libraries || [];
setLibraries(libs);
if (libs.length > 0) {
const preferred = info.libraryName || formData.libraryName;
const defaultLib = libs.find(lib => lib.title === preferred) || libs[0];
setSelectedLibraryId(defaultLib.id);
setFormData(prev => ({ ...prev, libraryName: defaultLib.title }));
onConnectSuccess({
...info,
libraryName: defaultLib.title
});
const saveResult = await apiService.updateLibrary(defaultLib.title);
if (saveResult.status !== 'success') {
setError(saveResult.message || 'Failed to save library selection');
}
} 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"
onClick={onClose}
>
<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 flex flex-col max-h-[90vh]"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-6 py-4 bg-gray-800 border-b border-gray-700 flex items-center justify-between flex-none">
<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 overflow-y-auto custom-scrollbar">
<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 || isConnecting}
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 || isConnecting}
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 || isConnecting}
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 || isConnecting}
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 || isConnecting}
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 || isConnecting}
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 || isConnecting}
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>
</>
)}
</div>
{/* Advanced Options */}
{!isConnected && (
<div className="border border-gray-800 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="w-full flex items-center justify-between px-3 py-2 bg-gray-800/50 hover:bg-gray-800 text-xs font-medium text-gray-400 hover:text-gray-200 transition-colors"
>
<div className="flex items-center gap-2">
<Settings size={14} />
<span>Advanced Options</span>
</div>
{showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
{showAdvanced && (
<div className="p-3 bg-gray-900/50 space-y-3 animate-in slide-in-from-top-2">
<div>
<label className="text-xs text-gray-500 mb-1 block">Connection Timeout (Seconds)</label>
<input
type="number"
min="1"
max="60"
name="timeout"
value={formData.timeout || 9}
onChange={handleTimeoutChange}
disabled={isConnecting}
className="w-full h-8 px-2 bg-gray-800 border border-gray-700 rounded-md text-xs text-white focus:border-plex-orange focus:outline-none"
/>
</div>
</div>
)}
</div>
)}
{!isConnected ? (
<button
type="submit"
className={`w-full mt-4 py-2.5 rounded-lg text-sm font-bold text-gray-900 transition-all shadow-lg flex items-center justify-center gap-2
${isConnecting
? 'bg-red-500/80 hover:bg-red-500 text-white animate-pulse'
: 'bg-plex-orange hover:bg-yellow-500 active:scale-[0.98] shadow-plex-orange/20'
}`}
>
{isConnecting ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>Connecting... <span className="opacity-75 font-normal ml-1">(Cancel)</span></span>
</>
) : '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;