better
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -32,3 +32,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
*.sqlite
|
||||
|
||||
32
db.ts
Normal file
32
db.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import type { RoundState } from "./game.ts";
|
||||
|
||||
export const db = new Database("quipslop.sqlite", { create: true });
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS rounds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
num INTEGER,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
data TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
export function saveRound(round: RoundState) {
|
||||
const insert = db.prepare("INSERT INTO rounds (num, data) VALUES ($num, $data)");
|
||||
insert.run({ $num: round.num, $data: JSON.stringify(round) });
|
||||
}
|
||||
|
||||
export function getRounds(page: number = 1, limit: number = 10) {
|
||||
const offset = (page - 1) * limit;
|
||||
const countQuery = db.query("SELECT COUNT(*) as count FROM rounds").get() as { count: number };
|
||||
const rows = db.query("SELECT data FROM rounds ORDER BY id DESC LIMIT $limit OFFSET $offset")
|
||||
.all({ $limit: limit, $offset: offset }) as { data: string }[];
|
||||
return {
|
||||
rounds: rows.map(r => JSON.parse(r.data) as RoundState),
|
||||
total: countQuery.count,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(countQuery.count / limit)
|
||||
};
|
||||
}
|
||||
101
frontend.css
101
frontend.css
@@ -93,13 +93,38 @@ body {
|
||||
.sidebar__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar__section--standings {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar__section--link {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 24px 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__section--history {
|
||||
flex: 1;
|
||||
border-top: 1px solid var(--border);
|
||||
min-height: 0;
|
||||
.history-link {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.history-link:hover {
|
||||
background: var(--border);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.sidebar__header {
|
||||
@@ -117,15 +142,8 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sidebar__history-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 16px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.standing {
|
||||
@@ -219,67 +237,6 @@ body {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Past Rounds Mini ─────────────────────────────────────────── */
|
||||
|
||||
.past-round-mini {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.past-round-mini__top {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.past-round-mini__num {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.past-round-mini__prompt {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.past-round-mini__winner {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.small-model-name {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.small-model-name .model-logo {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.past-round-mini__tie {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Arena ─────────────────────────────────────────────────────── */
|
||||
|
||||
.arena {
|
||||
|
||||
38
frontend.tsx
38
frontend.tsx
@@ -252,28 +252,6 @@ function Arena({ round, total }: { round: RoundState; total: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PastRoundMini({ round }: { round: RoundState }) {
|
||||
const [contA, contB] = round.contestants;
|
||||
let votesA = 0, votesB = 0;
|
||||
for (const v of round.votes) {
|
||||
if (v.votedFor?.name === contA.name) votesA++;
|
||||
else if (v.votedFor?.name === contB.name) votesB++;
|
||||
}
|
||||
const winner = votesA > votesB ? contA : votesB > votesA ? contB : null;
|
||||
|
||||
return (
|
||||
<div className="past-round-mini">
|
||||
<div className="past-round-mini__top">
|
||||
<span className="past-round-mini__num">R{round.num}</span>
|
||||
<span className="past-round-mini__prompt">"{round.prompt}"</span>
|
||||
</div>
|
||||
<div className="past-round-mini__winner">
|
||||
{winner ? <><ModelName model={winner} showLogo={true} className="small-model-name" /> won</> : <span className="past-round-mini__tie">Tie</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GameOver({ scores }: { scores: Record<string, number> }) {
|
||||
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
|
||||
const champion = sorted[0];
|
||||
@@ -350,16 +328,12 @@ function Sidebar({ scores, activeRound, completed }: { scores: Record<string, nu
|
||||
)}
|
||||
</div>
|
||||
|
||||
{completed.length > 0 && (
|
||||
<div className="sidebar__section sidebar__section--history">
|
||||
<div className="sidebar__header">PAST ROUNDS</div>
|
||||
<div className="sidebar__history-list">
|
||||
{[...completed].reverse().map(round => (
|
||||
<PastRoundMini key={round.num} round={round} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="sidebar__section sidebar__section--link">
|
||||
<a href="/history" className="history-link">
|
||||
<span>📚 View Past Games</span>
|
||||
<span>→</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
5
game.ts
5
game.ts
@@ -10,7 +10,7 @@ export const MODELS = [
|
||||
{ id: "moonshotai/kimi-k2", name: "Kimi K2" },
|
||||
// { id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "deepseek/deepseek-v3.2", name: "DeepSeek 3.2" },
|
||||
{ id: "z-ai/glm-5", name: "GLM-5" },
|
||||
// { id: "z-ai/glm-5", name: "GLM-5" },
|
||||
{ id: "openai/gpt-5.2", name: "GPT-5.2" },
|
||||
{ id: "anthropic/claude-opus-4.6", name: "Opus 4.6" },
|
||||
{ id: "anthropic/claude-sonnet-4.6", name: "Sonnet 4.6" },
|
||||
@@ -249,6 +249,8 @@ export async function callVote(
|
||||
return cleaned.startsWith("A") ? "A" : "B";
|
||||
}
|
||||
|
||||
import { saveRound } from "./db.ts";
|
||||
|
||||
// ── Game loop ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function runGame(
|
||||
@@ -391,6 +393,7 @@ export async function runGame(
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
|
||||
// Archive round
|
||||
saveRound(round);
|
||||
state.completed = [...state.completed, round];
|
||||
state.active = null;
|
||||
rerender();
|
||||
|
||||
300
history.css
Normal file
300
history.css
Normal file
@@ -0,0 +1,300 @@
|
||||
/* ── Base ─────────────────────────────────────────────── */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--bg: #050505;
|
||||
--surface: #0a0a0a;
|
||||
--surface-2: #141414;
|
||||
--border: #222222;
|
||||
--border-light: #333333;
|
||||
--text: #ffffff;
|
||||
--text-dim: #a1a1a1;
|
||||
--text-muted: #555555;
|
||||
--primary: #4285F4;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Inter', -apple-system, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ── Header ───────────────────────────────────────────────────── */
|
||||
.header {
|
||||
padding: 24px 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.header__logo {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.header__tagline {
|
||||
color: var(--text-muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.header__nav a {
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.header__nav a:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Main Layout ──────────────────────────────────────────────── */
|
||||
.main {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 64px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ── Loading / Error ──────────────────────────────────────────── */
|
||||
.loading, .error, .empty {
|
||||
text-align: center;
|
||||
padding: 64px 0;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.error { color: #ef4444; }
|
||||
|
||||
/* ── History Card ─────────────────────────────────────────────── */
|
||||
.history-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.history-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.history-card__prompt-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.history-card__prompter {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.history-card__prompt {
|
||||
font-family: 'DM Serif Display', serif;
|
||||
font-size: 32px;
|
||||
line-height: 1.2;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.history-card__meta {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.history-card__showdown {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.history-contestant {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.history-contestant--winner {
|
||||
border-color: var(--border-light);
|
||||
background: var(--bg);
|
||||
box-shadow: inset 0 0 0 1px var(--border);
|
||||
}
|
||||
|
||||
.history-contestant__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.history-contestant__winner-badge {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
border-radius: 4px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.history-contestant__answer {
|
||||
font-family: 'DM Serif Display', serif;
|
||||
font-size: 20px;
|
||||
line-height: 1.3;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.history-contestant--winner .history-contestant__answer {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.history-contestant__votes {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.history-contestant__score {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.history-contestant__voters {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.voter-mini-logo {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ── Pagination ───────────────────────────────────────────────── */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.pagination__btn {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pagination__btn:hover:not(:disabled) {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.pagination__btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination__info {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ── Utility ──────────────────────────────────────────────────── */
|
||||
.model-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.model-logo {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.history-card__showdown {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
16
history.html
Normal file
16
history.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Quipslop History</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="./history.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./history.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
248
history.tsx
Normal file
248
history.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./history.css";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Model = { id: string; name: string };
|
||||
type TaskInfo = { model: Model; startedAt: number; finishedAt?: number; result?: string; error?: string };
|
||||
type VoteInfo = { voter: Model; startedAt: number; finishedAt?: number; votedFor?: Model; error?: boolean };
|
||||
type RoundState = {
|
||||
num: number;
|
||||
phase: "prompting" | "answering" | "voting" | "done";
|
||||
prompter: Model;
|
||||
promptTask: TaskInfo;
|
||||
prompt?: string;
|
||||
contestants: [Model, Model];
|
||||
answerTasks: [TaskInfo, TaskInfo];
|
||||
votes: VoteInfo[];
|
||||
scoreA?: number;
|
||||
scoreB?: number;
|
||||
};
|
||||
|
||||
// ── Shared UI Utils ─────────────────────────────────────────────────────────
|
||||
|
||||
const MODEL_COLORS: Record<string, string> = {
|
||||
"Gemini 3.1 Pro": "#4285F4",
|
||||
"Kimi K2": "#00E599",
|
||||
"DeepSeek 3.2": "#4D6BFE",
|
||||
"GLM-5": "#1F63EC",
|
||||
"GPT-5.2": "#10A37F",
|
||||
"Opus 4.6": "#D97757",
|
||||
"Sonnet 4.6": "#D97757",
|
||||
"Grok 4.1": "#FFFFFF",
|
||||
};
|
||||
|
||||
function getColor(name: string): string {
|
||||
return MODEL_COLORS[name] ?? "#A1A1A1";
|
||||
}
|
||||
|
||||
function getLogo(name: string): string | null {
|
||||
if (name.includes("Gemini")) return "/assets/logos/gemini.svg";
|
||||
if (name.includes("Kimi")) return "/assets/logos/kimi.svg";
|
||||
if (name.includes("DeepSeek")) return "/assets/logos/deepseek.svg";
|
||||
if (name.includes("GLM")) return "/assets/logos/glm.svg";
|
||||
if (name.includes("GPT")) return "/assets/logos/openai.svg";
|
||||
if (name.includes("Opus") || name.includes("Sonnet")) return "/assets/logos/claude.svg";
|
||||
if (name.includes("Grok")) return "/assets/logos/grok.svg";
|
||||
return null;
|
||||
}
|
||||
|
||||
function ModelName({ model, className = "" }: { model: Model; className?: string }) {
|
||||
const logo = getLogo(model.name);
|
||||
const color = getColor(model.name);
|
||||
return (
|
||||
<span className={`model-name ${className}`} style={{ color }}>
|
||||
{logo && <img src={logo} alt="" className="model-logo" />}
|
||||
{model.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Components ──────────────────────────────────────────────────────────────
|
||||
|
||||
function HistoryContestant({
|
||||
task,
|
||||
votes,
|
||||
voters
|
||||
}: {
|
||||
task: TaskInfo;
|
||||
votes: number;
|
||||
voters: Model[];
|
||||
}) {
|
||||
const color = getColor(task.model.name);
|
||||
return (
|
||||
<div className={`history-contestant`} style={{ borderColor: color }}>
|
||||
<div className="history-contestant__header">
|
||||
<ModelName model={task.model} />
|
||||
</div>
|
||||
<div className="history-contestant__answer">
|
||||
“{task.result}”
|
||||
</div>
|
||||
<div className="history-contestant__votes">
|
||||
<div className="history-contestant__score" style={{ color }}>
|
||||
{votes} {votes === 1 ? 'vote' : 'votes'}
|
||||
</div>
|
||||
<div className="history-contestant__voters">
|
||||
{voters.map(v => {
|
||||
const logo = getLogo(v.name);
|
||||
if (!logo) return null;
|
||||
return <img key={v.name} src={logo} title={v.name} alt={v.name} className="voter-mini-logo" />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryCard({ round }: { round: RoundState }) {
|
||||
const [contA, contB] = round.contestants;
|
||||
|
||||
let votesA = 0, votesB = 0;
|
||||
const votersA: Model[] = [];
|
||||
const votersB: Model[] = [];
|
||||
|
||||
for (const v of round.votes) {
|
||||
if (v.votedFor?.name === contA.name) { votesA++; votersA.push(v.voter); }
|
||||
else if (v.votedFor?.name === contB.name) { votesB++; votersB.push(v.voter); }
|
||||
}
|
||||
|
||||
const isAWinner = votesA > votesB;
|
||||
const isBWinner = votesB > votesA;
|
||||
|
||||
return (
|
||||
<div className="history-card">
|
||||
<div className="history-card__header">
|
||||
<div className="history-card__prompt-section">
|
||||
<div className="history-card__prompter">
|
||||
Prompted by <ModelName model={round.prompter} />
|
||||
</div>
|
||||
<div className="history-card__prompt">
|
||||
{round.prompt}
|
||||
</div>
|
||||
</div>
|
||||
<div className="history-card__meta">
|
||||
<div>R{round.num}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="history-card__showdown">
|
||||
<div className={`history-contestant ${isAWinner ? "history-contestant--winner" : ""}`}>
|
||||
<div className="history-contestant__header">
|
||||
<ModelName model={contA} />
|
||||
{isAWinner && <div className="history-contestant__winner-badge">WINNER</div>}
|
||||
</div>
|
||||
<div className="history-contestant__answer">“{round.answerTasks[0].result}”</div>
|
||||
<div className="history-contestant__votes">
|
||||
<div className="history-contestant__score" style={{ color: getColor(contA.name) }}>
|
||||
{votesA} {votesA === 1 ? 'vote' : 'votes'}
|
||||
</div>
|
||||
<div className="history-contestant__voters">
|
||||
{votersA.map(v => getLogo(v.name) && <img key={v.name} src={getLogo(v.name)!} title={v.name} className="voter-mini-logo" />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`history-contestant ${isBWinner ? "history-contestant--winner" : ""}`}>
|
||||
<div className="history-contestant__header">
|
||||
<ModelName model={contB} />
|
||||
{isBWinner && <div className="history-contestant__winner-badge">WINNER</div>}
|
||||
</div>
|
||||
<div className="history-contestant__answer">“{round.answerTasks[1].result}”</div>
|
||||
<div className="history-contestant__votes">
|
||||
<div className="history-contestant__score" style={{ color: getColor(contB.name) }}>
|
||||
{votesB} {votesB === 1 ? 'vote' : 'votes'}
|
||||
</div>
|
||||
<div className="history-contestant__voters">
|
||||
{votersB.map(v => getLogo(v.name) && <img key={v.name} src={getLogo(v.name)!} title={v.name} className="voter-mini-logo" />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── App ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function App() {
|
||||
const [rounds, setRounds] = useState<RoundState[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch(`/api/history?page=${page}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setRounds(data.rounds);
|
||||
setTotalPages(data.totalPages || 1);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [page]);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="header">
|
||||
<div className="header__left">
|
||||
<a href="/" className="header__logo">QUIPSLOP</a>
|
||||
<span className="header__tagline">History</span>
|
||||
</div>
|
||||
<nav className="header__nav">
|
||||
<a href="/">← Back to Game</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="main">
|
||||
<div className="page-title">Past Rounds</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">Loading...</div>
|
||||
) : error ? (
|
||||
<div className="error">{error}</div>
|
||||
) : rounds.length === 0 ? (
|
||||
<div className="empty">No past rounds found.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="history-list" style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
|
||||
{rounds.map(r => (
|
||||
<HistoryCard key={r.num + "-" + Math.random()} round={r} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<button
|
||||
className="pagination__btn"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
>
|
||||
PREV
|
||||
</button>
|
||||
<span className="pagination__info">Page {page} of {totalPages}</span>
|
||||
<button
|
||||
className="pagination__btn"
|
||||
disabled={page === totalPages}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
>
|
||||
NEXT
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Mount ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
root.render(<App />);
|
||||
15
server.ts
15
server.ts
@@ -1,5 +1,7 @@
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import index from "./index.html";
|
||||
import indexHtml from "./index.html";
|
||||
import historyHtml from "./history.html";
|
||||
import { getRounds } from "./db.ts";
|
||||
import {
|
||||
MODELS,
|
||||
LOG_FILE,
|
||||
@@ -39,12 +41,13 @@ function broadcast() {
|
||||
|
||||
// ── Server ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const port = parseInt(process.env.PORT ?? "3000", 10);
|
||||
const port = parseInt(process.env.PORT ?? "5109", 10); // 5109 = SLOP
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
routes: {
|
||||
"/": index,
|
||||
"/": indexHtml,
|
||||
"/history": historyHtml,
|
||||
},
|
||||
fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
@@ -53,6 +56,12 @@ const server = Bun.serve({
|
||||
const file = Bun.file(path);
|
||||
return new Response(file);
|
||||
}
|
||||
if (url.pathname === "/api/history") {
|
||||
const page = parseInt(url.searchParams.get("page") || "1", 10);
|
||||
return new Response(JSON.stringify(getRounds(page)), {
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/ws") {
|
||||
const upgraded = server.upgrade(req);
|
||||
if (!upgraded) {
|
||||
|
||||
Reference in New Issue
Block a user