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 (
+
+ );
+}
+
+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 (
+
+
+
+
+
+ Admin Access
+
+ Enter your passcode once. A secure cookie will keep this browser
+ logged in.
+
+
+
+
+ {error && {error}
}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
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({