Add multi-download-client support and UI management

Implements support for configuring both qBittorrent and SABnzbd simultaneously, including migration from legacy config, protocol-aware routing, and protocol filtering. Adds new CRUD API routes for download clients, new UI management components, and updates setup and settings flows to use the new multi-client architecture. Updates documentation to describe the new structure and usage.
This commit is contained in:
kikootwo
2026-01-29 09:21:33 -05:00
parent 3290ebbc9d
commit 2cda6decbe
26 changed files with 3452 additions and 924 deletions
@@ -0,0 +1,102 @@
/**
* Component: Download Client Card
* Documentation: documentation/phase3/download-clients.md
*/
'use client';
import React from 'react';
interface DownloadClientCardProps {
client: {
id: string;
type: 'qbittorrent' | 'sabnzbd';
name: string;
url: string;
enabled: boolean;
};
onEdit: () => void;
onDelete: () => void;
}
export function DownloadClientCard({ client, onEdit, onDelete }: DownloadClientCardProps) {
const typeName = client.type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd';
const typeColor = client.type === 'qbittorrent' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300';
// Truncate URL for display
const displayUrl = client.url.length > 40 ? `${client.url.substring(0, 40)}...` : client.url;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-4 hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between gap-3">
{/* Client Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100 truncate">
{client.name}
</h3>
{!client.enabled && (
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
Disabled
</span>
)}
</div>
<div className="flex flex-col gap-1">
<span className={`inline-block text-xs px-2 py-1 rounded font-medium ${typeColor} w-fit`}>
{typeName}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate" title={client.url}>
{displayUrl}
</p>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* Edit Button */}
<button
onClick={onEdit}
className="p-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors"
title="Edit client"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
{/* Delete Button */}
<button
onClick={onDelete}
className="p-2 text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
title="Delete client"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,374 @@
/**
* Component: Download Client Management Container
* Documentation: documentation/phase3/download-clients.md
*/
'use client';
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/Button';
import { DownloadClientCard } from './DownloadClientCard';
import { DownloadClientModal } from './DownloadClientModal';
import { fetchWithAuth } from '@/lib/utils/api';
interface DownloadClient {
id: string;
type: 'qbittorrent' | 'sabnzbd';
name: string;
url: string;
username?: string;
password: string;
enabled: boolean;
disableSSLVerify: boolean;
remotePathMappingEnabled: boolean;
remotePath?: string;
localPath?: string;
category?: string;
}
interface DownloadClientManagementProps {
mode: 'wizard' | 'settings';
initialClients?: DownloadClient[];
onClientsChange?: (clients: DownloadClient[]) => void;
}
export function DownloadClientManagement({
mode,
initialClients = [],
onClientsChange,
}: DownloadClientManagementProps) {
const [clients, setClients] = useState<DownloadClient[]>(initialClients);
const [modalState, setModalState] = useState<{
isOpen: boolean;
mode: 'add' | 'edit';
clientType?: 'qbittorrent' | 'sabnzbd';
currentClient?: DownloadClient;
}>({ isOpen: false, mode: 'add' });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{
isOpen: boolean;
clientId?: string;
clientName?: string;
}>({ isOpen: false });
// Fetch clients when in settings mode
useEffect(() => {
if (mode === 'settings') {
fetchClients();
}
}, [mode]);
// Sync with parent when clients change
useEffect(() => {
if (onClientsChange) {
onClientsChange(clients);
}
}, [clients, onClientsChange]);
// Sync with initialClients prop changes (wizard mode)
useEffect(() => {
if (mode === 'wizard') {
setClients(initialClients);
}
}, [initialClients, mode]);
const fetchClients = async () => {
setLoading(true);
setError(null);
try {
const response = await fetchWithAuth('/api/admin/settings/download-clients');
if (!response.ok) {
throw new Error('Failed to fetch download clients');
}
const data = await response.json();
setClients(data.clients || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch download clients');
} finally {
setLoading(false);
}
};
const handleAddClient = (type: 'qbittorrent' | 'sabnzbd') => {
// Check if this type already exists
const existingClient = clients.find(c => c.type === type && c.enabled);
if (existingClient) {
setError(`A ${type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'} client is already configured.`);
return;
}
setModalState({
isOpen: true,
mode: 'add',
clientType: type,
});
};
const handleEditClient = (client: DownloadClient) => {
setModalState({
isOpen: true,
mode: 'edit',
currentClient: client,
});
};
const handleDeleteClient = (client: DownloadClient) => {
setDeleteConfirm({
isOpen: true,
clientId: client.id,
clientName: client.name,
});
};
const confirmDelete = async () => {
if (!deleteConfirm.clientId) return;
setLoading(true);
setError(null);
try {
if (mode === 'settings') {
// API call for settings mode
const response = await fetchWithAuth(`/api/admin/settings/download-clients/${deleteConfirm.clientId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete download client');
}
await fetchClients(); // Refresh list
} else {
// Local removal for wizard mode
setClients(clients.filter(c => c.id !== deleteConfirm.clientId));
}
setDeleteConfirm({ isOpen: false });
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete download client');
} finally {
setLoading(false);
}
};
const handleSaveClient = async (clientData: any) => {
setLoading(true);
setError(null);
try {
if (mode === 'settings') {
// API call for settings mode
if (modalState.mode === 'add') {
const response = await fetchWithAuth('/api/admin/settings/download-clients', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(clientData),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to add download client');
}
await fetchClients(); // Refresh list
} else {
const response = await fetchWithAuth(`/api/admin/settings/download-clients/${clientData.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(clientData),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to update download client');
}
await fetchClients(); // Refresh list
}
} else {
// Local update for wizard mode
if (modalState.mode === 'add') {
const newClient = {
...clientData,
id: `temp-${Date.now()}`, // Temporary ID for wizard mode
};
setClients([...clients, newClient]);
} else {
setClients(clients.map(c => (c.id === clientData.id ? { ...c, ...clientData } : c)));
}
}
setModalState({ isOpen: false, mode: 'add' });
} catch (err) {
throw err; // Re-throw to let modal handle the error
} finally {
setLoading(false);
}
};
const hasQBittorrent = clients.some(c => c.type === 'qbittorrent' && c.enabled);
const hasSABnzbd = clients.some(c => c.type === 'sabnzbd' && c.enabled);
return (
<div className="space-y-6">
{/* Error Display */}
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-300 rounded-lg">
<p className="text-sm">{error}</p>
<button
onClick={() => setError(null)}
className="mt-2 text-xs underline hover:no-underline"
>
Dismiss
</button>
</div>
)}
{/* Add Client Section */}
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
Add Download Client
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* qBittorrent Card */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-start justify-between mb-3">
<div>
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
qBittorrent
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Torrent downloads
</p>
</div>
<span className="inline-block text-xs px-2 py-1 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-medium">
Torrent
</span>
</div>
{hasQBittorrent ? (
<div className="text-sm text-gray-500 dark:text-gray-400">
Already configured
</div>
) : (
<Button
onClick={() => handleAddClient('qbittorrent')}
variant="primary"
size="sm"
disabled={loading}
>
Add qBittorrent
</Button>
)}
</div>
{/* SABnzbd Card */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-start justify-between mb-3">
<div>
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
SABnzbd
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Usenet/NZB downloads
</p>
</div>
<span className="inline-block text-xs px-2 py-1 rounded bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 font-medium">
Usenet
</span>
</div>
{hasSABnzbd ? (
<div className="text-sm text-gray-500 dark:text-gray-400">
Already configured
</div>
) : (
<Button
onClick={() => handleAddClient('sabnzbd')}
variant="primary"
size="sm"
disabled={loading}
>
Add SABnzbd
</Button>
)}
</div>
</div>
</div>
{/* Configured Clients Section */}
{clients.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
Configured Clients
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{clients.map(client => (
<DownloadClientCard
key={client.id}
client={client}
onEdit={() => handleEditClient(client)}
onDelete={() => handleDeleteClient(client)}
/>
))}
</div>
</div>
)}
{/* Empty State */}
{clients.length === 0 && !loading && (
<div className="text-center py-12 bg-gray-50 dark:bg-gray-800/50 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-700">
<p className="text-gray-600 dark:text-gray-400 mb-2">
No download clients configured yet
</p>
<p className="text-sm text-gray-500 dark:text-gray-500">
Add at least one client to start downloading audiobooks
</p>
</div>
)}
{/* Client Modal */}
<DownloadClientModal
isOpen={modalState.isOpen}
onClose={() => setModalState({ isOpen: false, mode: 'add' })}
mode={modalState.mode}
clientType={modalState.clientType}
initialClient={modalState.currentClient}
onSave={handleSaveClient}
apiMode={mode}
/>
{/* Delete Confirmation Modal */}
{deleteConfirm.isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
Delete Download Client
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Are you sure you want to delete <strong>{deleteConfirm.clientName}</strong>? This action cannot be undone.
</p>
<div className="flex justify-end gap-3">
<Button
onClick={() => setDeleteConfirm({ isOpen: false })}
variant="secondary"
disabled={loading}
>
Cancel
</Button>
<Button
onClick={confirmDelete}
variant="danger"
disabled={loading}
>
{loading ? 'Deleting...' : 'Delete'}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,451 @@
/**
* Component: Download Client Configuration Modal
* Documentation: documentation/phase3/download-clients.md
*/
'use client';
import React, { useState, useEffect } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { fetchWithAuth } from '@/lib/utils/api';
interface DownloadClientModalProps {
isOpen: boolean;
onClose: () => void;
mode: 'add' | 'edit';
clientType?: 'qbittorrent' | 'sabnzbd';
initialClient?: {
id: string;
type: 'qbittorrent' | 'sabnzbd';
name: string;
url: string;
username?: string;
password: string;
enabled: boolean;
disableSSLVerify: boolean;
remotePathMappingEnabled: boolean;
remotePath?: string;
localPath?: string;
category?: string;
};
onSave: (client: any) => Promise<void>;
apiMode: 'wizard' | 'settings';
}
export function DownloadClientModal({
isOpen,
onClose,
mode,
clientType,
initialClient,
onSave,
apiMode,
}: DownloadClientModalProps) {
const type = mode === 'edit' ? initialClient?.type : clientType;
const typeName = type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd';
// Form state
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [enabled, setEnabled] = useState(true);
const [disableSSLVerify, setDisableSSLVerify] = useState(false);
const [remotePathMappingEnabled, setRemotePathMappingEnabled] = useState(false);
const [remotePath, setRemotePath] = useState('');
const [localPath, setLocalPath] = useState('');
const [category, setCategory] = useState('readmeabook');
const [testing, setTesting] = useState(false);
const [saving, setSaving] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
// Reset form when modal opens
useEffect(() => {
if (isOpen) {
if (mode === 'edit' && initialClient) {
setName(initialClient.name);
setUrl(initialClient.url);
setUsername(initialClient.username || '');
// In wizard mode, use actual password from local state
// In settings mode, mask password (server doesn't send real passwords)
setPassword(apiMode === 'wizard' ? initialClient.password : '********');
setEnabled(initialClient.enabled);
setDisableSSLVerify(initialClient.disableSSLVerify);
setRemotePathMappingEnabled(initialClient.remotePathMappingEnabled);
setRemotePath(initialClient.remotePath || '');
setLocalPath(initialClient.localPath || '');
setCategory(initialClient.category || 'readmeabook');
} else {
// Add mode defaults
setName(typeName);
setUrl('');
setUsername('');
setPassword('');
setEnabled(true);
setDisableSSLVerify(false);
setRemotePathMappingEnabled(false);
setRemotePath('');
setLocalPath('');
setCategory('readmeabook');
}
setTestResult(null);
setErrors({});
}
}, [isOpen, mode, initialClient, type]);
const validate = () => {
const newErrors: Record<string, string> = {};
if (!name.trim()) {
newErrors.name = 'Name is required';
}
if (!url.trim()) {
newErrors.url = 'URL is required';
}
if (type === 'qbittorrent' && !username.trim()) {
newErrors.username = 'Username is required for qBittorrent';
}
if (!password.trim() || (mode === 'add' && password === '********')) {
newErrors.password = type === 'qbittorrent' ? 'Password is required' : 'API key is required';
}
if (remotePathMappingEnabled) {
if (!remotePath.trim()) {
newErrors.remotePath = 'Remote path is required when path mapping is enabled';
}
if (!localPath.trim()) {
newErrors.localPath = 'Local path is required when path mapping is enabled';
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleTestConnection = async () => {
if (!validate()) {
return;
}
setTesting(true);
setTestResult(null);
try {
// If editing and password is masked, send clientId so server uses stored password
const isPasswordMasked = password === '********';
const testData = {
type,
url,
username: type === 'qbittorrent' ? username : undefined,
password: isPasswordMasked ? undefined : password,
// Include clientId when editing so server can use stored password
...(mode === 'edit' && initialClient && isPasswordMasked ? { clientId: initialClient.id } : {}),
disableSSLVerify,
remotePathMappingEnabled,
remotePath: remotePathMappingEnabled ? remotePath : undefined,
localPath: remotePathMappingEnabled ? localPath : undefined,
};
const endpoint = apiMode === 'wizard'
? '/api/setup/test-download-client'
: '/api/admin/settings/download-clients/test';
// Wizard mode: no auth required (public endpoint during setup)
// Settings mode: use fetchWithAuth to include JWT token
const response = apiMode === 'wizard'
? await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(testData),
})
: await fetchWithAuth(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(testData),
});
const data = await response.json();
if (response.ok && data.success) {
// Handle both endpoint response formats (settings returns message, wizard returns version)
const message = data.message || (data.version ? `Connected successfully (v${data.version})` : 'Connection successful');
setTestResult({ success: true, message });
} else {
setTestResult({ success: false, message: data.error || 'Connection test failed' });
}
} catch (error) {
setTestResult({
success: false,
message: error instanceof Error ? error.message : 'Connection test failed',
});
} finally {
setTesting(false);
}
};
const handleSave = async () => {
if (!validate()) {
return;
}
if (!testResult?.success) {
setErrors({ ...errors, test: 'Please test the connection before saving' });
return;
}
setSaving(true);
try {
const clientData: any = {
type,
name,
url,
username: type === 'qbittorrent' ? username : undefined,
password: password === '********' ? undefined : password, // Don't send masked password on edit
enabled,
disableSSLVerify,
remotePathMappingEnabled,
remotePath: remotePathMappingEnabled ? remotePath : undefined,
localPath: remotePathMappingEnabled ? localPath : undefined,
category,
};
if (mode === 'edit' && initialClient) {
clientData.id = initialClient.id;
}
await onSave(clientData);
onClose();
} catch (error) {
setErrors({
...errors,
save: error instanceof Error ? error.message : 'Failed to save client',
});
} finally {
setSaving(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`${mode === 'add' ? 'Add' : 'Edit'} ${typeName}`}
size="lg"
>
<div className="space-y-4">
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Name
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={`My ${typeName}`}
error={errors.name}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Friendly name to identify this client
</p>
</div>
{/* URL */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
URL
</label>
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={type === 'qbittorrent' ? 'http://localhost:8080' : 'http://localhost:8081'}
error={errors.url}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Web UI URL (e.g., http://localhost:8080)
</p>
</div>
{/* Username (qBittorrent only) */}
{type === 'qbittorrent' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Username
</label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="admin"
error={errors.username}
/>
</div>
)}
{/* Password / API Key */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{type === 'qbittorrent' ? 'Password' : 'API Key'}
</label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={type === 'qbittorrent' ? 'Password' : 'API Key from SABnzbd Config > General'}
error={errors.password}
/>
{type === 'sabnzbd' && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Found in SABnzbd under Config General API Key
</p>
)}
</div>
{/* SSL Verification */}
{url.startsWith('https://') && (
<div className="flex items-start">
<input
type="checkbox"
id="disableSSLVerify"
checked={disableSSLVerify}
onChange={(e) => setDisableSSLVerify(e.target.checked)}
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="disableSSLVerify" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
Disable SSL certificate verification
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Use for self-signed certificates (not recommended for production)
</p>
</label>
</div>
)}
{/* Enabled Toggle */}
<div className="flex items-start">
<input
type="checkbox"
id="enabled"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="enabled" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
Enabled
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Use this client for downloads
</p>
</label>
</div>
{/* Remote Path Mapping */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<div className="flex items-start mb-3">
<input
type="checkbox"
id="remotePathMapping"
checked={remotePathMappingEnabled}
onChange={(e) => setRemotePathMappingEnabled(e.target.checked)}
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="remotePathMapping" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
Enable Remote Path Mapping
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Use when download client sees a different filesystem than ReadMeABook
</p>
</label>
</div>
{remotePathMappingEnabled && (
<div className="space-y-3 ml-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Remote Path ({typeName})
</label>
<Input
value={remotePath}
onChange={(e) => setRemotePath(e.target.value)}
placeholder="F:\Docker\downloads\completed\books"
error={errors.remotePath}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Path as seen by {typeName}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Local Path (ReadMeABook)
</label>
<Input
value={localPath}
onChange={(e) => setLocalPath(e.target.value)}
placeholder="/downloads"
error={errors.localPath}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Path as seen by ReadMeABook
</p>
</div>
</div>
)}
</div>
{/* Test Result */}
{testResult && (
<div
className={`p-3 rounded-md ${
testResult.success
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
<p className="text-sm">{testResult.message}</p>
</div>
)}
{/* Errors */}
{errors.test && (
<div className="p-3 rounded-md bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300">
<p className="text-sm">{errors.test}</p>
</div>
)}
{errors.save && (
<div className="p-3 rounded-md bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-300">
<p className="text-sm">{errors.save}</p>
</div>
)}
{/* Action Buttons */}
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<Button
onClick={handleTestConnection}
disabled={testing}
variant="secondary"
>
{testing ? 'Testing...' : 'Test Connection'}
</Button>
<div className="flex gap-2">
<Button onClick={onClose} variant="secondary" disabled={saving}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={saving || !testResult?.success}
>
{saving ? 'Saving...' : mode === 'add' ? 'Add Client' : 'Save Changes'}
</Button>
</div>
</div>
</div>
</Modal>
);
}
+20 -12
View File
@@ -5,7 +5,7 @@
'use client';
import React, { useEffect } from 'react';
import React, { useEffect, useRef, useCallback } from 'react';
import { cn } from '@/lib/utils/cn';
interface ModalProps {
@@ -25,25 +25,33 @@ export function Modal({
size = 'md',
showCloseButton = true,
}: ModalProps) {
// Close on ESC key
// Use ref to avoid re-running effect when onClose changes
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
// Stable close handler
const handleClose = useCallback(() => {
onCloseRef.current();
}, []);
// Close on ESC key and manage body scroll
useEffect(() => {
if (!isOpen) return;
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
handleClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEsc);
// Prevent body scroll when modal is open
document.body.style.overflow = 'hidden';
}
document.addEventListener('keydown', handleEsc);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleEsc);
document.body.style.overflow = 'unset';
document.body.style.overflow = '';
};
}, [isOpen, onClose]);
}, [isOpen, handleClose]);
if (!isOpen) return null;
@@ -60,7 +68,7 @@ export function Modal({
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
onClick={onClose}
onClick={handleClose}
/>
{/* Modal container */}
@@ -80,7 +88,7 @@ export function Modal({
</h2>
{showCloseButton && (
<button
onClick={onClose}
onClick={handleClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg