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";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { Nav, useAuth } from "@/components/Nav";
|
import { Nav, useAuth } from "@/components/Nav";
|
||||||
|
|
||||||
interface NodeRow {
|
interface NodeRow {
|
||||||
|
|
@ -10,17 +11,8 @@ interface NodeRow {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DsConfig {
|
interface DsConfig {
|
||||||
id?: string;
|
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
nodeName?: string;
|
nodeName?: string;
|
||||||
baseUrl: string;
|
|
||||||
username: string;
|
|
||||||
downloadDestination: string;
|
|
||||||
downloadHostPath: string;
|
|
||||||
fourKHostPath: string;
|
|
||||||
enabled: boolean;
|
|
||||||
lastOkAt?: string | null;
|
|
||||||
lastError?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SearchHit {
|
interface SearchHit {
|
||||||
|
|
@ -61,13 +53,7 @@ interface JobRow {
|
||||||
movieTitle: string;
|
movieTitle: string;
|
||||||
movieYear: number | null;
|
movieYear: number | null;
|
||||||
posterUrl: string | null;
|
posterUrl: string | null;
|
||||||
tmdbId: number | null;
|
|
||||||
imdbId: string | null;
|
|
||||||
destFolderName: string | null;
|
|
||||||
importedPath: string | null;
|
|
||||||
error: string | null;
|
error: string | null;
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(n: number): string {
|
function formatBytes(n: number): string {
|
||||||
|
|
@ -97,26 +83,28 @@ function statusClass(s: string): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyConfig = (nodeId = ""): DsConfig => ({
|
function statusLabel(s: string): string {
|
||||||
nodeId,
|
switch (s) {
|
||||||
baseUrl: "",
|
case "DOWNLOADING":
|
||||||
username: "",
|
return "Downloaden";
|
||||||
downloadDestination: "Downloads",
|
case "FINISHED":
|
||||||
downloadHostPath: "/volume1/Downloads",
|
return "Klaar";
|
||||||
fourKHostPath: "/volume1/Media/Films/4K",
|
case "IMPORTING":
|
||||||
enabled: true,
|
return "Importeren";
|
||||||
});
|
case "IMPORTED":
|
||||||
|
return "Geïmporteerd";
|
||||||
|
case "FAILED":
|
||||||
|
return "Mislukt";
|
||||||
|
default:
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function DownloadsPage() {
|
export default function DownloadsPage() {
|
||||||
useAuth();
|
useAuth();
|
||||||
const [nodes, setNodes] = useState<NodeRow[]>([]);
|
const [nodes, setNodes] = useState<NodeRow[]>([]);
|
||||||
const [configs, setConfigs] = useState<DsConfig[]>([]);
|
const [configs, setConfigs] = useState<DsConfig[]>([]);
|
||||||
const [nodeId, setNodeId] = useState("");
|
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 [query, setQuery] = useState("");
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
|
|
@ -145,7 +133,7 @@ export default function DownloadsPage() {
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadAll = useCallback(() => {
|
const loadBootstrap = useCallback(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch("/api/v1/admin/nodes", { credentials: "include" }).then((r) => r.json()),
|
fetch("/api/v1/admin/nodes", { credentials: "include" }).then((r) => r.json()),
|
||||||
fetch("/api/v1/admin/downloads/configs", { 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);
|
setNodes(nodeList);
|
||||||
const list: DsConfig[] = c.configs ?? [];
|
const list: DsConfig[] = c.configs ?? [];
|
||||||
setConfigs(list);
|
setConfigs(list);
|
||||||
const preferred = list[0]?.nodeId || nodeList[0]?.id || "";
|
setNodeId((prev) => prev || list[0]?.nodeId || nodeList[0]?.id || "");
|
||||||
setNodeId((prev) => prev || preferred);
|
|
||||||
});
|
});
|
||||||
loadJobs();
|
loadJobs();
|
||||||
}, [loadJobs]);
|
}, [loadJobs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAll();
|
loadBootstrap();
|
||||||
const t = setInterval(loadJobs, 15_000);
|
const t = setInterval(loadJobs, 15_000);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, [loadAll, loadJobs]);
|
}, [loadBootstrap, 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]);
|
|
||||||
|
|
||||||
const hasConfig = useMemo(() => configs.some((c) => c.nodeId === nodeId), [configs, nodeId]);
|
const hasConfig = useMemo(() => configs.some((c) => c.nodeId === nodeId), [configs, nodeId]);
|
||||||
|
const configuredNodes = useMemo(() => {
|
||||||
async function saveConfig() {
|
const ids = new Set(configs.map((c) => c.nodeId));
|
||||||
setCfgBusy(true);
|
return nodes.filter((n) => ids.has(n.id));
|
||||||
setCfgMsg(null);
|
}, [nodes, configs]);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runSearch() {
|
async function runSearch() {
|
||||||
if (!query.trim() || !nodeId) return;
|
if (!query.trim() || !nodeId) return;
|
||||||
|
|
@ -268,10 +222,7 @@ export default function DownloadsPage() {
|
||||||
setSubs([]);
|
setSubs([]);
|
||||||
setSelectedSubId(null);
|
setSelectedSubId(null);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({ languages: "nl", releaseHint });
|
||||||
languages: "nl",
|
|
||||||
releaseHint,
|
|
||||||
});
|
|
||||||
if (meta.imdbId) params.set("imdbId", meta.imdbId);
|
if (meta.imdbId) params.set("imdbId", meta.imdbId);
|
||||||
else params.set("tmdbId", String(meta.tmdbId));
|
else params.set("tmdbId", String(meta.tmdbId));
|
||||||
const res = await fetch(`/api/v1/admin/downloads/subtitles?${params}`, {
|
const res = await fetch(`/api/v1/admin/downloads/subtitles?${params}`, {
|
||||||
|
|
@ -284,7 +235,7 @@ export default function DownloadsPage() {
|
||||||
}
|
}
|
||||||
setSubsConfigured(data.configured !== false);
|
setSubsConfigured(data.configured !== false);
|
||||||
if (data.configured === false) {
|
if (data.configured === false) {
|
||||||
setSubsMsg("OpenSubtitles API-key niet geconfigureerd op Master");
|
setSubsMsg("OpenSubtitles staat uit — zet aan onder Instellingen");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const results: SubtitleHit[] = data.results ?? [];
|
const results: SubtitleHit[] = data.results ?? [];
|
||||||
|
|
@ -354,7 +305,7 @@ export default function DownloadsPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Nav />
|
<Nav />
|
||||||
<div className="container">
|
<div className="container downloads-page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<div className="page-kicker">Synology · 4K</div>
|
<div className="page-kicker">Synology · 4K</div>
|
||||||
|
|
@ -362,165 +313,111 @@ export default function DownloadsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="stack-section">
|
{!hasConfig && (
|
||||||
<div className="section-head">
|
<div className="card downloads-banner">
|
||||||
<h2 className="section-title">Download Station</h2>
|
<p>
|
||||||
<p className="muted">
|
Download Station is nog niet geconfigureerd.{" "}
|
||||||
Zoek via de NAS, bevestig IMDb/TMDB, en na finish gaat de film naar de vaste 4K-map.
|
<Link href="/settings?tab=download-station">
|
||||||
|
Ga naar Instellingen → Download Station
|
||||||
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<section className="stack-section">
|
||||||
<div className="section-head">
|
<div className="card downloads-search-card">
|
||||||
<h2 className="section-title">Zoeken</h2>
|
<div className="downloads-toolbar">
|
||||||
<p className="muted">Vereist BT Search-modules in Download Station op de NAS.</p>
|
<label className="downloads-field">
|
||||||
</div>
|
<span>Node</span>
|
||||||
<div className="card">
|
<select
|
||||||
<div className="inline-form">
|
value={nodeId}
|
||||||
<input
|
onChange={(e) => setNodeId(e.target.value)}
|
||||||
value={query}
|
disabled={configuredNodes.length === 0}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
>
|
||||||
placeholder="Film + 2160p / UHD…"
|
<option value="">Kies node…</option>
|
||||||
onKeyDown={(e) => e.key === "Enter" && void runSearch()}
|
{configuredNodes.map((n) => (
|
||||||
/>
|
<option key={n.id} value={n.id}>
|
||||||
<button type="button" disabled={searching || !hasConfig} onClick={runSearch}>
|
{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"}
|
{searching ? "Zoeken…" : "Zoeken"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{searchErr && <p className="danger-text">{searchErr}</p>}
|
{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>
|
</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>
|
||||||
|
|
||||||
<section className="stack-section">
|
<section className="stack-section">
|
||||||
|
|
@ -529,22 +426,25 @@ export default function DownloadsPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="list-cards">
|
<div className="list-cards">
|
||||||
|
{jobs.length === 0 && <p className="muted">Nog geen downloads.</p>}
|
||||||
{jobs.map((j) => (
|
{jobs.map((j) => (
|
||||||
<article key={j.id} className="list-card">
|
<article key={j.id} className="list-card">
|
||||||
<div className="list-card-top">
|
<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>
|
<span className="muted">{j.nodeName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="list-card-title">
|
<div className="list-card-title">
|
||||||
{j.movieTitle}
|
{j.movieTitle}
|
||||||
{j.movieYear ? ` (${j.movieYear})` : ""}
|
{j.movieYear ? ` (${j.movieYear})` : ""}
|
||||||
</div>
|
</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.error && <div className="danger-text">{j.error}</div>}
|
||||||
{(j.status === "FAILED" || j.status === "FINISHED") && (
|
{(j.status === "FAILED" || j.status === "FINISHED") && (
|
||||||
<button type="button" onClick={() => void retryImport(j.id)}>
|
<div className="action-row">
|
||||||
Opnieuw importeren
|
<button type="button" onClick={() => void retryImport(j.id)}>
|
||||||
</button>
|
Opnieuw importeren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
|
@ -564,7 +464,7 @@ export default function DownloadsPage() {
|
||||||
{jobs.map((j) => (
|
{jobs.map((j) => (
|
||||||
<tr key={j.id}>
|
<tr key={j.id}>
|
||||||
<td>
|
<td>
|
||||||
<span className={statusClass(j.status)}>{j.status}</span>
|
<span className={statusClass(j.status)}>{statusLabel(j.status)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{j.movieTitle}
|
{j.movieTitle}
|
||||||
|
|
@ -576,7 +476,7 @@ export default function DownloadsPage() {
|
||||||
<td>
|
<td>
|
||||||
{(j.status === "FAILED" || j.status === "FINISHED") && (
|
{(j.status === "FAILED" || j.status === "FINISHED") && (
|
||||||
<button type="button" onClick={() => void retryImport(j.id)}>
|
<button type="button" onClick={() => void retryImport(j.id)}>
|
||||||
Retry import
|
Retry
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -598,13 +498,12 @@ export default function DownloadsPage() {
|
||||||
|
|
||||||
{pick && (
|
{pick && (
|
||||||
<div className="modal-backdrop" onClick={() => setPick(null)}>
|
<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>
|
<div className="page-kicker">Bevestig match</div>
|
||||||
<h2 style={{ marginBottom: "0.5rem" }}>Wat is dit?</h2>
|
<h2 className="downloads-modal-title">Wat is dit?</h2>
|
||||||
<p className="muted" style={{ marginBottom: "1rem" }}>
|
<p className="muted downloads-source">{pick.title}</p>
|
||||||
{pick.title}
|
|
||||||
</p>
|
<div className="downloads-toolbar downloads-toolbar-stack">
|
||||||
<div className="inline-form">
|
|
||||||
<input
|
<input
|
||||||
value={metaQ}
|
value={metaQ}
|
||||||
onChange={(e) => setMetaQ(e.target.value)}
|
onChange={(e) => setMetaQ(e.target.value)}
|
||||||
|
|
@ -615,6 +514,7 @@ export default function DownloadsPage() {
|
||||||
{metaBusy ? "…" : "Zoek"}
|
{metaBusy ? "…" : "Zoek"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="match-list">
|
<div className="match-list">
|
||||||
{metaHits.map((m) => (
|
{metaHits.map((m) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -642,7 +542,7 @@ export default function DownloadsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedMeta && (
|
{selectedMeta && (
|
||||||
<div style={{ marginTop: "1rem" }}>
|
<div className="downloads-subs">
|
||||||
<h3 className="card-subtitle">Ondertitel (NL)</h3>
|
<h3 className="card-subtitle">Ondertitel (NL)</h3>
|
||||||
{subsBusy && <p className="muted">OpenSubtitles zoeken…</p>}
|
{subsBusy && <p className="muted">OpenSubtitles zoeken…</p>}
|
||||||
{subsMsg && <p className="muted">{subsMsg}</p>}
|
{subsMsg && <p className="muted">{subsMsg}</p>}
|
||||||
|
|
@ -683,8 +583,12 @@ export default function DownloadsPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{jobErr && <p className="danger-text">{jobErr}</p>}
|
{jobErr && <p className="danger-text">{jobErr}</p>}
|
||||||
<div className="inline-form" style={{ marginTop: "1rem" }}>
|
<div className="downloads-modal-actions">
|
||||||
<button type="button" disabled={jobBusy || !selectedMeta} onClick={() => void confirmDownload()}>
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={jobBusy || !selectedMeta}
|
||||||
|
onClick={() => void confirmDownload()}
|
||||||
|
>
|
||||||
{jobBusy ? "Starten…" : "Bevestigen + downloaden"}
|
{jobBusy ? "Starten…" : "Bevestigen + downloaden"}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn-logout" onClick={() => setPick(null)}>
|
<button type="button" className="btn-logout" onClick={() => setPick(null)}>
|
||||||
|
|
|
||||||
|
|
@ -1328,3 +1328,158 @@ tr.row-selected {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
object-fit: cover;
|
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";
|
"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";
|
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 {
|
interface ShelfRow {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -15,11 +23,69 @@ interface ShelfRow {
|
||||||
paths?: Array<{ path: string; nodeName: string }>;
|
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() {
|
export default function SettingsPage() {
|
||||||
useAuth();
|
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 [shelves, setShelves] = useState<ShelfRow[]>([]);
|
||||||
const [newShelfName, setNewShelfName] = useState("");
|
const [newShelfName, setNewShelfName] = useState("");
|
||||||
const [newShelfKind, setNewShelfKind] = useState<"MOVIE" | "SERIES">("MOVIE");
|
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 [osApiKey, setOsApiKey] = useState("");
|
||||||
const [osUsername, setOsUsername] = useState("");
|
const [osUsername, setOsUsername] = useState("");
|
||||||
const [osPassword, setOsPassword] = useState("");
|
const [osPassword, setOsPassword] = useState("");
|
||||||
|
|
@ -37,6 +103,25 @@ export default function SettingsPage() {
|
||||||
.then((d) => setShelves(d.shelves ?? []));
|
.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(() => {
|
const loadOpenSubtitles = useCallback(() => {
|
||||||
fetch("/api/v1/admin/settings/opensubtitles", { credentials: "include" })
|
fetch("/api/v1/admin/settings/opensubtitles", { credentials: "include" })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
|
|
@ -54,8 +139,20 @@ export default function SettingsPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadShelves();
|
loadShelves();
|
||||||
|
loadDs();
|
||||||
loadOpenSubtitles();
|
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() {
|
async function createShelf() {
|
||||||
if (!newShelfName.trim()) return;
|
if (!newShelfName.trim()) return;
|
||||||
|
|
@ -88,6 +185,34 @@ export default function SettingsPage() {
|
||||||
loadShelves();
|
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() {
|
async function saveOpenSubtitles() {
|
||||||
setOsBusy(true);
|
setOsBusy(true);
|
||||||
setOsMsg(null);
|
setOsMsg(null);
|
||||||
|
|
@ -124,203 +249,302 @@ export default function SettingsPage() {
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<div className="page-kicker">Library structure</div>
|
<div className="page-kicker">Cluster config</div>
|
||||||
<h1>Instellingen</h1>
|
<h1>Instellingen</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="stack-section">
|
<nav className="settings-tabs" aria-label="Instellingen secties">
|
||||||
<div className="section-head">
|
{TABS.map((t) => (
|
||||||
<h2 className="section-title">Bibliotheek-planken</h2>
|
<button
|
||||||
<p className="muted">
|
key={t.id}
|
||||||
Namen zoals Kids Films of NL Series. Op Nodes koppel je scanpaden aan een plank —
|
type="button"
|
||||||
zelfde plank op meerdere nodes = één Stremio-lijst.
|
className={tab === t.id ? "active" : ""}
|
||||||
</p>
|
onClick={() => selectTab(t.id)}
|
||||||
</div>
|
>
|
||||||
|
<span className="settings-tab-full">{t.label}</span>
|
||||||
|
<span className="settings-tab-short">{t.short}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div className="card">
|
{tab === "shelves" && (
|
||||||
<h3 className="card-subtitle">Plank toevoegen</h3>
|
<section className="stack-section">
|
||||||
<div className="inline-form">
|
<div className="section-head">
|
||||||
<input
|
<h2 className="section-title">Bibliotheek-planken</h2>
|
||||||
value={newShelfName}
|
<p className="muted">
|
||||||
onChange={(e) => setNewShelfName(e.target.value)}
|
Namen zoals Kids Films of NL Series. Op Nodes koppel je scanpaden aan een plank.
|
||||||
placeholder="bijv. Kids Films"
|
</p>
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="card-subtitle">Bestaande planken</h3>
|
<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">
|
<div className="card">
|
||||||
<table>
|
<h3 className="card-subtitle">Bestaande planken</h3>
|
||||||
<thead>
|
<div className="desktop-table">
|
||||||
<tr>
|
<table>
|
||||||
<th>Naam</th>
|
<thead>
|
||||||
<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 && (
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="muted" style={{ textAlign: "center" }}>
|
<th>Naam</th>
|
||||||
Laden…
|
<th>Type</th>
|
||||||
</td>
|
<th>Pad(en)</th>
|
||||||
|
<th>Bestanden</th>
|
||||||
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{shelves.map((s) => (
|
||||||
</div>
|
<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">
|
<div className="list-cards">
|
||||||
{shelves.map((s) => (
|
{shelves.map((s) => (
|
||||||
<article key={s.id} className="list-card">
|
<article key={s.id} className="list-card">
|
||||||
<div className="list-card-top">
|
<div className="list-card-top">
|
||||||
<strong>{s.name}</strong>
|
<strong>{s.name}</strong>
|
||||||
<span className="status offline">
|
<span className="status offline">
|
||||||
{s.kind === "MOVIE" ? "Films" : "Series"}
|
{s.kind === "MOVIE" ? "Films" : "Series"}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
) : null}
|
<div className="list-card-meta">
|
||||||
{s.scanRootCount > 0 && s.mediaFileCount === 0 && (
|
<span>{s.mediaFileCount} bestanden</span>
|
||||||
<div className="warn-hint">Pad gekoppeld, nog geen scan</div>
|
<span>{s.scanRootCount} pad(en)</span>
|
||||||
)}
|
|
||||||
{s.slug !== "films" && s.slug !== "series" && (
|
|
||||||
<div className="action-row">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="danger"
|
|
||||||
onClick={() => deleteShelf(s.id, s.name)}
|
|
||||||
>
|
|
||||||
Verwijderen
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{s.paths?.length ? (
|
||||||
</article>
|
<div className="path-block">
|
||||||
))}
|
{s.paths.map((p) => (
|
||||||
{shelves.length === 0 && <p className="muted">Laden…</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>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
)}
|
||||||
|
|
||||||
<section className="stack-section">
|
{tab === "download-station" && (
|
||||||
<div className="section-head">
|
<section className="stack-section">
|
||||||
<h2 className="section-title">OpenSubtitles</h2>
|
<div className="section-head">
|
||||||
<p className="muted">
|
<h2 className="section-title">Download Station</h2>
|
||||||
Voor NL-ondertitels bij Downloads. Key aanmaken op opensubtitles.com → API consumers.
|
<p className="muted">
|
||||||
Gebruikersnaam/wachtwoord verhogen de downloadlimiet.
|
Synology DS-login en vaste paden voor 4K-import. Zoeken doe je onder{" "}
|
||||||
</p>
|
<Link href="/downloads">Downloads</Link>.
|
||||||
</div>
|
</p>
|
||||||
<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>
|
||||||
<div className="inline-form" style={{ marginTop: "0.75rem" }}>
|
<div className="card">
|
||||||
<button type="button" disabled={osBusy} onClick={() => void saveOpenSubtitles()}>
|
<div className="inline-form" style={{ marginBottom: "1rem" }}>
|
||||||
{osBusy ? "Bezig…" : "Opslaan + testen"}
|
<select value={nodeId} onChange={(e) => setNodeId(e.target.value)}>
|
||||||
</button>
|
<option value="">Kies Synology-node…</option>
|
||||||
<span className="muted">
|
{nodes.map((n) => (
|
||||||
Status: {osConfigured ? "geconfigureerd" : "niet geconfigureerd"}
|
<option key={n.id} value={n.id}>
|
||||||
</span>
|
{n.name} ({n.status})
|
||||||
{osMsg && <span className="ok-text">{osMsg}</span>}
|
</option>
|
||||||
{osErr && <span className="danger-text">{osErr}</span>}
|
))}
|
||||||
|
</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>
|
||||||
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue