Auto-probe on account add and always render an Abonnement row, with session-cookie fallback when overview is unavailable.
677 lines
22 KiB
TypeScript
677 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import { FormEvent, useCallback, useEffect, useState } from "react";
|
|
|
|
type TweakMeta = {
|
|
key: string;
|
|
label: string;
|
|
hint: string;
|
|
kind: "number" | "boolean" | "string";
|
|
unit?: string;
|
|
};
|
|
|
|
type ViaplayTweaks = Record<string, number | boolean | string>;
|
|
|
|
type ViaplayAccount = {
|
|
id: string;
|
|
label: string;
|
|
email: string;
|
|
hasSession: boolean;
|
|
hasAccessToken: boolean;
|
|
hasProfileId: boolean;
|
|
hasPassword: boolean;
|
|
enabled: boolean;
|
|
eventCount: number;
|
|
healthStatus: "ok" | "dead" | "unknown";
|
|
healthCheckedAt: string | null;
|
|
healthError: string | null;
|
|
subscriptionSummary: string | null;
|
|
partnerName: string | null;
|
|
overviewCheckedAt: string | null;
|
|
};
|
|
|
|
type ViaplaySettings = {
|
|
configured: boolean;
|
|
enabled: boolean;
|
|
accounts: ViaplayAccount[];
|
|
accountCount: number;
|
|
eventCount: number;
|
|
eventOwnerCount: number;
|
|
lastSyncAt: string | null;
|
|
lastSyncSummary: Record<string, unknown> | null;
|
|
lastPreLiveAt: string | null;
|
|
lastPreLiveSummary: Record<string, unknown> | null;
|
|
wvdPath: string;
|
|
wvdExists: boolean;
|
|
tweaks: ViaplayTweaks;
|
|
tweakDefaults: ViaplayTweaks;
|
|
tweakMeta: TweakMeta[];
|
|
};
|
|
|
|
export type ViaplayOverview = {
|
|
configured: boolean;
|
|
enabled: boolean;
|
|
accountCount: number;
|
|
eventCount: number;
|
|
lastSyncAt: string | null;
|
|
};
|
|
|
|
export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) => void }) {
|
|
const [settings, setSettings] = useState<ViaplaySettings | null>(null);
|
|
const [enabled, setEnabled] = useState(true);
|
|
const [tweaks, setTweaks] = useState<ViaplayTweaks>({});
|
|
const [showTweaks, setShowTweaks] = useState(false);
|
|
const [showCookies, setShowCookies] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
|
|
const [newLabel, setNewLabel] = useState("");
|
|
const [newEmail, setNewEmail] = useState("");
|
|
const [newSession, setNewSession] = useState("");
|
|
const [newAccessToken, setNewAccessToken] = useState("");
|
|
const [newProfileId, setNewProfileId] = useState("");
|
|
|
|
const applySettings = useCallback(
|
|
(d: ViaplaySettings) => {
|
|
setSettings(d);
|
|
setEnabled(d.enabled !== false);
|
|
setTweaks(d.tweaks ?? d.tweakDefaults ?? {});
|
|
onChanged?.({
|
|
configured: d.configured,
|
|
enabled: d.enabled !== false,
|
|
accountCount: d.accountCount,
|
|
eventCount: d.eventCount,
|
|
lastSyncAt: d.lastSyncAt,
|
|
});
|
|
},
|
|
[onChanged]
|
|
);
|
|
|
|
const load = useCallback(async () => {
|
|
const r = await fetch("/api/v1/admin/scripts/viaplay", { credentials: "include" });
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error?.message ?? "Viaplay-instellingen laden mislukt");
|
|
applySettings(d);
|
|
}, [applySettings]);
|
|
|
|
useEffect(() => {
|
|
void load().catch((e) => setErr(String(e.message ?? e)));
|
|
}, [load]);
|
|
|
|
async function saveSettings(e: FormEvent) {
|
|
e.preventDefault();
|
|
setBusy(true);
|
|
setErr(null);
|
|
setMsg(null);
|
|
try {
|
|
const r = await fetch("/api/v1/admin/scripts/viaplay", {
|
|
method: "PUT",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ enabled, tweaks }),
|
|
});
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error?.message ?? "Opslaan mislukt");
|
|
applySettings(d);
|
|
setMsg("Viaplay-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/viaplay/accounts", {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
label: newLabel.trim() || undefined,
|
|
email: newEmail.trim(),
|
|
session: newSession.trim() || undefined,
|
|
accessToken: newAccessToken.trim() || undefined,
|
|
profileId: newProfileId.trim() || undefined,
|
|
enabled: true,
|
|
}),
|
|
});
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error?.message ?? "Account toevoegen mislukt");
|
|
const emailKey = newEmail.trim().toLowerCase();
|
|
applySettings(d);
|
|
setNewLabel("");
|
|
setNewEmail("");
|
|
setNewSession("");
|
|
setNewAccessToken("");
|
|
setNewProfileId("");
|
|
const added = (d.accounts as ViaplayAccount[] | undefined)?.find(
|
|
(a) => a.email === emailKey
|
|
);
|
|
const sub = added?.subscriptionSummary;
|
|
setMsg(
|
|
sub
|
|
? `Account toegevoegd · abonnement: ${sub}`
|
|
: "Account toegevoegd — abonnement nog niet zichtbaar, druk Check"
|
|
);
|
|
} catch (e) {
|
|
setErr(String((e as Error).message ?? e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function toggleAccount(account: ViaplayAccount) {
|
|
setBusy(true);
|
|
setErr(null);
|
|
try {
|
|
const r = await fetch(`/api/v1/admin/scripts/viaplay/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: ViaplayAccount) {
|
|
if (!confirm(`Account “${account.label || account.email}” verwijderen?`)) return;
|
|
setBusy(true);
|
|
setErr(null);
|
|
setMsg(null);
|
|
try {
|
|
const r = await fetch(`/api/v1/admin/scripts/viaplay/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/viaplay/accounts/${accountId}/probe`
|
|
: "/api/v1/admin/scripts/viaplay/accounts/probe";
|
|
const r = await fetch(url, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(accountId ? { accountId } : {}),
|
|
});
|
|
const d = await r.json().catch(() => ({}));
|
|
if (!r.ok) {
|
|
throw new Error(
|
|
d?.error?.message || d?.message || `Statuscheck mislukt (${r.status})`
|
|
);
|
|
}
|
|
applySettings(d);
|
|
const list: ViaplayAccount[] = d.accounts ?? [];
|
|
const ok = list.filter((a) => a.healthStatus === "ok").length;
|
|
const dead = list.filter((a) => a.healthStatus === "dead").length;
|
|
const sample = list.find((a) => a.id === accountId) || list.find((a) => a.subscriptionSummary);
|
|
const sub = sample?.subscriptionSummary ? ` · ${sample.subscriptionSummary}` : "";
|
|
setMsg(`Accountstatus: ${ok} actief · ${dead} probleem${sub}`);
|
|
} catch (e) {
|
|
setErr(String((e as Error).message ?? e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function runSync() {
|
|
setBusy(true);
|
|
setErr(null);
|
|
setMsg(null);
|
|
try {
|
|
const r = await fetch("/api/v1/admin/scripts/viaplay/sync", {
|
|
method: "POST",
|
|
credentials: "include",
|
|
});
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error?.message ?? "Sync mislukt");
|
|
setMsg(`Viaplay-agenda: ${d.events ?? 0} events · ${d.daysOk ?? 0}/${d.daysTried ?? 0} dagen (${d.durationMs ?? "?"} ms)`);
|
|
await load();
|
|
} catch (e) {
|
|
setErr(String((e as Error).message ?? e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function runPreLive() {
|
|
setBusy(true);
|
|
setErr(null);
|
|
setMsg(null);
|
|
try {
|
|
const r = await fetch("/api/v1/admin/scripts/viaplay/prelive", {
|
|
method: "POST",
|
|
credentials: "include",
|
|
});
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error?.message ?? "Pre-live mislukt");
|
|
setMsg(
|
|
`Pre-live: ${d.refreshed ?? 0} refreshed · ${d.failed ?? 0} failed · ${d.checked ?? 0} checked (${d.durationMs ?? "?"} ms)`
|
|
);
|
|
await load();
|
|
} catch (e) {
|
|
setErr(String((e as Error).message ?? e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function healthBadge(a: ViaplayAccount) {
|
|
if (!a.enabled) {
|
|
return (
|
|
<span className="muted" style={{ fontSize: "0.85rem" }}>
|
|
uit
|
|
</span>
|
|
);
|
|
}
|
|
if (a.healthStatus === "ok") {
|
|
return (
|
|
<span
|
|
className="ok-banner"
|
|
style={{ display: "inline", padding: "2px 8px", fontSize: "0.8rem" }}
|
|
>
|
|
actief
|
|
</span>
|
|
);
|
|
}
|
|
if (a.healthStatus === "dead") {
|
|
return (
|
|
<span
|
|
className="err-banner"
|
|
title={a.healthError || "Sessie ongeldig"}
|
|
style={{ display: "inline", padding: "2px 8px", fontSize: "0.8rem" }}
|
|
>
|
|
probleem
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<span className="muted" style={{ fontSize: "0.85rem" }}>
|
|
onbekend
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function resetTweaks() {
|
|
if (settings?.tweakDefaults) setTweaks({ ...settings.tweakDefaults });
|
|
}
|
|
|
|
const accounts = settings?.accounts ?? [];
|
|
const meta = settings?.tweakMeta ?? [];
|
|
const summary = settings?.lastSyncSummary;
|
|
|
|
return (
|
|
<>
|
|
{msg && (
|
|
<p className="ok-banner" role="status">
|
|
{msg}
|
|
</p>
|
|
)}
|
|
{err && (
|
|
<p className="err-banner" role="alert">
|
|
{err}
|
|
</p>
|
|
)}
|
|
|
|
<div className="card" style={{ maxWidth: 760 }}>
|
|
<h2 style={{ marginTop: 0 }}>Viaplay</h2>
|
|
<p className="muted" style={{ marginTop: 0 }}>
|
|
Sportagenda en live streams voor gekoppelde Viaplay-accounts. Voeg een account toe met
|
|
sessie-token, access token en optioneel profile-id (uit een ingelogde browsersessie).
|
|
Agenda: max 3 dagen vooruit (vervangt Viaplay uit de externe events-feed). Manifests en
|
|
licenties cache-first (±5 min vóór start).
|
|
</p>
|
|
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: "1rem" }}>
|
|
<button type="button" className="btn-primary" disabled={busy} onClick={() => void runSync()}>
|
|
Agenda syncen
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={busy || accounts.length === 0}
|
|
onClick={() => void runPreLive()}
|
|
>
|
|
Pre-live vernieuwen
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={busy || accounts.length === 0}
|
|
onClick={() => void probeAccounts()}
|
|
>
|
|
Accounts controleren
|
|
</button>
|
|
</div>
|
|
|
|
<h3 style={{ marginBottom: "0.5rem" }}>Accounts</h3>
|
|
{accounts.length === 0 ? (
|
|
<p className="muted">Nog geen accounts — voeg e-mail + sessie-tokens 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={{ flex: "1 1 280px", minWidth: 0 }}>
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
|
|
<strong>{a.label || a.email}</strong>
|
|
{healthBadge(a)}
|
|
<span className="muted" style={{ fontSize: "0.85rem" }}>
|
|
{a.email}
|
|
{a.eventCount ? ` · ${a.eventCount} events` : ""}
|
|
</span>
|
|
</div>
|
|
<div
|
|
style={{
|
|
marginTop: 8,
|
|
padding: "8px 10px",
|
|
borderRadius: 6,
|
|
background: "var(--surface-2, rgba(127,127,127,0.12))",
|
|
fontSize: "0.9rem",
|
|
}}
|
|
>
|
|
<div>
|
|
<span className="muted">Abonnement: </span>
|
|
{a.subscriptionSummary ? (
|
|
<strong>{a.subscriptionSummary}</strong>
|
|
) : (
|
|
<span className="muted">nog niet opgehaald — druk Check</span>
|
|
)}
|
|
</div>
|
|
{a.partnerName && a.subscriptionSummary && !a.subscriptionSummary.includes(a.partnerName) ? (
|
|
<div className="muted" style={{ fontSize: "0.8rem", marginTop: 2 }}>
|
|
Partner: {a.partnerName}
|
|
</div>
|
|
) : null}
|
|
{a.healthError ? (
|
|
<div className="muted" style={{ fontSize: "0.8rem", marginTop: 4 }}>
|
|
{a.healthError}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
|
<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(180px, 1fr))",
|
|
gap: "0.75rem",
|
|
}}
|
|
>
|
|
<label className="field">
|
|
Label
|
|
<input
|
|
value={newLabel}
|
|
onChange={(e) => setNewLabel(e.target.value)}
|
|
placeholder="bijv. Thuis"
|
|
disabled={busy}
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
E-mail
|
|
<input
|
|
type="email"
|
|
value={newEmail}
|
|
onChange={(e) => setNewEmail(e.target.value)}
|
|
required
|
|
disabled={busy}
|
|
autoComplete="username"
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className="btn-secondary"
|
|
style={{ marginTop: 10 }}
|
|
onClick={() => setShowCookies((v) => !v)}
|
|
>
|
|
{showCookies ? "Tokens verbergen" : "Sessie-tokens plakken (vereist)"}
|
|
</button>
|
|
|
|
{showCookies && (
|
|
<div style={{ marginTop: 10, display: "grid", gap: "0.75rem" }}>
|
|
<label className="field">
|
|
session
|
|
<textarea
|
|
value={newSession}
|
|
onChange={(e) => setNewSession(e.target.value)}
|
|
rows={2}
|
|
disabled={busy}
|
|
placeholder="session-waarde uit ingelogde browsersessie"
|
|
style={{ fontFamily: "monospace", fontSize: "0.8rem" }}
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
accessToken
|
|
<textarea
|
|
value={newAccessToken}
|
|
onChange={(e) => setNewAccessToken(e.target.value)}
|
|
rows={2}
|
|
disabled={busy}
|
|
placeholder="access token"
|
|
style={{ fontFamily: "monospace", fontSize: "0.8rem" }}
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
profileId (optioneel)
|
|
<input
|
|
value={newProfileId}
|
|
onChange={(e) => setNewProfileId(e.target.value)}
|
|
disabled={busy}
|
|
placeholder="profile id"
|
|
/>
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
<button type="submit" className="btn-primary" disabled={busy} style={{ marginTop: 10 }}>
|
|
Account toevoegen
|
|
</button>
|
|
</form>
|
|
|
|
<form onSubmit={saveSettings} className="form-group">
|
|
<label className="field" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={enabled}
|
|
onChange={(e) => setEnabled(e.target.checked)}
|
|
disabled={busy}
|
|
/>
|
|
Viaplay-integratie actief
|
|
</label>
|
|
|
|
<div style={{ marginTop: "1rem" }}>
|
|
<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>
|
|
<p className="muted" style={{ fontSize: "0.85rem", marginTop: 8 }}>
|
|
WVD: {settings?.wvdExists ? "gevonden" : "ontbreekt"} ·{" "}
|
|
<code style={{ fontSize: "0.8rem" }}>{settings?.wvdPath}</code>
|
|
</p>
|
|
<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={item.kind === "number" ? "number" : "text"}
|
|
value={value == null ? "" : String(value)}
|
|
disabled={busy}
|
|
onChange={(e) =>
|
|
setTweaks((prev) => ({
|
|
...prev,
|
|
[item.key]:
|
|
item.kind === "number"
|
|
? e.target.value === ""
|
|
? 0
|
|
: Number(e.target.value)
|
|
: e.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
<span className="muted" style={{ fontSize: "0.8rem" }}>
|
|
{item.hint}
|
|
</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</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:{" "}
|
|
{settings?.configured ? (
|
|
<span className="ok-banner" style={{ display: "inline", padding: "2px 8px" }}>
|
|
geconfigureerd ({settings.accountCount} account
|
|
{settings.accountCount === 1 ? "" : "s"}
|
|
{settings.eventCount ? ` · ${settings.eventCount} events` : ""}
|
|
)
|
|
</span>
|
|
) : (
|
|
<span>nog niet compleet (account + sessie-tokens)</span>
|
|
)}
|
|
{settings?.lastSyncAt && (
|
|
<>
|
|
<br />
|
|
Laatste agenda-sync: {new Date(settings.lastSyncAt).toLocaleString("nl-NL")}
|
|
{summary && (
|
|
<>
|
|
{" "}
|
|
(
|
|
{[
|
|
summary.events != null ? `${String(summary.events)} events` : null,
|
|
summary.daysOk != null ? `${String(summary.daysOk)} dagen` : null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" · ") || "ok"}
|
|
)
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
{settings?.lastPreLiveAt && (
|
|
<>
|
|
<br />
|
|
Laatste pre-live: {new Date(settings.lastPreLiveAt).toLocaleString("nl-NL")}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|