From 8b3fb1b856eb430b61e32f16d292080402e894b3 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 1 Sep 2026 03:17:34 +0200 Subject: [PATCH] Clarify viewers admin page with expandable cards and clear status. Separate account, Google Play, and devices; collapse details by default so the overview stays scannable. Co-authored-by: Cursor --- apps/admin-ui/src/app/viewers/page.tsx | 1041 ++++++++++++++++++------ 1 file changed, 770 insertions(+), 271 deletions(-) diff --git a/apps/admin-ui/src/app/viewers/page.tsx b/apps/admin-ui/src/app/viewers/page.tsx index b5b33a5..9f7bc4f 100644 --- a/apps/admin-ui/src/app/viewers/page.tsx +++ b/apps/admin-ui/src/app/viewers/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { FormEvent, useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; import { Nav, useAuth } from "@/components/Nav"; type Device = { @@ -45,14 +46,25 @@ type PlayConfig = { }; const SYNC_LABELS: Record = { - NOT_ENABLED: "Niet ingeschakeld", - PENDING_ADD: "Bezig met toevoegen…", + NOT_ENABLED: "Uit", + PENDING_ADD: "Bezig…", ACTIVE: "Actief", - PENDING_REMOVE: "Bezig met verwijderen…", - REMOVED: "Verwijderd", + PENDING_REMOVE: "Bezig…", + REMOVED: "Uit", ERROR: "Fout", }; +function formatWhen(iso: string | null) { + if (!iso) return null; + return new Date(iso).toLocaleString("nl-NL", { + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + export default function ViewersPage() { useAuth(); const [viewers, setViewers] = useState([]); @@ -61,9 +73,13 @@ export default function ViewersPage() { const [password, setPassword] = useState(""); const [name, setName] = useState(""); const [busy, setBusy] = useState(false); + const [bulkBusy, setBulkBusy] = useState(false); const [msg, setMsg] = useState(null); const [err, setErr] = useState(null); const [playEmails, setPlayEmails] = useState>({}); + const [expandedId, setExpandedId] = useState(null); + const [showCreate, setShowCreate] = useState(false); + const [actionId, setActionId] = useState(null); const load = useCallback(() => { fetch("/api/v1/admin/viewers", { credentials: "include" }) @@ -74,9 +90,7 @@ export default function ViewersPage() { setPlayEmails((prev) => { const next = { ...prev }; for (const v of list) { - if (next[v.id] === undefined) { - next[v.id] = v.googlePlayEmail ?? ""; - } + if (next[v.id] === undefined) next[v.id] = v.googlePlayEmail ?? ""; } return next; }); @@ -92,6 +106,14 @@ export default function ViewersPage() { .catch(() => undefined); }, [load]); + const stats = useMemo(() => { + const active = viewers.filter((v) => v.enabled).length; + const appOn = viewers.filter((v) => v.appAccess).length; + const playOn = viewers.filter((v) => v.googlePlayAccess).length; + const playErr = viewers.filter((v) => v.googlePlaySyncStatus === "ERROR").length; + return { total: viewers.length, active, appOn, playOn, playErr }; + }, [viewers]); + async function createViewer(e: FormEvent) { e.preventDefault(); setBusy(true); @@ -109,10 +131,12 @@ export default function ViewersPage() { setErr(data.error?.message ?? "Aanmaken mislukt"); return; } - setMsg(`Kijker ${data.viewer.email} aangemaakt`); + setMsg(`${data.viewer.email} aangemaakt`); setEmail(""); setPassword(""); setName(""); + setShowCreate(false); + setExpandedId(data.viewer.id); load(); } finally { setBusy(false); @@ -120,75 +144,135 @@ export default function ViewersPage() { } async function toggle(v: Viewer, field: "enabled" | "appAccess") { - await fetch(`/api/v1/admin/viewers/${v.id}`, { - method: "PATCH", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ [field]: !v[field] }), - }); - load(); + setActionId(v.id); + setErr(null); + try { + const res = await fetch(`/api/v1/admin/viewers/${v.id}`, { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ [field]: !v[field] }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setErr(data.error?.message ?? "Bijwerken mislukt"); + } + load(); + } finally { + setActionId(null); + } } async function setPlayAccess(v: Viewer, enabled: boolean) { + setActionId(v.id); setMsg(null); setErr(null); - const res = await fetch(`/api/v1/admin/viewers/${v.id}/google-play-access`, { - method: "PATCH", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - enabled, - email: playEmails[v.id]?.trim() || null, - }), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) { - setErr(data.error?.message ?? "Google Play-toegang bijwerken mislukt"); - } else { - setMsg(data.message ?? (enabled ? "Google Play access enabled." : "Google Play access removed.")); - if (data.syncError) setErr(data.syncError); + try { + const res = await fetch(`/api/v1/admin/viewers/${v.id}/google-play-access`, { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + enabled, + email: playEmails[v.id]?.trim() || null, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setErr(data.error?.message ?? "Google Play bijwerken mislukt"); + } else { + setMsg( + enabled + ? data.syncError + ? "Instelling opgeslagen — synchronisatie mislukt, probeer opnieuw" + : "Google Play-toegang ingeschakeld" + : data.syncError + ? "Instelling opgeslagen — synchronisatie mislukt, probeer opnieuw" + : "Google Play-toegang uitgeschakeld" + ); + if (data.syncError) setErr(data.syncError); + } + load(); + } finally { + setActionId(null); + } + } + + async function savePlayEmail(v: Viewer) { + setActionId(v.id); + setMsg(null); + setErr(null); + try { + const res = await fetch(`/api/v1/admin/viewers/${v.id}/google-play-access`, { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + enabled: v.googlePlayAccess, + email: playEmails[v.id]?.trim() || null, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setErr(data.error?.message ?? "E-mail opslaan mislukt"); + } else { + setMsg("Google Play-e-mail opgeslagen"); + if (data.syncError) setErr(data.syncError); + } + load(); + } finally { + setActionId(null); } - load(); } async function retryPlaySync(v: Viewer) { + setActionId(v.id); setMsg(null); setErr(null); - const res = await fetch(`/api/v1/admin/viewers/${v.id}/google-play-access/retry`, { - method: "POST", - credentials: "include", - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) { - setErr(data.error?.message ?? "Synchronisatie mislukt"); - } else { - setMsg(data.message ?? "Synchronization completed."); - if (data.syncError) setErr(data.syncError); + try { + const res = await fetch(`/api/v1/admin/viewers/${v.id}/google-play-access/retry`, { + method: "POST", + credentials: "include", + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setErr(data.error?.message ?? "Synchronisatie mislukt"); + } else { + setMsg(data.syncError ? "Synchronisatie mislukt" : "Synchronisatie gelukt"); + if (data.syncError) setErr(data.syncError); + } + load(); + } finally { + setActionId(null); } - load(); } async function bulkAction(path: "sync-all" | "reconcile") { + setBulkBusy(true); setMsg(null); setErr(null); - const res = await fetch(`/api/v1/admin/google-play-access/${path}`, { - method: "POST", - credentials: "include", - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) { - setErr(data.error?.message ?? "Actie mislukt"); - return; + try { + const res = await fetch(`/api/v1/admin/google-play-access/${path}`, { + method: "POST", + credentials: "include", + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setErr(data.error?.message ?? "Actie mislukt"); + return; + } + const s = data.summary; + setMsg( + `${path === "sync-all" ? "Sync" : "Reconcile"}: ${s.scanned} gecontroleerd · ${s.correct} ok · ${s.added} toegevoegd · ${s.removed} verwijderd · ${s.errors} fouten` + ); + load(); + } finally { + setBulkBusy(false); } - const s = data.summary; - setMsg( - `Scanned: ${s.scanned} · Correct: ${s.correct} · Added: ${s.added} · Removed: ${s.removed} · Errors: ${s.errors}` - ); - load(); } async function revokeDevice(deviceId: string) { - if (!confirm("Apparaat intrekken?")) return; + if (!confirm("Apparaat intrekken? De app moet opnieuw worden gekoppeld.")) return; await fetch(`/api/v1/admin/viewers/devices/${deviceId}/revoke`, { method: "POST", credentials: "include", @@ -199,257 +283,672 @@ export default function ViewersPage() { return ( <>