Move Download Station into Settings tabs and harden Downloads for mobile.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d89b207acf
commit
213faa15ea
3 changed files with 708 additions and 425 deletions
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Nav, useAuth } from "@/components/Nav";
|
||||
|
||||
interface NodeRow {
|
||||
|
|
@ -10,17 +11,8 @@ interface NodeRow {
|
|||
}
|
||||
|
||||
interface DsConfig {
|
||||
id?: string;
|
||||
nodeId: string;
|
||||
nodeName?: string;
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
downloadDestination: string;
|
||||
downloadHostPath: string;
|
||||
fourKHostPath: string;
|
||||
enabled: boolean;
|
||||
lastOkAt?: string | null;
|
||||
lastError?: string | null;
|
||||
}
|
||||
|
||||
interface SearchHit {
|
||||
|
|
@ -61,13 +53,7 @@ interface JobRow {
|
|||
movieTitle: string;
|
||||
movieYear: number | null;
|
||||
posterUrl: string | null;
|
||||
tmdbId: number | null;
|
||||
imdbId: string | null;
|
||||
destFolderName: string | null;
|
||||
importedPath: string | null;
|
||||
error: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
|
|
@ -97,26 +83,28 @@ function statusClass(s: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
const emptyConfig = (nodeId = ""): DsConfig => ({
|
||||
nodeId,
|
||||
baseUrl: "",
|
||||
username: "",
|
||||
downloadDestination: "Downloads",
|
||||
downloadHostPath: "/volume1/Downloads",
|
||||
fourKHostPath: "/volume1/Media/Films/4K",
|
||||
enabled: true,
|
||||
});
|
||||
function statusLabel(s: string): string {
|
||||
switch (s) {
|
||||
case "DOWNLOADING":
|
||||
return "Downloaden";
|
||||
case "FINISHED":
|
||||
return "Klaar";
|
||||
case "IMPORTING":
|
||||
return "Importeren";
|
||||
case "IMPORTED":
|
||||
return "Geïmporteerd";
|
||||
case "FAILED":
|
||||
return "Mislukt";
|
||||
default:
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
export default function DownloadsPage() {
|
||||
useAuth();
|
||||
const [nodes, setNodes] = useState<NodeRow[]>([]);
|
||||
const [configs, setConfigs] = useState<DsConfig[]>([]);
|
||||
const [nodeId, setNodeId] = useState("");
|
||||
const [cfg, setCfg] = useState<DsConfig>(emptyConfig());
|
||||
const [password, setPassword] = useState("");
|
||||
const [cfgMsg, setCfgMsg] = useState<string | null>(null);
|
||||
const [cfgErr, setCfgErr] = useState<string | null>(null);
|
||||
const [cfgBusy, setCfgBusy] = useState(false);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
|
@ -145,7 +133,7 @@ export default function DownloadsPage() {
|
|||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const loadAll = useCallback(() => {
|
||||
const loadBootstrap = useCallback(() => {
|
||||
Promise.all([
|
||||
fetch("/api/v1/admin/nodes", { credentials: "include" }).then((r) => r.json()),
|
||||
fetch("/api/v1/admin/downloads/configs", { credentials: "include" }).then((r) => r.json()),
|
||||
|
|
@ -160,56 +148,22 @@ export default function DownloadsPage() {
|
|||
setNodes(nodeList);
|
||||
const list: DsConfig[] = c.configs ?? [];
|
||||
setConfigs(list);
|
||||
const preferred = list[0]?.nodeId || nodeList[0]?.id || "";
|
||||
setNodeId((prev) => prev || preferred);
|
||||
setNodeId((prev) => prev || list[0]?.nodeId || nodeList[0]?.id || "");
|
||||
});
|
||||
loadJobs();
|
||||
}, [loadJobs]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
loadBootstrap();
|
||||
const t = setInterval(loadJobs, 15_000);
|
||||
return () => clearInterval(t);
|
||||
}, [loadAll, loadJobs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!nodeId) return;
|
||||
const existing = configs.find((c) => c.nodeId === nodeId);
|
||||
setCfg(existing ? { ...existing } : emptyConfig(nodeId));
|
||||
setPassword("");
|
||||
setCfgMsg(null);
|
||||
setCfgErr(null);
|
||||
}, [nodeId, configs]);
|
||||
}, [loadBootstrap, loadJobs]);
|
||||
|
||||
const hasConfig = useMemo(() => configs.some((c) => c.nodeId === nodeId), [configs, nodeId]);
|
||||
|
||||
async function saveConfig() {
|
||||
setCfgBusy(true);
|
||||
setCfgMsg(null);
|
||||
setCfgErr(null);
|
||||
try {
|
||||
const res = await fetch("/api/v1/admin/downloads/config", {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...cfg,
|
||||
nodeId,
|
||||
password: password || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setCfgErr(data.error?.message ?? "Opslaan mislukt");
|
||||
return;
|
||||
}
|
||||
setCfgMsg("Opgeslagen — verbinding OK");
|
||||
setPassword("");
|
||||
loadAll();
|
||||
} finally {
|
||||
setCfgBusy(false);
|
||||
}
|
||||
}
|
||||
const configuredNodes = useMemo(() => {
|
||||
const ids = new Set(configs.map((c) => c.nodeId));
|
||||
return nodes.filter((n) => ids.has(n.id));
|
||||
}, [nodes, configs]);
|
||||
|
||||
async function runSearch() {
|
||||
if (!query.trim() || !nodeId) return;
|
||||
|
|
@ -268,10 +222,7 @@ export default function DownloadsPage() {
|
|||
setSubs([]);
|
||||
setSelectedSubId(null);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
languages: "nl",
|
||||
releaseHint,
|
||||
});
|
||||
const params = new URLSearchParams({ languages: "nl", releaseHint });
|
||||
if (meta.imdbId) params.set("imdbId", meta.imdbId);
|
||||
else params.set("tmdbId", String(meta.tmdbId));
|
||||
const res = await fetch(`/api/v1/admin/downloads/subtitles?${params}`, {
|
||||
|
|
@ -284,7 +235,7 @@ export default function DownloadsPage() {
|
|||
}
|
||||
setSubsConfigured(data.configured !== false);
|
||||
if (data.configured === false) {
|
||||
setSubsMsg("OpenSubtitles API-key niet geconfigureerd op Master");
|
||||
setSubsMsg("OpenSubtitles staat uit — zet aan onder Instellingen");
|
||||
return;
|
||||
}
|
||||
const results: SubtitleHit[] = data.results ?? [];
|
||||
|
|
@ -354,7 +305,7 @@ export default function DownloadsPage() {
|
|||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<div className="container">
|
||||
<div className="container downloads-page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-kicker">Synology · 4K</div>
|
||||
|
|
@ -362,165 +313,111 @@ export default function DownloadsPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<section className="stack-section">
|
||||
<div className="section-head">
|
||||
<h2 className="section-title">Download Station</h2>
|
||||
<p className="muted">
|
||||
Zoek via de NAS, bevestig IMDb/TMDB, en na finish gaat de film naar de vaste 4K-map.
|
||||
{!hasConfig && (
|
||||
<div className="card downloads-banner">
|
||||
<p>
|
||||
Download Station is nog niet geconfigureerd.{" "}
|
||||
<Link href="/settings?tab=download-station">
|
||||
Ga naar Instellingen → Download Station
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="inline-form" style={{ marginBottom: "1rem" }}>
|
||||
<select value={nodeId} onChange={(e) => setNodeId(e.target.value)}>
|
||||
<option value="">Kies Synology-node…</option>
|
||||
{nodes.map((n) => (
|
||||
<option key={n.id} value={n.id}>
|
||||
{n.name} ({n.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<h3 className="card-subtitle">Configuratie</h3>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
DSM URL
|
||||
<input
|
||||
value={cfg.baseUrl}
|
||||
onChange={(e) => setCfg({ ...cfg, baseUrl: e.target.value })}
|
||||
placeholder="https://192.168.x.x:5001"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Gebruiker
|
||||
<input
|
||||
value={cfg.username}
|
||||
onChange={(e) => setCfg({ ...cfg, username: e.target.value })}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Wachtwoord {hasConfig ? "(leeg = behouden)" : ""}
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
DS destination (share-naam, géén /volume1)
|
||||
<input
|
||||
value={cfg.downloadDestination}
|
||||
onChange={(e) => setCfg({ ...cfg, downloadDestination: e.target.value })}
|
||||
placeholder="Downloads"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Download host-pad (node, wél /volume1/…)
|
||||
<input
|
||||
value={cfg.downloadHostPath}
|
||||
onChange={(e) => setCfg({ ...cfg, downloadHostPath: e.target.value })}
|
||||
placeholder="/volume1/Downloads"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
4K bibliotheek host-pad
|
||||
<input
|
||||
value={cfg.fourKHostPath}
|
||||
onChange={(e) => setCfg({ ...cfg, fourKHostPath: e.target.value })}
|
||||
placeholder="/volume1/Media/Films/4K"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="inline-form" style={{ marginTop: "0.75rem" }}>
|
||||
<button type="button" disabled={cfgBusy || !nodeId} onClick={saveConfig}>
|
||||
{cfgBusy ? "Bezig…" : "Opslaan + testen"}
|
||||
</button>
|
||||
{cfgMsg && <span className="ok-text">{cfgMsg}</span>}
|
||||
{cfgErr && <span className="danger-text">{cfgErr}</span>}
|
||||
</div>
|
||||
{cfg.lastError && <p className="muted danger-text">Laatste fout: {cfg.lastError}</p>}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="stack-section">
|
||||
<div className="section-head">
|
||||
<h2 className="section-title">Zoeken</h2>
|
||||
<p className="muted">Vereist BT Search-modules in Download Station op de NAS.</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="inline-form">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Film + 2160p / UHD…"
|
||||
onKeyDown={(e) => e.key === "Enter" && void runSearch()}
|
||||
/>
|
||||
<button type="button" disabled={searching || !hasConfig} onClick={runSearch}>
|
||||
<div className="card downloads-search-card">
|
||||
<div className="downloads-toolbar">
|
||||
<label className="downloads-field">
|
||||
<span>Node</span>
|
||||
<select
|
||||
value={nodeId}
|
||||
onChange={(e) => setNodeId(e.target.value)}
|
||||
disabled={configuredNodes.length === 0}
|
||||
>
|
||||
<option value="">Kies node…</option>
|
||||
{configuredNodes.map((n) => (
|
||||
<option key={n.id} value={n.id}>
|
||||
{n.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="downloads-field downloads-field-grow">
|
||||
<span>Zoeken</span>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Film + 2160p / UHD…"
|
||||
onKeyDown={(e) => e.key === "Enter" && void runSearch()}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="downloads-search-btn"
|
||||
disabled={searching || !hasConfig || !query.trim()}
|
||||
onClick={() => void runSearch()}
|
||||
>
|
||||
{searching ? "Zoeken…" : "Zoeken"}
|
||||
</button>
|
||||
</div>
|
||||
{searchErr && <p className="danger-text">{searchErr}</p>}
|
||||
{!hasConfig && <p className="muted">Sla eerst de DS-configuratie op.</p>}
|
||||
|
||||
{hits.length > 0 && (
|
||||
<>
|
||||
<div className="list-cards" style={{ marginTop: "1rem" }}>
|
||||
{hits.map((h) => (
|
||||
<article key={`${h.title}-${h.size}-${h.seeds}`} className="list-card">
|
||||
<div className="list-card-top">
|
||||
<div className="list-card-title">{h.title}</div>
|
||||
<button type="button" disabled={!h.uri} onClick={() => void openMatch(h)}>
|
||||
Kiezen
|
||||
</button>
|
||||
</div>
|
||||
<div className="list-card-meta muted">
|
||||
<span>{formatBytes(h.size)}</span>
|
||||
<span>
|
||||
{h.seeds}/{h.peers} seeds
|
||||
</span>
|
||||
<span>{h.module || "—"}</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="desktop-table" style={{ marginTop: "1rem" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Titel</th>
|
||||
<th>Grootte</th>
|
||||
<th>Seeds</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hits.map((h) => (
|
||||
<tr key={`${h.title}-${h.size}-${h.seeds}`}>
|
||||
<td>
|
||||
<div>{h.title}</div>
|
||||
<div className="muted">{h.module}</div>
|
||||
</td>
|
||||
<td>{formatBytes(h.size)}</td>
|
||||
<td>
|
||||
{h.seeds}/{h.peers}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" disabled={!h.uri} onClick={() => void openMatch(h)}>
|
||||
Kiezen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hits.length > 0 && (
|
||||
<div className="card">
|
||||
<h3 className="card-subtitle">{hits.length} resultaten</h3>
|
||||
<div className="list-cards downloads-results">
|
||||
{hits.map((h) => (
|
||||
<article key={`${h.title}-${h.size}-${h.seeds}`} className="list-card">
|
||||
<div className="list-card-title">{h.title}</div>
|
||||
<div className="list-card-meta muted">
|
||||
<span>{formatBytes(h.size)}</span>
|
||||
<span>
|
||||
{h.seeds}/{h.peers} seeds
|
||||
</span>
|
||||
<span>{h.module || "—"}</span>
|
||||
</div>
|
||||
<div className="action-row">
|
||||
<button type="button" disabled={!h.uri} onClick={() => void openMatch(h)}>
|
||||
Kiezen
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="desktop-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Titel</th>
|
||||
<th>Grootte</th>
|
||||
<th>Seeds</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hits.map((h) => (
|
||||
<tr key={`${h.title}-${h.size}-${h.seeds}`}>
|
||||
<td>
|
||||
<div>{h.title}</div>
|
||||
<div className="muted">{h.module}</div>
|
||||
</td>
|
||||
<td>{formatBytes(h.size)}</td>
|
||||
<td>
|
||||
{h.seeds}/{h.peers}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" disabled={!h.uri} onClick={() => void openMatch(h)}>
|
||||
Kiezen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="stack-section">
|
||||
|
|
@ -529,22 +426,25 @@ export default function DownloadsPage() {
|
|||
</div>
|
||||
<div className="card">
|
||||
<div className="list-cards">
|
||||
{jobs.length === 0 && <p className="muted">Nog geen downloads.</p>}
|
||||
{jobs.map((j) => (
|
||||
<article key={j.id} className="list-card">
|
||||
<div className="list-card-top">
|
||||
<span className={statusClass(j.status)}>{j.status}</span>
|
||||
<span className={statusClass(j.status)}>{statusLabel(j.status)}</span>
|
||||
<span className="muted">{j.nodeName}</span>
|
||||
</div>
|
||||
<div className="list-card-title">
|
||||
{j.movieTitle}
|
||||
{j.movieYear ? ` (${j.movieYear})` : ""}
|
||||
</div>
|
||||
<div className="muted">{j.sourceTitle}</div>
|
||||
<div className="muted downloads-source">{j.sourceTitle}</div>
|
||||
{j.error && <div className="danger-text">{j.error}</div>}
|
||||
{(j.status === "FAILED" || j.status === "FINISHED") && (
|
||||
<button type="button" onClick={() => void retryImport(j.id)}>
|
||||
Opnieuw importeren
|
||||
</button>
|
||||
<div className="action-row">
|
||||
<button type="button" onClick={() => void retryImport(j.id)}>
|
||||
Opnieuw importeren
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
|
|
@ -564,7 +464,7 @@ export default function DownloadsPage() {
|
|||
{jobs.map((j) => (
|
||||
<tr key={j.id}>
|
||||
<td>
|
||||
<span className={statusClass(j.status)}>{j.status}</span>
|
||||
<span className={statusClass(j.status)}>{statusLabel(j.status)}</span>
|
||||
</td>
|
||||
<td>
|
||||
{j.movieTitle}
|
||||
|
|
@ -576,7 +476,7 @@ export default function DownloadsPage() {
|
|||
<td>
|
||||
{(j.status === "FAILED" || j.status === "FINISHED") && (
|
||||
<button type="button" onClick={() => void retryImport(j.id)}>
|
||||
Retry import
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
|
|
@ -598,13 +498,12 @@ export default function DownloadsPage() {
|
|||
|
||||
{pick && (
|
||||
<div className="modal-backdrop" onClick={() => setPick(null)}>
|
||||
<div className="modal-panel" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-panel downloads-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="page-kicker">Bevestig match</div>
|
||||
<h2 style={{ marginBottom: "0.5rem" }}>Wat is dit?</h2>
|
||||
<p className="muted" style={{ marginBottom: "1rem" }}>
|
||||
{pick.title}
|
||||
</p>
|
||||
<div className="inline-form">
|
||||
<h2 className="downloads-modal-title">Wat is dit?</h2>
|
||||
<p className="muted downloads-source">{pick.title}</p>
|
||||
|
||||
<div className="downloads-toolbar downloads-toolbar-stack">
|
||||
<input
|
||||
value={metaQ}
|
||||
onChange={(e) => setMetaQ(e.target.value)}
|
||||
|
|
@ -615,6 +514,7 @@ export default function DownloadsPage() {
|
|||
{metaBusy ? "…" : "Zoek"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="match-list">
|
||||
{metaHits.map((m) => (
|
||||
<button
|
||||
|
|
@ -642,7 +542,7 @@ export default function DownloadsPage() {
|
|||
</div>
|
||||
|
||||
{selectedMeta && (
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<div className="downloads-subs">
|
||||
<h3 className="card-subtitle">Ondertitel (NL)</h3>
|
||||
{subsBusy && <p className="muted">OpenSubtitles zoeken…</p>}
|
||||
{subsMsg && <p className="muted">{subsMsg}</p>}
|
||||
|
|
@ -683,8 +583,12 @@ export default function DownloadsPage() {
|
|||
)}
|
||||
|
||||
{jobErr && <p className="danger-text">{jobErr}</p>}
|
||||
<div className="inline-form" style={{ marginTop: "1rem" }}>
|
||||
<button type="button" disabled={jobBusy || !selectedMeta} onClick={() => void confirmDownload()}>
|
||||
<div className="downloads-modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={jobBusy || !selectedMeta}
|
||||
onClick={() => void confirmDownload()}
|
||||
>
|
||||
{jobBusy ? "Starten…" : "Bevestigen + downloaden"}
|
||||
</button>
|
||||
<button type="button" className="btn-logout" onClick={() => setPick(null)}>
|
||||
|
|
|
|||
|
|
@ -1328,3 +1328,158 @@ tr.row-selected {
|
|||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* ——— Settings tabs ——— */
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin: 0 0 1.25rem;
|
||||
padding-bottom: 0.15rem;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.settings-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.settings-tabs button {
|
||||
flex: 0 0 auto;
|
||||
min-height: 40px;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-dim);
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.settings-tabs button.active {
|
||||
color: var(--text-bright);
|
||||
border-color: var(--border-strong);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
.settings-tab-short {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.settings-tab-full {
|
||||
display: none;
|
||||
}
|
||||
.settings-tab-short {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
/* ——— Downloads (mobile-first) ——— */
|
||||
.downloads-banner p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.downloads-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.downloads-toolbar-stack {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.downloads-toolbar-stack input,
|
||||
.downloads-toolbar-stack button {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
}
|
||||
.downloads-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
flex: 1 1 140px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.downloads-field-grow {
|
||||
flex: 2 1 200px;
|
||||
}
|
||||
.downloads-field select,
|
||||
.downloads-field input {
|
||||
margin: 0;
|
||||
min-height: 44px;
|
||||
width: 100%;
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--border-dim);
|
||||
color: var(--text-bright);
|
||||
border-radius: 7px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.downloads-search-btn {
|
||||
flex: 0 0 auto;
|
||||
min-height: 44px;
|
||||
align-self: flex-end;
|
||||
}
|
||||
.downloads-source {
|
||||
word-break: break-word;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.downloads-results .list-card-title {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.downloads-modal-title {
|
||||
margin-bottom: 0.35rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.downloads-subs {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.downloads-modal-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.downloads-modal-actions button {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.downloads-modal-actions {
|
||||
flex-direction: row;
|
||||
}
|
||||
.downloads-modal-actions button {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.downloads-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.downloads-field,
|
||||
.downloads-field-grow {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.downloads-search-btn {
|
||||
width: 100%;
|
||||
align-self: stretch;
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.downloads-page .desktop-table {
|
||||
display: none !important;
|
||||
}
|
||||
.downloads-page .list-cards,
|
||||
.downloads-page .downloads-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.modal-panel.downloads-modal {
|
||||
width: 100%;
|
||||
max-height: min(92vh, 920px);
|
||||
border-radius: 14px 14px 0 0;
|
||||
padding: 1rem 1rem calc(1rem + var(--safe-bottom));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Nav, useAuth } from "@/components/Nav";
|
||||
|
||||
type SettingsTab = "shelves" | "download-station" | "opensubtitles";
|
||||
|
||||
function parseTab(v: string | null): SettingsTab {
|
||||
if (v === "download-station" || v === "opensubtitles" || v === "shelves") return v;
|
||||
return "shelves";
|
||||
}
|
||||
|
||||
interface ShelfRow {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -15,11 +23,69 @@ interface ShelfRow {
|
|||
paths?: Array<{ path: string; nodeName: string }>;
|
||||
}
|
||||
|
||||
interface NodeRow {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface DsConfig {
|
||||
id?: string;
|
||||
nodeId: string;
|
||||
nodeName?: string;
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
downloadDestination: string;
|
||||
downloadHostPath: string;
|
||||
fourKHostPath: string;
|
||||
enabled: boolean;
|
||||
lastOkAt?: string | null;
|
||||
lastError?: string | null;
|
||||
}
|
||||
|
||||
const TABS: Array<{ id: SettingsTab; label: string; short: string }> = [
|
||||
{ id: "shelves", label: "Planken", short: "Planken" },
|
||||
{ id: "download-station", label: "Download Station", short: "DS" },
|
||||
{ id: "opensubtitles", label: "OpenSubtitles", short: "Subs" },
|
||||
];
|
||||
|
||||
const emptyConfig = (nodeId = ""): DsConfig => ({
|
||||
nodeId,
|
||||
baseUrl: "",
|
||||
username: "",
|
||||
downloadDestination: "Downloads",
|
||||
downloadHostPath: "/volume1/Downloads",
|
||||
fourKHostPath: "/volume1/Media/Films/4K",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
export default function SettingsPage() {
|
||||
useAuth();
|
||||
const [tab, setTab] = useState<SettingsTab>("shelves");
|
||||
|
||||
useEffect(() => {
|
||||
setTab(parseTab(new URLSearchParams(window.location.search).get("tab")));
|
||||
}, []);
|
||||
|
||||
function selectTab(id: SettingsTab) {
|
||||
setTab(id);
|
||||
const url = id === "shelves" ? "/settings" : `/settings?tab=${id}`;
|
||||
window.history.replaceState(null, "", url);
|
||||
}
|
||||
|
||||
const [shelves, setShelves] = useState<ShelfRow[]>([]);
|
||||
const [newShelfName, setNewShelfName] = useState("");
|
||||
const [newShelfKind, setNewShelfKind] = useState<"MOVIE" | "SERIES">("MOVIE");
|
||||
|
||||
const [nodes, setNodes] = useState<NodeRow[]>([]);
|
||||
const [configs, setConfigs] = useState<DsConfig[]>([]);
|
||||
const [nodeId, setNodeId] = useState("");
|
||||
const [cfg, setCfg] = useState<DsConfig>(emptyConfig());
|
||||
const [password, setPassword] = useState("");
|
||||
const [cfgMsg, setCfgMsg] = useState<string | null>(null);
|
||||
const [cfgErr, setCfgErr] = useState<string | null>(null);
|
||||
const [cfgBusy, setCfgBusy] = useState(false);
|
||||
|
||||
const [osApiKey, setOsApiKey] = useState("");
|
||||
const [osUsername, setOsUsername] = useState("");
|
||||
const [osPassword, setOsPassword] = useState("");
|
||||
|
|
@ -37,6 +103,25 @@ export default function SettingsPage() {
|
|||
.then((d) => setShelves(d.shelves ?? []));
|
||||
}, []);
|
||||
|
||||
const loadDs = useCallback(() => {
|
||||
Promise.all([
|
||||
fetch("/api/v1/admin/nodes", { credentials: "include" }).then((r) => r.json()),
|
||||
fetch("/api/v1/admin/downloads/configs", { credentials: "include" }).then((r) => r.json()),
|
||||
]).then(([n, c]) => {
|
||||
const nodeList: NodeRow[] = (n.nodes ?? []).map(
|
||||
(x: { id: string; name: string; status: string }) => ({
|
||||
id: x.id,
|
||||
name: x.name,
|
||||
status: x.status,
|
||||
})
|
||||
);
|
||||
setNodes(nodeList);
|
||||
const list: DsConfig[] = c.configs ?? [];
|
||||
setConfigs(list);
|
||||
setNodeId((prev) => prev || list[0]?.nodeId || nodeList[0]?.id || "");
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadOpenSubtitles = useCallback(() => {
|
||||
fetch("/api/v1/admin/settings/opensubtitles", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
|
|
@ -54,8 +139,20 @@ export default function SettingsPage() {
|
|||
|
||||
useEffect(() => {
|
||||
loadShelves();
|
||||
loadDs();
|
||||
loadOpenSubtitles();
|
||||
}, [loadShelves, loadOpenSubtitles]);
|
||||
}, [loadShelves, loadDs, loadOpenSubtitles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!nodeId) return;
|
||||
const existing = configs.find((c) => c.nodeId === nodeId);
|
||||
setCfg(existing ? { ...existing } : emptyConfig(nodeId));
|
||||
setPassword("");
|
||||
setCfgMsg(null);
|
||||
setCfgErr(null);
|
||||
}, [nodeId, configs]);
|
||||
|
||||
const hasConfig = useMemo(() => configs.some((c) => c.nodeId === nodeId), [configs, nodeId]);
|
||||
|
||||
async function createShelf() {
|
||||
if (!newShelfName.trim()) return;
|
||||
|
|
@ -88,6 +185,34 @@ export default function SettingsPage() {
|
|||
loadShelves();
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
setCfgBusy(true);
|
||||
setCfgMsg(null);
|
||||
setCfgErr(null);
|
||||
try {
|
||||
const res = await fetch("/api/v1/admin/downloads/config", {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...cfg,
|
||||
nodeId,
|
||||
password: password || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setCfgErr(data.error?.message ?? "Opslaan mislukt");
|
||||
return;
|
||||
}
|
||||
setCfgMsg("Opgeslagen — verbinding OK");
|
||||
setPassword("");
|
||||
loadDs();
|
||||
} finally {
|
||||
setCfgBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveOpenSubtitles() {
|
||||
setOsBusy(true);
|
||||
setOsMsg(null);
|
||||
|
|
@ -124,203 +249,302 @@ export default function SettingsPage() {
|
|||
<div className="container">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-kicker">Library structure</div>
|
||||
<div className="page-kicker">Cluster config</div>
|
||||
<h1>Instellingen</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="stack-section">
|
||||
<div className="section-head">
|
||||
<h2 className="section-title">Bibliotheek-planken</h2>
|
||||
<p className="muted">
|
||||
Namen zoals Kids Films of NL Series. Op Nodes koppel je scanpaden aan een plank —
|
||||
zelfde plank op meerdere nodes = één Stremio-lijst.
|
||||
</p>
|
||||
</div>
|
||||
<nav className="settings-tabs" aria-label="Instellingen secties">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={tab === t.id ? "active" : ""}
|
||||
onClick={() => selectTab(t.id)}
|
||||
>
|
||||
<span className="settings-tab-full">{t.label}</span>
|
||||
<span className="settings-tab-short">{t.short}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="card">
|
||||
<h3 className="card-subtitle">Plank toevoegen</h3>
|
||||
<div className="inline-form">
|
||||
<input
|
||||
value={newShelfName}
|
||||
onChange={(e) => setNewShelfName(e.target.value)}
|
||||
placeholder="bijv. Kids Films"
|
||||
/>
|
||||
<select
|
||||
value={newShelfKind}
|
||||
onChange={(e) => setNewShelfKind(e.target.value as "MOVIE" | "SERIES")}
|
||||
>
|
||||
<option value="MOVIE">Films</option>
|
||||
<option value="SERIES">Series</option>
|
||||
</select>
|
||||
<button type="button" onClick={createShelf}>
|
||||
Toevoegen
|
||||
</button>
|
||||
{tab === "shelves" && (
|
||||
<section className="stack-section">
|
||||
<div className="section-head">
|
||||
<h2 className="section-title">Bibliotheek-planken</h2>
|
||||
<p className="muted">
|
||||
Namen zoals Kids Films of NL Series. Op Nodes koppel je scanpaden aan een plank.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 className="card-subtitle">Bestaande planken</h3>
|
||||
<div className="card">
|
||||
<h3 className="card-subtitle">Plank toevoegen</h3>
|
||||
<div className="inline-form">
|
||||
<input
|
||||
value={newShelfName}
|
||||
onChange={(e) => setNewShelfName(e.target.value)}
|
||||
placeholder="bijv. Kids Films"
|
||||
/>
|
||||
<select
|
||||
value={newShelfKind}
|
||||
onChange={(e) => setNewShelfKind(e.target.value as "MOVIE" | "SERIES")}
|
||||
>
|
||||
<option value="MOVIE">Films</option>
|
||||
<option value="SERIES">Series</option>
|
||||
</select>
|
||||
<button type="button" onClick={createShelf}>
|
||||
Toevoegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="desktop-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Naam</th>
|
||||
<th>Type</th>
|
||||
<th>Pad(en)</th>
|
||||
<th>Bestanden</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shelves.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>{s.name}</td>
|
||||
<td>{s.kind === "MOVIE" ? "Films" : "Series"}</td>
|
||||
<td className="path-cell">
|
||||
{s.paths?.length ? (
|
||||
s.paths.map((p) => (
|
||||
<div key={`${p.nodeName}:${p.path}`} className="path-line">
|
||||
<span className="muted">{p.nodeName}:</span> {p.path}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="muted">{s.scanRootCount} pad(en)</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{s.mediaFileCount}
|
||||
{s.scanRootCount > 0 && s.mediaFileCount === 0 && (
|
||||
<div className="warn-hint">Pad gekoppeld, nog geen scan</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{s.slug !== "films" && s.slug !== "series" && (
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => deleteShelf(s.id, s.name)}
|
||||
>
|
||||
Verwijderen
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{shelves.length === 0 && (
|
||||
<div className="card">
|
||||
<h3 className="card-subtitle">Bestaande planken</h3>
|
||||
<div className="desktop-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td colSpan={5} className="muted" style={{ textAlign: "center" }}>
|
||||
Laden…
|
||||
</td>
|
||||
<th>Naam</th>
|
||||
<th>Type</th>
|
||||
<th>Pad(en)</th>
|
||||
<th>Bestanden</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shelves.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>{s.name}</td>
|
||||
<td>{s.kind === "MOVIE" ? "Films" : "Series"}</td>
|
||||
<td className="path-cell">
|
||||
{s.paths?.length ? (
|
||||
s.paths.map((p) => (
|
||||
<div key={`${p.nodeName}:${p.path}`} className="path-line">
|
||||
<span className="muted">{p.nodeName}:</span> {p.path}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="muted">{s.scanRootCount} pad(en)</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{s.mediaFileCount}
|
||||
{s.scanRootCount > 0 && s.mediaFileCount === 0 && (
|
||||
<div className="warn-hint">Pad gekoppeld, nog geen scan</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{s.slug !== "films" && s.slug !== "series" && (
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => deleteShelf(s.id, s.name)}
|
||||
>
|
||||
Verwijderen
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{shelves.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="muted" style={{ textAlign: "center" }}>
|
||||
Laden…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="list-cards">
|
||||
{shelves.map((s) => (
|
||||
<article key={s.id} className="list-card">
|
||||
<div className="list-card-top">
|
||||
<strong>{s.name}</strong>
|
||||
<span className="status offline">
|
||||
{s.kind === "MOVIE" ? "Films" : "Series"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="list-card-meta">
|
||||
<span>{s.mediaFileCount} bestanden</span>
|
||||
<span>{s.scanRootCount} pad(en)</span>
|
||||
</div>
|
||||
{s.paths?.length ? (
|
||||
<div className="path-block">
|
||||
{s.paths.map((p) => (
|
||||
<div key={`${p.nodeName}:${p.path}`} className="path-line">
|
||||
<span className="muted">{p.nodeName}</span>
|
||||
<code>{p.path}</code>
|
||||
</div>
|
||||
))}
|
||||
<div className="list-cards">
|
||||
{shelves.map((s) => (
|
||||
<article key={s.id} className="list-card">
|
||||
<div className="list-card-top">
|
||||
<strong>{s.name}</strong>
|
||||
<span className="status offline">
|
||||
{s.kind === "MOVIE" ? "Films" : "Series"}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{s.scanRootCount > 0 && s.mediaFileCount === 0 && (
|
||||
<div className="warn-hint">Pad gekoppeld, nog geen scan</div>
|
||||
)}
|
||||
{s.slug !== "films" && s.slug !== "series" && (
|
||||
<div className="action-row">
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => deleteShelf(s.id, s.name)}
|
||||
>
|
||||
Verwijderen
|
||||
</button>
|
||||
<div className="list-card-meta">
|
||||
<span>{s.mediaFileCount} bestanden</span>
|
||||
<span>{s.scanRootCount} pad(en)</span>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
{shelves.length === 0 && <p className="muted">Laden…</p>}
|
||||
{s.paths?.length ? (
|
||||
<div className="path-block">
|
||||
{s.paths.map((p) => (
|
||||
<div key={`${p.nodeName}:${p.path}`} className="path-line">
|
||||
<span className="muted">{p.nodeName}</span>
|
||||
<code>{p.path}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{s.scanRootCount > 0 && s.mediaFileCount === 0 && (
|
||||
<div className="warn-hint">Pad gekoppeld, nog geen scan</div>
|
||||
)}
|
||||
{s.slug !== "films" && s.slug !== "series" && (
|
||||
<div className="action-row">
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => deleteShelf(s.id, s.name)}
|
||||
>
|
||||
Verwijderen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
{shelves.length === 0 && <p className="muted">Laden…</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="stack-section">
|
||||
<div className="section-head">
|
||||
<h2 className="section-title">OpenSubtitles</h2>
|
||||
<p className="muted">
|
||||
Voor NL-ondertitels bij Downloads. Key aanmaken op opensubtitles.com → API consumers.
|
||||
Gebruikersnaam/wachtwoord verhogen de downloadlimiet.
|
||||
</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
API-key {osHasKey ? "(leeg = behouden)" : ""}
|
||||
<input
|
||||
type="password"
|
||||
value={osApiKey}
|
||||
onChange={(e) => setOsApiKey(e.target.value)}
|
||||
placeholder={osHasKey ? "••••••••" : "API-key"}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Gebruikersnaam
|
||||
<input
|
||||
value={osUsername}
|
||||
onChange={(e) => setOsUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Wachtwoord {osHasPassword ? "(leeg = behouden)" : ""}
|
||||
<input
|
||||
type="password"
|
||||
value={osPassword}
|
||||
onChange={(e) => setOsPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<label className="check-label" style={{ alignSelf: "end" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={osEnabled}
|
||||
onChange={(e) => setOsEnabled(e.target.checked)}
|
||||
/>
|
||||
Ingeschakeld
|
||||
</label>
|
||||
{tab === "download-station" && (
|
||||
<section className="stack-section">
|
||||
<div className="section-head">
|
||||
<h2 className="section-title">Download Station</h2>
|
||||
<p className="muted">
|
||||
Synology DS-login en vaste paden voor 4K-import. Zoeken doe je onder{" "}
|
||||
<Link href="/downloads">Downloads</Link>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-form" style={{ marginTop: "0.75rem" }}>
|
||||
<button type="button" disabled={osBusy} onClick={() => void saveOpenSubtitles()}>
|
||||
{osBusy ? "Bezig…" : "Opslaan + testen"}
|
||||
</button>
|
||||
<span className="muted">
|
||||
Status: {osConfigured ? "geconfigureerd" : "niet geconfigureerd"}
|
||||
</span>
|
||||
{osMsg && <span className="ok-text">{osMsg}</span>}
|
||||
{osErr && <span className="danger-text">{osErr}</span>}
|
||||
<div className="card">
|
||||
<div className="inline-form" style={{ marginBottom: "1rem" }}>
|
||||
<select value={nodeId} onChange={(e) => setNodeId(e.target.value)}>
|
||||
<option value="">Kies Synology-node…</option>
|
||||
{nodes.map((n) => (
|
||||
<option key={n.id} value={n.id}>
|
||||
{n.name} ({n.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
DSM URL
|
||||
<input
|
||||
value={cfg.baseUrl}
|
||||
onChange={(e) => setCfg({ ...cfg, baseUrl: e.target.value })}
|
||||
placeholder="https://192.168.x.x:5001"
|
||||
inputMode="url"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Gebruiker
|
||||
<input
|
||||
value={cfg.username}
|
||||
onChange={(e) => setCfg({ ...cfg, username: e.target.value })}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Wachtwoord {hasConfig ? "(leeg = behouden)" : ""}
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
DS destination (share-naam)
|
||||
<input
|
||||
value={cfg.downloadDestination}
|
||||
onChange={(e) => setCfg({ ...cfg, downloadDestination: e.target.value })}
|
||||
placeholder="Downloads"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Download host-pad
|
||||
<input
|
||||
value={cfg.downloadHostPath}
|
||||
onChange={(e) => setCfg({ ...cfg, downloadHostPath: e.target.value })}
|
||||
placeholder="/volume1/Downloads"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
4K bibliotheek host-pad
|
||||
<input
|
||||
value={cfg.fourKHostPath}
|
||||
onChange={(e) => setCfg({ ...cfg, fourKHostPath: e.target.value })}
|
||||
placeholder="/volume1/Media/Films/4K"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="inline-form" style={{ marginTop: "0.75rem" }}>
|
||||
<button type="button" disabled={cfgBusy || !nodeId} onClick={() => void saveConfig()}>
|
||||
{cfgBusy ? "Bezig…" : "Opslaan + testen"}
|
||||
</button>
|
||||
{cfgMsg && <span className="ok-text">{cfgMsg}</span>}
|
||||
{cfgErr && <span className="danger-text">{cfgErr}</span>}
|
||||
</div>
|
||||
{cfg.lastError && <p className="muted danger-text">Laatste fout: {cfg.lastError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "opensubtitles" && (
|
||||
<section className="stack-section">
|
||||
<div className="section-head">
|
||||
<h2 className="section-title">OpenSubtitles</h2>
|
||||
<p className="muted">
|
||||
NL-ondertitels bij Downloads. Key via opensubtitles.com → API consumers.
|
||||
</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
API-key {osHasKey ? "(leeg = behouden)" : ""}
|
||||
<input
|
||||
type="password"
|
||||
value={osApiKey}
|
||||
onChange={(e) => setOsApiKey(e.target.value)}
|
||||
placeholder={osHasKey ? "••••••••" : "API-key"}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Gebruikersnaam
|
||||
<input
|
||||
value={osUsername}
|
||||
onChange={(e) => setOsUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Wachtwoord {osHasPassword ? "(leeg = behouden)" : ""}
|
||||
<input
|
||||
type="password"
|
||||
value={osPassword}
|
||||
onChange={(e) => setOsPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<label className="check-label" style={{ alignSelf: "end" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={osEnabled}
|
||||
onChange={(e) => setOsEnabled(e.target.checked)}
|
||||
/>
|
||||
Ingeschakeld
|
||||
</label>
|
||||
</div>
|
||||
<div className="inline-form" style={{ marginTop: "0.75rem" }}>
|
||||
<button type="button" disabled={osBusy} onClick={() => void saveOpenSubtitles()}>
|
||||
{osBusy ? "Bezig…" : "Opslaan + testen"}
|
||||
</button>
|
||||
<span className="muted">
|
||||
Status: {osConfigured ? "geconfigureerd" : "niet geconfigureerd"}
|
||||
</span>
|
||||
{osMsg && <span className="ok-text">{osMsg}</span>}
|
||||
{osErr && <span className="danger-text">{osErr}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue