From 2ee8afe582ca9763daeb1ba88f91a2cca931c163 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Wed, 6 May 2026 22:56:13 -0600 Subject: [PATCH] feat: add game page frontend with grid, word list, timer, and dual input Recreates base.html and main.html templates (lost during reset). Adds game.html with setup UI (category picker, board size slider), interactive grid table, word list sidebar, text input, click-to-select, timer, and completion banner. Adds game.js for all frontend logic. Extends theme.css with game grid and form control styles using the indigo/coral palette. Co-Authored-By: Claude Opus 4.6 --- .../versions/71016fe57b9d_initial_tables.py | 1 - static/css/theme.css | 77 ++++++ static/js/game.js | 257 ++++++++++++++++++ templates/base.html | 48 ++++ templates/game.html | 104 +++++++ templates/main.html | 14 + 6 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 static/js/game.js create mode 100644 templates/base.html create mode 100644 templates/game.html create mode 100644 templates/main.html diff --git a/alembic/versions/71016fe57b9d_initial_tables.py b/alembic/versions/71016fe57b9d_initial_tables.py index a3dee64..c2ffb88 100644 --- a/alembic/versions/71016fe57b9d_initial_tables.py +++ b/alembic/versions/71016fe57b9d_initial_tables.py @@ -11,7 +11,6 @@ from typing import Sequence, Union from alembic import op import sqlalchemy as sa - # revision identifiers, used by Alembic. revision: str = "71016fe57b9d" down_revision: Union[str, Sequence[str], None] = None diff --git a/static/css/theme.css b/static/css/theme.css index 7eef097..9a4da8c 100644 --- a/static/css/theme.css +++ b/static/css/theme.css @@ -145,3 +145,80 @@ body { font-size: 0.85rem; margin-top: 3rem; } + +/* Game grid */ +.game-grid { + border-collapse: collapse; +} + +.game-grid td { + width: 2.5rem; + height: 2.5rem; + text-align: center; + vertical-align: middle; + font-size: 1.1rem; + font-weight: 600; + font-family: monospace; + border: 1px solid var(--indigo-mid); + cursor: pointer; + user-select: none; + transition: background-color 0.15s; +} + +.game-grid td:hover { + background-color: var(--indigo-mid); +} + +.game-grid td.cell-selected { + background-color: rgba(233, 69, 96, 0.3); + outline: 2px solid var(--coral); + outline-offset: -2px; +} + +.game-grid td.cell-found { + background-color: var(--coral); + color: #ffffff; +} + +/* Word list */ +.list-group-item { + background-color: var(--indigo-dark); + border-color: var(--indigo-mid); + color: var(--text-primary); +} + +.list-group-item.word-found { + text-decoration: line-through; + color: var(--coral); +} + +.card-header { + background-color: var(--indigo-mid); + border-color: var(--indigo-mid); + color: #ffffff; + font-weight: 600; +} + +/* Form controls in dark theme */ +.form-select, +.form-control { + background-color: var(--indigo-dark); + border-color: var(--indigo-mid); + color: var(--text-primary); +} + +.form-select:focus, +.form-control:focus { + background-color: var(--indigo-dark); + border-color: var(--coral); + color: var(--text-primary); + box-shadow: 0 0 0 0.2rem rgba(233, 69, 96, 0.25); +} + +.form-range::-webkit-slider-thumb { + background: var(--coral); +} + +.form-range::-moz-range-thumb { + background: var(--coral); +} diff --git a/static/js/game.js b/static/js/game.js new file mode 100644 index 0000000..799a90a --- /dev/null +++ b/static/js/game.js @@ -0,0 +1,257 @@ +(function () { + "use strict"; + + let gameState = null; + let timerInterval = null; + let selectedCells = []; + + // DOM refs + const setupDiv = document.getElementById("game-setup"); + const boardDiv = document.getElementById("game-board"); + const categorySelect = document.getElementById("category-select"); + const boardSizeInput = document.getElementById("board-size"); + const sizeDisplay = document.getElementById("size-display"); + const newGameBtn = document.getElementById("new-game-btn"); + const gridTable = document.getElementById("grid-table"); + const wordList = document.getElementById("word-list"); + const wordInput = document.getElementById("word-input"); + const guessBtn = document.getElementById("guess-btn"); + const submitSelectionBtn = document.getElementById("submit-selection-btn"); + const clearSelectionBtn = document.getElementById("clear-selection-btn"); + const guessFeedback = document.getElementById("guess-feedback"); + const timerEl = document.getElementById("timer"); + const completionBanner = document.getElementById("completion-banner"); + const finalTime = document.getElementById("final-time"); + const playAgainBtn = document.getElementById("play-again-btn"); + const inputArea = document.getElementById("input-area"); + + // Load categories + async function loadCategories() { + const resp = await fetch("/api/categories"); + const categories = await resp.json(); + categorySelect.innerHTML = ""; + categories.forEach(function (cat) { + const opt = document.createElement("option"); + opt.value = cat.id; + opt.textContent = cat.name.charAt(0).toUpperCase() + cat.name.slice(1); + categorySelect.appendChild(opt); + }); + } + + boardSizeInput.addEventListener("input", function () { + sizeDisplay.textContent = this.value; + }); + + // New game + newGameBtn.addEventListener("click", async function () { + const categoryId = parseInt(categorySelect.value); + const boardSize = parseInt(boardSizeInput.value); + const resp = await fetch("/api/game/new", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ category_id: categoryId, board_size: boardSize }), + }); + gameState = await resp.json(); + showBoard(); + }); + + function showBoard() { + setupDiv.classList.add("d-none"); + boardDiv.classList.remove("d-none"); + completionBanner.classList.add("d-none"); + playAgainBtn.classList.add("d-none"); + inputArea.classList.remove("d-none"); + selectedCells = []; + renderGrid(); + renderWordList(); + startTimer(); + } + + function renderGrid() { + gridTable.innerHTML = ""; + gameState.grid.forEach(function (row, r) { + const tr = document.createElement("tr"); + row.forEach(function (letter, c) { + const td = document.createElement("td"); + td.textContent = letter; + td.dataset.row = r; + td.dataset.col = c; + td.addEventListener("click", function () { + onCellClick(r, c, td); + }); + tr.appendChild(td); + }); + gridTable.appendChild(tr); + }); + } + + function renderWordList() { + wordList.innerHTML = ""; + gameState.words.forEach(function (w) { + const li = document.createElement("li"); + li.className = "list-group-item"; + li.textContent = w.text; + li.dataset.word = w.text; + if (w.found) { + li.classList.add("word-found"); + } + wordList.appendChild(li); + }); + } + + // Timer + function startTimer() { + if (timerInterval) clearInterval(timerInterval); + const startedAt = new Date(gameState.started_at); + function tick() { + const elapsed = Math.floor((Date.now() - startedAt.getTime()) / 1000); + const mins = Math.floor(elapsed / 60); + const secs = elapsed % 60; + timerEl.textContent = mins + ":" + (secs < 10 ? "0" : "") + secs; + } + tick(); + timerInterval = setInterval(tick, 1000); + } + + function stopTimer() { + if (timerInterval) { + clearInterval(timerInterval); + timerInterval = null; + } + } + + // Text guess + guessBtn.addEventListener("click", submitTextGuess); + wordInput.addEventListener("keydown", function (e) { + if (e.key === "Enter") submitTextGuess(); + }); + + async function submitTextGuess() { + const word = wordInput.value.trim(); + if (!word) return; + wordInput.value = ""; + await sendGuess({ word: word }); + } + + // Click selection + function onCellClick(row, col, td) { + if (gameState.status === "completed") return; + + // Check if already selected — deselect + const idx = selectedCells.findIndex( + function (c) { return c.row === row && c.col === col; } + ); + if (idx !== -1) { + selectedCells.splice(idx, 1); + td.classList.remove("cell-selected"); + updateSelectionButtons(); + return; + } + + // Validate straight line + if (selectedCells.length >= 2) { + if (!isValidExtension(row, col)) { + showFeedback("Selection must form a straight line.", "text-warning"); + return; + } + } + + selectedCells.push({ row: row, col: col }); + td.classList.add("cell-selected"); + updateSelectionButtons(); + } + + function isValidExtension(row, col) { + if (selectedCells.length < 2) return true; + const dr = selectedCells[1].row - selectedCells[0].row; + const dc = selectedCells[1].col - selectedCells[0].col; + const len = selectedCells.length; + const expectedRow = selectedCells[0].row + dr * len; + const expectedCol = selectedCells[0].col + dc * len; + return row === expectedRow && col === expectedCol; + } + + function updateSelectionButtons() { + if (selectedCells.length > 0) { + submitSelectionBtn.classList.remove("d-none"); + clearSelectionBtn.classList.remove("d-none"); + } else { + submitSelectionBtn.classList.add("d-none"); + clearSelectionBtn.classList.add("d-none"); + } + } + + submitSelectionBtn.addEventListener("click", async function () { + if (selectedCells.length === 0) return; + await sendGuess({ cells: selectedCells }); + clearSelection(); + }); + + clearSelectionBtn.addEventListener("click", clearSelection); + + function clearSelection() { + selectedCells = []; + document.querySelectorAll(".cell-selected").forEach(function (td) { + td.classList.remove("cell-selected"); + }); + updateSelectionButtons(); + } + + // Send guess to server + async function sendGuess(payload) { + const resp = await fetch("/api/game/" + gameState.id + "/guess", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const result = await resp.json(); + gameState.words = result.words; + gameState.status = result.status; + + if (result.correct) { + highlightFoundCells(result.cells); + renderWordList(); + showFeedback("Found: " + result.word + "!", "text-success"); + } else { + showFeedback("Not a match.", "text-danger"); + } + + if (result.status === "completed") { + onGameComplete(result.completed_at); + } + } + + function highlightFoundCells(cells) { + cells.forEach(function (c) { + const td = gridTable.querySelector( + 'td[data-row="' + c.row + '"][data-col="' + c.col + '"]' + ); + if (td) td.classList.add("cell-found"); + }); + } + + function showFeedback(msg, cls) { + guessFeedback.textContent = msg; + guessFeedback.className = "mt-2 " + cls; + setTimeout(function () { + guessFeedback.textContent = ""; + guessFeedback.className = "mt-2"; + }, 2000); + } + + function onGameComplete(completedAt) { + stopTimer(); + completionBanner.classList.remove("d-none"); + finalTime.textContent = timerEl.textContent; + playAgainBtn.classList.remove("d-none"); + inputArea.classList.add("d-none"); + } + + playAgainBtn.addEventListener("click", function () { + boardDiv.classList.add("d-none"); + setupDiv.classList.remove("d-none"); + }); + + // Init + loadCategories(); +})(); diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..91e23b7 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,48 @@ + + + + + + {% block title %}Word Search{% end %} + + + + + + +
+ {% block content %}{% end %} +
+ + + + diff --git a/templates/game.html b/templates/game.html new file mode 100644 index 0000000..4b5a7e8 --- /dev/null +++ b/templates/game.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} + +{% block title %}Word Search — Play{% end %} + +{% block content %} + +
+

New Game

+
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+

Word Search

+
+ 0:00 + +
+
+ + + + +
+ +
+
+
+
+
+
+ + +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+ + +
+
+
Words to Find
+
    +
    +
    +
    +
    + + +{% end %} diff --git a/templates/main.html b/templates/main.html new file mode 100644 index 0000000..1a09a71 --- /dev/null +++ b/templates/main.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block title %}Word Search — Home{% end %} + +{% block content %} +
    +

    Word Search

    +

    + Find hidden words in a grid of letters. Words can appear horizontally, + vertically, or diagonally. How fast can you find them all? +

    + Start Game +
    +{% end %}