Move Download Station into Settings tabs and harden Downloads for mobile.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-30 20:15:18 +02:00
parent d89b207acf
commit 213faa15ea
3 changed files with 708 additions and 425 deletions

View file

@ -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)}>

View file

@ -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));
}
}

View file

@ -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>
</>
);