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 { 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 {
|
import {
|
||||||
useFormatProfiles,
|
useFormatProfiles,
|
||||||
useCreateFormatProfile,
|
useCreateFormatProfile,
|
||||||
|
|
@ -20,11 +20,20 @@ import {
|
||||||
type NotificationSetting,
|
type NotificationSetting,
|
||||||
} from '../api/hooks/useNotifications';
|
} from '../api/hooks/useNotifications';
|
||||||
import { useApiKey, useRegenerateApiKey, useAppSettings, useUpdateAppSettings } from '../api/hooks/useSystem';
|
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 { Table, type Column } from '../components/Table';
|
||||||
import { Modal } from '../components/Modal';
|
import { Modal } from '../components/Modal';
|
||||||
import { FormatProfileForm, type FormatProfileFormValues } from '../components/FormatProfileForm';
|
import { FormatProfileForm, type FormatProfileFormValues } from '../components/FormatProfileForm';
|
||||||
import { PlatformSettingsForm, type PlatformSettingsFormValues } from '../components/PlatformSettingsForm';
|
import { PlatformSettingsForm, type PlatformSettingsFormValues } from '../components/PlatformSettingsForm';
|
||||||
import { NotificationForm, type NotificationFormValues } from '../components/NotificationForm';
|
import { NotificationForm, type NotificationFormValues } from '../components/NotificationForm';
|
||||||
|
import { MediaServerForm, type MediaServerFormValues } from '../components/MediaServerForm';
|
||||||
import { SkeletonSettings } from '../components/Skeleton';
|
import { SkeletonSettings } from '../components/Skeleton';
|
||||||
import type { FormatProfile, PlatformSettings } from '@shared/types/index';
|
import type { FormatProfile, PlatformSettings } from '@shared/types/index';
|
||||||
|
|
||||||
|
|
@ -78,6 +87,18 @@ export function SettingsPage() {
|
||||||
const [showRegenerateConfirm, setShowRegenerateConfirm] = useState(false);
|
const [showRegenerateConfirm, setShowRegenerateConfirm] = useState(false);
|
||||||
const [copySuccess, setCopySuccess] = 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 ──
|
// ── App Settings state ──
|
||||||
const { data: appSettings, isLoading: appSettingsLoading } = useAppSettings();
|
const { data: appSettings, isLoading: appSettingsLoading } = useAppSettings();
|
||||||
const updateAppSettingsMutation = useUpdateAppSettings();
|
const updateAppSettingsMutation = useUpdateAppSettings();
|
||||||
|
|
@ -162,6 +183,67 @@ export function SettingsPage() {
|
||||||
);
|
);
|
||||||
}, [settingsDirty, settingsValid, checkInterval, concurrentDownloads, updateAppSettingsMutation]);
|
}, [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 ──
|
// ── Format Profile handlers ──
|
||||||
|
|
||||||
const handleCreateProfile = useCallback(
|
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 ──
|
// ── Notification columns ──
|
||||||
|
|
||||||
const notificationColumns = useMemo<Column<NotificationSetting>[]>(
|
const notificationColumns = useMemo<Column<NotificationSetting>[]>(
|
||||||
|
|
@ -1069,6 +1263,58 @@ export function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 ── */}
|
{/* ── Notifications section ── */}
|
||||||
<section style={{ marginBottom: 'var(--space-8)' }}>
|
<section style={{ marginBottom: 'var(--space-8)' }}>
|
||||||
<div
|
<div
|
||||||
|
|
@ -1211,6 +1457,81 @@ export function SettingsPage() {
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</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 ── */}
|
{/* ── Notification: Create modal ── */}
|
||||||
<Modal title="New Notification Channel" open={showCreateNotifModal} onClose={() => setShowCreateNotifModal(false)} width={520}>
|
<Modal title="New Notification Channel" open={showCreateNotifModal} onClose={() => setShowCreateNotifModal(false)} width={520}>
|
||||||
<NotificationForm
|
<NotificationForm
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue