stremio/apps/admin-ui/src/app/series/page.tsx
Jos Vooges | STH db8e3e572f Add OMDb admin settings and poster API for IMDb metadata.
Store the key encrypted in the DB (not git) and proxy OMDb posters so clients never see the API key.
2026-09-16 23:33:01 +02:00

1022 lines
36 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { Nav, useAuth } from "@/components/Nav";
interface SeriesFolder {
folder: string;
fileCount: number;
}
interface SeriesNode {
id: string;
name: string;
fileCount: number;
folders?: SeriesFolder[];
}
interface SeriesFile {
id: string;
node: string;
resolution: string | null;
available: boolean;
releaseName?: string;
path?: string | null;
folder?: string | null;
}
interface Series {
id: string;
title: string;
year: number | null;
imdbId: string | null;
tmdbId?: number | null;
posterUrl?: string | null;
episodesWithFiles?: number;
fileCount?: number;
nodes?: SeriesNode[];
seasons: Array<{
seasonNumber: number;
episodes: Array<{
seasonNumber: number;
episodeNumber: number;
title: string | null;
files: SeriesFile[];
}>;
}>;
}
interface NodeOption {
id: string;
name: string;
status?: string;
}
interface PathFile {
id: string;
node: string;
path: string | null;
folder: string | null;
releaseName: string;
mediaType: string;
movie: { id: string; title: string; year: number | null } | null;
series: {
id: string;
title: string;
year: number | null;
seasonNumber: number;
episodeNumber: number;
} | null;
parsedHint: { title?: string; season?: number; episode?: number; year?: number } | null;
}
interface MetaHit {
tmdbId: number | null;
title: string;
year: number | null;
overview: string | null;
posterUrl: string | null;
imdbId: string | null;
source?: "tmdb" | "omdb";
}
export default function SeriesPage() {
useAuth();
const [series, setSeries] = useState<Series[]>([]);
const [pathFiles, setPathFiles] = useState<PathFile[] | null>(null);
const [nodes, setNodes] = useState<NodeOption[]>([]);
const [nodeId, setNodeId] = useState("");
const [search, setSearch] = useState("");
const [pathSearch, setPathSearch] = useState("");
const [loading, setLoading] = useState(false);
const [searched, setSearched] = useState(false);
const [limitHit, setLimitHit] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
const [busyFileId, setBusyFileId] = useState<string | null>(null);
const [msg, setMsg] = useState("");
const [linkSeriesId, setLinkSeriesId] = useState<string | null>(null);
const [linkQuery, setLinkQuery] = useState("");
const [linkHits, setLinkHits] = useState<MetaHit[]>([]);
const [linkBusy, setLinkBusy] = useState(false);
const [linkMsg, setLinkMsg] = useState("");
const [folderRelink, setFolderRelink] = useState<{
nodeId: string;
nodeName: string;
folder: string;
fileCount: number;
} | null>(null);
const [folderQuery, setFolderQuery] = useState("");
const [folderHits, setFolderHits] = useState<MetaHit[]>([]);
const [folderBusy, setFolderBusy] = useState(false);
const [folderMsg, setFolderMsg] = useState("");
function hasSearchQuery(q = search, pathQ = pathSearch) {
return q.trim().length >= 2 || pathQ.trim().length >= 2;
}
function loadSeries(q = search, filterNodeId = nodeId, pathQ = pathSearch) {
if (!hasSearchQuery(q, pathQ)) {
setSeries([]);
setSearched(false);
setLimitHit(false);
return;
}
const params = new URLSearchParams();
if (q.trim()) params.set("search", q.trim());
if (filterNodeId) params.set("nodeId", filterNodeId);
if (pathQ.trim()) params.set("path", pathQ.trim());
setLoading(true);
setSearched(true);
fetch(`/api/v1/admin/series?${params}`, { credentials: "include" })
.then((r) => r.json())
.then((d) => {
const list = d.series ?? [];
setSeries(list);
setLimitHit(Boolean(d.limit && list.length >= d.limit));
})
.finally(() => setLoading(false));
}
function loadPathFiles(pathQ = pathSearch, filterNodeId = nodeId) {
if (!pathQ.trim() && !filterNodeId) {
setPathFiles(null);
return;
}
if (!pathQ.trim()) {
setPathFiles(null);
return;
}
const params = new URLSearchParams();
if (pathQ.trim()) params.set("path", pathQ.trim());
if (filterNodeId) params.set("nodeId", filterNodeId);
fetch(`/api/v1/admin/media-files?${params}`, { credentials: "include" })
.then((r) => r.json())
.then((d) => setPathFiles(d.files ?? []));
}
function load(q = search, filterNodeId = nodeId, pathQ = pathSearch) {
loadSeries(q, filterNodeId, pathQ);
if (pathQ.trim()) loadPathFiles(pathQ, filterNodeId);
else setPathFiles(null);
}
useEffect(() => {
fetch("/api/v1/admin/nodes", { credentials: "include" })
.then((r) => r.json())
.then((d) => {
const list = (d.nodes ?? []) as NodeOption[];
setNodes(list.filter((n) => n.id).sort((a, b) => a.name.localeCompare(b.name)));
});
}, []);
useEffect(() => {
if (!searched) return;
load(search, nodeId, pathSearch);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodeId]);
async function rematchFile(fileId: string) {
setBusyFileId(fileId);
setMsg("");
try {
const res = await fetch(`/api/v1/admin/media-files/${fileId}/rematch`, {
method: "POST",
credentials: "include",
});
const data = await res.json();
if (!res.ok) {
setMsg(data.error?.message ?? "Rematch mislukt");
return;
}
const label = data.series
? `${data.series.title} S${String(data.series.seasonNumber).padStart(2, "0")}E${String(data.series.episodeNumber).padStart(2, "0")}`
: data.movie?.title ?? "onbekend";
setMsg(`Opnieuw gekoppeld → ${label}`);
load();
} finally {
setBusyFileId(null);
}
}
function openLink(s: Series) {
setLinkSeriesId(s.id);
setLinkQuery(s.title);
setLinkHits([]);
setLinkMsg("");
void searchMeta(s.title);
}
function openFolderRelink(opts: {
nodeId: string;
nodeName: string;
folder: string;
fileCount: number;
}) {
const folderName = opts.folder.split("/").filter(Boolean).pop() || opts.folder;
setFolderRelink(opts);
setFolderQuery(folderName);
setFolderHits([]);
setFolderMsg("");
void searchFolderMeta(folderName);
}
async function searchFolderMeta(q = folderQuery) {
const query = q.trim();
if (!query) return;
setFolderBusy(true);
setFolderMsg("");
try {
const res = await fetch(
`/api/v1/admin/metadata/search?type=series&q=${encodeURIComponent(query)}`,
{ credentials: "include" }
);
const data = await res.json();
if (!res.ok) {
setFolderMsg(data.error?.message ?? "Zoeken mislukt");
setFolderHits([]);
return;
}
setFolderHits(data.results ?? []);
if (!(data.results ?? []).length) setFolderMsg("Geen resultaten");
} finally {
setFolderBusy(false);
}
}
async function applyFolderRelink(hit: MetaHit) {
if (!folderRelink) return;
if (
!confirm(
`Alle ${folderRelink.fileCount} bestanden in\n${folderRelink.folder}\nop ${folderRelink.nodeName}\nherkoppelen naar “${hit.title}${hit.year ? ` (${hit.year})` : ""}”?`
)
) {
return;
}
setFolderBusy(true);
setFolderMsg("Hele map herkoppelen…");
try {
const res = await fetch(`/api/v1/admin/library/relink-folder`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nodeId: folderRelink.nodeId,
folder: folderRelink.folder,
...(hit.tmdbId != null ? { tmdbId: hit.tmdbId } : {}),
...(hit.imdbId ? { imdbId: hit.imdbId } : {}),
}),
});
const data = await res.json();
if (!res.ok) {
setFolderMsg(data.error?.message ?? "Herkoppelen mislukt");
return;
}
setMsg(
`Map herkoppeld → ${data.seriesTitle}: ${data.moved}/${data.total} bestanden` +
(data.failed ? ` (${data.failed} mislukt)` : "")
);
setFolderRelink(null);
setExpanded(data.seriesId ?? expanded);
load();
} finally {
setFolderBusy(false);
}
}
async function applyFolderImdbDirect() {
const raw = folderQuery.trim();
if (!folderRelink) return;
const tmdbFromUrl = raw.match(/themoviedb\.org\/(?:movie|tv)\/(\d+)/i)?.[1];
const tmdbId = tmdbFromUrl
? parseInt(tmdbFromUrl, 10)
: /^\d{1,8}$/.test(raw)
? parseInt(raw, 10)
: null;
const idLabel = tmdbId != null ? `TMDB ${tmdbId}` : raw;
if (tmdbId == null && !/^tt\d+$/i.test(raw)) {
setFolderMsg("Vul een titel, TMDB-id of IMDb-id (tt…) in.");
return;
}
if (
!confirm(
`Alle ${folderRelink.fileCount} bestanden in\n${folderRelink.folder}\nherkoppelen via ${idLabel}?`
)
) {
return;
}
setFolderBusy(true);
setFolderMsg(
tmdbId != null ? "Hele map herkoppelen via TMDB…" : "Hele map herkoppelen via IMDb…"
);
try {
const res = await fetch(`/api/v1/admin/library/relink-folder`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nodeId: folderRelink.nodeId,
folder: folderRelink.folder,
...(tmdbId != null ? { tmdbId } : { imdbId: raw }),
}),
});
const data = await res.json();
if (!res.ok) {
setFolderMsg(data.error?.message ?? "Herkoppelen mislukt");
return;
}
setMsg(
`Map herkoppeld → ${data.seriesTitle}: ${data.moved}/${data.total} bestanden` +
(data.failed ? ` (${data.failed} mislukt)` : "")
);
setFolderRelink(null);
setExpanded(data.seriesId ?? expanded);
load();
} finally {
setFolderBusy(false);
}
}
async function searchMeta(q = linkQuery) {
const query = q.trim();
if (!query) return;
setLinkBusy(true);
setLinkMsg("");
try {
const res = await fetch(
`/api/v1/admin/metadata/search?type=series&q=${encodeURIComponent(query)}`,
{ credentials: "include" }
);
const data = await res.json();
if (!res.ok) {
setLinkMsg(data.error?.message ?? "Zoeken mislukt");
setLinkHits([]);
return;
}
setLinkHits(data.results ?? []);
if (!(data.results ?? []).length) setLinkMsg("Geen resultaten");
} finally {
setLinkBusy(false);
}
}
async function applyLink(hit: MetaHit) {
if (!linkSeriesId) return;
setLinkBusy(true);
setLinkMsg("Koppelen…");
try {
const res = await fetch(`/api/v1/admin/series/${linkSeriesId}/link`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...(hit.tmdbId != null ? { tmdbId: hit.tmdbId } : {}),
...(hit.imdbId ? { imdbId: hit.imdbId } : {}),
}),
});
const data = await res.json();
if (!res.ok) {
setLinkMsg(data.error?.message ?? "Opslaan mislukt");
return;
}
setLinkMsg(
data.merged
? "Gekoppeld (samengevoegd). Metadata + afleveringen vernieuwd."
: "Gekoppeld. Metadata + afleveringen vernieuwd."
);
setLinkSeriesId(null);
setExpanded(data.seriesId ?? linkSeriesId);
load();
} finally {
setLinkBusy(false);
}
}
async function applyImdbDirect() {
const raw = linkQuery.trim();
if (!linkSeriesId) return;
const tmdbFromUrl = raw.match(/themoviedb\.org\/(?:movie|tv)\/(\d+)/i)?.[1];
const tmdbId = tmdbFromUrl
? parseInt(tmdbFromUrl, 10)
: /^\d{1,8}$/.test(raw)
? parseInt(raw, 10)
: null;
if (tmdbId != null && Number.isFinite(tmdbId)) {
setLinkBusy(true);
setLinkMsg("Koppelen via TMDB…");
try {
const res = await fetch(`/api/v1/admin/series/${linkSeriesId}/link`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tmdbId }),
});
const data = await res.json();
if (!res.ok) {
setLinkMsg(data.error?.message ?? "Opslaan mislukt");
return;
}
setLinkMsg("Gekoppeld via TMDB. Metadata vernieuwd.");
setLinkSeriesId(null);
setExpanded(data.seriesId ?? linkSeriesId);
load();
} finally {
setLinkBusy(false);
}
return;
}
if (!/^tt\d+$/i.test(raw)) {
setLinkMsg("Vul een titel, TMDB-id of IMDb-id (tt…) in.");
return;
}
setLinkBusy(true);
setLinkMsg("Koppelen via IMDb…");
try {
const res = await fetch(`/api/v1/admin/series/${linkSeriesId}/link`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imdbId: raw }),
});
const data = await res.json();
if (!res.ok) {
setLinkMsg(data.error?.message ?? "Opslaan mislukt");
return;
}
setLinkMsg("Gekoppeld via IMDb. Metadata vernieuwd.");
setLinkSeriesId(null);
setExpanded(data.seriesId ?? linkSeriesId);
load();
} finally {
setLinkBusy(false);
}
}
const activeSeries = series.find((s) => s.id === linkSeriesId);
const selectedNodeName = nodes.find((n) => n.id === nodeId)?.name;
return (
<>
<Nav />
<div className="container">
<div className="page-header">
<div>
<div className="page-kicker">Library</div>
<h1>Series</h1>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
if (!hasSearchQuery()) {
setMsg("Typ minstens 2 tekens in titel of pad.");
return;
}
setMsg("");
load();
}}
style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}
>
<select
value={nodeId}
onChange={(e) => setNodeId(e.target.value)}
style={{ marginBottom: 0, minWidth: 180 }}
aria-label="Filter op node"
>
<option value="">Alle nodes</option>
{nodes.map((n) => (
<option key={n.id} value={n.id}>
{n.name}
{n.status === "OFFLINE" ? " (offline)" : ""}
</option>
))}
</select>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Zoek titel / IMDb…"
style={{ width: 180, marginBottom: 0 }}
autoFocus
/>
<input
value={pathSearch}
onChange={(e) => setPathSearch(e.target.value)}
placeholder="Zoek map/pad… (bv. Breaking Bad)"
style={{ width: 260, marginBottom: 0 }}
/>
<button type="submit" disabled={loading}>
{loading ? "Zoeken…" : "Zoek"}
</button>
</form>
</div>
<p className="muted page-lead">
Zoek op titel of pad (min. 2 tekens) — de hele bibliotheek wordt niet meer geladen. Per node
zie je de show-map. Padzoeken toont ook losse bestanden om foutmatches opnieuw te koppelen.
{searched
? nodeId
? ` Filter: ${selectedNodeName ?? "node"} · ${series.length} series.`
: ` ${series.length} series.`
: ""}
{pathSearch.trim() && searched ? ` Padfilter: “${pathSearch.trim()}”.` : ""}
</p>
{msg && (
<p className="muted" style={{ marginBottom: "1rem" }}>
{msg}
</p>
)}
{pathFiles && (
<div className="card" style={{ marginBottom: "1.25rem" }}>
<h2 style={{ fontSize: "1rem", marginBottom: "0.75rem", color: "#fafafa", textTransform: "none" }}>
Bestanden op pad ({pathFiles.length})
</h2>
{pathFiles.length === 0 ? (
<p className="muted" style={{ margin: 0 }}>
Geen bestanden. Na node-upgrade 1.3.7+ eerst rescan zodat paden worden opgeslagen.
</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{pathFiles.map((f) => (
<div
key={f.id}
style={{
borderTop: "1px solid #2a2d3a",
paddingTop: "0.75rem",
fontSize: "0.875rem",
}}
>
<div style={{ color: "#e4e4e7" }}>{f.path || f.releaseName}</div>
<div className="muted tiny" style={{ marginTop: 4 }}>
{f.node}
{f.folder ? ` · map ${f.folder}` : ""}
{" · "}
{f.series
? `gekoppeld: ${f.series.title} S${String(f.series.seasonNumber).padStart(2, "0")}E${String(f.series.episodeNumber).padStart(2, "0")}`
: f.movie
? `gekoppeld als film: ${f.movie.title}`
: "niet gekoppeld"}
{f.parsedHint?.title
? ` · parse: ${f.parsedHint.title}${
f.parsedHint.season != null
? ` S${f.parsedHint.season}E${f.parsedHint.episode}`
: ""
}`
: ""}
</div>
<button
type="button"
style={{ marginTop: 8 }}
disabled={busyFileId === f.id}
onClick={() => void rematchFile(f.id)}
>
{busyFileId === f.id ? "Bezig…" : "Opnieuw matchen op pad"}
</button>
</div>
))}
</div>
)}
</div>
)}
{series.map((s) => (
<div key={s.id} className="card">
<div
style={{ cursor: "pointer", display: "flex", justifyContent: "space-between", gap: "1rem" }}
onClick={() => setExpanded(expanded === s.id ? null : s.id)}
>
<div style={{ display: "flex", gap: "0.75rem", alignItems: "flex-start" }}>
{s.posterUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={s.posterUrl}
alt=""
width={40}
height={60}
style={{ objectFit: "cover", borderRadius: 4, flexShrink: 0 }}
/>
)}
<div>
<strong>{s.title}</strong>
{s.year ? ` (${s.year})` : ""}
<div style={{ color: "#71717a", fontSize: "0.875rem", marginTop: 4 }}>
{s.episodesWithFiles ?? 0} afleveringen · {s.fileCount ?? 0} bestanden · {s.seasons.length}{" "}
seizoen(en)
{s.imdbId ? ` · ${s.imdbId}` : " · geen IMDb"}
{s.tmdbId ? ` · TMDB ${s.tmdbId}` : ""}
</div>
{(s.nodes?.length ?? 0) > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 10 }}>
{s.nodes!.map((n) => (
<div key={n.id}>
<span
style={{
fontSize: "0.75rem",
padding: "2px 8px",
borderRadius: 999,
border: "1px solid #2a2d3a",
background: nodeId === n.id ? "#1e293b" : "#12141c",
color: nodeId === n.id ? "#e2e8f0" : "#a1a1aa",
}}
>
{n.name} · {n.fileCount} files
</span>
{(n.folders?.length ?? 0) > 0 ? (
<div className="muted mono tiny" style={{ marginTop: 4, paddingLeft: 4 }}>
{n.folders!.map((f) => (
<div
key={f.folder}
style={{
display: "flex",
gap: 8,
flexWrap: "wrap",
alignItems: "center",
marginBottom: 4,
}}
>
<span>
{f.folder}{" "}
<span style={{ color: "#52525b" }}>({f.fileCount})</span>
</span>
<button
type="button"
style={{ fontSize: "0.7rem", padding: "2px 8px" }}
onClick={(e) => {
e.stopPropagation();
openFolderRelink({
nodeId: n.id,
nodeName: n.name,
folder: f.folder,
fileCount: f.fileCount,
});
}}
>
Hele map herkoppelen
</button>
</div>
))}
</div>
) : (
<div className="muted tiny" style={{ marginTop: 4, paddingLeft: 4 }}>
Map onbekend — upgrade node naar 1.3.7+ en rescan
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
<span style={{ color: "#71717a" }}>{expanded === s.id ? "▲" : "▼"}</span>
</div>
{expanded === s.id && (
<div style={{ marginTop: "1rem" }}>
<div style={{ marginBottom: "1rem", display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
openLink(s);
}}
>
Juiste content zoeken
</button>
</div>
{s.seasons.map((season) => (
<div key={season.seasonNumber} style={{ marginTop: "1rem", paddingLeft: "1rem" }}>
<h3 style={{ fontSize: "0.875rem", color: "#a1a1aa", marginBottom: "0.5rem" }}>
Season {season.seasonNumber}
</h3>
{season.episodes.map((ep) => (
<div key={ep.episodeNumber} style={{ fontSize: "0.875rem", padding: "0.35rem 0" }}>
<div>
S{String(ep.seasonNumber).padStart(2, "0")}E
{String(ep.episodeNumber).padStart(2, "0")} {ep.title ?? ""}
</div>
{ep.files.map((f) => (
<div
key={f.id}
style={{
color: "#71717a",
fontSize: "0.8rem",
marginTop: 2,
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
alignItems: "center",
}}
>
<span className="mono">
{f.node} · {f.path || f.releaseName || "?"}
{f.resolution ? ` (${f.resolution})` : ""}
</span>
<button
type="button"
className="danger"
style={{ fontSize: "0.75rem", padding: "2px 8px" }}
disabled={busyFileId === f.id}
onClick={(e) => {
e.stopPropagation();
void rematchFile(f.id);
}}
>
{busyFileId === f.id ? "…" : "Rematch"}
</button>
</div>
))}
</div>
))}
</div>
))}
</div>
)}
</div>
))}
{series.length === 0 && (
<div className="card" style={{ textAlign: "center", color: "#71717a" }}>
{loading
? "Zoeken…"
: searched
? nodeId || pathSearch
? "Geen series voor dit filter. Check scan-roots / pad / rescan."
: "Geen series gevonden"
: "Typ een titel of pad en klik Zoek"}
</div>
)}
{limitHit && (
<p className="muted" style={{ marginTop: "0.75rem", textAlign: "center" }}>
Max. 50 resultaten — verfijn je zoekopdracht als je titel er niet tussen staat.
</p>
)}
</div>
{linkSeriesId && (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.65)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 50,
padding: "1rem",
}}
onClick={() => !linkBusy && setLinkSeriesId(null)}
>
<div
className="card"
style={{ width: "min(640px, 100%)", maxHeight: "90vh", overflow: "auto", margin: 0 }}
onClick={(e) => e.stopPropagation()}
>
<h2 style={{ fontSize: "1.1rem", color: "#fafafa", textTransform: "none", marginBottom: "0.5rem" }}>
Juiste serie zoeken
</h2>
<p style={{ color: "#a1a1aa", fontSize: "0.85rem", marginBottom: "1rem" }}>
Huidig: <strong style={{ color: "#e4e4e7" }}>{activeSeries?.title}</strong>
{activeSeries?.year ? ` (${activeSeries.year})` : ""}
{activeSeries?.imdbId ? ` · ${activeSeries.imdbId}` : " · geen IMDb"}
</p>
<form
onSubmit={(e) => {
e.preventDefault();
void searchMeta();
}}
style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}
>
<input
value={linkQuery}
onChange={(e) => setLinkQuery(e.target.value)}
placeholder="Titel, TMDB-id of IMDb-id (tt…)"
style={{ flex: 1, minWidth: 200, marginBottom: 0 }}
autoFocus
/>
<button type="submit" disabled={linkBusy}>
Zoek
</button>
<button type="button" disabled={linkBusy} onClick={() => void applyImdbDirect()}>
Koppel id
</button>
</form>
{linkMsg && (
<p style={{ color: "#a1a1aa", fontSize: "0.85rem", marginBottom: "0.75rem" }}>{linkMsg}</p>
)}
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{linkHits.map((hit) => (
<button
key={`${hit.source ?? "tmdb"}-${hit.tmdbId ?? hit.imdbId}`}
type="button"
disabled={linkBusy}
onClick={() => void applyLink(hit)}
style={{
display: "flex",
gap: "0.75rem",
textAlign: "left",
background: "#0f1117",
border: "1px solid #2a2d3a",
borderRadius: 8,
padding: "0.75rem",
cursor: "pointer",
color: "#fafafa",
}}
>
{hit.posterUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={hit.posterUrl}
alt=""
width={48}
height={72}
style={{ objectFit: "cover", borderRadius: 4, flexShrink: 0 }}
/>
) : (
<div
style={{
width: 48,
height: 72,
background: "#1f2937",
borderRadius: 4,
flexShrink: 0,
}}
/>
)}
<div>
<div>
<strong>{hit.title}</strong>
{hit.year ? ` (${hit.year})` : ""}
</div>
<div style={{ color: "#71717a", fontSize: "0.8rem" }}>
{hit.source === "omdb" ? "IMDb/OMDb" : "TMDB"}
{hit.tmdbId != null ? ` ${hit.tmdbId}` : ""}
{hit.imdbId ? ` · ${hit.imdbId}` : ""}
</div>
{hit.overview && (
<div
style={{
color: "#a1a1aa",
fontSize: "0.8rem",
marginTop: 4,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{hit.overview}
</div>
)}
</div>
</button>
))}
</div>
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
<button type="button" className="danger" disabled={linkBusy} onClick={() => setLinkSeriesId(null)}>
Annuleren
</button>
</div>
</div>
</div>
)}
{folderRelink && (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.65)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 50,
padding: "1rem",
}}
onClick={() => !folderBusy && setFolderRelink(null)}
>
<div
className="card"
style={{ width: "min(640px, 100%)", maxHeight: "90vh", overflow: "auto", margin: 0 }}
onClick={(e) => e.stopPropagation()}
>
<h2 style={{ fontSize: "1.1rem", color: "#fafafa", textTransform: "none", marginBottom: "0.5rem" }}>
Hele map herkoppelen
</h2>
<p style={{ color: "#a1a1aa", fontSize: "0.85rem", marginBottom: "1rem" }}>
Node: <strong style={{ color: "#e4e4e7" }}>{folderRelink.nodeName}</strong>
<br />
Map: <span className="mono">{folderRelink.folder}</span>
<br />
{folderRelink.fileCount} bestanden — kies de juiste serie (niet de verkeerde TMDB-hit).
Seizoen/aflevering blijft uit het pad.
</p>
<form
onSubmit={(e) => {
e.preventDefault();
void searchFolderMeta();
}}
style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}
>
<input
value={folderQuery}
onChange={(e) => setFolderQuery(e.target.value)}
placeholder="Titel, TMDB-id of IMDb-id (tt…)"
style={{ flex: 1, minWidth: 200, marginBottom: 0 }}
autoFocus
/>
<button type="submit" disabled={folderBusy}>
Zoek
</button>
<button type="button" disabled={folderBusy} onClick={() => void applyFolderImdbDirect()}>
Koppel id
</button>
</form>
{folderMsg && (
<p style={{ color: "#a1a1aa", fontSize: "0.85rem", marginBottom: "0.75rem" }}>{folderMsg}</p>
)}
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{folderHits.map((hit) => (
<button
key={`${hit.source ?? "tmdb"}-${hit.tmdbId ?? hit.imdbId}`}
type="button"
disabled={folderBusy}
onClick={() => void applyFolderRelink(hit)}
style={{
display: "flex",
gap: "0.75rem",
textAlign: "left",
background: "#0f1117",
border: "1px solid #2a2d3a",
borderRadius: 8,
padding: "0.75rem",
cursor: "pointer",
color: "#fafafa",
}}
>
{hit.posterUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={hit.posterUrl}
alt=""
width={48}
height={72}
style={{ objectFit: "cover", borderRadius: 4, flexShrink: 0 }}
/>
) : (
<div
style={{
width: 48,
height: 72,
background: "#1f2937",
borderRadius: 4,
flexShrink: 0,
}}
/>
)}
<div>
<div>
<strong>{hit.title}</strong>
{hit.year ? ` (${hit.year})` : ""}
</div>
<div style={{ color: "#71717a", fontSize: "0.8rem" }}>
{hit.source === "omdb" ? "IMDb/OMDb" : "TMDB"}
{hit.tmdbId != null ? ` ${hit.tmdbId}` : ""}
{hit.imdbId ? ` · ${hit.imdbId}` : ""}
</div>
{hit.overview && (
<div
style={{
color: "#a1a1aa",
fontSize: "0.8rem",
marginTop: 4,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{hit.overview}
</div>
)}
</div>
</button>
))}
</div>
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
<button
type="button"
className="danger"
disabled={folderBusy}
onClick={() => setFolderRelink(null)}
>
Annuleren
</button>
</div>
</div>
</div>
)}
</>
);
}