"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([]); const [selectedId, setSelectedId] = useState(null); const [detail, setDetail] = useState(null); const [epgOptions, setEpgOptions] = useState([]); const [epgFilter, setEpgFilter] = useState(""); const [logoOptions, setLogoOptions] = useState([]); const [logoFilter, setLogoFilter] = useState(""); const [newListName, setNewListName] = useState(""); const [listName, setListName] = useState(""); const [listEnabled, setListEnabled] = useState(true); const [editingChannelId, setEditingChannelId] = useState(null); const [channelForm, setChannelForm] = useState(emptyChannelForm()); const [busy, setBusy] = useState(false); const [syncBusy, setSyncBusy] = useState(false); const [odidoConfigured, setOdidoConfigured] = useState(false); const [msg, setMsg] = useState(null); const [err, setErr] = useState(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 = { 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 ( <>