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:
parent
9ef0323480
commit
01f4a2d38a
3 changed files with 832 additions and 1 deletions
111
src/frontend/src/api/hooks/useMediaServers.ts
Normal file
111
src/frontend/src/api/hooks/useMediaServers.ts
Normal 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`),
|
||||
});
|
||||
}
|
||||
399
src/frontend/src/components/MediaServerForm.tsx
Normal file
399
src/frontend/src/components/MediaServerForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue