admin and higher limits
This commit is contained in:
330
admin.css
Normal file
330
admin.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
19
admin.html
Normal file
19
admin.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>quipslop Admin</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Inter:wght@400;500;600;700;900&family=JetBrains+Mono:wght@400;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="./admin.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./admin.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
378
admin.tsx
Normal file
378
admin.tsx
Normal file
@@ -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<string> {
|
||||
const text = await res.text();
|
||||
if (text) return text;
|
||||
return `Request failed (${res.status})`;
|
||||
}
|
||||
|
||||
async function requestAdminJson(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<AdminResponse> {
|
||||
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 (
|
||||
<div className="status-card">
|
||||
<div className="status-card__label">{label}</div>
|
||||
<div className="status-card__value">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [mode, setMode] = useState<Mode>("checking");
|
||||
const [snapshot, setSnapshot] = useState<AdminSnapshot | null>(null);
|
||||
const [passcode, setPasscode] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState<string | null>(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 (
|
||||
<div className="admin admin--centered">
|
||||
<div className="loading">Checking admin session...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "locked") {
|
||||
return (
|
||||
<div className="admin admin--centered">
|
||||
<main className="panel panel--login">
|
||||
<a href="/" className="logo-link">
|
||||
<img src="/assets/logo.svg" alt="quipslop" />
|
||||
</a>
|
||||
<h1>Admin Access</h1>
|
||||
<p className="muted">
|
||||
Enter your passcode once. A secure cookie will keep this browser
|
||||
logged in.
|
||||
</p>
|
||||
|
||||
<form
|
||||
onSubmit={onLogin}
|
||||
className="login-form"
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
>
|
||||
<label htmlFor="passcode" className="field-label">
|
||||
Passcode
|
||||
</label>
|
||||
<input
|
||||
id="passcode"
|
||||
type="password"
|
||||
value={passcode}
|
||||
onChange={(e) => setPasscode(e.target.value)}
|
||||
className="text-input"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
required
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn--primary"
|
||||
disabled={busy || !passcode.trim()}
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
>
|
||||
{pending === "login" ? "Checking..." : "Unlock Admin"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="quick-links">
|
||||
<a href="/">Live Game</a>
|
||||
<a href="/history">History</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin">
|
||||
<header className="admin-header">
|
||||
<a href="/" className="logo-link">
|
||||
quipslop
|
||||
</a>
|
||||
<nav className="quick-links">
|
||||
<a href="/">Live Game</a>
|
||||
<a href="/history">History</a>
|
||||
<button className="link-button" onClick={onLogout} disabled={busy}>
|
||||
Logout
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="panel panel--main">
|
||||
<div className="panel-head">
|
||||
<h1>Admin Console</h1>
|
||||
<p>
|
||||
Pause/resume the game loop, export all data as JSON, or wipe all
|
||||
stored data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<section className="status-grid" aria-live="polite">
|
||||
<StatusCard
|
||||
label="Engine"
|
||||
value={snapshot?.isPaused ? "Paused" : "Running"}
|
||||
/>
|
||||
<StatusCard
|
||||
label="Active Round"
|
||||
value={snapshot?.isRunningRound ? "In Progress" : "Idle"}
|
||||
/>
|
||||
<StatusCard
|
||||
label="Persisted Rounds"
|
||||
value={String(snapshot?.persistedRounds ?? 0)}
|
||||
/>
|
||||
<StatusCard label="Viewers" value={String(snapshot?.viewerCount ?? 0)} />
|
||||
</section>
|
||||
|
||||
<section className="actions" aria-label="Admin actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
disabled={busy || Boolean(snapshot?.isPaused)}
|
||||
onClick={() => runControl("/api/admin/pause", "pause")}
|
||||
>
|
||||
{pending === "pause" ? "Pausing..." : "Pause"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy || !snapshot?.isPaused}
|
||||
onClick={() => runControl("/api/admin/resume", "resume")}
|
||||
>
|
||||
{pending === "resume" ? "Resuming..." : "Resume"}
|
||||
</button>
|
||||
<button type="button" className="btn" disabled={busy} onClick={onExport}>
|
||||
{pending === "export" ? "Exporting..." : "Export JSON"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--danger"
|
||||
disabled={busy}
|
||||
onClick={() => setIsResetOpen(true)}
|
||||
>
|
||||
Reset Data
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{isResetOpen && (
|
||||
<div className="modal-backdrop" role="dialog" aria-modal="true">
|
||||
<div className="modal">
|
||||
<h2>Reset all data?</h2>
|
||||
<p>
|
||||
This permanently deletes every saved round and resets scores.
|
||||
Current game flow is also paused.
|
||||
</p>
|
||||
<p>
|
||||
Type <code>{RESET_TOKEN}</code> to continue.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={resetText}
|
||||
onChange={(e) => setResetText(e.target.value)}
|
||||
className="text-input"
|
||||
placeholder={RESET_TOKEN}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
setIsResetOpen(false);
|
||||
setResetText("");
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--danger"
|
||||
onClick={onReset}
|
||||
disabled={busy || resetText !== RESET_TOKEN}
|
||||
>
|
||||
{pending === "reset" ? "Resetting..." : "Confirm Reset"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
root.render(<App />);
|
||||
5
db.ts
5
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';");
|
||||
}
|
||||
|
||||
@@ -352,6 +352,11 @@ body {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.standings__links {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.standings__title {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
|
||||
29
frontend.tsx
29
frontend.tsx
@@ -36,6 +36,8 @@ type GameState = {
|
||||
active: RoundState | null;
|
||||
scores: Record<string, number>;
|
||||
done: boolean;
|
||||
isPaused: boolean;
|
||||
generation: number;
|
||||
};
|
||||
type ServerMessage = {
|
||||
type: "state";
|
||||
@@ -340,9 +342,14 @@ function Standings({
|
||||
<aside className="standings">
|
||||
<div className="standings__head">
|
||||
<span className="standings__title">Standings</span>
|
||||
<a href="/history" className="standings__link">
|
||||
History →
|
||||
</a>
|
||||
<div className="standings__links">
|
||||
<a href="/history" className="standings__link">
|
||||
History
|
||||
</a>
|
||||
<a href="/admin" className="standings__link">
|
||||
Admin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="standings__list">
|
||||
{sorted.map(([name, score], i) => {
|
||||
@@ -443,9 +450,19 @@ function App() {
|
||||
<a href="/" className="logo">
|
||||
<img src="/assets/logo.svg" alt="quipslop" />
|
||||
</a>
|
||||
<div className="viewer-pill" aria-live="polite">
|
||||
<span className="viewer-pill__dot" />
|
||||
{viewerCount} viewer{viewerCount === 1 ? "" : "s"} watching
|
||||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||||
{state.isPaused && (
|
||||
<div
|
||||
className="viewer-pill"
|
||||
style={{ color: "var(--text-muted)", borderColor: "var(--border)" }}
|
||||
>
|
||||
Paused
|
||||
</div>
|
||||
)}
|
||||
<div className="viewer-pill" aria-live="polite">
|
||||
<span className="viewer-pill__dot" />
|
||||
{viewerCount} viewer{viewerCount === 1 ? "" : "s"} watching
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
46
game.ts
46
game.ts
@@ -72,6 +72,7 @@ export type GameState = {
|
||||
scores: Record<string, number>;
|
||||
done: boolean;
|
||||
isPaused: boolean;
|
||||
generation: number;
|
||||
};
|
||||
|
||||
// ── OpenRouter ──────────────────────────────────────────────────────────────
|
||||
@@ -254,8 +255,9 @@ export async function runGame(
|
||||
rerender: () => void,
|
||||
) {
|
||||
let startRound = 1;
|
||||
if (state.completed.length > 0) {
|
||||
startRound = state.completed[state.completed.length - 1].num + 1;
|
||||
const lastCompletedRound = state.completed.at(-1);
|
||||
if (lastCompletedRound) {
|
||||
startRound = lastCompletedRound.num + 1;
|
||||
}
|
||||
|
||||
const endRound = startRound + runs - 1;
|
||||
@@ -264,6 +266,7 @@ export async function runGame(
|
||||
while (state.isPaused) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
const roundGeneration = state.generation;
|
||||
|
||||
const shuffled = shuffle([...MODELS]);
|
||||
const prompter = shuffled[0]!;
|
||||
@@ -300,11 +303,17 @@ export async function runGame(
|
||||
3,
|
||||
`R${r}:prompt:${prompter.name}`,
|
||||
);
|
||||
if (state.generation !== roundGeneration) {
|
||||
continue;
|
||||
}
|
||||
round.promptTask.finishedAt = Date.now();
|
||||
round.promptTask.result = prompt;
|
||||
round.prompt = prompt;
|
||||
rerender();
|
||||
} catch {
|
||||
if (state.generation !== roundGeneration) {
|
||||
continue;
|
||||
}
|
||||
round.promptTask.finishedAt = Date.now();
|
||||
round.promptTask.error = "Failed after 3 attempts";
|
||||
round.phase = "done";
|
||||
@@ -323,6 +332,9 @@ export async function runGame(
|
||||
|
||||
await Promise.all(
|
||||
round.answerTasks.map(async (task) => {
|
||||
if (state.generation !== roundGeneration) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const answer = await withRetry(
|
||||
() => callGenerateAnswer(task.model, round.prompt!),
|
||||
@@ -330,15 +342,27 @@ export async function runGame(
|
||||
3,
|
||||
`R${r}:answer:${task.model.name}`,
|
||||
);
|
||||
if (state.generation !== roundGeneration) {
|
||||
return;
|
||||
}
|
||||
task.result = answer;
|
||||
} catch {
|
||||
if (state.generation !== roundGeneration) {
|
||||
return;
|
||||
}
|
||||
task.error = "Failed to answer";
|
||||
task.result = "[no answer]";
|
||||
}
|
||||
if (state.generation !== roundGeneration) {
|
||||
return;
|
||||
}
|
||||
task.finishedAt = Date.now();
|
||||
rerender();
|
||||
}),
|
||||
);
|
||||
if (state.generation !== roundGeneration) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Vote phase ──
|
||||
round.phase = "voting";
|
||||
@@ -350,6 +374,9 @@ export async function runGame(
|
||||
|
||||
await Promise.all(
|
||||
round.votes.map(async (vote) => {
|
||||
if (state.generation !== roundGeneration) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const showAFirst = Math.random() > 0.5;
|
||||
const first = showAFirst ? { answer: answerA } : { answer: answerB };
|
||||
@@ -361,6 +388,9 @@ export async function runGame(
|
||||
3,
|
||||
`R${r}:vote:${vote.voter.name}`,
|
||||
);
|
||||
if (state.generation !== roundGeneration) {
|
||||
return;
|
||||
}
|
||||
const votedFor = showAFirst
|
||||
? result === "A"
|
||||
? contA
|
||||
@@ -372,12 +402,21 @@ export async function runGame(
|
||||
vote.finishedAt = Date.now();
|
||||
vote.votedFor = votedFor;
|
||||
} catch {
|
||||
if (state.generation !== roundGeneration) {
|
||||
return;
|
||||
}
|
||||
vote.finishedAt = Date.now();
|
||||
vote.error = true;
|
||||
}
|
||||
if (state.generation !== roundGeneration) {
|
||||
return;
|
||||
}
|
||||
rerender();
|
||||
}),
|
||||
);
|
||||
if (state.generation !== roundGeneration) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Score ──
|
||||
let votesA = 0;
|
||||
@@ -397,6 +436,9 @@ export async function runGame(
|
||||
rerender();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
if (state.generation !== roundGeneration) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Archive round
|
||||
saveRound(round);
|
||||
|
||||
@@ -55,6 +55,12 @@ body {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
@@ -280,4 +286,4 @@ body {
|
||||
.history-card__showdown {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
history.tsx
11
history.tsx
@@ -266,9 +266,14 @@ function App() {
|
||||
<main className="main">
|
||||
<div className="page-header">
|
||||
<div className="page-title">Past Rounds</div>
|
||||
<a href="/" className="back-link">
|
||||
← Back to Game
|
||||
</a>
|
||||
<div className="page-links">
|
||||
<a href="/" className="back-link">
|
||||
← Back to Game
|
||||
</a>
|
||||
<a href="/admin" className="back-link">
|
||||
Admin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
|
||||
@@ -223,10 +223,11 @@ function Game({ runs }: { runs: number }) {
|
||||
const stateRef = useRef<GameState>({
|
||||
completed: [],
|
||||
active: null,
|
||||
scores: Object.fromEntries(MODELS.map((m) => [m.name, 0])),
|
||||
done: false,
|
||||
isPaused: false,
|
||||
});
|
||||
scores: Object.fromEntries(MODELS.map((m) => [m.name, 0])),
|
||||
done: false,
|
||||
isPaused: false,
|
||||
generation: 0,
|
||||
});
|
||||
const [, setTick] = useState(0);
|
||||
const rerender = useCallback(() => setTick((t) => t + 1), []);
|
||||
|
||||
|
||||
241
server.ts
241
server.ts
@@ -2,7 +2,8 @@ import type { ServerWebSocket } from "bun";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import indexHtml from "./index.html";
|
||||
import historyHtml from "./history.html";
|
||||
import { getRounds, getAllRounds } from "./db.ts";
|
||||
import adminHtml from "./admin.html";
|
||||
import { clearAllRounds, getRounds, getAllRounds } from "./db.ts";
|
||||
import {
|
||||
MODELS,
|
||||
LOG_FILE,
|
||||
@@ -52,6 +53,7 @@ const gameState: GameState = {
|
||||
scores: initialScores,
|
||||
done: false,
|
||||
isPaused: false,
|
||||
generation: 0,
|
||||
};
|
||||
|
||||
// ── Guardrails ──────────────────────────────────────────────────────────────
|
||||
@@ -71,7 +73,7 @@ const ADMIN_LIMIT_PER_MIN = parsePositiveInt(
|
||||
process.env.ADMIN_LIMIT_PER_MIN,
|
||||
10,
|
||||
);
|
||||
const MAX_WS_GLOBAL = parsePositiveInt(process.env.MAX_WS_GLOBAL, 2_000);
|
||||
const MAX_WS_GLOBAL = parsePositiveInt(process.env.MAX_WS_GLOBAL, 100_000);
|
||||
const MAX_WS_PER_IP = parsePositiveInt(process.env.MAX_WS_PER_IP, 8);
|
||||
const MAX_HISTORY_PAGE = parsePositiveInt(
|
||||
process.env.MAX_HISTORY_PAGE,
|
||||
@@ -86,6 +88,8 @@ const MAX_HISTORY_CACHE_KEYS = parsePositiveInt(
|
||||
process.env.MAX_HISTORY_CACHE_KEYS,
|
||||
500,
|
||||
);
|
||||
const ADMIN_COOKIE = "quipslop_admin";
|
||||
const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
||||
|
||||
const requestWindows = new Map<string, number[]>();
|
||||
const wsByIp = new Map<string, number>();
|
||||
@@ -136,11 +140,59 @@ function secureCompare(a: string, b: string): boolean {
|
||||
return timingSafeEqual(aBuf, bBuf);
|
||||
}
|
||||
|
||||
function parseCookies(req: Request): Record<string, string> {
|
||||
const raw = req.headers.get("cookie");
|
||||
if (!raw) return {};
|
||||
const cookies: Record<string, string> = {};
|
||||
for (const pair of raw.split(";")) {
|
||||
const idx = pair.indexOf("=");
|
||||
if (idx <= 0) continue;
|
||||
const key = pair.slice(0, idx).trim();
|
||||
const val = pair.slice(idx + 1).trim();
|
||||
if (!key) continue;
|
||||
try {
|
||||
cookies[key] = decodeURIComponent(val);
|
||||
} catch {
|
||||
cookies[key] = val;
|
||||
}
|
||||
}
|
||||
return cookies;
|
||||
}
|
||||
|
||||
function buildAdminCookie(
|
||||
passcode: string,
|
||||
isSecure: boolean,
|
||||
maxAgeSeconds = ADMIN_COOKIE_MAX_AGE_SECONDS,
|
||||
): string {
|
||||
const parts = [
|
||||
`${ADMIN_COOKIE}=${encodeURIComponent(passcode)}`,
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Strict",
|
||||
`Max-Age=${maxAgeSeconds}`,
|
||||
];
|
||||
if (isSecure) {
|
||||
parts.push("Secure");
|
||||
}
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
function clearAdminCookie(isSecure: boolean): string {
|
||||
return buildAdminCookie("", isSecure, 0);
|
||||
}
|
||||
|
||||
function getProvidedAdminSecret(req: Request, url: URL): string {
|
||||
const headerOrQuery =
|
||||
req.headers.get("x-admin-secret") ?? url.searchParams.get("secret");
|
||||
if (headerOrQuery) return headerOrQuery;
|
||||
const cookies = parseCookies(req);
|
||||
return cookies[ADMIN_COOKIE] ?? "";
|
||||
}
|
||||
|
||||
function isAdminAuthorized(req: Request, url: URL): boolean {
|
||||
const expected = process.env.ADMIN_SECRET;
|
||||
if (!expected) return false;
|
||||
const provided =
|
||||
req.headers.get("x-admin-secret") ?? url.searchParams.get("secret") ?? "";
|
||||
const provided = getProvidedAdminSecret(req, url);
|
||||
if (!provided) return false;
|
||||
return secureCompare(provided, expected);
|
||||
}
|
||||
@@ -178,6 +230,17 @@ function broadcast() {
|
||||
}
|
||||
}
|
||||
|
||||
function getAdminSnapshot() {
|
||||
return {
|
||||
isPaused: gameState.isPaused,
|
||||
isRunningRound: Boolean(gameState.active),
|
||||
done: gameState.done,
|
||||
completedInMemory: gameState.completed.length,
|
||||
persistedRounds: getRounds(1, 1).total,
|
||||
viewerCount: clients.size,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Server ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const port = parseInt(process.env.PORT ?? "5109", 10); // 5109 = SLOP
|
||||
@@ -187,8 +250,9 @@ const server = Bun.serve<WsData>({
|
||||
routes: {
|
||||
"/": indexHtml,
|
||||
"/history": historyHtml,
|
||||
"/admin": adminHtml,
|
||||
},
|
||||
fetch(req, server) {
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
const ip = getClientIp(req, server);
|
||||
|
||||
@@ -207,7 +271,108 @@ const server = Bun.serve<WsData>({
|
||||
return new Response("ok", { status: 200 });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/pause" || url.pathname === "/api/resume") {
|
||||
if (url.pathname === "/api/admin/login") {
|
||||
if (req.method !== "POST") {
|
||||
return new Response("Method Not Allowed", {
|
||||
status: 405,
|
||||
headers: { Allow: "POST" },
|
||||
});
|
||||
}
|
||||
if (isRateLimited(`admin:${ip}`, ADMIN_LIMIT_PER_MIN, WINDOW_MS)) {
|
||||
return new Response("Too Many Requests", { status: 429 });
|
||||
}
|
||||
|
||||
const expected = process.env.ADMIN_SECRET;
|
||||
if (!expected) {
|
||||
return new Response("ADMIN_SECRET is not configured", { status: 503 });
|
||||
}
|
||||
|
||||
let passcode = "";
|
||||
try {
|
||||
const body = await req.json();
|
||||
passcode = String((body as Record<string, unknown>).passcode ?? "");
|
||||
} catch {
|
||||
return new Response("Invalid JSON body", { status: 400 });
|
||||
}
|
||||
|
||||
if (!passcode || !secureCompare(passcode, expected)) {
|
||||
return new Response("Invalid passcode", { status: 401 });
|
||||
}
|
||||
|
||||
const isSecure = url.protocol === "https:";
|
||||
return new Response(JSON.stringify({ ok: true, ...getAdminSnapshot() }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": buildAdminCookie(passcode, isSecure),
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/admin/logout") {
|
||||
if (req.method !== "POST") {
|
||||
return new Response("Method Not Allowed", {
|
||||
status: 405,
|
||||
headers: { Allow: "POST" },
|
||||
});
|
||||
}
|
||||
const isSecure = url.protocol === "https:";
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
"Set-Cookie": clearAdminCookie(isSecure),
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/admin/status") {
|
||||
if (isRateLimited(`admin:${ip}`, ADMIN_LIMIT_PER_MIN, WINDOW_MS)) {
|
||||
return new Response("Too Many Requests", { status: 429 });
|
||||
}
|
||||
if (!isAdminAuthorized(req, url)) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true, ...getAdminSnapshot() }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/admin/export") {
|
||||
if (req.method !== "GET") {
|
||||
return new Response("Method Not Allowed", {
|
||||
status: 405,
|
||||
headers: { Allow: "GET" },
|
||||
});
|
||||
}
|
||||
if (isRateLimited(`admin:${ip}`, ADMIN_LIMIT_PER_MIN, WINDOW_MS)) {
|
||||
return new Response("Too Many Requests", { status: 429 });
|
||||
}
|
||||
if (!isAdminAuthorized(req, url)) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const payload = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
rounds: getAllRounds(),
|
||||
state: gameState,
|
||||
};
|
||||
return new Response(JSON.stringify(payload, null, 2), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": `attachment; filename="quipslop-export-${Date.now()}.json"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/admin/reset") {
|
||||
if (req.method !== "POST") {
|
||||
return new Response("Method Not Allowed", {
|
||||
status: 405,
|
||||
@@ -221,16 +386,76 @@ const server = Bun.serve<WsData>({
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/pause") {
|
||||
let confirm = "";
|
||||
try {
|
||||
const body = await req.json();
|
||||
confirm = String((body as Record<string, unknown>).confirm ?? "");
|
||||
} catch {
|
||||
return new Response("Invalid JSON body", { status: 400 });
|
||||
}
|
||||
if (confirm !== "RESET") {
|
||||
return new Response("Confirmation token must be RESET", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
clearAllRounds();
|
||||
historyCache.clear();
|
||||
gameState.completed = [];
|
||||
gameState.active = null;
|
||||
gameState.scores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
|
||||
gameState.done = false;
|
||||
gameState.isPaused = true;
|
||||
gameState.generation += 1;
|
||||
broadcast();
|
||||
|
||||
log("WARN", "admin", "Database reset requested", { ip });
|
||||
return new Response(JSON.stringify({ ok: true, ...getAdminSnapshot() }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname === "/api/pause" ||
|
||||
url.pathname === "/api/resume" ||
|
||||
url.pathname === "/api/admin/pause" ||
|
||||
url.pathname === "/api/admin/resume"
|
||||
) {
|
||||
if (req.method !== "POST") {
|
||||
return new Response("Method Not Allowed", {
|
||||
status: 405,
|
||||
headers: { Allow: "POST" },
|
||||
});
|
||||
}
|
||||
if (isRateLimited(`admin:${ip}`, ADMIN_LIMIT_PER_MIN, WINDOW_MS)) {
|
||||
return new Response("Too Many Requests", { status: 429 });
|
||||
}
|
||||
if (!isAdminAuthorized(req, url)) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
if (url.pathname.endsWith("/pause")) {
|
||||
gameState.isPaused = true;
|
||||
} else {
|
||||
gameState.isPaused = false;
|
||||
}
|
||||
broadcast();
|
||||
const action = url.pathname.endsWith("/pause") ? "Paused" : "Resumed";
|
||||
if (url.pathname === "/api/pause" || url.pathname === "/api/resume") {
|
||||
return new Response(action, { status: 200 });
|
||||
}
|
||||
return new Response(
|
||||
url.pathname === "/api/pause" ? "Paused" : "Resumed",
|
||||
JSON.stringify({ ok: true, action, ...getAdminSnapshot() }),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user