From dc9ac4e7c8eaad85838cc7417a62d73337949395 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 22 Feb 2026 01:20:19 -0800 Subject: [PATCH] admin and higher limits --- admin.css | 330 ++++++++++++++++++++++++++++++++++++++++++++ admin.html | 19 +++ admin.tsx | 378 +++++++++++++++++++++++++++++++++++++++++++++++++++ db.ts | 5 + frontend.css | 5 + frontend.tsx | 29 +++- game.ts | 46 ++++++- history.css | 8 +- history.tsx | 11 +- quipslop.tsx | 9 +- server.ts | 241 ++++++++++++++++++++++++++++++-- 11 files changed, 1057 insertions(+), 24 deletions(-) create mode 100644 admin.css create mode 100644 admin.html create mode 100644 admin.tsx diff --git a/admin.css b/admin.css new file mode 100644 index 0000000..527dba4 --- /dev/null +++ b/admin.css @@ -0,0 +1,330 @@ +*, +*::before, +*::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg: #0a0a0a; + --surface: #111; + --border: #1c1c1c; + --text: #ededed; + --text-dim: #888; + --text-muted: #444; + --accent: #D97757; + --serif: 'DM Serif Display', Georgia, serif; + --sans: 'Inter', -apple-system, sans-serif; + --mono: 'JetBrains Mono', 'SF Mono', monospace; + --danger: #ef4444; +} + +body { + background: var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + min-height: 100vh; + min-height: 100dvh; +} + +.admin { + max-width: 980px; + margin: 0 auto; + padding: 32px 24px 48px; +} + +.admin--centered { + min-height: 100vh; + min-height: 100dvh; + display: grid; + place-items: center; + padding: 24px; +} + +.admin-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 28px; + gap: 16px; +} + +.logo-link { + display: inline-flex; + text-decoration: none; +} + +.logo-link img { + height: 20px; + width: auto; +} + +.panel { + width: 100%; + background: var(--surface); + border: 1px solid var(--border); +} + +.panel--login { + max-width: 460px; + padding: 32px; + display: grid; + gap: 18px; +} + +.panel--login h1, +.panel-head h1 { + font-family: var(--serif); + font-size: clamp(36px, 5vw, 48px); + line-height: 1; + letter-spacing: -0.8px; +} + +.panel--main { + padding: 28px; +} + +.panel-head { + margin-bottom: 24px; +} + +.panel-head p, +.muted { + color: var(--text-dim); + max-width: 64ch; +} + +.login-form { + display: grid; + gap: 12px; +} + +.field-label { + font-family: var(--mono); + text-transform: uppercase; + letter-spacing: 1px; + font-size: 11px; + color: var(--text-muted); +} + +.text-input { + width: 100%; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 14px; + padding: 10px 12px; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.text-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +.btn, +.link-button { + border: 1px solid var(--border); + border-radius: 999px; + background: rgba(255, 255, 255, 0.02); + color: var(--text); + font-family: var(--mono); + font-size: 12px; + letter-spacing: 0.6px; + text-transform: uppercase; + padding: 10px 16px; + cursor: pointer; + transition: transform 0.15s ease, border-color 0.2s ease, background 0.2s ease; +} + +.btn:hover:not(:disabled), +.link-button:hover:not(:disabled) { + transform: translateY(-1px); + border-color: #3a3a3a; +} + +.btn:disabled, +.link-button:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; +} + +.btn--primary { + background: var(--accent); + border-color: var(--accent); + color: var(--bg); + font-weight: 700; +} + +.btn--danger { + background: transparent; + border-color: var(--danger); + color: var(--danger); +} + +.quick-links { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.quick-links a, +.link-button { + color: var(--text-dim); + text-decoration: none; + font-size: 12px; + font-family: var(--mono); + background: none; + border: none; + padding: 0; + text-transform: uppercase; + letter-spacing: 0.8px; +} + +.quick-links a:hover, +.link-button:hover { + color: var(--text); +} + +.status-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.status-card { + border: 1px solid var(--border); + padding: 14px; + background: var(--bg); +} + +.status-card__label { + font-family: var(--mono); + font-size: 10px; + letter-spacing: 1px; + color: var(--text-muted); + text-transform: uppercase; + margin-bottom: 6px; +} + +.status-card__value { + font-size: 22px; + line-height: 1; + font-weight: 700; +} + +.actions { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.error-banner { + border: 1px solid var(--danger); + background: rgba(239, 68, 68, 0.1); + color: var(--danger); + padding: 10px 12px; + font-size: 14px; + font-family: var(--mono); +} + +.loading { + border: 1px solid var(--border); + background: var(--surface); + padding: 14px 18px; + font-family: var(--mono); + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-dim); +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + display: grid; + place-items: center; + padding: 20px; + backdrop-filter: blur(4px); + z-index: 10; +} + +.modal { + width: min(540px, 100%); + border: 1px solid var(--border); + background: var(--surface); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6); + padding: 24px; + display: grid; + gap: 14px; +} + +.modal h2 { + font-family: var(--serif); + font-size: 34px; + line-height: 1; +} + +.modal p { + color: var(--text-dim); +} + +.modal code { + font-family: var(--mono); + background: rgba(255, 255, 255, 0.08); + border: 1px solid var(--border); + padding: 2px 8px; + border-radius: 999px; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 8px; +} + +@media (max-width: 860px) { + .status-grid, + .actions { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 560px) { + .admin { + padding: 18px 14px 26px; + } + + .panel--main, + .panel--login { + padding: 20px; + } + + .status-grid, + .actions { + grid-template-columns: 1fr; + } + + .admin-header { + flex-direction: column; + align-items: flex-start; + } + + .modal { + padding: 18px; + } + + .modal h2 { + font-size: 28px; + } +} diff --git a/admin.html b/admin.html new file mode 100644 index 0000000..22224a8 --- /dev/null +++ b/admin.html @@ -0,0 +1,19 @@ + + + + + + quipslop Admin + + + + + + +
+ + + diff --git a/admin.tsx b/admin.tsx new file mode 100644 index 0000000..423c1d2 --- /dev/null +++ b/admin.tsx @@ -0,0 +1,378 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { createRoot } from "react-dom/client"; +import "./admin.css"; + +type AdminSnapshot = { + isPaused: boolean; + isRunningRound: boolean; + done: boolean; + completedInMemory: number; + persistedRounds: number; + viewerCount: number; +}; + +type AdminResponse = { ok: true } & AdminSnapshot; +type Mode = "checking" | "locked" | "ready"; + +const RESET_TOKEN = "RESET"; + +async function readErrorMessage(res: Response): Promise { + const text = await res.text(); + if (text) return text; + return `Request failed (${res.status})`; +} + +async function requestAdminJson( + path: string, + init?: RequestInit, +): Promise { + const headers = new Headers(init?.headers); + if (!headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + const response = await fetch(path, { + ...init, + headers, + cache: "no-store", + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response)); + } + + return (await response.json()) as AdminResponse; +} + +function StatusCard({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function App() { + const [mode, setMode] = useState("checking"); + const [snapshot, setSnapshot] = useState(null); + const [passcode, setPasscode] = useState(""); + const [error, setError] = useState(null); + const [pending, setPending] = useState(null); + const [isResetOpen, setIsResetOpen] = useState(false); + const [resetText, setResetText] = useState(""); + + useEffect(() => { + let mounted = true; + + requestAdminJson("/api/admin/status") + .then((data) => { + if (!mounted) return; + setSnapshot(data); + setMode("ready"); + }) + .catch(() => { + if (!mounted) return; + setSnapshot(null); + setMode("locked"); + }); + + return () => { + mounted = false; + }; + }, []); + + const busy = useMemo(() => pending !== null, [pending]); + + async function onLogin(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setPending("login"); + try { + const data = await requestAdminJson("/api/admin/login", { + method: "POST", + body: JSON.stringify({ passcode }), + }); + setSnapshot(data); + setPasscode(""); + setMode("ready"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to log in"); + } finally { + setPending(null); + } + } + + async function runControl(path: string, task: string) { + setError(null); + setPending(task); + try { + const data = await requestAdminJson(path, { method: "POST" }); + setSnapshot(data); + } catch (err) { + const message = err instanceof Error ? err.message : "Admin action failed"; + if (message.toLowerCase().includes("unauthorized")) { + setMode("locked"); + setSnapshot(null); + } + setError(message); + } finally { + setPending(null); + } + } + + async function onExport() { + setError(null); + setPending("export"); + try { + const response = await fetch("/api/admin/export", { cache: "no-store" }); + if (!response.ok) { + throw new Error(await readErrorMessage(response)); + } + + const blob = await response.blob(); + const disposition = response.headers.get("content-disposition") ?? ""; + const fileNameMatch = disposition.match(/filename="([^"]+)"/i); + const fileName = fileNameMatch?.[1] ?? `quipslop-export-${Date.now()}.json`; + + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + } catch (err) { + const message = err instanceof Error ? err.message : "Export failed"; + if (message.toLowerCase().includes("unauthorized")) { + setMode("locked"); + setSnapshot(null); + } + setError(message); + } finally { + setPending(null); + } + } + + async function onReset() { + setError(null); + setPending("reset"); + try { + const data = await requestAdminJson("/api/admin/reset", { + method: "POST", + body: JSON.stringify({ confirm: RESET_TOKEN }), + }); + setSnapshot(data); + setResetText(""); + setIsResetOpen(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Reset failed"); + } finally { + setPending(null); + } + } + + async function onLogout() { + setError(null); + setPending("logout"); + try { + await fetch("/api/admin/logout", { + method: "POST", + cache: "no-store", + }); + setSnapshot(null); + setPasscode(""); + setMode("locked"); + } finally { + setPending(null); + } + } + + if (mode === "checking") { + return ( +
+
Checking admin session...
+
+ ); + } + + if (mode === "locked") { + return ( +
+
+ + quipslop + +

Admin Access

+

+ Enter your passcode once. A secure cookie will keep this browser + logged in. +

+ +
+ + setPasscode(e.target.value)} + className="text-input" + autoFocus + autoComplete="off" + required + data-1p-ignore + data-lpignore="true" + /> + +
+ + {error &&
{error}
} + + +
+
+ ); + } + + return ( +
+
+ + quipslop + + +
+ +
+
+

Admin Console

+

+ Pause/resume the game loop, export all data as JSON, or wipe all + stored data. +

+
+ + {error &&
{error}
} + +
+ + + + +
+ +
+ + + + +
+
+ + {isResetOpen && ( +
+
+

Reset all data?

+

+ This permanently deletes every saved round and resets scores. + Current game flow is also paused. +

+

+ Type {RESET_TOKEN} to continue. +

+ setResetText(e.target.value)} + className="text-input" + placeholder={RESET_TOKEN} + autoFocus + /> +
+ + +
+
+
+ )} +
+ ); +} + +const root = createRoot(document.getElementById("root")!); +root.render(); diff --git a/db.ts b/db.ts index 619ddf2..6555a05 100644 --- a/db.ts +++ b/db.ts @@ -36,3 +36,8 @@ export function getAllRounds() { const rows = db.query("SELECT data FROM rounds ORDER BY num ASC, id ASC").all() as { data: string }[]; return rows.map(r => JSON.parse(r.data) as RoundState); } + +export function clearAllRounds() { + db.exec("DELETE FROM rounds;"); + db.exec("DELETE FROM sqlite_sequence WHERE name = 'rounds';"); +} diff --git a/frontend.css b/frontend.css index aac7f91..ed778c3 100644 --- a/frontend.css +++ b/frontend.css @@ -352,6 +352,11 @@ body { justify-content: space-between; } +.standings__links { + display: flex; + gap: 10px; +} + .standings__title { font-family: var(--mono); font-size: 11px; diff --git a/frontend.tsx b/frontend.tsx index a1de7c0..e51531f 100644 --- a/frontend.tsx +++ b/frontend.tsx @@ -36,6 +36,8 @@ type GameState = { active: RoundState | null; scores: Record; done: boolean; + isPaused: boolean; + generation: number; }; type ServerMessage = { type: "state"; @@ -340,9 +342,14 @@ function Standings({