- 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>
379 lines
11 KiB
TypeScript
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 />);
|