Squashed 'sample-front-end/' changes from 0881bf1..601ffe4
601ffe4 fix: Refine UI layout and visual elements 4689aaa feat: Add request timeout and cancellation to API calls git-subtree-dir: sample-front-end git-subtree-split: 601ffe468a78955839eef6c839314d9b96ea204d
This commit is contained in:
@@ -22,6 +22,10 @@ const App: React.FC = () => {
|
|||||||
const [loadingLocal, setLoadingLocal] = useState(false);
|
const [loadingLocal, setLoadingLocal] = useState(false);
|
||||||
const [loadingCloud, setLoadingCloud] = useState(false);
|
const [loadingCloud, setLoadingCloud] = useState(false);
|
||||||
|
|
||||||
|
// Abort Controllers for Refresh Actions
|
||||||
|
const localAbortRef = useRef<AbortController | null>(null);
|
||||||
|
const cloudAbortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
// Connection Modal State
|
// Connection Modal State
|
||||||
const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false);
|
const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false);
|
||||||
|
|
||||||
@@ -100,36 +104,71 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
// Fetch Local Playlists
|
// Fetch Local Playlists
|
||||||
const refreshLocal = useCallback(async () => {
|
const refreshLocal = useCallback(async () => {
|
||||||
|
if (localAbortRef.current) localAbortRef.current.abort();
|
||||||
|
const abortController = new AbortController();
|
||||||
|
localAbortRef.current = abortController;
|
||||||
|
|
||||||
setLoadingLocal(true);
|
setLoadingLocal(true);
|
||||||
const result = await apiService.getPlaylists(ServerType.LOCAL);
|
const result = await apiService.getPlaylists(ServerType.LOCAL, abortController.signal);
|
||||||
if (result.status === 'success') {
|
if (result.status === 'success') {
|
||||||
setLocalPlaylists(result.data);
|
setLocalPlaylists(result.data);
|
||||||
}
|
}
|
||||||
setLoadingLocal(false);
|
setLoadingLocal(false);
|
||||||
|
localAbortRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const cancelLocalRefresh = () => {
|
||||||
|
if (localAbortRef.current) {
|
||||||
|
localAbortRef.current.abort();
|
||||||
|
localAbortRef.current = null;
|
||||||
|
setLoadingLocal(false);
|
||||||
|
addToast("Local refresh cancelled.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Fetch Cloud Playlists and Info
|
// Fetch Cloud Playlists and Info
|
||||||
const refreshCloud = useCallback(async () => {
|
const refreshCloud = useCallback(async () => {
|
||||||
|
if (cloudAbortRef.current) cloudAbortRef.current.abort();
|
||||||
|
const abortController = new AbortController();
|
||||||
|
cloudAbortRef.current = abortController;
|
||||||
|
|
||||||
setLoadingCloud(true);
|
setLoadingCloud(true);
|
||||||
// Fetch playlists
|
// Fetch playlists
|
||||||
const playlistResult = await apiService.getPlaylists(ServerType.CLOUD);
|
const playlistResult = await apiService.getPlaylists(ServerType.CLOUD, abortController.signal);
|
||||||
if (playlistResult.status === 'success') {
|
if (!abortController.signal.aborted) {
|
||||||
setCloudPlaylists(playlistResult.data);
|
if (playlistResult.status === 'success') {
|
||||||
|
setCloudPlaylists(playlistResult.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch server info
|
||||||
|
const infoResult = await apiService.getServerStatus(abortController.signal);
|
||||||
|
if (infoResult.status === 'success') {
|
||||||
|
setCloudServerInfo(infoResult.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingCloud(false);
|
||||||
|
cloudAbortRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch server info
|
|
||||||
const infoResult = await apiService.getServerStatus();
|
|
||||||
if (infoResult.status === 'success') {
|
|
||||||
setCloudServerInfo(infoResult.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoadingCloud(false);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const cancelCloudRefresh = () => {
|
||||||
|
if (cloudAbortRef.current) {
|
||||||
|
cloudAbortRef.current.abort();
|
||||||
|
cloudAbortRef.current = null;
|
||||||
|
setLoadingCloud(false);
|
||||||
|
addToast("Cloud refresh cancelled.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Initial Load
|
// Initial Load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshLocal();
|
refreshLocal();
|
||||||
refreshCloud();
|
refreshCloud();
|
||||||
|
return () => {
|
||||||
|
// Cleanup on unmount
|
||||||
|
if (localAbortRef.current) localAbortRef.current.abort();
|
||||||
|
if (cloudAbortRef.current) cloudAbortRef.current.abort();
|
||||||
|
}
|
||||||
}, [refreshLocal, refreshCloud]);
|
}, [refreshLocal, refreshCloud]);
|
||||||
|
|
||||||
// Handle Strategy Change
|
// Handle Strategy Change
|
||||||
@@ -146,8 +185,8 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
const handleConnectSuccess = (serverInfo: PlexServerConnection) => {
|
const handleConnectSuccess = (serverInfo: PlexServerConnection) => {
|
||||||
setCloudServerInfo(serverInfo);
|
setCloudServerInfo(serverInfo);
|
||||||
// Removed implicit toast here to allow the caller (ConnectionModal) to handle specific messaging
|
// Refresh playlists after new connection
|
||||||
refreshCloud(); // Refresh playlists after new connection
|
refreshCloud();
|
||||||
};
|
};
|
||||||
|
|
||||||
const getToastStyles = (toast: Toast): React.CSSProperties => {
|
const getToastStyles = (toast: Toast): React.CSSProperties => {
|
||||||
@@ -221,7 +260,8 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<main className="flex-1 overflow-hidden relative z-10">
|
<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">
|
{/* Reduced gap from gap-3/gap-6 to gap-2/gap-3 for tighter layout */}
|
||||||
|
<div className="absolute inset-0 flex flex-col md:flex-row max-w-7xl mx-auto p-4 md:p-6 gap-2 md:gap-3">
|
||||||
|
|
||||||
{/* Left Column - Local */}
|
{/* Left Column - Local */}
|
||||||
<div className="flex-1 min-h-0 h-full w-full">
|
<div className="flex-1 min-h-0 h-full w-full">
|
||||||
@@ -230,12 +270,11 @@ const App: React.FC = () => {
|
|||||||
playlists={localPlaylists}
|
playlists={localPlaylists}
|
||||||
isLoading={loadingLocal}
|
isLoading={loadingLocal}
|
||||||
onRefresh={refreshLocal}
|
onRefresh={refreshLocal}
|
||||||
|
onCancel={cancelLocalRefresh}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Strategy Selector - Positioned specifically between headers */}
|
{/* 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
|
<div className="absolute
|
||||||
z-30
|
z-30
|
||||||
/* Mobile Positioning: Center Vertically, Anchored Right */
|
/* Mobile Positioning: Center Vertically, Anchored Right */
|
||||||
@@ -259,6 +298,7 @@ const App: React.FC = () => {
|
|||||||
playlists={cloudPlaylists}
|
playlists={cloudPlaylists}
|
||||||
isLoading={loadingCloud}
|
isLoading={loadingCloud}
|
||||||
onRefresh={refreshCloud}
|
onRefresh={refreshCloud}
|
||||||
|
onCancel={cancelCloudRefresh}
|
||||||
serverInfo={cloudServerInfo}
|
serverInfo={cloudServerInfo}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { PlexConnectionSettings, PlexServerConnection, PlexLibrary } from '../types';
|
import { PlexConnectionSettings, PlexServerConnection, PlexLibrary } from '../types';
|
||||||
import { apiService } from '../services/api';
|
import { apiService } from '../services/api';
|
||||||
import { X, Server, Lock, User, Key, Globe, Eye, EyeOff, CheckCircle, Library, ChevronDown } from 'lucide-react';
|
import { X, Server, Lock, User, Key, Globe, Eye, EyeOff, CheckCircle, Library, ChevronDown, ChevronRight, Settings, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
interface ConnectionModalProps {
|
interface ConnectionModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -18,18 +18,22 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
|
|||||||
port: '32400',
|
port: '32400',
|
||||||
token: '',
|
token: '',
|
||||||
username: '',
|
username: '',
|
||||||
password: ''
|
password: '',
|
||||||
|
timeout: 9
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isConnecting, setIsConnecting] = useState(false);
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
// Post-connection state
|
// Post-connection state
|
||||||
const [connectedServerInfo, setConnectedServerInfo] = useState<PlexServerConnection | null>(null);
|
const [connectedServerInfo, setConnectedServerInfo] = useState<PlexServerConnection | null>(null);
|
||||||
const [libraries, setLibraries] = useState<PlexLibrary[]>([]);
|
const [libraries, setLibraries] = useState<PlexLibrary[]>([]);
|
||||||
const [selectedLibraryId, setSelectedLibraryId] = useState<string>('');
|
const [selectedLibraryId, setSelectedLibraryId] = useState<string>('');
|
||||||
|
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
// Reset state when opening
|
// Reset state when opening
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -38,6 +42,12 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
|
|||||||
setLibraries([]);
|
setLibraries([]);
|
||||||
setSelectedLibraryId('');
|
setSelectedLibraryId('');
|
||||||
}
|
}
|
||||||
|
return () => {
|
||||||
|
// Cleanup any pending request if modal closes
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
@@ -47,6 +57,11 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
|
|||||||
setFormData(prev => ({ ...prev, [name]: value }));
|
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 = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const handleLibraryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
const newId = e.target.value;
|
const newId = e.target.value;
|
||||||
setSelectedLibraryId(newId);
|
setSelectedLibraryId(newId);
|
||||||
@@ -55,31 +70,46 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
|
|||||||
if (lib && connectedServerInfo) {
|
if (lib && connectedServerInfo) {
|
||||||
const updatedInfo = { ...connectedServerInfo, libraryName: lib.title };
|
const updatedInfo = { ...connectedServerInfo, libraryName: lib.title };
|
||||||
setConnectedServerInfo(updatedInfo);
|
setConnectedServerInfo(updatedInfo);
|
||||||
// Notify parent of update
|
|
||||||
onConnectSuccess(updatedInfo);
|
onConnectSuccess(updatedInfo);
|
||||||
// Show toast
|
|
||||||
onShowMessage(`Library switched to ${lib.title}`);
|
onShowMessage(`Library switched to ${lib.title}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isTokenProvided = formData.token.trim().length > 0;
|
const isTokenProvided = formData.token.trim().length > 0;
|
||||||
|
|
||||||
// Dynamic styles for disabled fields
|
|
||||||
const disabledInputClass = isTokenProvided
|
const disabledInputClass = isTokenProvided
|
||||||
? "bg-gray-700/50 text-gray-500 line-through decoration-gray-500 cursor-not-allowed border-gray-700"
|
? "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";
|
: "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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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);
|
setError(null);
|
||||||
setIsConnecting(true);
|
setIsConnecting(true);
|
||||||
|
|
||||||
const result = await apiService.connectToPlex(formData);
|
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);
|
setIsConnecting(false);
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
|
||||||
if (result.status === 'success' && result.data) {
|
if (result.status === 'success' && result.data) {
|
||||||
// Update Token field and clear user/pass
|
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
token: result.data.token,
|
token: result.data.token,
|
||||||
@@ -89,17 +119,13 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
|
|||||||
|
|
||||||
const info = result.data.serverInfo;
|
const info = result.data.serverInfo;
|
||||||
setConnectedServerInfo(info);
|
setConnectedServerInfo(info);
|
||||||
|
|
||||||
// Explicitly show connection message here
|
|
||||||
onShowMessage(`Successfully connected to ${info.name || 'Plex Server'}`);
|
onShowMessage(`Successfully connected to ${info.name || 'Plex Server'}`);
|
||||||
|
|
||||||
// Handle libraries
|
|
||||||
const libs = info.libraries || [];
|
const libs = info.libraries || [];
|
||||||
setLibraries(libs);
|
setLibraries(libs);
|
||||||
if (libs.length > 0) {
|
if (libs.length > 0) {
|
||||||
const defaultLib = libs[0];
|
const defaultLib = libs[0];
|
||||||
setSelectedLibraryId(defaultLib.id);
|
setSelectedLibraryId(defaultLib.id);
|
||||||
// Pass connection info back with default library name explicitly set (though mock already does it)
|
|
||||||
onConnectSuccess({
|
onConnectSuccess({
|
||||||
...info,
|
...info,
|
||||||
libraryName: defaultLib.title
|
libraryName: defaultLib.title
|
||||||
@@ -116,10 +142,10 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
<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">
|
<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]">
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-6 py-4 bg-gray-800 border-b border-gray-700 flex items-center justify-between">
|
<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">
|
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
<Server size={18} className={isConnected ? "text-green-400" : "text-plex-orange"} />
|
<Server size={18} className={isConnected ? "text-green-400" : "text-plex-orange"} />
|
||||||
{isConnected ? 'Server Connected' : 'Connect Plex Server'}
|
{isConnected ? 'Server Connected' : 'Connect Plex Server'}
|
||||||
@@ -130,7 +156,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="p-6">
|
<div className="p-6 overflow-y-auto custom-scrollbar">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -148,7 +174,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
|
|||||||
name="protocol"
|
name="protocol"
|
||||||
value={formData.protocol}
|
value={formData.protocol}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
disabled={isConnected}
|
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' : ''}`}
|
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="http">HTTP</option>
|
||||||
@@ -164,7 +190,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
|
|||||||
type="text"
|
type="text"
|
||||||
name="address"
|
name="address"
|
||||||
required
|
required
|
||||||
disabled={isConnected}
|
disabled={isConnected || isConnecting}
|
||||||
placeholder="IP Address or Domain"
|
placeholder="IP Address or Domain"
|
||||||
value={formData.address}
|
value={formData.address}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
@@ -178,7 +204,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="port"
|
name="port"
|
||||||
disabled={isConnected}
|
disabled={isConnected || isConnecting}
|
||||||
placeholder="Port (e.g. 32400)"
|
placeholder="Port (e.g. 32400)"
|
||||||
value={formData.port}
|
value={formData.port}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
@@ -201,7 +227,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="token"
|
name="token"
|
||||||
disabled={isConnected}
|
disabled={isConnected || isConnecting}
|
||||||
placeholder="X-Plex-Token (Optional)"
|
placeholder="X-Plex-Token (Optional)"
|
||||||
value={formData.token}
|
value={formData.token}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
@@ -223,7 +249,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="username"
|
name="username"
|
||||||
disabled={isTokenProvided}
|
disabled={isTokenProvided || isConnecting}
|
||||||
placeholder="Username / Email"
|
placeholder="Username / Email"
|
||||||
value={formData.username}
|
value={formData.username}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
@@ -239,7 +265,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
|
|||||||
<input
|
<input
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
name="password"
|
name="password"
|
||||||
disabled={isTokenProvided}
|
disabled={isTokenProvided || isConnecting}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
@@ -247,33 +273,67 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isTokenProvided}
|
disabled={isTokenProvided || isConnecting}
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
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'}`}
|
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} />}
|
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{isTokenProvided && (
|
|
||||||
<p className="text-[10px] text-yellow-500/80 italic text-center">
|
|
||||||
Credential login disabled when token is present.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</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 ? (
|
{!isConnected ? (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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 flex items-center justify-center gap-2
|
||||||
className={`w-full mt-4 py-2.5 rounded-lg text-sm font-bold text-gray-900 transition-all shadow-lg
|
|
||||||
${isConnecting
|
${isConnecting
|
||||||
? 'bg-gray-600 cursor-wait'
|
? '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'
|
: 'bg-plex-orange hover:bg-yellow-500 active:scale-[0.98] shadow-plex-orange/20'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isConnecting ? 'Connecting...' : 'Connect Server'}
|
{isConnecting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
<span>Connecting... <span className="opacity-75 font-normal ml-1">(Cancel)</span></span>
|
||||||
|
</>
|
||||||
|
) : 'Connect Server'}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 p-2 bg-green-500/10 border border-green-500/20 rounded-lg text-center">
|
<div className="mt-2 p-2 bg-green-500/10 border border-green-500/20 rounded-lg text-center">
|
||||||
|
|||||||
+43
-21
@@ -2,17 +2,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Playlist, ServerType, PlexServerConnection } from '../types';
|
import { Playlist, ServerType, PlexServerConnection } from '../types';
|
||||||
import PlaylistCard from './PlaylistCard';
|
import PlaylistCard from './PlaylistCard';
|
||||||
import { RefreshCw, Server, Cloud, WifiOff } from 'lucide-react';
|
import { RefreshCw, Server, Cloud, WifiOff, X } from 'lucide-react';
|
||||||
|
|
||||||
interface ServerPanelProps {
|
interface ServerPanelProps {
|
||||||
type: ServerType;
|
type: ServerType;
|
||||||
playlists: Playlist[];
|
playlists: Playlist[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
serverInfo?: PlexServerConnection;
|
serverInfo?: PlexServerConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, onRefresh, serverInfo }) => {
|
const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, onRefresh, onCancel, serverInfo }) => {
|
||||||
const isLocal = type === ServerType.LOCAL;
|
const isLocal = type === ServerType.LOCAL;
|
||||||
|
|
||||||
let Icon = isLocal ? Server : Cloud;
|
let Icon = isLocal ? Server : Cloud;
|
||||||
@@ -65,21 +66,30 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle Refresh/Cancel Click
|
||||||
|
const handleAction = () => {
|
||||||
|
if (isLoading && onCancel) {
|
||||||
|
onCancel();
|
||||||
|
} else {
|
||||||
|
onRefresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
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`}>
|
<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 */}
|
{/* Header */}
|
||||||
{/* Mobile: Order Last (Right side), Vertical Text */}
|
<div
|
||||||
{/* Desktop: Order First (Top side), Horizontal Text */}
|
className={`
|
||||||
<div className={`
|
relative flex-none
|
||||||
relative flex-none
|
order-last md:order-first
|
||||||
order-last md:order-first
|
w-[72px] md:w-full
|
||||||
w-[72px] md:w-full
|
h-full md:h-auto md:min-h-[80px]
|
||||||
h-full md:h-auto md:min-h-[80px]
|
flex flex-col md:flex-row items-center justify-between
|
||||||
flex flex-col md:flex-row items-center justify-between
|
py-6 md:py-0 md:px-8
|
||||||
py-6 md:py-0 md:px-8
|
bg-gray-800/60 border-l md:border-l-0 md:border-b border-white/5
|
||||||
bg-gray-800/60 border-l md:border-l-0 md:border-b border-white/5
|
`}
|
||||||
`}>
|
>
|
||||||
|
|
||||||
{/* Title Group */}
|
{/* 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">
|
<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">
|
||||||
@@ -89,9 +99,8 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
|
|||||||
<Icon size={22} strokeWidth={2} />
|
<Icon size={22} strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Text Container - Vertical on Mobile, Horizontal on Desktop */}
|
{/* Text Container */}
|
||||||
<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">
|
<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">
|
<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}>
|
<h2 className="text-sm md:text-lg font-bold text-gray-100 tracking-wide whitespace-nowrap" title={displayTitle}>
|
||||||
{displayTitle}
|
{displayTitle}
|
||||||
@@ -103,14 +112,27 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Refresh Button */}
|
{/* Refresh/Stop Button */}
|
||||||
<button
|
<button
|
||||||
onClick={onRefresh}
|
onClick={handleAction}
|
||||||
disabled={isLoading}
|
className={`flex-shrink-0 p-2.5 rounded-full transition-all active:scale-90 mt-4 md:mt-0 md:ml-4 border border-transparent group relative
|
||||||
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"
|
${isLoading
|
||||||
title="Refresh Playlists"
|
? 'text-plex-orange bg-plex-orange/10 border-plex-orange/20 hover:bg-red-500/10 hover:border-red-500/30'
|
||||||
|
: 'text-gray-400 hover:text-white hover:bg-white/10'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
title={isLoading ? "Cancel Refresh" : "Refresh Playlists"}
|
||||||
>
|
>
|
||||||
<RefreshCw size={20} className={isLoading ? 'animate-spin text-plex-orange' : ''} strokeWidth={2} />
|
{isLoading ? (
|
||||||
|
<div className="relative flex items-center justify-center">
|
||||||
|
{/* Outer Spinner */}
|
||||||
|
<RefreshCw size={20} strokeWidth={2} className="animate-spin opacity-40 group-hover:opacity-20 transition-opacity" />
|
||||||
|
{/* Inner Cancel X */}
|
||||||
|
<X size={12} strokeWidth={3} className="absolute text-plex-orange group-hover:text-red-400 transition-colors" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<RefreshCw size={20} strokeWidth={2} />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -129,10 +129,10 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative group" ref={dropdownRef}>
|
<div className="relative group" ref={dropdownRef}>
|
||||||
{/* Trigger Button */}
|
{/* Trigger Button - Added Ring to create visual 'cutout' over panels */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
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"
|
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-[6px] md:ring-8 ring-gray-900 backdrop-blur-sm active:scale-95"
|
||||||
title={`Current Strategy: ${selectedOption.label}`}
|
title={`Current Strategy: ${selectedOption.label}`}
|
||||||
>
|
>
|
||||||
<selectedOption.icon size={22} className={selectedOption.color} strokeWidth={2.5} />
|
<selectedOption.icon size={22} className={selectedOption.color} strokeWidth={2.5} />
|
||||||
|
|||||||
+75
-25
@@ -13,28 +13,28 @@ const MOCK_LIBRARIES: PlexLibrary[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Helper to simulate network request or call actual API
|
// Helper to simulate network request or call actual API
|
||||||
const fetchPlaylists = async (type: ServerType): Promise<Playlist[]> => {
|
const fetchPlaylists = async (type: ServerType, signal?: AbortSignal): Promise<Playlist[]> => {
|
||||||
// In a real Docker environment with FastAPI, you would do:
|
return new Promise((resolve, reject) => {
|
||||||
// const response = await fetch(`/api/playlists/${type.toLowerCase()}`);
|
const timer = setTimeout(() => {
|
||||||
// const data = await response.json();
|
|
||||||
// return data;
|
|
||||||
|
|
||||||
// Mocking for UI demonstration
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (type === ServerType.LOCAL) {
|
if (type === ServerType.LOCAL) {
|
||||||
resolve([...MOCK_LOCAL_PLAYLISTS]);
|
resolve([...MOCK_LOCAL_PLAYLISTS]);
|
||||||
} else {
|
} else {
|
||||||
resolve([...MOCK_CLOUD_PLAYLISTS]);
|
resolve([...MOCK_CLOUD_PLAYLISTS]);
|
||||||
}
|
}
|
||||||
}, SIMULATE_DELAY_MS);
|
}, SIMULATE_DELAY_MS);
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener('abort', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'));
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchServerStatus = async (): Promise<PlexServerConnection> => {
|
const fetchServerStatus = async (signal?: AbortSignal): Promise<PlexServerConnection> => {
|
||||||
// Mocking server status
|
return new Promise((resolve, reject) => {
|
||||||
return new Promise((resolve) => {
|
const timer = setTimeout(() => {
|
||||||
setTimeout(() => {
|
|
||||||
// 90% chance of success for demo
|
// 90% chance of success for demo
|
||||||
const isSuccess = Math.random() > 0.1;
|
const isSuccess = Math.random() > 0.1;
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
@@ -51,12 +51,32 @@ const fetchServerStatus = async (): Promise<PlexServerConnection> => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, SIMULATE_DELAY_MS);
|
}, SIMULATE_DELAY_MS);
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener('abort', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'));
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const authenticatePlex = async (settings: PlexConnectionSettings): Promise<{ token: string, serverInfo: PlexServerConnection }> => {
|
const authenticatePlex = async (settings: PlexConnectionSettings, signal?: AbortSignal): Promise<{ token: string, serverInfo: PlexServerConnection }> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
setTimeout(() => {
|
// Determine effective timeout
|
||||||
|
const timeoutSeconds = settings.timeout || 9;
|
||||||
|
const timeoutMs = timeoutSeconds * 1000;
|
||||||
|
|
||||||
|
// Simulate latency (random between 1s and 2s, or longer to test timeout)
|
||||||
|
const latency = 1500;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
// Check if we timed out (simulated)
|
||||||
|
if (latency > timeoutMs) {
|
||||||
|
reject(new Error(`Connection timed out after ${settings.timeout}s`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Simulate validation
|
// Simulate validation
|
||||||
if (!settings.address) {
|
if (!settings.address) {
|
||||||
reject(new Error("Server address is required"));
|
reject(new Error("Server address is required"));
|
||||||
@@ -84,26 +104,49 @@ const authenticatePlex = async (settings: PlexConnectionSettings): Promise<{ tok
|
|||||||
libraries: MOCK_LIBRARIES
|
libraries: MOCK_LIBRARIES
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, 1500);
|
}, latency);
|
||||||
|
|
||||||
|
// Handle User Cancellation
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener('abort', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Actual Timeout Logic (if latency was indeterminate in real world)
|
||||||
|
// In this mock, the latency is fixed at 1500, but logic above simulates the check.
|
||||||
|
// However, if the user set timeout < 1.5s, we should reject sooner.
|
||||||
|
if (timeoutMs < latency) {
|
||||||
|
setTimeout(() => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error(`Connection timed out after ${settings.timeout}s`));
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiService = {
|
export const apiService = {
|
||||||
getPlaylists: async (serverType: ServerType): Promise<ApiResponse<Playlist[]>> => {
|
getPlaylists: async (serverType: ServerType, signal?: AbortSignal): Promise<ApiResponse<Playlist[]>> => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchPlaylists(serverType);
|
const data = await fetchPlaylists(serverType, signal);
|
||||||
return { data, status: 'success' };
|
return { data, status: 'success' };
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
// Return a specific status or handle gracefully
|
||||||
|
return { data: [], status: 'error', message: 'Request cancelled' };
|
||||||
|
}
|
||||||
console.error(`Error fetching ${serverType} playlists:`, error);
|
console.error(`Error fetching ${serverType} playlists:`, error);
|
||||||
return { data: [], status: 'error', message: 'Failed to fetch playlists' };
|
return { data: [], status: 'error', message: 'Failed to fetch playlists' };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
getServerStatus: async (): Promise<ApiResponse<PlexServerConnection>> => {
|
getServerStatus: async (signal?: AbortSignal): Promise<ApiResponse<PlexServerConnection>> => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchServerStatus();
|
const data = await fetchServerStatus(signal);
|
||||||
return { data, status: 'success' };
|
return { data, status: 'success' };
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
if (error.name === 'AbortError') return { data: { isConnected: false }, status: 'error', message: 'Cancelled' };
|
||||||
return {
|
return {
|
||||||
data: { isConnected: false },
|
data: { isConnected: false },
|
||||||
status: 'error',
|
status: 'error',
|
||||||
@@ -112,11 +155,18 @@ export const apiService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
connectToPlex: async (settings: PlexConnectionSettings): Promise<ApiResponse<{ token: string, serverInfo: PlexServerConnection }>> => {
|
connectToPlex: async (settings: PlexConnectionSettings, signal?: AbortSignal): Promise<ApiResponse<{ token: string, serverInfo: PlexServerConnection }>> => {
|
||||||
try {
|
try {
|
||||||
const data = await authenticatePlex(settings);
|
const data = await authenticatePlex(settings, signal);
|
||||||
return { data, status: 'success', message: 'Connected successfully' };
|
return { data, status: 'success', message: 'Connected successfully' };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
return {
|
||||||
|
data: { token: '', serverInfo: { isConnected: false } },
|
||||||
|
status: 'error',
|
||||||
|
message: 'Connection cancelled'
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
data: { token: '', serverInfo: { isConnected: false } },
|
data: { token: '', serverInfo: { isConnected: false } },
|
||||||
status: 'error',
|
status: 'error',
|
||||||
@@ -124,4 +174,4 @@ export const apiService = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user