From 77f68d440c0854eba90fac724041d0fc6a02542c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 20 Feb 2026 04:49:40 -0800 Subject: [PATCH] logo, fix db loading --- check-db.ts | 4 + db.ts | 7 +- frontend.css | 946 ++++++++++++++++++++--------------------- frontend.tsx | 374 +++++++--------- game.ts | 11 +- index.html | 1 + public/assets/logo.svg | 9 + server.ts | 24 +- todo.md | 0 9 files changed, 656 insertions(+), 720 deletions(-) create mode 100644 check-db.ts create mode 100644 public/assets/logo.svg create mode 100644 todo.md diff --git a/check-db.ts b/check-db.ts new file mode 100644 index 0000000..0d2d0a1 --- /dev/null +++ b/check-db.ts @@ -0,0 +1,4 @@ +import { Database } from "bun:sqlite"; +const db = new Database("quipslop.sqlite"); +const rows = db.query("SELECT id, num FROM rounds ORDER BY id DESC LIMIT 20").all(); +console.log(rows); diff --git a/db.ts b/db.ts index ef5b438..619ddf2 100644 --- a/db.ts +++ b/db.ts @@ -21,7 +21,7 @@ export function saveRound(round: RoundState) { 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") + const rows = db.query("SELECT data FROM rounds ORDER BY num DESC, 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), @@ -31,3 +31,8 @@ export function getRounds(page: number = 1, limit: number = 10) { totalPages: Math.ceil(countQuery.count / limit) }; } + +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); +} diff --git a/frontend.css b/frontend.css index 60fddbf..385bb93 100644 --- a/frontend.css +++ b/frontend.css @@ -1,485 +1,433 @@ -/* ── Reset & Base ─────────────────────────────────────────────── */ +/* ── Reset & Variables ────────────────────────────────────────── */ -* { margin: 0; padding: 0; box-sizing: border-box; } +*, *::before, *::after { 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; + --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; } body { background: var(--bg); color: var(--text); - font-family: 'Inter', -apple-system, sans-serif; - font-size: 15px; + font-family: var(--sans); + font-size: 14px; line-height: 1.5; - height: 100vh; - overflow: hidden; -webkit-font-smoothing: antialiased; + min-height: 100vh; + min-height: 100dvh; } -/* ── App Layout ───────────────────────────────────────────────── */ +/* ── Layout ──────────────────────────────────────────────────── */ .app { - height: 100vh; + min-height: 100vh; + min-height: 100dvh; display: flex; flex-direction: column; } -/* ── Layout Grid ──────────────────────────────────────────────── */ - .layout { flex: 1; display: flex; - overflow: hidden; + flex-direction: column; + min-height: 0; } .main { flex: 1; - overflow: hidden; - padding: 32px 64px; + padding: 20px; + overflow-y: auto; display: flex; flex-direction: column; - justify-content: center; - align-items: center; - position: relative; } -.main-logo { - position: absolute; - top: 32px; - left: 48px; - font-family: 'JetBrains Mono', monospace; - font-size: 24px; - font-weight: 700; - letter-spacing: -1px; - color: var(--text); +/* ── Header ──────────────────────────────────────────────────── */ + +.header { + flex-shrink: 0; + margin-bottom: 28px; +} + +.logo { + display: inline-flex; text-decoration: none; - z-index: 10; } -/* ── Sidebar ──────────────────────────────────────────────────── */ +.logo img { + height: 20px; + width: auto; +} -.sidebar { - width: 320px; - border-left: 1px solid var(--border); - background: var(--surface); +/* ── Arena ────────────────────────────────────────────────────── */ + +.arena { + flex: 1; display: flex; flex-direction: column; - overflow: hidden; +} + +.arena__meta { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 24px; flex-shrink: 0; } -.sidebar__section { - display: flex; - flex-direction: column; +.arena__round { + font-family: var(--mono); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; } -.sidebar__section--standings { +.arena__round .dim { + color: var(--text-muted); +} + +.arena__phase { + font-family: var(--mono); + font-size: 11px; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--text-dim); +} + +/* ── Prompt ───────────────────────────────────────────────────── */ + +.prompt { + margin-bottom: 32px; + flex-shrink: 0; +} + +.prompt__by { + font-family: var(--mono); + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.prompt__text { + font-family: var(--serif); + font-size: clamp(28px, 5vw, 52px); + line-height: 1.15; + color: var(--text); + letter-spacing: -0.5px; + border-left: 3px solid var(--accent); + padding-left: 20px; +} + +.prompt__text--loading { + color: var(--text-muted); + border-left-color: var(--border); +} + +.prompt__text--error { + color: #ef4444; + font-family: var(--mono); + font-size: 16px; + border-left-color: #ef4444; +} + +/* ── Showdown ─────────────────────────────────────────────────── */ + +.showdown { + display: flex; + flex-direction: column; + gap: 16px; flex: 1; min-height: 0; } -.sidebar__section--link { - border-top: 1px solid var(--border); - padding: 24px 32px; - flex-shrink: 0; -} +/* ── Contestant ───────────────────────────────────────────────── */ -.history-link { - display: flex; - justify-content: space-between; - align-items: center; +.contestant { + border-left: 3px solid var(--accent); 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 { - font-family: 'JetBrains Mono', monospace; - font-size: 14px; - font-weight: 700; - letter-spacing: 1px; - text-transform: uppercase; - padding: 24px 32px 16px; - color: var(--text-dim); -} - -.sidebar__list { - padding: 0 16px 16px; display: flex; flex-direction: column; - gap: 4px; - flex: 1; - overflow-y: auto; -} - -.standing { - display: flex; - align-items: center; - gap: 16px; - padding: 12px 16px; - border-radius: 8px; - transition: background 0.2s; -} - -.standing--active { - background: var(--surface-2); -} - -.standing__rank { - width: 20px; - text-align: center; - font-family: 'JetBrains Mono', monospace; - font-weight: 700; - color: var(--text-muted); - font-size: 13px; - flex-shrink: 0; -} - -.standing__info { - flex: 1; - min-width: 0; -} - -.standing__name-row { - display: flex; - align-items: center; - gap: 8px; -} - -.standing__name { - font-weight: 600; - font-size: 14px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: flex; - align-items: center; - gap: 8px; -} - -.standing__role { - font-size: 12px; - flex-shrink: 0; -} - -.standing__bar-row { - display: flex; - align-items: center; gap: 12px; - margin-top: 6px; + transition: background 0.3s; } -.standing__bar { +.contestant--winner { + border-left-width: 4px; + background: rgba(255, 255, 255, 0.03); + animation: winner-reveal 1.5s ease-out; +} + +@keyframes winner-reveal { + 0% { background: rgba(255, 255, 255, 0); } + 30% { background: rgba(255, 255, 255, 0.06); } + 100% { background: rgba(255, 255, 255, 0.03); } +} + +.contestant__head { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +.win-tag { + font-family: var(--mono); + font-size: 10px; + font-weight: 700; + letter-spacing: 1px; + padding: 3px 8px; + background: var(--text); + color: var(--bg); + border-radius: 3px; +} + +.contestant__body { flex: 1; - height: 4px; + min-height: 0; +} + +.answer { + font-family: var(--serif); + font-size: clamp(20px, 3.5vw, 28px); + line-height: 1.3; + color: var(--text-dim); + letter-spacing: -0.3px; +} + +.contestant--winner .answer { + color: var(--text); +} + +.answer--loading { + color: var(--text-muted); +} + +.answer--error { + color: #ef4444; + font-family: var(--mono); + font-size: 13px; +} + +.contestant__foot { + display: flex; + flex-direction: column; + gap: 8px; + flex-shrink: 0; +} + +/* ── Vote Bar ─────────────────────────────────────────────────── */ + +.vote-bar { + height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; } -.standing__bar-fill { +.vote-bar__fill { + height: 100%; + border-radius: 2px; + transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1); +} + +.vote-meta { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--mono); + font-size: 12px; +} + +.vote-meta__count { + font-weight: 700; + font-size: 16px; +} + +.vote-meta__label { + color: var(--text-muted); +} + +.vote-meta__dots { + display: flex; + gap: 4px; + margin-left: auto; +} + +.voter-dot { + width: 16px; + height: 16px; + border-radius: 50%; + object-fit: contain; + opacity: 0.7; +} + +.voter-dot--letter { + font-size: 10px; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; +} + +/* ── Tie ──────────────────────────────────────────────────────── */ + +.tie-label { + text-align: center; + font-family: var(--mono); + font-size: 13px; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--text-muted); + padding: 16px 0; +} + +/* ── Standings ────────────────────────────────────────────────── */ + +.standings { + border-top: 1px solid var(--border); + background: var(--surface); + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 12px; + max-height: 220px; + overflow-y: auto; + flex-shrink: 0; +} + +.standings__head { + display: flex; + align-items: baseline; + justify-content: space-between; +} + +.standings__title { + font-family: var(--mono); + font-size: 11px; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--text-dim); +} + +.standings__link { + font-family: var(--mono); + font-size: 11px; + color: var(--text-muted); + text-decoration: none; + transition: color 0.2s; +} + +.standings__link:hover { + color: var(--text); +} + +.standings__list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.standing { + display: flex; + align-items: center; + gap: 10px; + padding: 5px 0; +} + +.standing--active { + opacity: 1; +} + +.standing__rank { + width: 22px; + text-align: center; + font-family: var(--mono); + font-size: 12px; + color: var(--text-muted); + flex-shrink: 0; +} + +.standing__bar { + flex: 1; + height: 3px; + background: var(--border); + border-radius: 2px; + overflow: hidden; + min-width: 40px; +} + +.standing__fill { height: 100%; border-radius: 2px; transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1); } .standing__score { - font-family: 'JetBrains Mono', monospace; + font-family: var(--mono); font-size: 12px; font-weight: 700; color: var(--text-dim); - min-width: 32px; + min-width: 16px; text-align: right; } -.sidebar__legend { - padding: 16px 32px; - border-top: 1px solid var(--border); +/* ── Connecting ───────────────────────────────────────────────── */ + +.connecting { + height: 100vh; + height: 100dvh; display: flex; flex-direction: column; - gap: 12px; - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - color: var(--text-muted); -} - -/* ── Arena ─────────────────────────────────────────────────────── */ - -.arena { - width: 100%; - max-width: 1100px; - display: flex; - flex-direction: column; - max-height: 100%; -} - -.arena__header-row { - display: flex; - justify-content: space-between; - align-items: baseline; - margin-bottom: 32px; - padding-bottom: 16px; - border-bottom: 1px solid var(--border); - flex-shrink: 0; -} - -.arena__round-badge { - font-family: 'JetBrains Mono', monospace; - font-size: 14px; - font-weight: 700; - color: var(--text); - letter-spacing: 1px; -} - -.arena__round-of { - color: var(--text-muted); -} - -.arena__phase { - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - letter-spacing: 1px; - text-transform: uppercase; - color: var(--text-dim); -} - -/* ── Prompt Card ──────────────────────────────────────────────── */ - -.prompt-card { - margin-bottom: 48px; - flex-shrink: 0; -} - -.prompt-card__by { - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - color: var(--text-muted); - margin-bottom: 16px; - text-transform: uppercase; - letter-spacing: 1px; - display: flex; align-items: center; - gap: 8px; -} - -.prompt-card__text { - font-family: 'DM Serif Display', serif; - font-size: 48px; - line-height: 1.1; - color: var(--text); - max-width: 95%; - letter-spacing: -1px; -} - -.prompt-card__text--loading { - color: var(--text-muted); -} - -/* ── Showdown ─────────────────────────────────────────────────── */ - -.showdown { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 48px; - margin-bottom: 32px; - flex: 1; - min-height: 0; -} - -.contestant { - background: var(--bg); - display: flex; - flex-direction: column; - position: relative; - border-top: 4px solid var(--border); - padding-top: 24px; - overflow: hidden; -} - -.contestant__header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 24px; - padding-bottom: 16px; - border-bottom: 1px solid var(--border); - flex-shrink: 0; -} - -.contestant__name { - font-family: 'Inter', sans-serif; - font-weight: 600; - font-size: 18px; - display: flex; - align-items: center; - gap: 12px; -} - -.contestant__winner-badge { - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - font-weight: 700; - padding: 4px 8px; - background: var(--text); - color: var(--bg); - border-radius: 4px; - letter-spacing: 1px; -} - -.contestant__answer { - flex: 1; - overflow-y: auto; - min-height: 120px; -} - -.contestant__text { - font-family: 'DM Serif Display', serif; - font-size: 28px; - line-height: 1.3; - color: var(--text-dim); - letter-spacing: -0.5px; -} - -.contestant--winner .contestant__text { - color: var(--text); -} - -.contestant__thinking { - color: var(--text-muted); - font-family: 'DM Serif Display', serif; - font-size: 28px; -} - -.contestant__error { - color: #ef4444; - font-family: 'JetBrains Mono', monospace; - font-size: 14px; -} - -/* ── Votes within Contestant ──────────────────────────────────── */ - -.contestant__votes-container { - margin-top: 24px; - display: flex; - flex-direction: column; + justify-content: center; gap: 16px; - flex-shrink: 0; + background: var(--bg); } -.vote-bar { - height: 2px; - background: var(--border); - width: 100%; -} - -.vote-bar__fill { - height: 100%; - transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1); -} - -.vote-bar__label { +.connecting__logo { display: flex; - justify-content: space-between; - margin-top: 12px; - font-family: 'JetBrains Mono', monospace; +} + +.connecting__logo img { + height: 64px; + width: auto; +} + +.connecting__sub { + font-family: var(--mono); font-size: 13px; - color: var(--text-dim); -} - -.vote-bar__count { - font-weight: 700; - color: var(--text); - font-size: 18px; -} - -.contestant__voters { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.voter-badge { - display: inline-flex; - align-items: center; - gap: 6px; - background: var(--surface-2); - border: 1px solid var(--border); - padding: 6px 10px; - border-radius: 6px; - font-size: 12px; - font-family: 'Inter', sans-serif; - font-weight: 500; -} - -.voter-badge--pending { - opacity: 0.5; - border-style: dashed; -} - -.voter-badge--error { - opacity: 0.5; color: var(--text-muted); + letter-spacing: 1px; + text-transform: uppercase; } -.pending-votes { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 8px; - margin-top: 16px; - padding-top: 16px; - border-top: 1px solid var(--border); - flex-shrink: 0; -} +/* ── Waiting ──────────────────────────────────────────────────── */ -/* ── Round Result (Tie) ───────────────────────────────────────── */ - -.round-result { - text-align: center; - font-family: 'JetBrains Mono', monospace; - font-size: 16px; - font-weight: 700; - letter-spacing: 2px; - padding: 24px; - color: var(--text-dim); - border: 1px solid var(--border); - border-radius: 8px; - margin-top: 24px; - flex-shrink: 0; -} - -/* ── Waiting State ────────────────────────────────────────────── */ - -.arena-waiting { +.waiting { flex: 1; display: flex; align-items: center; justify-content: center; - font-family: 'DM Serif Display', serif; + font-family: var(--serif); + font-size: 28px; color: var(--text-muted); - font-size: 48px; - opacity: 0.5; - padding: 64px 0; } /* ── Game Over ────────────────────────────────────────────────── */ @@ -491,153 +439,169 @@ body { align-items: center; justify-content: center; text-align: center; - padding: 64px 0; + gap: 28px; + padding: 48px 0; } -.game-over__title { - font-family: 'JetBrains Mono', monospace; - font-size: 24px; +.game-over__label { + font-family: var(--mono); + font-size: 14px; font-weight: 700; - letter-spacing: 4px; + letter-spacing: 3px; + text-transform: uppercase; color: var(--text-muted); - margin-bottom: 48px; } -.game-over__champion { +.game-over__winner { display: flex; flex-direction: column; align-items: center; - gap: 24px; + gap: 16px; } -.game-over__crown { - font-size: 48px; -} +.game-over__crown { font-size: 40px; } .game-over__name { - font-family: 'DM Serif Display', serif; - font-size: 72px; - line-height: 1; + font-family: var(--serif); + font-size: clamp(32px, 7vw, 56px); + line-height: 1.1; display: flex; align-items: center; - gap: 24px; + gap: 16px; } -.game-over__name img { - width: 64px; - height: 64px; -} +.game-over__name img { width: 40px; height: 40px; } -.game-over__subtitle { - font-family: 'Inter', sans-serif; - font-size: 16px; +.game-over__sub { + font-size: 14px; color: var(--text-dim); } -/* ── Connecting ───────────────────────────────────────────────── */ +/* ── Next Round Toast ─────────────────────────────────────────── */ -.connecting { - height: 100vh; +.next-toast { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: var(--surface); + border: 1px solid var(--border); + padding: 10px 20px; + border-radius: 20px; + font-family: var(--mono); + font-size: 12px; + color: var(--text-dim); display: flex; - flex-direction: column; align-items: center; - justify-content: center; - gap: 24px; - background: var(--bg); + gap: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6); + z-index: 100; + animation: toast-in 0.3s cubic-bezier(0.22, 1, 0.36, 1); + white-space: nowrap; + max-width: calc(100vw - 40px); } -.connecting__logo { - font-family: 'JetBrains Mono', monospace; - font-size: 48px; - font-weight: 700; - letter-spacing: -2px; - color: var(--text); +@keyframes toast-in { + from { opacity: 0; transform: translate(-50%, 12px); } + to { opacity: 1; transform: translate(-50%, 0); } } -.connecting__text { - color: var(--text-muted); - font-family: 'JetBrains Mono', monospace; - font-size: 16px; - letter-spacing: 2px; - text-transform: uppercase; -} +/* ── Model Tag ────────────────────────────────────────────────── */ -/* ── Utility ──────────────────────────────────────────────────── */ - -.model-name { +.model-tag { display: inline-flex; align-items: center; - gap: 8px; + gap: 6px; font-weight: 600; + font-size: 14px; + white-space: nowrap; } -.model-logo { +.model-tag--sm { + font-size: 12px; + gap: 4px; +} + +.model-tag__logo { width: 18px; height: 18px; object-fit: contain; } -.timer { - color: var(--text-muted); - font-family: 'JetBrains Mono', monospace; +.model-tag--sm .model-tag__logo { + width: 14px; + height: 14px; } +/* ── Utility ──────────────────────────────────────────────────── */ + .dots span { - animation: dot-blink 1.4s infinite; + animation: blink 1.4s infinite; } - .dots span:nth-child(2) { animation-delay: 0.2s; } .dots span:nth-child(3) { animation-delay: 0.4s; } -@keyframes dot-blink { +@keyframes blink { 0%, 80%, 100% { opacity: 0.15; } 40% { opacity: 1; } } -/* ── Next Round Toast ─────────────────────────────────────────── */ - -.next-round-toast { - position: fixed; - bottom: 32px; - left: 50%; - transform: translateX(-50%); - background: var(--surface-2); - border: 1px solid var(--border); - padding: 16px 32px; - border-radius: 32px; - font-family: 'JetBrains Mono', monospace; - font-size: 14px; - color: var(--text-dim); - display: flex; - align-items: center; - gap: 12px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); - z-index: 100; - animation: slide-up 0.3s cubic-bezier(0.22, 1, 0.36, 1); -} - -@keyframes slide-up { - from { opacity: 0; transform: translate(-50%, 20px); } - to { opacity: 1; transform: translate(-50%, 0); } -} - -/* ── Scrollbar ────────────────────────────────────────────────── */ - -::-webkit-scrollbar { width: 8px; } +::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } -::-webkit-scrollbar-thumb:hover { background: var(--border-light); } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } -/* ── Responsive ───────────────────────────────────────────────── */ +/* ── Desktop (1024px+) ───────────────────────────────────────── */ -@media (max-width: 1100px) { - .layout { flex-direction: column; } - .sidebar { - width: 100%; - border-left: none; - border-top: 1px solid var(--border); - max-height: 300px; - overflow-y: auto; +@media (min-width: 1024px) { + body { + height: 100vh; + overflow: hidden; + } + + .app { height: 100vh; } + + .layout { + flex-direction: row; + overflow: hidden; + } + + .main { + padding: 32px 48px; + position: relative; + align-items: center; + } + + .header { + position: absolute; + top: 32px; + left: 48px; + right: 48px; + margin-bottom: 0; + } + + .arena { + max-width: 900px; + width: 100%; + margin: auto 0; + } + + .prompt { margin-bottom: 40px; } + .prompt__text { padding-left: 24px; } + + .showdown { + flex-direction: row; + align-items: flex-start; + gap: 32px; + } + + .contestant { flex: 1; } + + .standings { + width: 280px; + border-top: none; + border-left: 1px solid var(--border); + max-height: none; + overflow-y: auto; + padding: 24px; } - .showdown { grid-template-columns: 1fr; gap: 32px; } } diff --git a/frontend.tsx b/frontend.tsx index 59c21b1..85c7066 100644 --- a/frontend.tsx +++ b/frontend.tsx @@ -1,8 +1,8 @@ -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect } from "react"; import { createRoot } from "react-dom/client"; import "./frontend.css"; -// ── Types (mirrors game.ts) ───────────────────────────────────────────────── +// ── Types ──────────────────────────────────────────────────────────────────── type Model = { id: string; name: string }; type TaskInfo = { model: Model; startedAt: number; finishedAt?: number; result?: string; error?: string }; @@ -22,7 +22,7 @@ type RoundState = { type GameState = { completed: RoundState[]; active: RoundState | null; scores: Record; done: boolean }; type ServerMessage = { type: "state"; data: GameState; totalRounds: number }; -// ── Model Assets & Colors ─────────────────────────────────────────────────── +// ── Model colors & logos ───────────────────────────────────────────────────── const MODEL_COLORS: Record = { "Gemini 3.1 Pro": "#4285F4", @@ -52,69 +52,57 @@ function getLogo(name: string): string | null { return null; } -// ── Components ────────────────────────────────────────────────────────────── +// ── Helpers ────────────────────────────────────────────────────────────────── -function Timer({ startedAt, finishedAt }: { startedAt: number; finishedAt?: number }) { - const [now, setNow] = useState(Date.now()); - useEffect(() => { - if (finishedAt) return; - const id = setInterval(() => setNow(Date.now()), 100); - return () => clearInterval(id); - }, [finishedAt]); - const elapsed = ((finishedAt ?? now) - startedAt) / 1000; - return {elapsed.toFixed(1)}s; +function Dots() { + return ...; } -function ModelName({ model, className = "", showLogo = true }: { model: Model; className?: string, showLogo?: boolean }) { +function ModelTag({ model, small }: { model: Model; small?: boolean }) { const logo = getLogo(model.name); const color = getColor(model.name); return ( - - {showLogo && logo && } + + {logo && } {model.name} ); } +// ── Prompt ─────────────────────────────────────────────────────────────────── + function PromptCard({ round }: { round: RoundState }) { if (round.phase === "prompting" && !round.prompt) { return ( -
-
- is cooking up a prompt… -
-
- ... +
+
+ is writing a prompt
+
); } if (round.promptTask.error) { return ( -
-
Prompt generation failed
+
+
Prompt generation failed
); } return ( -
-
- Prompted by -
-
{round.prompt}
+
+
Prompted by
+
{round.prompt}
); } -function ContestantPanel({ - task, - voteCount, - totalVotes, - isWinner, - showVotes, - voters, +// ── Contestant ─────────────────────────────────────────────────────────────── + +function ContestantCard({ + task, voteCount, totalVotes, isWinner, showVotes, voters, }: { task: TaskInfo; voteCount: number; @@ -127,43 +115,45 @@ function ContestantPanel({ const pct = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0; return ( -
-
-
- -
- {isWinner &&
WINNER
} +
+
+ + {isWinner && WIN}
- -
+ +
{!task.finishedAt ? ( - - ... - +

) : task.error ? ( - ✗ {task.error} +

{task.error}

) : ( - “{task.result}” +

“{task.result}”

)}
{showVotes && ( -
-
-
-
-
-
- {voteCount} - {pct}% -
+
+
+
-
- {voters.map((v, i) => ( -
- -
- ))} +
+ {voteCount} + vote{voteCount !== 1 ? "s" : ""} + + {voters.map((v, i) => { + const logo = getLogo(v.voter.name); + return logo ? ( + {v.voter.name} + ) : ( + + {v.voter.name[0]} + + ); + })} +
)} @@ -171,191 +161,144 @@ function ContestantPanel({ ); } -function PendingVotes({ votes }: { votes: VoteInfo[] }) { - if (votes.length === 0) return null; - return ( -
- {votes.map((v, i) => ( -
- - {!v.finishedAt ? " deliberating…" : " abstained"} -
- ))} -
- ); -} +// ── Arena ───────────────────────────────────────────────────────────────────── -function Arena({ round, total }: { round: RoundState; total: number }) { +function Arena({ round, total }: { round: RoundState; total: number | null }) { const [contA, contB] = round.contestants; const showVotes = round.phase === "voting" || round.phase === "done"; const isDone = round.phase === "done"; - let votesA = 0, - votesB = 0; + 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 totalVotes = votesA + votesB; - const votersA = round.votes.filter(v => v.votedFor?.name === contA.name); const votersB = round.votes.filter(v => v.votedFor?.name === contB.name); - const pendingOrAbstained = round.votes.filter(v => !v.finishedAt || v.error || !v.votedFor); - const phaseLabel = - round.phase === "prompting" - ? "✍️ WRITING PROMPT" - : round.phase === "answering" - ? "💭 ANSWERING" - : round.phase === "voting" - ? "🗳️ JUDGES VOTING" - : "✅ ROUND COMPLETE"; + const phaseText = + round.phase === "prompting" ? "Writing prompt" : + round.phase === "answering" ? "Answering" : + round.phase === "voting" ? "Judges voting" : + "Complete"; return (
-
-
- ROUND {round.num} {total !== null && / {total}} -
-
{phaseLabel}
+
+ + Round {round.num}{total ? /{total} : null} + + {phaseText}
{round.phase !== "prompting" && ( - <> -
- votesB} - showVotes={showVotes} - voters={votersA} - /> - votesA} - showVotes={showVotes} - voters={votersB} - /> -
+
+ votesB} + showVotes={showVotes} + voters={votersA} + /> + votesA} + showVotes={showVotes} + voters={votersB} + /> +
+ )} - {showVotes && } - - {isDone && votesA === votesB && ( -
IT’S A TIE!
- )} - + {isDone && votesA === votesB && totalVotes > 0 && ( +
Tie
)}
); } +// ── Game Over ──────────────────────────────────────────────────────────────── + function GameOver({ scores }: { scores: Record }) { const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]); const champion = sorted[0]; return (
-
GAME OVER
+
Game Over
{champion && champion[1] > 0 && ( -
-
👑
-
+
+ 👑 + {getLogo(champion[0]) && } {champion[0]} -
-
is the funniest AI!
+ + is the funniest AI
)}
); } -function Sidebar({ scores, activeRound, completed }: { scores: Record; activeRound: RoundState | null; completed: RoundState[] }) { +// ── Standings ──────────────────────────────────────────────────────────────── + +function Standings({ scores, activeRound }: { scores: Record; activeRound: RoundState | null }) { const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]); const maxScore = sorted[0]?.[1] || 1; const competing = activeRound ? new Set([activeRound.contestants[0].name, activeRound.contestants[1].name]) : new Set(); - const judging = activeRound ? new Set(activeRound.votes.map((v) => v.voter.name)) : new Set(); - const prompting = activeRound?.prompter.name ?? null; return ( -