679 lines
24 KiB
TypeScript
679 lines
24 KiB
TypeScript
"use client";
|
||
|
||
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||
import Link from "next/link";
|
||
import { Nav, useAuth } from "@/components/Nav";
|
||
|
||
type TweakMeta = {
|
||
key: string;
|
||
label: string;
|
||
hint: string;
|
||
kind: "number" | "boolean";
|
||
unit?: string;
|
||
};
|
||
|
||
type OdidoTweaks = Record<string, number | boolean>;
|
||
|
||
type OdidoAccount = {
|
||
id: string;
|
||
label: string;
|
||
username: string;
|
||
hasPin: boolean;
|
||
enabled: boolean;
|
||
channelCount: number;
|
||
healthStatus: "ok" | "dead" | "unknown";
|
||
healthCheckedAt: string | null;
|
||
healthError: string | null;
|
||
};
|
||
|
||
type OdidoSettings = {
|
||
configured: boolean;
|
||
enabled: boolean;
|
||
hasWvd: boolean;
|
||
wvdPath: string;
|
||
wvdUploadedAt: string | null;
|
||
lastSyncAt: string | null;
|
||
lastSyncSummary: Record<string, unknown> | null;
|
||
lastMpdRefreshAt: string | null;
|
||
lastMpdRefreshSummary: Record<string, unknown> | null;
|
||
accounts: OdidoAccount[];
|
||
accountCount: number;
|
||
channelOwnerCount: number;
|
||
tweaks: OdidoTweaks;
|
||
tweakDefaults: OdidoTweaks;
|
||
tweakMeta: TweakMeta[];
|
||
};
|
||
|
||
function readFileAsBase64(file: File): Promise<string> {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const result = String(reader.result ?? "");
|
||
const comma = result.indexOf(",");
|
||
resolve(comma >= 0 ? result.slice(comma + 1) : result);
|
||
};
|
||
reader.onerror = () => reject(new Error("Bestand lezen mislukt"));
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
export default function ScriptsPage() {
|
||
useAuth();
|
||
const [odido, setOdido] = useState<OdidoSettings | null>(null);
|
||
const [wvdPath, setWvdPath] = useState("");
|
||
const [enabled, setEnabled] = useState(true);
|
||
const [tweaks, setTweaks] = useState<OdidoTweaks>({});
|
||
const [showTweaks, setShowTweaks] = useState(false);
|
||
const [busy, setBusy] = useState(false);
|
||
const [msg, setMsg] = useState<string | null>(null);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
const wvdInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const [newLabel, setNewLabel] = useState("");
|
||
const [newUsername, setNewUsername] = useState("");
|
||
const [newPin, setNewPin] = useState("");
|
||
|
||
const applySettings = useCallback((d: OdidoSettings) => {
|
||
setOdido(d);
|
||
setWvdPath(d.wvdPath ?? "");
|
||
setEnabled(d.enabled !== false);
|
||
setTweaks(d.tweaks ?? d.tweakDefaults ?? {});
|
||
}, []);
|
||
|
||
const load = useCallback(async () => {
|
||
const r = await fetch("/api/v1/admin/scripts/odido", { credentials: "include" });
|
||
const d = await r.json();
|
||
if (!r.ok) throw new Error(d.error?.message ?? "Odido-instellingen laden mislukt");
|
||
applySettings(d);
|
||
}, [applySettings]);
|
||
|
||
useEffect(() => {
|
||
void load().catch((e) => setErr(String(e.message ?? e)));
|
||
}, [load]);
|
||
|
||
async function saveOdido(e: FormEvent) {
|
||
e.preventDefault();
|
||
setBusy(true);
|
||
setErr(null);
|
||
setMsg(null);
|
||
try {
|
||
const r = await fetch("/api/v1/admin/scripts/odido", {
|
||
method: "PUT",
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ wvdPath: wvdPath.trim(), enabled, tweaks }),
|
||
});
|
||
const d = await r.json();
|
||
if (!r.ok) throw new Error(d.error?.message ?? "Opslaan mislukt");
|
||
applySettings(d);
|
||
setMsg("Odido-instellingen opgeslagen");
|
||
} catch (e) {
|
||
setErr(String((e as Error).message ?? e));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function addAccount(e: FormEvent) {
|
||
e.preventDefault();
|
||
setBusy(true);
|
||
setErr(null);
|
||
setMsg(null);
|
||
try {
|
||
const r = await fetch("/api/v1/admin/scripts/odido/accounts", {
|
||
method: "POST",
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
label: newLabel.trim() || undefined,
|
||
username: newUsername.trim(),
|
||
pin: newPin.trim(),
|
||
enabled: true,
|
||
}),
|
||
});
|
||
const d = await r.json();
|
||
if (!r.ok) throw new Error(d.error?.message ?? "Account toevoegen mislukt");
|
||
applySettings(d);
|
||
setNewLabel("");
|
||
setNewUsername("");
|
||
setNewPin("");
|
||
setMsg("Odido-account toegevoegd");
|
||
} catch (e) {
|
||
setErr(String((e as Error).message ?? e));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function toggleAccount(account: OdidoAccount) {
|
||
setBusy(true);
|
||
setErr(null);
|
||
try {
|
||
const r = await fetch(`/api/v1/admin/scripts/odido/accounts/${account.id}`, {
|
||
method: "PATCH",
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ enabled: !account.enabled }),
|
||
});
|
||
const d = await r.json();
|
||
if (!r.ok) throw new Error(d.error?.message ?? "Account bijwerken mislukt");
|
||
applySettings(d);
|
||
} catch (e) {
|
||
setErr(String((e as Error).message ?? e));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function removeAccount(account: OdidoAccount) {
|
||
if (!confirm(`Account “${account.label || account.username}” verwijderen?`)) return;
|
||
setBusy(true);
|
||
setErr(null);
|
||
setMsg(null);
|
||
try {
|
||
const r = await fetch(`/api/v1/admin/scripts/odido/accounts/${account.id}`, {
|
||
method: "DELETE",
|
||
credentials: "include",
|
||
});
|
||
const d = await r.json();
|
||
if (!r.ok) throw new Error(d.error?.message ?? "Verwijderen mislukt");
|
||
applySettings(d);
|
||
setMsg("Account verwijderd");
|
||
} catch (e) {
|
||
setErr(String((e as Error).message ?? e));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function probeAccounts(accountId?: string) {
|
||
setBusy(true);
|
||
setErr(null);
|
||
setMsg(null);
|
||
try {
|
||
const url = accountId
|
||
? `/api/v1/admin/scripts/odido/accounts/${accountId}/probe`
|
||
: "/api/v1/admin/scripts/odido/accounts/probe";
|
||
const r = await fetch(url, {
|
||
method: "POST",
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: accountId ? undefined : JSON.stringify({}),
|
||
});
|
||
const d = await r.json();
|
||
if (!r.ok) throw new Error(d.error?.message ?? "Statuscheck mislukt");
|
||
applySettings(d);
|
||
const list: OdidoAccount[] = d.accounts ?? [];
|
||
const ok = list.filter((a) => a.healthStatus === "ok").length;
|
||
const dead = list.filter((a) => a.healthStatus === "dead").length;
|
||
setMsg(`Accountstatus: ${ok} actief · ${dead} dood`);
|
||
} catch (e) {
|
||
setErr(String((e as Error).message ?? e));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
function healthBadge(a: OdidoAccount) {
|
||
if (!a.enabled) {
|
||
return (
|
||
<span className="muted" title="Uitgeschakeld" style={{ fontSize: "0.85rem" }}>
|
||
uit
|
||
</span>
|
||
);
|
||
}
|
||
if (a.healthStatus === "ok") {
|
||
return (
|
||
<span
|
||
className="ok-banner"
|
||
title={a.healthCheckedAt ? `Gecontroleerd ${new Date(a.healthCheckedAt).toLocaleString("nl-NL")}` : "Actief"}
|
||
style={{ display: "inline", padding: "2px 8px", fontSize: "0.8rem" }}
|
||
>
|
||
actief
|
||
</span>
|
||
);
|
||
}
|
||
if (a.healthStatus === "dead") {
|
||
return (
|
||
<span
|
||
className="err-banner"
|
||
title={a.healthError || "Login/heartbeat mislukt"}
|
||
style={{ display: "inline", padding: "2px 8px", fontSize: "0.8rem" }}
|
||
>
|
||
dood
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<span className="muted" style={{ fontSize: "0.85rem" }} title="Nog niet gecontroleerd">
|
||
onbekend
|
||
</span>
|
||
);
|
||
}
|
||
|
||
async function uploadWvd(file: File | null) {
|
||
if (!file) return;
|
||
setBusy(true);
|
||
setErr(null);
|
||
setMsg(null);
|
||
try {
|
||
if (file.size > 2 * 1024 * 1024) {
|
||
throw new Error("WVD-bestand mag max. 2 MB zijn");
|
||
}
|
||
const contentBase64 = await readFileAsBase64(file);
|
||
const r = await fetch("/api/v1/admin/scripts/odido/wvd", {
|
||
method: "POST",
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ contentBase64 }),
|
||
});
|
||
const d = await r.json();
|
||
if (!r.ok) throw new Error(d.error?.message ?? "WVD-upload mislukt");
|
||
applySettings(d);
|
||
setMsg(`WVD.wvd geüpload (${d.bytes ?? file.size} bytes)`);
|
||
} catch (e) {
|
||
setErr(String((e as Error).message ?? e));
|
||
} finally {
|
||
setBusy(false);
|
||
if (wvdInputRef.current) wvdInputRef.current.value = "";
|
||
}
|
||
}
|
||
|
||
async function refreshMpds() {
|
||
setBusy(true);
|
||
setErr(null);
|
||
setMsg(null);
|
||
try {
|
||
const r = await fetch("/api/v1/admin/scripts/odido/refresh-mpd", {
|
||
method: "POST",
|
||
credentials: "include",
|
||
});
|
||
const d = await r.json();
|
||
if (!r.ok) throw new Error(d.error?.message ?? "MPD-refresh mislukt");
|
||
setMsg(
|
||
`MPD-cache vernieuwd: ${d.refreshed ?? 0} ok · ${d.failed ?? 0} fout · ${d.skipped ?? 0} skip (${d.durationMs ?? "?"} ms)`
|
||
);
|
||
await load();
|
||
} catch (e) {
|
||
setErr(String((e as Error).message ?? e));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function flushMpds() {
|
||
if (!confirm("Alle gecachte Odido-MPD’s wissen? Volgende play haalt dan verse URLs.")) return;
|
||
setBusy(true);
|
||
setErr(null);
|
||
setMsg(null);
|
||
try {
|
||
const r = await fetch("/api/v1/admin/scripts/odido/flush-mpd", {
|
||
method: "POST",
|
||
credentials: "include",
|
||
});
|
||
const d = await r.json();
|
||
if (!r.ok) throw new Error(d.error?.message ?? "Flush mislukt");
|
||
setMsg(`MPD-cache geleegd (${d.cleared ?? 0} zenders)`);
|
||
await load();
|
||
} catch (e) {
|
||
setErr(String((e as Error).message ?? e));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
function resetTweaks() {
|
||
if (odido?.tweakDefaults) setTweaks({ ...odido.tweakDefaults });
|
||
}
|
||
|
||
const summary = odido?.lastSyncSummary;
|
||
const refreshSummary = odido?.lastMpdRefreshSummary;
|
||
const meta = odido?.tweakMeta ?? [];
|
||
const accounts = odido?.accounts ?? [];
|
||
|
||
return (
|
||
<>
|
||
<Nav />
|
||
<main className="page">
|
||
<div className="container">
|
||
<div className="page-header">
|
||
<div>
|
||
<div className="page-kicker">Integraties</div>
|
||
<h1>Scripts</h1>
|
||
</div>
|
||
</div>
|
||
<p className="muted page-lead">
|
||
Provider-scripts voor live-lijsten. Meerdere Odido-accounts: sync bouwt één unieke
|
||
zenderlijst (geen dubbels); overlap wordt round-robin verdeeld bij play/refresh. Koppel
|
||
daarna een lijst onder <Link href="/live-lists">Live-lijsten</Link>.
|
||
</p>
|
||
|
||
{msg && (
|
||
<p className="ok-banner" role="status">
|
||
{msg}
|
||
</p>
|
||
)}
|
||
{err && (
|
||
<p className="err-banner" role="alert">
|
||
{err}
|
||
</p>
|
||
)}
|
||
|
||
<div className="card" style={{ maxWidth: 720 }}>
|
||
<h2 style={{ marginTop: 0 }}>Odido</h2>
|
||
<p className="muted" style={{ marginTop: 0 }}>
|
||
Accounts met verschillende abonnementen vullen elkaar aan. Gedeelde zenders worden
|
||
load-balanced; unieke zenders komen van het account dat ze wél heeft.
|
||
</p>
|
||
|
||
<h3 style={{ marginBottom: "0.5rem", display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
|
||
Accounts
|
||
<button type="button" disabled={busy || accounts.length === 0} onClick={() => void probeAccounts()}>
|
||
Status controleren
|
||
</button>
|
||
</h3>
|
||
{accounts.length === 0 ? (
|
||
<p className="muted">Nog geen accounts — voeg hieronder je eerste klantnummer toe.</p>
|
||
) : (
|
||
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 1rem" }}>
|
||
{accounts.map((a) => (
|
||
<li
|
||
key={a.id}
|
||
style={{
|
||
display: "flex",
|
||
flexWrap: "wrap",
|
||
gap: 8,
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
padding: "0.6rem 0",
|
||
borderBottom: "1px solid var(--border, #333)",
|
||
}}
|
||
>
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
|
||
<strong>{a.label || a.username}</strong>
|
||
{healthBadge(a)}
|
||
<span className="muted" style={{ fontSize: "0.85rem" }}>
|
||
{a.username}
|
||
{a.id !== "legacy" ? ` · ${a.channelCount} zenders` : ""}
|
||
</span>
|
||
</div>
|
||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||
{a.id !== "legacy" && (
|
||
<>
|
||
<button type="button" disabled={busy} onClick={() => void probeAccounts(a.id)}>
|
||
Check
|
||
</button>
|
||
<button type="button" disabled={busy} onClick={() => void toggleAccount(a)}>
|
||
{a.enabled ? "Uitschakelen" : "Inschakelen"}
|
||
</button>
|
||
<button type="button" disabled={busy} onClick={() => void removeAccount(a)}>
|
||
Verwijderen
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
<form onSubmit={addAccount} className="form-group" style={{ marginBottom: "1.5rem" }}>
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
|
||
gap: "0.75rem",
|
||
}}
|
||
>
|
||
<label className="field">
|
||
Label (optioneel)
|
||
<input
|
||
value={newLabel}
|
||
onChange={(e) => setNewLabel(e.target.value)}
|
||
placeholder="bijv. Thuis / Zakelijk"
|
||
disabled={busy}
|
||
/>
|
||
</label>
|
||
<label className="field">
|
||
Klantnummer
|
||
<input
|
||
value={newUsername}
|
||
onChange={(e) => setNewUsername(e.target.value)}
|
||
placeholder="TV-klantnummer"
|
||
disabled={busy}
|
||
required
|
||
autoComplete="username"
|
||
/>
|
||
</label>
|
||
<label className="field">
|
||
Pincode
|
||
<input
|
||
type="password"
|
||
value={newPin}
|
||
onChange={(e) => setNewPin(e.target.value)}
|
||
placeholder="4-cijferig"
|
||
disabled={busy}
|
||
required
|
||
autoComplete="current-password"
|
||
/>
|
||
</label>
|
||
</div>
|
||
<button type="submit" className="btn-primary" disabled={busy} style={{ marginTop: 8 }}>
|
||
Account toevoegen
|
||
</button>
|
||
</form>
|
||
|
||
<form onSubmit={saveOdido} className="form-group">
|
||
<div className="field">
|
||
<span>WVD.wvd (Widevine device — gedeeld)</span>
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center", marginTop: 6 }}>
|
||
<input
|
||
ref={wvdInputRef}
|
||
type="file"
|
||
accept=".wvd,application/octet-stream"
|
||
disabled={busy}
|
||
onChange={(e) => void uploadWvd(e.target.files?.[0] ?? null)}
|
||
/>
|
||
</div>
|
||
<p className="muted" style={{ margin: "6px 0 0", fontSize: "0.85rem" }}>
|
||
{odido?.hasWvd ? (
|
||
<>
|
||
Aanwezig op server
|
||
{odido.wvdUploadedAt
|
||
? ` · geüpload ${new Date(odido.wvdUploadedAt).toLocaleString("nl-NL")}`
|
||
: ""}
|
||
{odido.wvdPath ? ` · ${odido.wvdPath}` : ""}
|
||
</>
|
||
) : (
|
||
<>Nog niet geüpload — kies je lokale WVD.wvd (max. 2 MB).</>
|
||
)}
|
||
</p>
|
||
</div>
|
||
<label className="field">
|
||
Pad op de server (optioneel)
|
||
<input
|
||
value={wvdPath}
|
||
onChange={(e) => setWvdPath(e.target.value)}
|
||
placeholder="/data/odido/WVD.wvd"
|
||
disabled={busy}
|
||
/>
|
||
</label>
|
||
<label className="field" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={enabled}
|
||
onChange={(e) => setEnabled(e.target.checked)}
|
||
disabled={busy}
|
||
/>
|
||
Odido-integratie actief
|
||
</label>
|
||
|
||
<div style={{ marginTop: "1.25rem" }}>
|
||
<button
|
||
type="button"
|
||
className="btn-secondary"
|
||
onClick={() => setShowTweaks((v) => !v)}
|
||
disabled={busy}
|
||
>
|
||
{showTweaks ? "Geavanceerd verbergen" : "Geavanceerd / tweaks tonen"}
|
||
</button>
|
||
</div>
|
||
|
||
{showTweaks && (
|
||
<div
|
||
style={{
|
||
marginTop: "1rem",
|
||
padding: "1rem",
|
||
border: "1px solid var(--border, #333)",
|
||
borderRadius: 8,
|
||
}}
|
||
>
|
||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
|
||
<h3 style={{ margin: 0 }}>Tweak-opties</h3>
|
||
<button type="button" disabled={busy} onClick={resetTweaks}>
|
||
Defaults herstellen
|
||
</button>
|
||
</div>
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))",
|
||
gap: "0.75rem 1rem",
|
||
marginTop: "0.75rem",
|
||
}}
|
||
>
|
||
{meta.map((item) => {
|
||
const value = tweaks[item.key];
|
||
if (item.kind === "boolean") {
|
||
return (
|
||
<label
|
||
key={item.key}
|
||
className="field"
|
||
style={{ display: "flex", alignItems: "flex-start", gap: 8 }}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={value === true}
|
||
disabled={busy}
|
||
onChange={(e) =>
|
||
setTweaks((prev) => ({ ...prev, [item.key]: e.target.checked }))
|
||
}
|
||
style={{ marginTop: 4 }}
|
||
/>
|
||
<span>
|
||
<strong style={{ fontWeight: 600 }}>{item.label}</strong>
|
||
<br />
|
||
<span className="muted" style={{ fontSize: "0.8rem" }}>
|
||
{item.hint}
|
||
</span>
|
||
</span>
|
||
</label>
|
||
);
|
||
}
|
||
return (
|
||
<label key={item.key} className="field">
|
||
{item.label}
|
||
{item.unit ? ` (${item.unit})` : ""}
|
||
<input
|
||
type="number"
|
||
value={typeof value === "number" ? value : ""}
|
||
disabled={busy}
|
||
onChange={(e) =>
|
||
setTweaks((prev) => ({
|
||
...prev,
|
||
[item.key]: e.target.value === "" ? 0 : Number(e.target.value),
|
||
}))
|
||
}
|
||
/>
|
||
<span className="muted" style={{ fontSize: "0.8rem" }}>
|
||
{item.hint}
|
||
</span>
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: "1rem" }}>
|
||
<button type="button" disabled={busy} onClick={() => void refreshMpds()}>
|
||
MPD-cache nu vernieuwen
|
||
</button>
|
||
<button type="button" disabled={busy} onClick={() => void flushMpds()}>
|
||
MPD-cache legen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<button type="submit" className="btn-primary" disabled={busy} style={{ marginTop: "1rem" }}>
|
||
Opslaan
|
||
</button>
|
||
</form>
|
||
|
||
<div className="muted" style={{ marginTop: "1rem", fontSize: "0.9rem" }}>
|
||
Status:{" "}
|
||
{odido?.configured ? (
|
||
<span className="ok-banner" style={{ display: "inline", padding: "2px 8px" }}>
|
||
geconfigureerd ({odido.accountCount} account
|
||
{odido.accountCount === 1 ? "" : "s"}
|
||
{odido.channelOwnerCount
|
||
? ` · ${odido.channelOwnerCount} zenders na sync`
|
||
: ""}
|
||
)
|
||
</span>
|
||
) : (
|
||
<span>nog niet compleet (minstens 1 account + WVD)</span>
|
||
)}
|
||
{odido?.lastSyncAt && (
|
||
<>
|
||
<br />
|
||
Laatste sync: {new Date(odido.lastSyncAt).toLocaleString("nl-NL")}
|
||
{summary && (
|
||
<>
|
||
{" "}
|
||
(
|
||
{[
|
||
summary.uniqueChannels != null
|
||
? `${String(summary.uniqueChannels)} uniek`
|
||
: null,
|
||
summary.overlapChannels != null
|
||
? `${String(summary.overlapChannels)} overlap`
|
||
: null,
|
||
summary.added != null ? `+${String(summary.added)}` : null,
|
||
summary.updated != null ? `~${String(summary.updated)}` : null,
|
||
]
|
||
.filter(Boolean)
|
||
.join(" · ") || "ok"}
|
||
)
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
{odido?.lastMpdRefreshAt && (
|
||
<>
|
||
<br />
|
||
Laatste MPD-refresh: {new Date(odido.lastMpdRefreshAt).toLocaleString("nl-NL")}
|
||
{refreshSummary && (
|
||
<>
|
||
{" "}
|
||
(
|
||
{[
|
||
refreshSummary.refreshed != null
|
||
? `${String(refreshSummary.refreshed)} ok`
|
||
: null,
|
||
refreshSummary.failed != null
|
||
? `${String(refreshSummary.failed)} fout`
|
||
: null,
|
||
]
|
||
.filter(Boolean)
|
||
.join(" · ")}
|
||
)
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</>
|
||
);
|
||
}
|