test: Built Media Servers settings section with full CRUD, connection t…

- "src/frontend/src/api/hooks/useMediaServers.ts"
- "src/frontend/src/components/MediaServerForm.tsx"
- "src/frontend/src/pages/Settings.tsx"

GSD-Task: S04/T04
This commit is contained in:
jlightner 2026-04-04 06:02:39 +00:00
parent 9ef0323480
commit 01f4a2d38a
3 changed files with 832 additions and 1 deletions

View file

@ -0,0 +1,111 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../client';
import type { MediaServer } from '@shared/types/index';
// ── Query Keys ──
export const mediaServerKeys = {
all: ['media-servers'] as const,
sections: (id: number) => ['media-servers', id, 'sections'] as const,
};
// ── Re-export for convenience ──
export type { MediaServer };
// ── Input Types ──
export interface CreateMediaServerInput {
name: string;
type: 'plex' | 'jellyfin';
url: string;
token: string;
librarySection?: string | null;
enabled?: boolean;
}
export interface UpdateMediaServerInput {
name?: string;
type?: 'plex' | 'jellyfin';
url?: string;
token?: string;
librarySection?: string | null;
enabled?: boolean;
}
export interface LibrarySection {
key: string;
title: string;
type: string;
}
export interface ConnectionTestResult {
success: boolean;
message: string;
serverName?: string;
}
// ── Queries ──
/** Fetch all media servers. */
export function useMediaServers() {
return useQuery({
queryKey: mediaServerKeys.all,
queryFn: () => apiClient.get<MediaServer[]>('/api/v1/media-servers'),
});
}
/** Fetch library sections for a saved media server by ID. */
export function useMediaServerSections(id: number | null) {
return useQuery({
queryKey: id !== null ? mediaServerKeys.sections(id) : ['media-servers', 'sections', 'none'],
queryFn: () => apiClient.get<LibrarySection[]>(`/api/v1/media-servers/${id}/sections`),
enabled: id !== null,
});
}
// ── Mutations ──
/** Create a new media server. Invalidates list on success. */
export function useCreateMediaServer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateMediaServerInput) =>
apiClient.post<MediaServer>('/api/v1/media-servers', input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: mediaServerKeys.all });
},
});
}
/** Update a media server by ID. Invalidates list on success. */
export function useUpdateMediaServer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, ...input }: UpdateMediaServerInput & { id: number }) =>
apiClient.put<MediaServer>(`/api/v1/media-servers/${id}`, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: mediaServerKeys.all });
},
});
}
/** Delete a media server by ID. Invalidates list on success. */
export function useDeleteMediaServer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiClient.del<void>(`/api/v1/media-servers/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: mediaServerKeys.all });
},
});
}
/** Test connection for a saved media server by ID. */
export function useTestMediaServer() {
return useMutation({
mutationFn: (id: number) =>
apiClient.post<ConnectionTestResult>(`/api/v1/media-servers/${id}/test`),
});
}

View file

@ -0,0 +1,399 @@
import { useState, useCallback, useEffect, type FormEvent } from 'react';
import { Loader, CheckCircle, XCircle, RefreshCw } from 'lucide-react';
import type { MediaServer } from '@shared/types/index';
import {
useTestMediaServer,
useMediaServerSections,
type LibrarySection,
} from '../api/hooks/useMediaServers';
// ── Types ──
export interface MediaServerFormValues {
name: string;
type: 'plex' | 'jellyfin';
url: string;
token: string;
librarySection: string | null;
enabled: boolean;
}
interface MediaServerFormProps {
/** Pass a server for edit mode. Omit for create mode. */
server?: MediaServer;
onSubmit: (values: MediaServerFormValues) => void;
onCancel: () => void;
isPending?: boolean;
error?: string | null;
}
// ── Shared styles ──
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
color: 'var(--text-secondary)',
marginBottom: 'var(--space-1)',
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: 'var(--space-2) var(--space-3)',
backgroundColor: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
color: 'var(--text-primary)',
fontSize: 'var(--font-size-base)',
};
const selectStyle: React.CSSProperties = {
...inputStyle,
cursor: 'pointer',
};
const fieldGroupStyle: React.CSSProperties = {
marginBottom: 'var(--space-4)',
};
const checkboxRowStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
marginBottom: 'var(--space-2)',
};
const checkboxStyle: React.CSSProperties = {
width: 16,
height: 16,
accentColor: 'var(--accent)',
cursor: 'pointer',
};
const checkboxLabelStyle: React.CSSProperties = {
fontSize: 'var(--font-size-sm)',
color: 'var(--text-secondary)',
cursor: 'pointer',
};
// ── Component ──
export function MediaServerForm({
server,
onSubmit,
onCancel,
isPending = false,
error,
}: MediaServerFormProps) {
const isEdit = !!server;
const [name, setName] = useState(server?.name ?? '');
const [type, setType] = useState<'plex' | 'jellyfin'>(server?.type ?? 'plex');
const [url, setUrl] = useState(server?.url ?? '');
const [token, setToken] = useState(''); // Never pre-fill redacted token
const [librarySection, setLibrarySection] = useState<string | null>(server?.librarySection ?? null);
const [enabled, setEnabled] = useState(server?.enabled ?? true);
const [validationError, setValidationError] = useState<string | null>(null);
// ── Test Connection ──
const testMutation = useTestMediaServer();
const [testResult, setTestResult] = useState<'success' | 'error' | null>(null);
const [testMessage, setTestMessage] = useState<string | null>(null);
const handleTestConnection = useCallback(() => {
if (!server) return; // Can only test saved servers
setTestResult(null);
setTestMessage(null);
testMutation.mutate(server.id, {
onSuccess: (data) => {
setTestResult(data.success ? 'success' : 'error');
setTestMessage(data.message);
},
onError: (err) => {
setTestResult('error');
setTestMessage(err instanceof Error ? err.message : 'Connection test failed');
},
});
}, [server, testMutation]);
// ── Sections fetch (only for saved servers) ──
const {
data: sections,
isLoading: sectionsLoading,
refetch: refetchSections,
} = useMediaServerSections(server?.id ?? null);
// Reset library section when type changes (sections differ between Plex and Jellyfin)
useEffect(() => {
if (!isEdit) {
setLibrarySection(null);
}
}, [type, isEdit]);
// ── Validation ──
const isValid =
name.trim().length > 0 &&
url.trim().length > 0 &&
(isEdit || token.trim().length > 0); // Token required for create, optional for edit (keep existing)
const handleSubmit = useCallback(
(e: FormEvent) => {
e.preventDefault();
setValidationError(null);
if (!name.trim()) {
setValidationError('Name is required.');
return;
}
if (!url.trim()) {
setValidationError('URL is required.');
return;
}
if (!isEdit && !token.trim()) {
setValidationError('Token is required.');
return;
}
const values: MediaServerFormValues = {
name: name.trim(),
type,
url: url.trim().replace(/\/+$/, ''), // Strip trailing slashes
token: token.trim(),
librarySection: librarySection || null,
enabled,
};
// In edit mode, only send token if user entered a new one
if (isEdit && !token.trim()) {
// Omit token field — the hook's UpdateMediaServerInput has all fields optional
const { token: _omit, ...rest } = values;
onSubmit(rest as MediaServerFormValues);
return;
}
onSubmit(values);
},
[name, type, url, token, librarySection, enabled, isEdit, onSubmit],
);
const displayError = validationError ?? error;
return (
<form onSubmit={handleSubmit}>
{displayError && (
<div
role="alert"
style={{
padding: 'var(--space-3)',
marginBottom: 'var(--space-4)',
backgroundColor: 'var(--danger-bg)',
border: '1px solid var(--danger)',
borderRadius: 'var(--radius-md)',
color: 'var(--danger)',
fontSize: 'var(--font-size-sm)',
}}
>
{displayError}
</div>
)}
{/* Name */}
<div style={fieldGroupStyle}>
<label htmlFor="ms-name" style={labelStyle}>
Name <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<input
id="ms-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Living Room Plex"
required
style={inputStyle}
autoFocus
/>
</div>
{/* Type */}
<div style={fieldGroupStyle}>
<label htmlFor="ms-type" style={labelStyle}>
Type <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<select
id="ms-type"
value={type}
onChange={(e) => setType(e.target.value as 'plex' | 'jellyfin')}
style={selectStyle}
>
<option value="plex">Plex</option>
<option value="jellyfin">Jellyfin</option>
</select>
</div>
{/* URL */}
<div style={fieldGroupStyle}>
<label htmlFor="ms-url" style={labelStyle}>
URL <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<input
id="ms-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={type === 'plex' ? 'http://192.168.1.100:32400' : 'http://192.168.1.100:8096'}
required
style={inputStyle}
/>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginTop: 'var(--space-1)', display: 'block' }}>
{type === 'plex'
? 'Plex server URL including port (default 32400)'
: 'Jellyfin server URL including port (default 8096)'}
</span>
</div>
{/* Token */}
<div style={fieldGroupStyle}>
<label htmlFor="ms-token" style={labelStyle}>
Token {!isEdit && <span style={{ color: 'var(--danger)' }}>*</span>}
</label>
<input
id="ms-token"
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder={isEdit ? '(leave empty to keep current)' : type === 'plex' ? 'X-Plex-Token value' : 'API key from Jellyfin dashboard'}
required={!isEdit}
style={inputStyle}
autoComplete="off"
/>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginTop: 'var(--space-1)', display: 'block' }}>
{type === 'plex'
? 'Find in Plex Web → Settings → General → X-Plex-Token'
: 'Dashboard → API Keys → Create'}
</span>
</div>
{/* Library Section (dropdown, only for saved servers) */}
{isEdit && (
<div style={fieldGroupStyle}>
<label htmlFor="ms-section" style={labelStyle}>
Library Section
</label>
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
<select
id="ms-section"
value={librarySection ?? ''}
onChange={(e) => setLibrarySection(e.target.value || null)}
style={{ ...selectStyle, flex: 1 }}
disabled={sectionsLoading}
>
<option value="">All libraries (scan all)</option>
{sections?.map((s: LibrarySection) => (
<option key={s.key} value={s.key}>
{s.title} ({s.type})
</option>
))}
</select>
<button
type="button"
onClick={() => refetchSections()}
disabled={sectionsLoading}
title="Refresh library sections"
aria-label="Refresh library sections"
className="btn-icon btn-icon-edit"
style={{ flexShrink: 0 }}
>
{sectionsLoading ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
) : (
<RefreshCw size={14} />
)}
</button>
</div>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', marginTop: 'var(--space-1)', display: 'block' }}>
Choose a specific library to scan, or scan all libraries after downloads.
</span>
</div>
)}
{/* Enabled */}
<div style={{ ...fieldGroupStyle, ...checkboxRowStyle }}>
<input
id="ms-enabled"
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
style={checkboxStyle}
/>
<label htmlFor="ms-enabled" style={checkboxLabelStyle}>
Enabled trigger scans when downloads complete
</label>
</div>
{/* Test Connection (only for saved servers) */}
{isEdit && (
<div style={{ ...fieldGroupStyle, display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
<button
type="button"
onClick={handleTestConnection}
disabled={testMutation.isPending}
className="btn btn-ghost"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-2)',
fontSize: 'var(--font-size-sm)',
}}
>
{testMutation.isPending ? (
<Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
) : testResult === 'success' ? (
<CheckCircle size={14} style={{ color: 'var(--success)' }} />
) : testResult === 'error' ? (
<XCircle size={14} style={{ color: 'var(--danger)' }} />
) : null}
Test Connection
</button>
{testMessage && (
<span
style={{
fontSize: 'var(--font-size-xs)',
color: testResult === 'success' ? 'var(--success)' : 'var(--danger)',
}}
>
{testMessage}
</span>
)}
</div>
)}
{/* Action buttons */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)', marginTop: 'var(--space-5)' }}>
<button
type="button"
onClick={onCancel}
disabled={isPending}
className="btn btn-ghost"
>
Cancel
</button>
<button
type="submit"
disabled={isPending || !isValid}
className="btn btn-primary"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-2)',
opacity: isPending || !isValid ? 0.6 : 1,
cursor: isPending || !isValid ? 'not-allowed' : 'pointer',
}}
>
{isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
{isEdit ? 'Save Changes' : 'Add Server'}
</button>
</div>
</form>
);
}

View file

@ -1,5 +1,5 @@
import { useState, useCallback, useMemo, useEffect } from 'react';
import { Plus, Pencil, Trash2, Loader, RefreshCw, Star, Bell, Send, CheckCircle, XCircle, Eye, EyeOff, Copy, RotateCw, Key, Globe, Save, FolderTree } from 'lucide-react';
import { Plus, Pencil, Trash2, Loader, RefreshCw, Star, Bell, Send, CheckCircle, XCircle, Eye, EyeOff, Copy, RotateCw, Key, Globe, Save, FolderTree, Server } from 'lucide-react';
import {
useFormatProfiles,
useCreateFormatProfile,
@ -20,11 +20,20 @@ import {
type NotificationSetting,
} from '../api/hooks/useNotifications';
import { useApiKey, useRegenerateApiKey, useAppSettings, useUpdateAppSettings } from '../api/hooks/useSystem';
import {
useMediaServers,
useCreateMediaServer,
useUpdateMediaServer,
useDeleteMediaServer,
useTestMediaServer,
type MediaServer,
} from '../api/hooks/useMediaServers';
import { Table, type Column } from '../components/Table';
import { Modal } from '../components/Modal';
import { FormatProfileForm, type FormatProfileFormValues } from '../components/FormatProfileForm';
import { PlatformSettingsForm, type PlatformSettingsFormValues } from '../components/PlatformSettingsForm';
import { NotificationForm, type NotificationFormValues } from '../components/NotificationForm';
import { MediaServerForm, type MediaServerFormValues } from '../components/MediaServerForm';
import { SkeletonSettings } from '../components/Skeleton';
import type { FormatProfile, PlatformSettings } from '@shared/types/index';
@ -78,6 +87,18 @@ export function SettingsPage() {
const [showRegenerateConfirm, setShowRegenerateConfirm] = useState(false);
const [copySuccess, setCopySuccess] = useState(false);
// ── Media Servers state ──
const { data: mediaServers, isLoading: mediaServersLoading } = useMediaServers();
const createMediaServerMutation = useCreateMediaServer();
const updateMediaServerMutation = useUpdateMediaServer();
const deleteMediaServerMutation = useDeleteMediaServer();
const testMediaServerMutation = useTestMediaServer();
const [showCreateMediaServerModal, setShowCreateMediaServerModal] = useState(false);
const [editingMediaServer, setEditingMediaServer] = useState<MediaServer | null>(null);
const [deletingMediaServer, setDeletingMediaServer] = useState<MediaServer | null>(null);
const [mediaServerTestResults, setMediaServerTestResults] = useState<Record<number, 'success' | 'error' | 'loading' | null>>({});
// ── App Settings state ──
const { data: appSettings, isLoading: appSettingsLoading } = useAppSettings();
const updateAppSettingsMutation = useUpdateAppSettings();
@ -162,6 +183,67 @@ export function SettingsPage() {
);
}, [settingsDirty, settingsValid, checkInterval, concurrentDownloads, updateAppSettingsMutation]);
// ── Media Server handlers ──
const handleCreateMediaServer = useCallback(
(values: MediaServerFormValues) => {
createMediaServerMutation.mutate(values, {
onSuccess: () => setShowCreateMediaServerModal(false),
});
},
[createMediaServerMutation],
);
const handleUpdateMediaServer = useCallback(
(values: MediaServerFormValues) => {
if (!editingMediaServer) return;
// Only send token if user entered a new one
const payload: Record<string, unknown> = {
id: editingMediaServer.id,
name: values.name,
type: values.type,
url: values.url,
librarySection: values.librarySection,
enabled: values.enabled,
};
if (values.token) {
payload.token = values.token;
}
updateMediaServerMutation.mutate(payload as Parameters<typeof updateMediaServerMutation.mutate>[0], {
onSuccess: () => setEditingMediaServer(null),
});
},
[editingMediaServer, updateMediaServerMutation],
);
const handleDeleteMediaServer = useCallback(() => {
if (!deletingMediaServer) return;
deleteMediaServerMutation.mutate(deletingMediaServer.id, {
onSuccess: () => setDeletingMediaServer(null),
});
}, [deletingMediaServer, deleteMediaServerMutation]);
const handleTestMediaServer = useCallback(
(id: number) => {
setMediaServerTestResults((prev) => ({ ...prev, [id]: 'loading' }));
testMediaServerMutation.mutate(id, {
onSuccess: (data) => {
setMediaServerTestResults((prev) => ({ ...prev, [id]: data.success ? 'success' : 'error' }));
setTimeout(() => {
setMediaServerTestResults((prev) => ({ ...prev, [id]: null }));
}, 4000);
},
onError: () => {
setMediaServerTestResults((prev) => ({ ...prev, [id]: 'error' }));
setTimeout(() => {
setMediaServerTestResults((prev) => ({ ...prev, [id]: null }));
}, 4000);
},
});
},
[testMediaServerMutation],
);
// ── Format Profile handlers ──
const handleCreateProfile = useCallback(
@ -519,6 +601,118 @@ export function SettingsPage() {
[],
);
// ── Media Server columns ──
const mediaServerColumns = useMemo<Column<MediaServer>[]>(
() => [
{
key: 'name',
label: 'Name',
render: (s) => (
<span style={{ fontWeight: 500, color: 'var(--text-primary)' }}>{s.name}</span>
),
},
{
key: 'type',
label: 'Type',
width: '100px',
render: (s) => (
<span
style={{
...badgeBase,
color: s.type === 'plex' ? '#e5a00d' : '#00a4dc',
backgroundColor: s.type === 'plex' ? 'rgba(229, 160, 13, 0.1)' : 'rgba(0, 164, 220, 0.1)',
}}
>
{s.type === 'plex' ? 'Plex' : 'Jellyfin'}
</span>
),
},
{
key: 'url',
label: 'URL',
render: (s) => (
<span style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-size-sm)', fontFamily: 'var(--font-mono, monospace)' }}>
{s.url}
</span>
),
},
{
key: 'section',
label: 'Library',
width: '120px',
render: (s) => (
<span style={{ color: 'var(--text-muted)', fontSize: 'var(--font-size-sm)' }}>
{s.librarySection ?? 'All'}
</span>
),
},
{
key: 'enabled',
label: 'Status',
width: '90px',
render: (s) => (
<span
style={{
...badgeBase,
color: s.enabled ? 'var(--success)' : 'var(--text-muted)',
backgroundColor: s.enabled ? 'var(--success-bg)' : 'var(--bg-hover)',
}}
>
{s.enabled ? 'Active' : 'Disabled'}
</span>
),
},
{
key: 'actions',
label: '',
width: '130px',
render: (s) => {
const result = mediaServerTestResults[s.id];
return (
<div style={{ display: 'flex', gap: 'var(--space-2)', justifyContent: 'flex-end', alignItems: 'center' }}>
{result === 'success' && <CheckCircle size={14} style={{ color: 'var(--success)' }} />}
{result === 'error' && <XCircle size={14} style={{ color: 'var(--danger)' }} />}
<button
onClick={(e) => { e.stopPropagation(); handleTestMediaServer(s.id); }}
title="Test connection"
aria-label={`Test ${s.name}`}
disabled={result === 'loading'}
className="btn-icon btn-icon-test"
style={{ opacity: result === 'loading' ? 0.5 : 1 }}
>
{result === 'loading'
? <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
: <Send size={14} />
}
</button>
<button
onClick={(e) => { e.stopPropagation(); setEditingMediaServer(s); }}
title="Edit server"
aria-label={`Edit ${s.name}`}
className="btn-icon btn-icon-edit"
>
<Pencil size={14} />
</button>
<button
onClick={(e) => { e.stopPropagation(); setDeletingMediaServer(s); }}
title="Delete server"
aria-label={`Delete ${s.name}`}
className="btn-icon btn-icon-delete"
>
<Trash2 size={14} />
</button>
</div>
);
},
},
],
[mediaServerTestResults, handleTestMediaServer],
);
// ── Notification columns ──
const notificationColumns = useMemo<Column<NotificationSetting>[]>(
@ -1069,6 +1263,58 @@ export function SettingsPage() {
</div>
</section>
{/* ── Media Servers section ── */}
<section style={{ marginBottom: 'var(--space-8)' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 'var(--space-4)',
}}
>
<div>
<h2 style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-lg)', fontWeight: 600, color: 'var(--text-primary)' }}>
<Server size={20} />
Media Servers
</h2>
<p style={{ color: 'var(--text-muted)', fontSize: 'var(--font-size-sm)', marginTop: 'var(--space-1)' }}>
Connect Plex or Jellyfin servers to automatically scan libraries after downloads complete.
</p>
</div>
<button
onClick={() => setShowCreateMediaServerModal(true)}
className="btn btn-primary"
>
<Plus size={16} />
Add Server
</button>
</div>
<div
style={{
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-xl)',
border: '1px solid var(--border)',
overflow: 'hidden',
}}
>
{mediaServersLoading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--space-8)', color: 'var(--text-muted)' }}>
<Loader size={16} style={{ animation: 'spin 1s linear infinite', marginRight: 'var(--space-2)' }} />
Loading media servers...
</div>
) : (
<Table
columns={mediaServerColumns}
data={mediaServers ?? []}
keyExtractor={(s) => s.id}
emptyMessage="No media servers configured. Add a Plex or Jellyfin server to enable automatic library scans."
/>
)}
</div>
</section>
{/* ── Notifications section ── */}
<section style={{ marginBottom: 'var(--space-8)' }}>
<div
@ -1211,6 +1457,81 @@ export function SettingsPage() {
)}
</Modal>
{/* ── Media Server: Create modal ── */}
<Modal title="Add Media Server" open={showCreateMediaServerModal} onClose={() => setShowCreateMediaServerModal(false)} width={520}>
<MediaServerForm
onSubmit={handleCreateMediaServer}
onCancel={() => setShowCreateMediaServerModal(false)}
isPending={createMediaServerMutation.isPending}
error={createMediaServerMutation.error instanceof Error ? createMediaServerMutation.error.message : null}
/>
</Modal>
{/* ── Media Server: Edit modal ── */}
<Modal
title={`Edit "${editingMediaServer?.name ?? ''}"`}
open={!!editingMediaServer}
onClose={() => setEditingMediaServer(null)}
width={520}
>
{editingMediaServer && (
<MediaServerForm
server={editingMediaServer}
onSubmit={handleUpdateMediaServer}
onCancel={() => setEditingMediaServer(null)}
isPending={updateMediaServerMutation.isPending}
error={updateMediaServerMutation.error instanceof Error ? updateMediaServerMutation.error.message : null}
/>
)}
</Modal>
{/* ── Media Server: Delete confirmation ── */}
<Modal
title="Delete Media Server"
open={!!deletingMediaServer}
onClose={() => setDeletingMediaServer(null)}
width={400}
>
<p style={{ color: 'var(--text-secondary)', marginBottom: 'var(--space-5)', lineHeight: 1.6 }}>
Are you sure you want to delete <strong style={{ color: 'var(--text-primary)' }}>{deletingMediaServer?.name}</strong>?
This action cannot be undone.
</p>
{deleteMediaServerMutation.error && (
<div
role="alert"
style={{
padding: 'var(--space-3)',
marginBottom: 'var(--space-4)',
backgroundColor: 'var(--danger-bg)',
border: '1px solid var(--danger)',
borderRadius: 'var(--radius-md)',
color: 'var(--danger)',
fontSize: 'var(--font-size-sm)',
}}
>
{deleteMediaServerMutation.error instanceof Error ? deleteMediaServerMutation.error.message : 'Delete failed'}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)' }}>
<button
onClick={() => setDeletingMediaServer(null)}
disabled={deleteMediaServerMutation.isPending}
className="btn btn-ghost"
>
Cancel
</button>
<button
onClick={handleDeleteMediaServer}
disabled={deleteMediaServerMutation.isPending}
className="btn btn-danger"
style={{ opacity: deleteMediaServerMutation.isPending ? 0.6 : 1 }}
>
{deleteMediaServerMutation.isPending && <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />}
Delete
</button>
</div>
</Modal>
{/* ── Notification: Create modal ── */}
<Modal title="New Notification Channel" open={showCreateNotifModal} onClose={() => setShowCreateNotifModal(false)} width={520}>
<NotificationForm