stremio/apps/admin-ui/src/app/live-lists/page.tsx

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>
</>
);
}