867 lines
32 KiB
TypeScript
867 lines
32 KiB
TypeScript
"use client";
|
|
|
|
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Nav, useAuth } from "@/components/Nav";
|
|
|
|
type LiveListSummary = {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
provider?: string | null;
|
|
sortOrder: number;
|
|
enabled: boolean;
|
|
channelCount: number;
|
|
viewerCount: number;
|
|
updatedAt: string;
|
|
};
|
|
|
|
type LiveChannelRow = {
|
|
id: string;
|
|
externalId?: string | null;
|
|
mediaId?: string | null;
|
|
contentId?: string | null;
|
|
name: string;
|
|
logoUrl: string | null;
|
|
mpdUrl: string;
|
|
kid: string;
|
|
key: string;
|
|
hasKid: boolean;
|
|
hasKey: boolean;
|
|
epgChannelId: string | null;
|
|
sortOrder: number;
|
|
enabled: boolean;
|
|
};
|
|
|
|
type LiveListDetail = {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
provider?: string | null;
|
|
sortOrder: number;
|
|
enabled: boolean;
|
|
channels: LiveChannelRow[];
|
|
};
|
|
|
|
type EpgOption = { id: string; name: string };
|
|
type LogoOption = { id: string; name: string; url: string };
|
|
|
|
type ChannelForm = {
|
|
name: string;
|
|
logoUrl: string;
|
|
mpdUrl: string;
|
|
kid: string;
|
|
key: string;
|
|
epgChannelId: string;
|
|
enabled: boolean;
|
|
};
|
|
|
|
function emptyChannelForm(): ChannelForm {
|
|
return {
|
|
name: "",
|
|
logoUrl: "",
|
|
mpdUrl: "",
|
|
kid: "",
|
|
key: "",
|
|
epgChannelId: "",
|
|
enabled: true,
|
|
};
|
|
}
|
|
|
|
function formFromChannel(c: LiveChannelRow): ChannelForm {
|
|
return {
|
|
name: c.name,
|
|
logoUrl: c.logoUrl ?? "",
|
|
mpdUrl: c.mpdUrl,
|
|
kid: "",
|
|
key: "",
|
|
epgChannelId: c.epgChannelId ?? "",
|
|
enabled: c.enabled,
|
|
};
|
|
}
|
|
|
|
/** Korte preview in zender-overzicht (volledige URL via title/tooltip). */
|
|
function shortUrl(url: string, max = 42): string {
|
|
const s = url.trim();
|
|
if (s.startsWith("odido://")) return "Odido (live resolve)";
|
|
if (s.length <= max) return s;
|
|
try {
|
|
const u = new URL(s);
|
|
const path = u.pathname.split("/").filter(Boolean).pop() ?? "";
|
|
const host = u.host.replace(/^www\./, "");
|
|
const tip = path ? `${host}/…/${path}` : host;
|
|
return tip.length <= max ? tip : `${tip.slice(0, max - 1)}…`;
|
|
} catch {
|
|
return `${s.slice(0, max - 1)}…`;
|
|
}
|
|
}
|
|
|
|
export default function LiveListsPage() {
|
|
useAuth();
|
|
const [lists, setLists] = useState<LiveListSummary[]>([]);
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [detail, setDetail] = useState<LiveListDetail | null>(null);
|
|
const [epgOptions, setEpgOptions] = useState<EpgOption[]>([]);
|
|
const [epgFilter, setEpgFilter] = useState("");
|
|
const [logoOptions, setLogoOptions] = useState<LogoOption[]>([]);
|
|
const [logoFilter, setLogoFilter] = useState("");
|
|
const [newListName, setNewListName] = useState("");
|
|
const [listName, setListName] = useState("");
|
|
const [listEnabled, setListEnabled] = useState(true);
|
|
const [editingChannelId, setEditingChannelId] = useState<string | null>(null);
|
|
const [channelForm, setChannelForm] = useState<ChannelForm>(emptyChannelForm());
|
|
const [busy, setBusy] = useState(false);
|
|
const [syncBusy, setSyncBusy] = useState(false);
|
|
const [odidoConfigured, setOdidoConfigured] = useState(false);
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
|
|
const loadLists = useCallback(async () => {
|
|
const r = await fetch("/api/v1/admin/live-lists", { credentials: "include" });
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error?.message ?? "Lijsten laden mislukt");
|
|
setLists(d.lists ?? []);
|
|
}, []);
|
|
|
|
const loadDetail = useCallback(async (id: string) => {
|
|
const r = await fetch(`/api/v1/admin/live-lists/${id}`, { credentials: "include" });
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error?.message ?? "Lijst laden mislukt");
|
|
setDetail(d);
|
|
setListName(d.name ?? "");
|
|
setListEnabled(d.enabled !== false);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadLists().catch((e) => setErr(String(e.message ?? e)));
|
|
fetch("/api/v1/admin/live-lists/epg-channels", { credentials: "include" })
|
|
.then((r) => r.json())
|
|
.then((d) => setEpgOptions(d.channels ?? []))
|
|
.catch(() => undefined);
|
|
fetch("/api/v1/admin/live-lists/logo-options", { credentials: "include" })
|
|
.then((r) => r.json())
|
|
.then((d) => setLogoOptions(d.logos ?? []))
|
|
.catch(() => undefined);
|
|
fetch("/api/v1/admin/scripts/odido", { credentials: "include" })
|
|
.then((r) => r.json())
|
|
.then((d) => setOdidoConfigured(!!d.configured && d.enabled !== false))
|
|
.catch(() => setOdidoConfigured(false));
|
|
}, [loadLists]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedId) {
|
|
setDetail(null);
|
|
return;
|
|
}
|
|
void loadDetail(selectedId).catch((e) => setErr(String(e.message ?? e)));
|
|
}, [selectedId, loadDetail]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedId && lists.length > 0) setSelectedId(lists[0].id);
|
|
}, [lists, selectedId]);
|
|
|
|
const filteredEpg = useMemo(() => {
|
|
const q = epgFilter.trim().toLowerCase();
|
|
if (!q) return epgOptions.slice(0, 200);
|
|
return epgOptions
|
|
.filter(
|
|
(o) =>
|
|
o.name.toLowerCase().includes(q) || o.id.toLowerCase().includes(q)
|
|
)
|
|
.slice(0, 200);
|
|
}, [epgOptions, epgFilter]);
|
|
|
|
const filteredLogos = useMemo(() => {
|
|
const q = logoFilter.trim().toLowerCase();
|
|
if (!q) return logoOptions.slice(0, 80);
|
|
return logoOptions
|
|
.filter(
|
|
(o) =>
|
|
o.name.toLowerCase().includes(q) ||
|
|
o.id.toLowerCase().includes(q) ||
|
|
o.url.toLowerCase().includes(q)
|
|
)
|
|
.slice(0, 80);
|
|
}, [logoOptions, logoFilter]);
|
|
|
|
function pickLogo(logo: LogoOption) {
|
|
setChannelForm((f) => ({ ...f, logoUrl: logo.url }));
|
|
setLogoFilter(logo.name);
|
|
}
|
|
|
|
async function createList(e: FormEvent) {
|
|
e.preventDefault();
|
|
const name = newListName.trim();
|
|
if (!name) return;
|
|
setBusy(true);
|
|
setErr(null);
|
|
try {
|
|
const r = await fetch("/api/v1/admin/live-lists", {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name }),
|
|
});
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error?.message ?? "Aanmaken mislukt");
|
|
setNewListName("");
|
|
setMsg(`Lijst “${d.name}” aangemaakt`);
|
|
await loadLists();
|
|
setSelectedId(d.id);
|
|
} catch (e) {
|
|
setErr(String((e as Error).message ?? e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function saveListMeta() {
|
|
if (!selectedId) return;
|
|
setBusy(true);
|
|
setErr(null);
|
|
try {
|
|
const r = await fetch(`/api/v1/admin/live-lists/${selectedId}`, {
|
|
method: "PATCH",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name: listName.trim(), enabled: listEnabled }),
|
|
});
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error?.message ?? "Opslaan mislukt");
|
|
setMsg("Lijst opgeslagen");
|
|
await loadLists();
|
|
await loadDetail(selectedId);
|
|
} catch (e) {
|
|
setErr(String((e as Error).message ?? e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function deleteList() {
|
|
if (!selectedId || !detail) return;
|
|
if (!confirm(`Lijst “${detail.name}” en alle zenders verwijderen?`)) return;
|
|
setBusy(true);
|
|
setErr(null);
|
|
try {
|
|
const r = await fetch(`/api/v1/admin/live-lists/${selectedId}`, {
|
|
method: "DELETE",
|
|
credentials: "include",
|
|
});
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error?.message ?? "Verwijderen mislukt");
|
|
setSelectedId(null);
|
|
setDetail(null);
|
|
setMsg("Lijst verwijderd");
|
|
await loadLists();
|
|
} catch (e) {
|
|
setErr(String((e as Error).message ?? e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function linkOdido() {
|
|
if (!selectedId) return;
|
|
setBusy(true);
|
|
setErr(null);
|
|
try {
|
|
const r = await fetch(`/api/v1/admin/live-lists/${selectedId}/odido/link`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
});
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error?.message ?? "Odido koppelen mislukt");
|
|
setMsg("Odido gekoppeld aan deze lijst");
|
|
await loadLists();
|
|
await loadDetail(selectedId);
|
|
} catch (e) {
|
|
setErr(String((e as Error).message ?? e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function unlinkOdido() {
|
|
if (!selectedId || !detail) return;
|
|
if (!confirm("Odido ontkoppelen? Bestaande zenders blijven staan.")) return;
|
|
setBusy(true);
|
|
setErr(null);
|
|
try {
|
|
const r = await fetch(`/api/v1/admin/live-lists/${selectedId}/odido/unlink`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
});
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error?.message ?? "Ontkoppelen mislukt");
|
|
setMsg("Odido ontkoppeld");
|
|
await loadLists();
|
|
await loadDetail(selectedId);
|
|
} catch (e) {
|
|
setErr(String((e as Error).message ?? e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function syncOdido() {
|
|
if (!selectedId) return;
|
|
if (
|
|
!confirm(
|
|
"Volledige Odido-sync starten? Alleen zenders in je abonnement (geen radio). Dit kan enkele minuten duren."
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
setSyncBusy(true);
|
|
setErr(null);
|
|
setMsg("Odido-sync bezig…");
|
|
try {
|
|
const r = await fetch(`/api/v1/admin/live-lists/${selectedId}/odido/sync`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
});
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error?.message ?? "Sync mislukt");
|
|
const parts = [
|
|
d.added != null ? `+${d.added} nieuw` : null,
|
|
d.updated != null ? `${d.updated} bijgewerkt` : null,
|
|
d.disabled != null ? `${d.disabled} uit` : null,
|
|
d.skipped != null ? `${d.skipped} overgeslagen` : null,
|
|
d.failed != null ? `${d.failed} mislukt` : null,
|
|
d.epgMatched != null ? `EPG ${d.epgMatched}` : null,
|
|
d.epgUnmatched != null && d.epgUnmatched > 0 ? `EPG-miss ${d.epgUnmatched}` : null,
|
|
d.durationMs != null ? `${Math.round(d.durationMs / 1000)}s` : null,
|
|
].filter(Boolean);
|
|
setMsg(`Odido-sync klaar: ${parts.join(" · ") || "ok"}`);
|
|
await loadLists();
|
|
await loadDetail(selectedId);
|
|
} catch (e) {
|
|
setErr(String((e as Error).message ?? e));
|
|
setMsg(null);
|
|
} finally {
|
|
setSyncBusy(false);
|
|
}
|
|
}
|
|
|
|
function startCreateChannel() {
|
|
setEditingChannelId("new");
|
|
setChannelForm(emptyChannelForm());
|
|
setLogoFilter("");
|
|
setEpgFilter("");
|
|
}
|
|
|
|
function startEditChannel(c: LiveChannelRow) {
|
|
setEditingChannelId(c.id);
|
|
setChannelForm(formFromChannel(c));
|
|
const match = logoOptions.find((o) => o.url === c.logoUrl);
|
|
setLogoFilter(match?.name ?? "");
|
|
setEpgFilter("");
|
|
}
|
|
|
|
async function saveChannel(e: FormEvent) {
|
|
e.preventDefault();
|
|
if (!selectedId) return;
|
|
setBusy(true);
|
|
setErr(null);
|
|
try {
|
|
const payload: Record<string, unknown> = {
|
|
name: channelForm.name.trim(),
|
|
logoUrl: channelForm.logoUrl.trim() || null,
|
|
mpdUrl: channelForm.mpdUrl.trim(),
|
|
epgChannelId: channelForm.epgChannelId.trim() || null,
|
|
enabled: channelForm.enabled,
|
|
};
|
|
if (channelForm.kid.trim()) payload.kid = channelForm.kid.trim();
|
|
if (channelForm.key.trim()) payload.key = channelForm.key.trim();
|
|
|
|
const isNew = editingChannelId === "new";
|
|
const kidFilled = !!channelForm.kid.trim();
|
|
const keyFilled = !!channelForm.key.trim();
|
|
if (kidFilled !== keyFilled) {
|
|
throw new Error("KID en KEY samen invullen, of beide leeg laten");
|
|
}
|
|
|
|
const r = await fetch(
|
|
isNew
|
|
? `/api/v1/admin/live-lists/${selectedId}/channels`
|
|
: `/api/v1/admin/live-channels/${editingChannelId}`,
|
|
{
|
|
method: isNew ? "POST" : "PATCH",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
}
|
|
);
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error?.message ?? "Zender opslaan mislukt");
|
|
setEditingChannelId(null);
|
|
setChannelForm(emptyChannelForm());
|
|
setMsg(isNew ? "Zender toegevoegd" : "Zender bijgewerkt");
|
|
await loadDetail(selectedId);
|
|
await loadLists();
|
|
} catch (e) {
|
|
setErr(String((e as Error).message ?? e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function deleteChannel(id: string, name: string) {
|
|
if (!selectedId) return;
|
|
if (!confirm(`Zender “${name}” verwijderen?`)) return;
|
|
setBusy(true);
|
|
setErr(null);
|
|
try {
|
|
const r = await fetch(`/api/v1/admin/live-channels/${id}`, {
|
|
method: "DELETE",
|
|
credentials: "include",
|
|
});
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error?.message ?? "Verwijderen mislukt");
|
|
if (editingChannelId === id) setEditingChannelId(null);
|
|
setMsg("Zender verwijderd");
|
|
await loadDetail(selectedId);
|
|
await loadLists();
|
|
} catch (e) {
|
|
setErr(String((e as Error).message ?? e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Nav />
|
|
<div className="container">
|
|
<div className="page-header">
|
|
<div>
|
|
<div className="page-kicker">Live TV</div>
|
|
<h1>Live-lijsten</h1>
|
|
</div>
|
|
</div>
|
|
<p className="muted page-lead">
|
|
ClearKey/DASH-zenderlijsten (MPD + KID + KEY). Koppel Odido via Scripts → Live-lijsten.
|
|
EPG komt uit de cluster-XMLTV (Instellingen → TV).
|
|
</p>
|
|
|
|
{msg && (
|
|
<p className="ok-banner" role="status">
|
|
{msg}
|
|
</p>
|
|
)}
|
|
{err && (
|
|
<p className="err-banner" role="alert">
|
|
{err}
|
|
</p>
|
|
)}
|
|
|
|
<div className="split-layout" style={{ display: "grid", gap: "1.25rem", gridTemplateColumns: "minmax(220px, 280px) 1fr" }}>
|
|
<aside className="card">
|
|
<h2 style={{ marginTop: 0, fontSize: "1.05rem" }}>Lijsten</h2>
|
|
<form onSubmit={createList} className="form-group" style={{ marginBottom: "1rem" }}>
|
|
<label className="field">
|
|
Nieuwe lijst
|
|
<input
|
|
value={newListName}
|
|
onChange={(e) => setNewListName(e.target.value)}
|
|
placeholder="Nederland"
|
|
disabled={busy}
|
|
/>
|
|
</label>
|
|
<button type="submit" className="btn-primary" disabled={busy || !newListName.trim()}>
|
|
Toevoegen
|
|
</button>
|
|
</form>
|
|
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
|
{lists.map((l) => (
|
|
<li key={l.id} style={{ marginBottom: 6 }}>
|
|
<button
|
|
type="button"
|
|
className={selectedId === l.id ? "btn-primary" : "btn"}
|
|
style={{ width: "100%", textAlign: "left" }}
|
|
onClick={() => setSelectedId(l.id)}
|
|
>
|
|
{l.name}
|
|
<span className="muted" style={{ display: "block", fontSize: "0.8rem" }}>
|
|
{l.channelCount} zenders · {l.enabled ? "aan" : "uit"}
|
|
{l.provider === "odido" ? " · Odido" : ""}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
{lists.length === 0 && <li className="muted">Nog geen lijsten.</li>}
|
|
</ul>
|
|
</aside>
|
|
|
|
<section>
|
|
{!detail ? (
|
|
<div className="card">
|
|
<p className="muted">Selecteer of maak een lijst.</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="card" style={{ marginBottom: "1rem" }}>
|
|
<h2 style={{ marginTop: 0 }}>{detail.name}</h2>
|
|
<div className="form-group">
|
|
<label className="field">
|
|
Naam
|
|
<input
|
|
value={listName}
|
|
onChange={(e) => setListName(e.target.value)}
|
|
disabled={busy}
|
|
/>
|
|
</label>
|
|
<label className="field" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={listEnabled}
|
|
onChange={(e) => setListEnabled(e.target.checked)}
|
|
disabled={busy}
|
|
/>
|
|
Lijst actief
|
|
</label>
|
|
</div>
|
|
<div className="detail-actions" style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
<button type="button" className="btn-primary" disabled={busy} onClick={() => void saveListMeta()}>
|
|
Lijst opslaan
|
|
</button>
|
|
<button type="button" className="danger" disabled={busy} onClick={() => void deleteList()}>
|
|
Verwijderen
|
|
</button>
|
|
<button type="button" disabled={busy || syncBusy} onClick={startCreateChannel}>
|
|
Zender toevoegen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card" style={{ marginBottom: "1rem" }}>
|
|
<h3 style={{ marginTop: 0 }}>Odido-script</h3>
|
|
{detail.provider === "odido" ? (
|
|
<>
|
|
<p className="muted" style={{ marginTop: 0 }}>
|
|
Deze lijst is gekoppeld aan Odido. “Volledig ophalen” checkt welke TV-zenders
|
|
in je abonnement zitten (geen radio) en vult/werkt de lijst bij.
|
|
</p>
|
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
<button
|
|
type="button"
|
|
className="btn-primary"
|
|
disabled={busy || syncBusy || !odidoConfigured}
|
|
onClick={() => void syncOdido()}
|
|
>
|
|
{syncBusy ? "Bezig met ophalen…" : "Volledig ophalen"}
|
|
</button>
|
|
<button type="button" disabled={busy || syncBusy} onClick={() => void unlinkOdido()}>
|
|
Ontkoppelen
|
|
</button>
|
|
<a className="btn" href="/scripts" style={{ display: "inline-flex", alignItems: "center" }}>
|
|
Instellingen
|
|
</a>
|
|
</div>
|
|
{!odidoConfigured && (
|
|
<p className="err-banner" style={{ marginTop: "0.75rem" }}>
|
|
Stel eerst klantnummer, pincode en WVD in onder Scripts.
|
|
</p>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<p className="muted" style={{ marginTop: 0 }}>
|
|
Koppel Odido om automatisch TV-zenders uit je abonnement in deze lijst te zetten.
|
|
</p>
|
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
<button
|
|
type="button"
|
|
className="btn-primary"
|
|
disabled={busy || !odidoConfigured}
|
|
onClick={() => void linkOdido()}
|
|
>
|
|
Odido koppelen
|
|
</button>
|
|
<a className="btn" href="/scripts" style={{ display: "inline-flex", alignItems: "center" }}>
|
|
Scripts / Odido-instellingen
|
|
</a>
|
|
</div>
|
|
{!odidoConfigured && (
|
|
<p className="muted" style={{ marginTop: "0.75rem" }}>
|
|
Nog niet geconfigureerd — vul eerst Scripts → Odido in.
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{editingChannelId && (
|
|
<div className="card" style={{ marginBottom: "1rem" }}>
|
|
<h3 style={{ marginTop: 0 }}>
|
|
{editingChannelId === "new" ? "Nieuwe zender" : "Zender bewerken"}
|
|
</h3>
|
|
<form onSubmit={saveChannel} className="form-group">
|
|
<label className="field">
|
|
Naam
|
|
<input
|
|
required
|
|
value={channelForm.name}
|
|
onChange={(e) => setChannelForm((f) => ({ ...f, name: e.target.value }))}
|
|
disabled={busy}
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
MPD-URL
|
|
<input
|
|
required
|
|
value={channelForm.mpdUrl}
|
|
onChange={(e) => setChannelForm((f) => ({ ...f, mpdUrl: e.target.value }))}
|
|
placeholder="https://…/manifest.mpd"
|
|
disabled={busy}
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
KID (32 hex, optioneel)
|
|
<input
|
|
value={channelForm.kid}
|
|
onChange={(e) => setChannelForm((f) => ({ ...f, kid: e.target.value }))}
|
|
placeholder={
|
|
editingChannelId !== "new"
|
|
? "•••••••• (leeg = behouden; wis KEY ook om DRM te verwijderen)"
|
|
: "Leeg = geen ClearKey"
|
|
}
|
|
disabled={busy}
|
|
autoComplete="off"
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
KEY (32 hex, optioneel)
|
|
<input
|
|
value={channelForm.key}
|
|
onChange={(e) => setChannelForm((f) => ({ ...f, key: e.target.value }))}
|
|
placeholder={
|
|
editingChannelId !== "new"
|
|
? "•••••••• (leeg = behouden)"
|
|
: "Leeg = geen ClearKey"
|
|
}
|
|
disabled={busy}
|
|
autoComplete="off"
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
Logo zoeken (tv-logos NL)
|
|
<input
|
|
list="channel-logo-options"
|
|
value={logoFilter}
|
|
onChange={(e) => {
|
|
const v = e.target.value;
|
|
setLogoFilter(v);
|
|
const hit = logoOptions.find(
|
|
(o) =>
|
|
o.name === v ||
|
|
o.id === v ||
|
|
o.url === v ||
|
|
o.name.toLowerCase() === v.trim().toLowerCase()
|
|
);
|
|
if (hit) setChannelForm((f) => ({ ...f, logoUrl: hit.url }));
|
|
}}
|
|
placeholder="Typ zendernaam, bijv. NPO 1 of RTL 4"
|
|
disabled={busy}
|
|
/>
|
|
</label>
|
|
<datalist id="channel-logo-options">
|
|
{filteredLogos.map((o) => (
|
|
<option key={o.id} value={o.name}>
|
|
{o.id}
|
|
</option>
|
|
))}
|
|
</datalist>
|
|
{filteredLogos.length > 0 && (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
gap: 8,
|
|
marginBottom: 12,
|
|
maxHeight: 140,
|
|
overflow: "auto",
|
|
}}
|
|
>
|
|
{filteredLogos.slice(0, 24).map((o) => (
|
|
<button
|
|
key={o.id}
|
|
type="button"
|
|
onClick={() => pickLogo(o)}
|
|
title={o.name}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
padding: "4px 8px",
|
|
background:
|
|
channelForm.logoUrl === o.url
|
|
? "rgba(229,9,20,0.25)"
|
|
: "rgba(255,255,255,0.06)",
|
|
border:
|
|
channelForm.logoUrl === o.url
|
|
? "1px solid #e50914"
|
|
: "1px solid transparent",
|
|
borderRadius: 6,
|
|
cursor: "pointer",
|
|
color: "inherit",
|
|
}}
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={o.url}
|
|
alt=""
|
|
width={28}
|
|
height={28}
|
|
style={{ objectFit: "contain", background: "#111" }}
|
|
/>
|
|
<span style={{ fontSize: "0.8rem" }}>{o.name}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<label className="field">
|
|
Logo-URL (optioneel)
|
|
<input
|
|
value={channelForm.logoUrl}
|
|
onChange={(e) => setChannelForm((f) => ({ ...f, logoUrl: e.target.value }))}
|
|
placeholder="Automatisch na logo-keuze, of plak eigen URL"
|
|
disabled={busy}
|
|
/>
|
|
</label>
|
|
{channelForm.logoUrl.trim() && (
|
|
<p className="muted field-hint" style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={channelForm.logoUrl}
|
|
alt="Logo preview"
|
|
width={48}
|
|
height={48}
|
|
style={{ objectFit: "contain", background: "#111", borderRadius: 4 }}
|
|
/>
|
|
Preview
|
|
</p>
|
|
)}
|
|
<label className="field">
|
|
EPG-kanaal
|
|
<input
|
|
list="epg-channel-options"
|
|
value={channelForm.epgChannelId}
|
|
onChange={(e) =>
|
|
setChannelForm((f) => ({ ...f, epgChannelId: e.target.value }))
|
|
}
|
|
placeholder="Zoek of plak XMLTV channel id"
|
|
disabled={busy}
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
Filter EPG-lijst
|
|
<input
|
|
value={epgFilter}
|
|
onChange={(e) => setEpgFilter(e.target.value)}
|
|
placeholder="Filter display-name…"
|
|
/>
|
|
</label>
|
|
<datalist id="epg-channel-options">
|
|
{filteredEpg.map((o) => (
|
|
<option key={o.id} value={o.id}>
|
|
{o.name}
|
|
</option>
|
|
))}
|
|
</datalist>
|
|
{filteredEpg.length > 0 && (
|
|
<p className="muted field-hint">
|
|
{filteredEpg.slice(0, 8).map((o) => `${o.name} (${o.id})`).join(" · ")}
|
|
{filteredEpg.length > 8 ? "…" : ""}
|
|
</p>
|
|
)}
|
|
<label className="field" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={channelForm.enabled}
|
|
onChange={(e) =>
|
|
setChannelForm((f) => ({ ...f, enabled: e.target.checked }))
|
|
}
|
|
disabled={busy}
|
|
/>
|
|
Zender actief
|
|
</label>
|
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
<button type="submit" className="btn-primary" disabled={busy}>
|
|
Opslaan
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={busy}
|
|
onClick={() => {
|
|
setEditingChannelId(null);
|
|
setChannelForm(emptyChannelForm());
|
|
}}
|
|
>
|
|
Annuleren
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
<div className="card">
|
|
<h3 style={{ marginTop: 0 }}>Zenders ({detail.channels.length})</h3>
|
|
{detail.channels.length === 0 ? (
|
|
<p className="muted">Nog geen zenders in deze lijst.</p>
|
|
) : (
|
|
<div className="desktop-table">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Naam</th>
|
|
<th>EPG</th>
|
|
<th>Keys</th>
|
|
<th>Status</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{detail.channels.map((c) => (
|
|
<tr key={c.id}>
|
|
<td>
|
|
<strong>{c.name}</strong>
|
|
<div
|
|
className="muted"
|
|
style={{ fontSize: "0.8rem", maxWidth: 280 }}
|
|
title={c.mpdUrl}
|
|
>
|
|
{shortUrl(c.mpdUrl)}
|
|
</div>
|
|
</td>
|
|
<td>{c.epgChannelId || "—"}</td>
|
|
<td>{c.hasKid && c.hasKey ? "ClearKey" : "geen DRM"}</td>
|
|
<td>{c.enabled ? "aan" : "uit"}</td>
|
|
<td style={{ whiteSpace: "nowrap" }}>
|
|
<button type="button" disabled={busy} onClick={() => startEditChannel(c)}>
|
|
Bewerken
|
|
</button>{" "}
|
|
<button
|
|
type="button"
|
|
className="danger"
|
|
disabled={busy}
|
|
onClick={() => void deleteChannel(c.id, c.name)}
|
|
>
|
|
Verwijderen
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|