diff --git a/apps/admin-ui/src/app/live-lists/page.tsx b/apps/admin-ui/src/app/live-lists/page.tsx new file mode 100644 index 0000000..af5495c --- /dev/null +++ b/apps/admin-ui/src/app/live-lists/page.tsx @@ -0,0 +1,569 @@ +"use client"; + +import { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; +import { Nav, useAuth } from "@/components/Nav"; + +type LiveListSummary = { + id: string; + name: string; + slug: string; + sortOrder: number; + enabled: boolean; + channelCount: number; + viewerCount: number; + updatedAt: string; +}; + +type LiveChannelRow = { + id: string; + 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; + sortOrder: number; + enabled: boolean; + channels: LiveChannelRow[]; +}; + +type EpgOption = { id: string; name: 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, + }; +} + +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 [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 [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); + }, [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]); + + 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); + } + } + + function startCreateChannel() { + setEditingChannelId("new"); + setChannelForm(emptyChannelForm()); + } + + function startEditChannel(c: LiveChannelRow) { + setEditingChannelId(c.id); + setChannelForm(formFromChannel(c)); + } + + 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"; + if (isNew && (!payload.kid || !payload.key)) { + throw new Error("KID en KEY zijn verplicht voor een nieuwe zender"); + } + + 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 ( + <> +