feat: add scoreboard with player names, per-category leaderboards, and themed tables

Add player_name column to games (migration included), with regex-based
validation and anti-abuse sanitization. New /scoreboard page shows recent
games from localStorage, popular categories with play counts, and top-10
per-category leaderboards sorted by completion time. Also includes
two-click reverse word matching, base URL prefix support for reverse-proxy
hosting, BasePageHandler refactoring, themed table CSS for all 5 themes,
and comprehensive test coverage for player names and scoreboard API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mathew Sir Guest the best
2026-05-07 10:38:19 -06:00
co-authored by Claude Opus 4.6
parent 9319f08875
commit 36c3c712fe
27 changed files with 3038 additions and 24 deletions
+22
View File
@@ -269,6 +269,28 @@ body {
font-weight: 600;
}
/* Tables (scoreboard) */
.table {
color: var(--text-primary);
border-color: var(--indigo-mid);
}
.table > :not(caption) > * > * {
background-color: var(--indigo-dark);
border-bottom-color: var(--indigo-mid);
color: var(--text-primary);
}
.table-hover > tbody > tr:hover > * {
background-color: var(--indigo-mid);
color: var(--heading-color);
}
.table thead th {
color: var(--heading-color);
border-bottom-color: var(--indigo-mid);
}
/* Form controls */
.form-select,
.form-control {
+5 -3
View File
@@ -1,6 +1,8 @@
(function () {
"use strict";
var BASE = document.body.dataset.baseUrl || "/";
let gameState = null;
let timerInterval = null;
let selectedCells = [];
@@ -35,7 +37,7 @@
// Load categories
async function loadCategories() {
try {
const resp = await fetch("/api/categories");
const resp = await fetch(BASE + "api/categories");
if (!resp.ok) throw new Error("Server error");
const categories = await resp.json();
categorySelect.innerHTML = "";
@@ -65,7 +67,7 @@
const boardSize = parseInt(boardSizeInput.value);
const wordCount = parseInt(wordCountInput.value);
const playerName = playerNameInput.value.trim();
const resp = await fetch("/api/game/new", {
const resp = await fetch(BASE + "api/game/new", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -253,7 +255,7 @@
// Send guess to server
async function sendGuess(payload) {
try {
const resp = await fetch("/api/game/" + gameState.id + "/guess", {
const resp = await fetch(BASE + "api/game/" + gameState.id + "/guess", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
+4 -2
View File
@@ -1,6 +1,8 @@
(function () {
"use strict";
var BASE = document.body.dataset.baseUrl || "/";
var loadingDiv = document.getElementById("scoreboard-loading");
var recentSection = document.getElementById("recent-games-section");
var recentTbody = document.querySelector("#recent-games-table tbody");
@@ -65,7 +67,7 @@
' <div class="card-body">' +
' <h5 class="card-title">' + escapeHtml(titleCase(c.name)) + '</h5>' +
' <p class="card-text text-muted">' + c.completed_games + ' games completed</p>' +
' <a href="/game" class="btn btn-accent btn-sm">Play</a>' +
' <a href="' + BASE + 'game" class="btn btn-accent btn-sm">Play</a>' +
' </div>' +
'</div>';
popularRow.appendChild(col);
@@ -115,7 +117,7 @@
async function loadScoreboard() {
try {
var resp = await fetch("/api/scoreboard");
var resp = await fetch(BASE + "api/scoreboard");
if (!resp.ok) throw new Error("Failed to load");
var data = await resp.json();