Files
argument.es/admin.tsx
Malin 4b0b9f8f50 chore: remove all Twitch/streaming references
- Remove Twitch link from standings sidebar
- Delete scripts/stream-browser.ts (Twitch streaming script)
- Remove start:stream and start:stream:dryrun npm scripts
- Fix quipslop-export filename fallback in admin.tsx
- Fix hardcoded quipslop.sqlite in check-db.ts
- Rewrite README.md in Spanish, no Twitch mentions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 13:30:58 +01:00

379 lines
11 KiB
TypeScript

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] ?? `argumentes-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">Comprobando sesión de administrador...</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="argument.es" />
</a>
<h1>Acceso de administrador</h1>
<p className="muted">
Introduce tu contraseña una vez. Una cookie segura mantendrá
esta sesión activa en el navegador.
</p>
<form
onSubmit={onLogin}
className="login-form"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
>
<label htmlFor="passcode" className="field-label">
Contraseña
</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" ? "Comprobando..." : "Desbloquear Admin"}
</button>
</form>
{error && <div className="error-banner">{error}</div>}
<div className="quick-links">
<a href="/">Juego en vivo</a>
<a href="/history">Historial</a>
</div>
</main>
</div>
);
}
return (
<div className="admin">
<header className="admin-header">
<a href="/" className="logo-link">
argument.es
</a>
<nav className="quick-links">
<a href="/">Juego en vivo</a>
<a href="/history">Historial</a>
<button className="link-button" onClick={onLogout} disabled={busy}>
Cerrar sesión
</button>
</nav>
</header>
<main className="panel panel--main">
<div className="panel-head">
<h1>Consola de administrador</h1>
<p>
Pausa/reanuda el bucle del juego, exporta todos los datos en JSON
o borra todos los datos almacenados.
</p>
</div>
{error && <div className="error-banner">{error}</div>}
<section className="status-grid" aria-live="polite">
<StatusCard
label="Motor"
value={snapshot?.isPaused ? "En pausa" : "Ejecutándose"}
/>
<StatusCard
label="Ronda activa"
value={snapshot?.isRunningRound ? "En curso" : "Inactivo"}
/>
<StatusCard
label="Rondas guardadas"
value={String(snapshot?.persistedRounds ?? 0)}
/>
<StatusCard label="Espectadores" 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" ? "Pausando..." : "Pausar"}
</button>
<button
type="button"
className="btn"
disabled={busy || !snapshot?.isPaused}
onClick={() => runControl("/api/admin/resume", "resume")}
>
{pending === "resume" ? "Reanudando..." : "Reanudar"}
</button>
<button type="button" className="btn" disabled={busy} onClick={onExport}>
{pending === "export" ? "Exportando..." : "Exportar JSON"}
</button>
<button
type="button"
className="btn btn--danger"
disabled={busy}
onClick={() => setIsResetOpen(true)}
>
Borrar datos
</button>
</section>
</main>
{isResetOpen && (
<div className="modal-backdrop" role="dialog" aria-modal="true">
<div className="modal">
<h2>¿Borrar todos los datos?</h2>
<p>
Esto elimina permanentemente todas las rondas guardadas y
reinicia las puntuaciones. El juego también se pausará.
</p>
<p>
Escribe <code>{RESET_TOKEN}</code> para continuar.
</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}
>
Cancelar
</button>
<button
type="button"
className="btn btn--danger"
onClick={onReset}
disabled={busy || resetText !== RESET_TOKEN}
>
{pending === "reset" ? "Borrando..." : "Confirmar borrado"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
const root = createRoot(document.getElementById("root")!);
root.render(<App />);