feat: expand to 1014 words, add title-case display and 2 new themes

- Move word data from hardcoded seed.py to data/words.json
- Expand from 210 words across 7 categories to 1014 words across 14
  categories (animals, food, colors, sports, space, nature, music,
  science, countries, occupations, ocean, weather, mythology, technology)
- Word list sidebar now shows title case (Tiger) while grid stays
  uppercase; text input remains case-insensitive
- Add Ocean theme (light, blue accents) and Forest theme (dark, amber
  accents on deep green)
- Replace hardcoded rgba values with --coral-bg CSS variable so
  selection highlighting adapts per theme

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mathew Sir Guest the best
2026-05-07 02:43:43 -06:00
co-authored by Claude Opus 4.6
parent cd1dc9200b
commit 9319f08875
13 changed files with 728 additions and 236 deletions
+4
View File
@@ -11,11 +11,13 @@ from handlers.main import MainHandler
from handlers.game import GameHandler from handlers.game import GameHandler
from handlers.howtoplay import HowToPlayHandler from handlers.howtoplay import HowToPlayHandler
from handlers.about import AboutHandler from handlers.about import AboutHandler
from handlers.scoreboard import ScoreboardPageHandler
from handlers.api import ( from handlers.api import (
CategoriesHandler, CategoriesHandler,
NewGameHandler, NewGameHandler,
GameStateHandler, GameStateHandler,
GuessHandler, GuessHandler,
ScoreboardAPIHandler,
) )
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -38,7 +40,9 @@ def make_app(session_factory=None, debug=False):
(r"/game", GameHandler), (r"/game", GameHandler),
(r"/howtoplay", HowToPlayHandler), (r"/howtoplay", HowToPlayHandler),
(r"/about", AboutHandler), (r"/about", AboutHandler),
(r"/scoreboard", ScoreboardPageHandler),
(r"/api/categories", CategoriesHandler), (r"/api/categories", CategoriesHandler),
(r"/api/scoreboard", ScoreboardAPIHandler),
(r"/api/game/new", NewGameHandler), (r"/api/game/new", NewGameHandler),
(r"/api/game/([^/]+)", GameStateHandler), (r"/api/game/([^/]+)", GameStateHandler),
(r"/api/game/([^/]+)/guess", GuessHandler), (r"/api/game/([^/]+)/guess", GuessHandler),
+184
View File
@@ -0,0 +1,184 @@
{
"animals": [
"TIGER", "DOLPHIN", "EAGLE", "WOLF", "BEAR", "LION", "HAWK", "DEER",
"FROG", "SHARK", "SNAKE", "WHALE", "ZEBRA", "PANDA", "OTTER", "FALCON",
"BISON", "CRANE", "MOOSE", "RAVEN", "JAGUAR", "PARROT", "TURTLE",
"SALMON", "FERRET", "COYOTE", "OSPREY", "LIZARD", "BADGER", "PELICAN",
"MONKEY", "RABBIT", "IGUANA", "CONDOR", "JACKAL", "GORILLA", "PENGUIN",
"GAZELLE", "HAMSTER", "LOBSTER", "PANTHER", "SPARROW", "BUFFALO",
"CHEETAH", "VULTURE", "GIRAFFE", "LEOPARD", "OCTOPUS", "ROOSTER",
"CRICKET", "FLAMINGO", "MUSTANG", "PIRANHA", "STARLING", "MACKEREL",
"MEERKAT", "WARTHOG", "WALLABY", "WOODCOCK", "BULLFROG", "CARDINAL",
"ELEPHANT", "CHIPMUNK", "SCORPION", "GOLDFISH", "BLUEBIRD", "HEDGEHOG",
"ANTELOPE", "MANATEE", "PHEASANT", "CHIMP", "STORK", "FINCH", "GOOSE",
"HERON", "HYENA", "KOALA", "LEMUR", "LLAMA", "LYNX", "VIPER", "TOAD",
"NEWT", "MULE", "MOTH", "WASP", "DOVE", "CROW", "WREN", "IBIS"
],
"food": [
"PIZZA", "SUSHI", "MANGO", "PASTA", "TACO", "BREAD", "STEAK", "GRAPE",
"MELON", "PEACH", "OLIVE", "LEMON", "BACON", "WAFFLE", "CREPE", "DONUT",
"SALAD", "CURRY", "BAGEL", "FUDGE", "SHRIMP", "CHEESE", "BUTTER",
"CHERRY", "PEPPER", "GINGER", "PRETZEL", "BRISKET", "NOODLE", "OYSTER",
"TURNIP", "RADISH", "CELERY", "COOKIE", "BISQUE", "ALMOND", "CASHEW",
"WALNUT", "PAPAYA", "BROWNIE", "BURRITO", "CHOWDER", "COBBLER",
"CRACKER", "CUSTARD", "DUMPLING", "GRANOLA", "LASAGNA", "MUFFIN",
"PANCAKE", "POPCORN", "PUDDING", "RAISIN", "RISOTTO", "SAUSAGE",
"SCALLOP", "SPINACH", "TRUFFLE", "BISCUIT", "AVOCADO", "APRICOT",
"CABBAGE", "CARROT", "CLAM", "CORN", "CRAB", "DATE", "DUCK", "FISH",
"GUAVA", "HERB", "KALE", "LAMB", "LIME", "MINT", "OKRA", "PEAR",
"PLUM", "PORK", "RICE", "SAGE", "TOFU", "TUNA", "YAMS", "BASIL",
"BERRY", "COCOA", "CUMIN", "GOUDA", "HONEY", "LEEK", "ONION", "THYME"
],
"colors": [
"CRIMSON", "VIOLET", "AMBER", "SCARLET", "INDIGO", "MAROON", "CORAL",
"IVORY", "SILVER", "GOLDEN", "BRONZE", "TEAL", "OLIVE", "PLUM", "RUST",
"SAGE", "NAVY", "JADE", "CYAN", "MAUVE", "COBALT", "COPPER", "EBONY",
"PEACH", "CREAM", "LEMON", "ORCHID", "SALMON", "SIENNA", "KHAKI",
"LILAC", "PEARL", "CHARCOAL", "MAGENTA", "EMERALD", "AZURE", "BEIGE",
"CHERRY", "FAWN", "FLAX", "GARNET", "HAZEL", "LAVENDER", "MAHOGANY",
"MUSTARD", "OPAL", "PERIWINKLE", "PEWTER", "RUBY", "SAFFRON",
"SAPPHIRE", "SEPIA", "TANGERINE", "TOPAZ", "TURQUOISE", "UMBER",
"VERMILION", "WINE", "BURGUNDY", "FUCHSIA", "THISTLE", "CERISE",
"DENIM", "IVORY", "PUMPKIN", "SLATE", "SMOKE", "TAUPE"
],
"sports": [
"SOCCER", "TENNIS", "BOXING", "RUGBY", "GOLF", "DIVING", "ROWING",
"HOCKEY", "FENCING", "SPRINT", "DISCUS", "SQUASH", "KARATE", "JUDO",
"RELAY", "SLALOM", "VAULT", "VOLLEY", "TACKLE", "PADDLE", "JAVELIN",
"HURDLE", "ARCHERY", "SURFING", "SKIING", "CYCLING", "SAILING",
"BOWLING", "CRICKET", "LACROSSE", "MARATHON", "WRESTLING", "SWIMMING",
"CLIMBING", "CURLING", "DRIBBLE", "PENALTY", "FIELDER", "PITCHER",
"BATTER", "GOALIE", "RACKET", "STROKE", "TROPHY", "LEAGUE", "MATCH",
"SCORE", "SERVE", "COACH", "ARENA", "CATCH", "CHASE", "DRILL", "FINAL",
"GUARD", "PITCH", "ROUND", "TRACK", "TRIAL", "BLOCK", "CRAWL", "DASH",
"LUNGE", "PUNCH", "THROW", "MEDAL", "RIDER", "SKATE", "SWING"
],
"space": [
"PLANET", "GALAXY", "NEBULA", "COMET", "ORBIT", "QUASAR", "PULSAR",
"LUNAR", "SOLAR", "METEOR", "AURORA", "ECLIPSE", "ZENITH", "CRATER",
"COSMOS", "PHOTON", "PLASMA", "ROCKET", "SATURN", "VENUS", "MARS",
"PLUTO", "TITAN", "VORTEX", "FLARE", "ASTEROID", "BLACKHOLE",
"CONSTELLATION", "GRAVITY", "HORIZON", "JUPITER", "MERCURY", "NEPTUNE",
"NEUTRON", "NUCLEUS", "PARSEC", "SHUTTLE", "STELLAR", "SUNSPOT",
"SUPERNOVA", "URANUS", "VOYAGER", "WORMHOLE", "CAPSULE", "DOCKING",
"EQUINOX", "FUSION", "MISSION", "MODULE", "SOLSTICE", "STARLIGHT",
"TELESCOPE", "APOGEE", "PERIGEE", "RADIANT", "SATELLITE", "SPECTRUM",
"NOVA", "STAR", "MOON", "BEAM", "DUST", "VOID", "DAWN", "DUSK",
"RING", "AXIS", "POLE"
],
"nature": [
"RIVER", "OCEAN", "CANYON", "FOREST", "DESERT", "ISLAND", "GLACIER",
"MEADOW", "VALLEY", "SUMMIT", "LAGOON", "TUNDRA", "MARSH", "CLIFF",
"DELTA", "FJORD", "GROVE", "RIDGE", "BROOK", "DUNE", "GEYSER",
"CAVERN", "RAPIDS", "SAVANNA", "BAMBOO", "BOULDER", "CASCADE",
"CYPRESS", "EROSION", "ESTUARY", "FOLIAGE", "GRANITE", "HABITAT",
"PLATEAU", "PRAIRIE", "RAVINE", "SEQUOIA", "TERRACE", "VOLCANO",
"WETLAND", "WILLOW", "BLOSSOM", "CANOPY", "JUNGLE", "LAGOON",
"MANGROVE", "OASIS", "PEBBLE", "SPRING", "STREAM", "THICKET",
"TIMBER", "BASIN", "BLUFF", "CEDAR", "CORAL", "CREST", "FALLS",
"FIELD", "FROST", "HEATH", "MAPLE", "MOSS", "PEAK", "PINE", "POND",
"REEF", "SAND", "SEED", "SOIL", "TIDE", "VINE", "WEED", "BIRCH",
"FERN"
],
"music": [
"GUITAR", "PIANO", "DRUMS", "VIOLIN", "FLUTE", "CELLO", "BANJO",
"ORGAN", "HARP", "TRUMPET", "RHYTHM", "MELODY", "CHORD", "TEMPO",
"FORTE", "TREBLE", "OCTAVE", "SONATA", "BALLAD", "OPERA", "HYMN",
"LYRIC", "CHORUS", "BRIDGE", "DUET", "ANTHEM", "CADENCE", "CLARINET",
"CONCERTO", "CYMBAL", "ENSEMBLE", "HARMONY", "MANDOLIN", "OVERTURE",
"PRELUDE", "QUARTET", "SERENADE", "SOPRANO", "SYMPHONY", "UKULELE",
"VIBRATO", "BASSOON", "BARITONE", "FALSETTO", "HARMONICA", "INTERLUDE",
"STACCATO", "ACOUSTIC", "ALTO", "ARIA", "BASS", "BEAT", "CLEF", "CODA",
"FLAT", "FUGUE", "GONG", "JAZZ", "LUTE", "NOTE", "OBOE", "RIFF",
"REED", "REST", "SOLO", "SONG", "TONE", "TUBA", "TUNE", "WALTZ"
],
"science": [
"ATOM", "CELL", "GENE", "ACID", "BASE", "BOND", "FORCE", "MASS",
"WAVE", "LENS", "VOLT", "WATT", "JOULE", "QUARK", "PRISM", "ALLOY",
"ANODE", "HELIX", "DIODE", "LASER", "OPTIC", "OXIDE", "PHASE",
"RADAR", "RATIO", "SOLID", "VAPOR", "BIOME", "CLONE", "DECAY",
"FIELD", "FLUID", "GAMMA", "INERT", "IONIC", "MOLAR", "ORBIT",
"PROBE", "RESIN", "STEEL", "TRAIT", "CARBON", "ENZYME", "FUSION",
"GENOME", "KELVIN", "MAGNET", "NEURON", "PHOTON", "PROTON", "REFLEX",
"STRAIN", "TENSOR", "THEORY", "VACUUM", "VECTOR", "VOLTAGE", "CALCIUM",
"CRYSTAL", "DENSITY", "ELEMENT", "FISSION", "FORMULA", "GRAVITY",
"ISOTOPE", "KINETIC", "LITHIUM", "MINERAL", "NUCLEUS", "OSMOSIS",
"POLYMER", "REACTOR", "SILICON", "THERMAL", "URANIUM", "CATALYST",
"ELECTRON", "HYDROGEN", "MOLECULE", "NITROGEN", "SPECTRUM"
],
"countries": [
"FRANCE", "BRAZIL", "JAPAN", "KENYA", "EGYPT", "CHILE", "CHINA",
"INDIA", "SPAIN", "ITALY", "GREECE", "PERU", "CUBA", "NEPAL",
"QATAR", "TONGA", "FIJI", "IRAN", "IRAQ", "LAOS", "MALI", "OMAN",
"CHAD", "GHANA", "NIGER", "BENIN", "GABON", "LIBYA", "SUDAN", "SYRIA",
"YEMEN", "KOREA", "SAMOA", "NAURU", "BELIZE", "PANAMA", "MEXICO",
"CANADA", "NORWAY", "SWEDEN", "POLAND", "TURKEY", "JORDAN", "ISRAEL",
"BHUTAN", "ANGOLA", "MALAWI", "ZAMBIA", "UGANDA", "RWANDA", "SERBIA",
"LATVIA", "MONACO", "TUVALU", "BOLIVIA", "CROATIA", "DENMARK",
"ECUADOR", "FINLAND", "GERMANY", "HUNGARY", "ICELAND", "IRELAND",
"JAMAICA", "LEBANON", "MOROCCO", "NIGERIA", "ROMANIA", "SENEGAL",
"TUNISIA", "UKRAINE", "URUGUAY", "VIETNAM", "AUSTRIA", "ALBANIA",
"BAHRAIN", "GEORGIA"
],
"occupations": [
"PILOT", "NURSE", "BAKER", "JUDGE", "CHEF", "TUTOR", "GUARD",
"MINER", "DIVER", "CLERK", "GUIDE", "USHER", "MASON", "SMITH",
"SCOUT", "CADET", "ACTOR", "AGENT", "COACH", "ENVOY", "MAYOR",
"FARMER", "LAWYER", "DOCTOR", "DANCER", "SINGER", "WELDER", "BARBER",
"BUTLER", "CASHIER", "DENTIST", "EDITOR", "ANGLER", "ARCHER",
"ARTIST", "AUTHOR", "BANKER", "BREWER", "BROKER", "DRIVER", "DRAFTER",
"FLORIST", "JEWELER", "PLUMBER", "PORTER", "RANGER", "SAILOR",
"SCHOLAR", "SOLDIER", "SURGEON", "TAILOR", "TELLER", "TRADER",
"USHER", "WAITER", "WARDEN", "CHEMIST", "CURATOR", "MARSHAL",
"OFFICER", "PAINTER", "JANITOR", "TRAINER", "MANAGER", "AUDITOR",
"ANALYST", "ADVISOR", "BUILDER", "COURIER"
],
"ocean": [
"WHALE", "CORAL", "SHARK", "SQUID", "KELP", "REEF", "TIDE", "WAVE",
"ABYSS", "COAST", "SHOAL", "SHELL", "SHRIMP", "MARLIN", "URCHIN",
"SPONGE", "ANCHOR", "HARBOR", "VESSEL", "VOYAGE", "LAGOON", "TRENCH",
"SEABED", "PLANKTON", "BARNACLE", "CURRENT", "DOLPHIN", "ESTUARY",
"HARPOON", "ICEBERG", "LOBSTER", "NARWHAL", "OCTOPUS", "PELICAN",
"SCALLOP", "SEAFOOD", "SUNFISH", "TSUNAMI", "ABALONE", "BARRACUDA",
"FLOUNDER", "MARITIME", "NAUTICAL", "SANDBAR", "SEAHORSE", "SEAGULL",
"STARFISH", "STINGRAY", "SURFING", "SWORDFISH", "TIDEPOOL", "TROPICAL",
"CLAM", "CRAB", "DOCK", "FISH", "GULL", "HULL", "KEEL", "MAST",
"PIER", "PORT", "SAIL", "SEAL", "SURF", "DECK", "BUOY"
],
"weather": [
"STORM", "CLOUD", "FROST", "SLEET", "FLOOD", "BREEZE", "CIRRUS",
"CYCONE", "GALE", "GUST", "HAIL", "HEAT", "MIST", "RAIN", "SNOW",
"WIND", "HUMID", "MUGGY", "BALMY", "SUNNY", "FOGGY", "MISTY",
"WINDY", "DUSTY", "DRAFT", "THAW", "FRONT", "SQUALL", "FUNNEL",
"BLIZZARD", "CLIMATE", "CYCLONE", "DRIZZLE", "DROUGHT", "MONSOON",
"RAINBOW", "SHELTER", "THUNDER", "TORNADO", "TROPICS", "TYPHOON",
"WARMING", "CELSIUS", "CUMULUS", "DEWDROP", "EROSION", "FLURRY",
"HEATWAVE", "HUMIDITY", "OVERCAST", "PRESSURE", "SNOWFALL", "TEMPEST",
"WINDMILL"
],
"mythology": [
"ZEUS", "HERA", "ARES", "ATLAS", "TITAN", "HYDRA", "MEDUSA",
"PHOENIX", "DRAGON", "ORACLE", "SPHINX", "SIREN", "MINOTAUR",
"CENTAUR", "CYCLOPS", "GRIFFIN", "KRAKEN", "NYMPH", "SATYR", "TRITON",
"POSEIDON", "ARTEMIS", "HERMES", "ATHENA", "APOLLO", "ODIN", "THOR",
"FREYA", "FENRIR", "VALKYRIE", "VALHALLA", "CERBERUS", "CHIMERA",
"GORGON", "HARPY", "PEGASUS", "UNICORN", "GOLEM", "DJINN", "BANSHEE",
"BASILISK", "GARGOYLE", "WEREWOLF", "LEVIATHAN", "ACHILLES",
"HERCULES", "ICARUS", "ODYSSEUS", "ORPHEUS", "PANDORA", "MERCURY",
"NEPTUNE", "MINERVA", "OLYMPUS", "ELYSIUM", "ASGARD"
],
"technology": [
"PIXEL", "MODEM", "CACHE", "CLOUD", "DEBUG", "DRONE", "FIBER",
"FLASH", "MACRO", "PROXY", "QUERY", "STACK", "TOKEN", "VIRUS",
"WIDGET", "BINARY", "BITMAP", "BRANCH", "BUFFER", "CIPHER", "CLIENT",
"CURSOR", "ENGINE", "FILTER", "FOLDER", "KERNEL", "LAPTOP", "MEMORY",
"MODULE", "NEURAL", "OUTPUT", "PARSER", "PORTAL", "RENDER", "ROUTER",
"SCRIPT", "SENSOR", "SERVER", "SOCKET", "SOURCE", "SWITCH", "SYNTAX",
"THREAD", "BACKUP", "CURSOR", "DOMAIN", "DRIVER", "ENCODE", "EXPORT",
"FORMAT", "HANDLE", "IMPORT", "MATRIX", "NEURAL", "OBJECT", "OFFSET",
"PACKET", "PLUGIN", "REBOOT", "SCROLL", "STATIC", "STREAM", "TOGGLE",
"UPLOAD", "VECTOR", "BYTE", "CHIP", "CODE", "DATA", "DISK", "FILE",
"GRID", "HASH", "HOST", "ICON", "LINK", "NODE", "PORT", "SCAN",
"SYNC", "TASK", "WIFI"
]
}
+108
View File
@@ -2,6 +2,7 @@
import collections import collections
import json import json
import re
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -33,6 +34,24 @@ def _check_rate(ip, bucket="default"):
return True return True
_PLAYER_NAME_RE = re.compile(r"^[a-zA-Z0-9 ]+$")
MAX_PLAYER_NAME_LEN = 20
def _sanitize_player_name(raw):
"""Validate and sanitize a player name. Returns name or None (invalid)."""
if not isinstance(raw, str):
return "Anonymous"
name = raw.strip()
if not name:
return "Anonymous"
if len(name) > MAX_PLAYER_NAME_LEN:
name = name[:MAX_PLAYER_NAME_LEN]
if not _PLAYER_NAME_RE.match(name):
return None
return name
class BaseAPIHandler(tornado.web.RequestHandler): class BaseAPIHandler(tornado.web.RequestHandler):
"""Base handler for JSON API endpoints.""" """Base handler for JSON API endpoints."""
@@ -112,6 +131,19 @@ class NewGameHandler(BaseAPIHandler):
return return
word_count = max(3, min(30, word_count)) word_count = max(3, min(30, word_count))
player_name = _sanitize_player_name(data.get("player_name", ""))
if player_name is None:
self.set_status(400)
self.write(
json.dumps(
{
"error": "Player name may only contain"
" letters, numbers, and spaces"
}
)
)
return
category = session.query(Category).filter_by(id=category_id).first() category = session.query(Category).filter_by(id=category_id).first()
if not category: if not category:
self.set_status(404) self.set_status(404)
@@ -130,6 +162,7 @@ class NewGameHandler(BaseAPIHandler):
self.write(json.dumps({"error": "Failed to generate board with words"})) self.write(json.dumps({"error": "Failed to generate board with words"}))
return return
game.player_name = player_name
session.add(game) session.add(game)
for w in words: for w in words:
session.add(w) session.add(w)
@@ -146,6 +179,7 @@ class NewGameHandler(BaseAPIHandler):
return { return {
"id": game.id, "id": game.id,
"board_size": game.board_size, "board_size": game.board_size,
"player_name": game.player_name,
"category": game.category.name, "category": game.category.name,
"grid": game.grid, "grid": game.grid,
"words": [{"text": w.text, "found": w.found} for w in game.words], "words": [{"text": w.text, "found": w.found} for w in game.words],
@@ -171,6 +205,7 @@ class GameStateHandler(BaseAPIHandler):
{ {
"id": game.id, "id": game.id,
"board_size": game.board_size, "board_size": game.board_size,
"player_name": game.player_name,
"category": game.category.name, "category": game.category.name,
"grid": game.grid, "grid": game.grid,
"words": [ "words": [
@@ -296,6 +331,7 @@ class GuessHandler(BaseAPIHandler):
"cells": cells, "cells": cells,
"words": [{"text": w.text, "found": w.found} for w in game.words], "words": [{"text": w.text, "found": w.found} for w in game.words],
"status": game.status, "status": game.status,
"player_name": game.player_name,
"completed_at": ( "completed_at": (
game.completed_at.isoformat() + "Z" if game.completed_at else None game.completed_at.isoformat() + "Z" if game.completed_at else None
), ),
@@ -308,5 +344,77 @@ class GuessHandler(BaseAPIHandler):
"cells": [], "cells": [],
"words": [{"text": w.text, "found": w.found} for w in game.words], "words": [{"text": w.text, "found": w.found} for w in game.words],
"status": game.status, "status": game.status,
"player_name": game.player_name,
"completed_at": None, "completed_at": None,
} }
class ScoreboardAPIHandler(BaseAPIHandler):
"""``GET /api/scoreboard`` -- leaderboards and popular categories."""
def get(self):
session = self.get_session()
try:
categories = session.query(Category).all()
leaderboards = {}
for cat in categories:
completed = (
session.query(Game)
.filter(
Game.category_id == cat.id,
Game.status == "completed",
Game.completed_at.isnot(None),
)
.all()
)
completed.sort(
key=lambda g: (g.completed_at - g.started_at).total_seconds()
)
top = completed[:10]
if top:
leaderboards[cat.name] = [
{
"player_name": g.player_name,
"time_seconds": (
g.completed_at - g.started_at
).total_seconds(),
"board_size": g.board_size,
"word_count": len(g.words),
"completed_at": g.completed_at.isoformat() + "Z",
}
for g in top
]
popular = []
for cat in categories:
count = (
session.query(Game)
.filter(
Game.category_id == cat.id,
Game.status == "completed",
)
.count()
)
popular.append(
{
"id": cat.id,
"name": cat.name,
"completed_games": count,
}
)
popular.sort(key=lambda x: x["completed_games"], reverse=True)
self.write(
json.dumps(
{
"leaderboards": leaderboards,
"popular_categories": popular,
}
)
)
except Exception:
session.rollback()
raise
finally:
session.close()
+10
View File
@@ -0,0 +1,10 @@
"""Scoreboard page handler."""
import tornado.web
class ScoreboardPageHandler(tornado.web.RequestHandler):
"""Serves the scoreboard page."""
def get(self):
self.render("scoreboard.html")
+3
View File
@@ -28,6 +28,9 @@ class Game(Base):
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
category_id = Column(Integer, ForeignKey("categories.id"), nullable=False) category_id = Column(Integer, ForeignKey("categories.id"), nullable=False)
board_size = Column(Integer, nullable=False, default=10) board_size = Column(Integer, nullable=False, default=10)
player_name = Column(
String(20), nullable=False, default="Anonymous", server_default="Anonymous"
)
grid = Column(JSON, nullable=False) grid = Column(JSON, nullable=False)
status = Column(String(20), nullable=False, default="in_progress") status = Column(String(20), nullable=False, default="in_progress")
started_at = Column( started_at = Column(
+14 -222
View File
@@ -1,227 +1,17 @@
"""Seed the database with word categories from data/words.json."""
import json
import os
from db import get_engine, get_session_factory, init_db from db import get_engine, get_session_factory, init_db
from models.category import Category, CategoryWord from models.category import Category, CategoryWord
SEED_DATA = { DATA_PATH = os.path.join(os.path.dirname(__file__), "data", "words.json")
"animals": [
"TIGER",
"DOLPHIN", def load_words():
"EAGLE", with open(DATA_PATH) as f:
"WOLF", return json.load(f)
"BEAR",
"LION",
"HAWK",
"DEER",
"FROG",
"SHARK",
"SNAKE",
"WHALE",
"ZEBRA",
"PANDA",
"OTTER",
"FALCON",
"BISON",
"CRANE",
"MOOSE",
"RAVEN",
"JAGUAR",
"PARROT",
"TURTLE",
"SALMON",
"FERRET",
"COYOTE",
"OSPREY",
"LIZARD",
"BADGER",
"PELICAN",
"MONKEY",
"RABBIT",
"IGUANA",
"CONDOR",
"JACKAL",
],
"colors": [
"CRIMSON",
"VIOLET",
"AMBER",
"SCARLET",
"INDIGO",
"MAROON",
"CORAL",
"IVORY",
"SILVER",
"GOLDEN",
"BRONZE",
"TEAL",
"OLIVE",
"PLUM",
"RUST",
"SAGE",
"NAVY",
"JADE",
"CYAN",
"MAUVE",
"COBALT",
"COPPER",
"EBONY",
"PEACH",
"CREAM",
"LEMON",
"ORCHID",
"SALMON",
"SIENNA",
"KHAKI",
"LILAC",
"PEARL",
"CHARCOAL",
"MAGENTA",
"EMERALD",
],
"food": [
"PIZZA",
"SUSHI",
"MANGO",
"PASTA",
"TACO",
"BREAD",
"STEAK",
"GRAPE",
"MELON",
"PEACH",
"OLIVE",
"LEMON",
"BACON",
"WAFFLE",
"CREPE",
"DONUT",
"SALAD",
"CURRY",
"BAGEL",
"FUDGE",
"SHRIMP",
"CHEESE",
"BUTTER",
"CHERRY",
"PEPPER",
"GINGER",
"PRETZEL",
"BRISKET",
"NOODLE",
"OYSTER",
"TURNIP",
"RADISH",
"CELERY",
"COOKIE",
"BISQUE",
],
"sports": [
"SOCCER",
"TENNIS",
"BOXING",
"RUGBY",
"GOLF",
"DIVING",
"ROWING",
"HOCKEY",
"FENCING",
"ARCHER",
"SPRINT",
"DISCUS",
"SQUASH",
"KARATE",
"JUDO",
"RELAY",
"SLALOM",
"CRAWL",
"VAULT",
"SERVE",
"VOLLEY",
"TACKLE",
"PADDLE",
"JAVELIN",
"HURDLE",
],
"space": [
"PLANET",
"GALAXY",
"NEBULA",
"COMET",
"ORBIT",
"QUASAR",
"PULSAR",
"LUNAR",
"SOLAR",
"METEOR",
"AURORA",
"ECLIPSE",
"ZENITH",
"CRATER",
"COSMOS",
"PHOTON",
"PLASMA",
"ROCKET",
"SATURN",
"VENUS",
"MARS",
"PLUTO",
"TITAN",
"VORTEX",
"FLARE",
],
"nature": [
"RIVER",
"OCEAN",
"CANYON",
"FOREST",
"DESERT",
"ISLAND",
"GLACIER",
"MEADOW",
"VALLEY",
"SUMMIT",
"LAGOON",
"TUNDRA",
"MARSH",
"CLIFF",
"DELTA",
"FJORD",
"GROVE",
"RIDGE",
"CORAL",
"BROOK",
"DUNE",
"GEYSER",
"CAVERN",
"RAPIDS",
"SAVANNA",
],
"music": [
"GUITAR",
"PIANO",
"DRUMS",
"VIOLIN",
"FLUTE",
"CELLO",
"BANJO",
"ORGAN",
"HARP",
"TRUMPET",
"RHYTHM",
"MELODY",
"CHORD",
"TEMPO",
"FORTE",
"TREBLE",
"OCTAVE",
"SONATA",
"BALLAD",
"OPERA",
"HYMN",
"LYRIC",
"CHORUS",
"BRIDGE",
"DUET",
],
}
def seed(): def seed():
@@ -230,7 +20,9 @@ def seed():
Session = get_session_factory(engine) Session = get_session_factory(engine)
session = Session() session = Session()
for cat_name, words in SEED_DATA.items(): word_data = load_words()
for cat_name, words in word_data.items():
existing = session.query(Category).filter_by(name=cat_name).first() existing = session.query(Category).filter_by(name=cat_name).first()
if existing: if existing:
cat = existing cat = existing
+91 -10
View File
@@ -1,12 +1,82 @@
:root { /* Theme variables — Indigo (default) */
:root,
[data-theme="indigo"] {
--indigo-darkest: #1a1a2e; --indigo-darkest: #1a1a2e;
--indigo-dark: #16213e; --indigo-dark: #16213e;
--indigo-mid: #0f3460; --indigo-mid: #0f3460;
--coral: #e94560; --coral: #e94560;
--coral-light: #ff6b81; --coral-light: #ff6b81;
--coral-bg: rgba(233, 69, 96, 0.3);
--gold: #f5c518; --gold: #f5c518;
--text-primary: #e0e0e0; --text-primary: #e0e0e0;
--text-muted: #a0aec0; --text-muted: #a0aec0;
--nav-hover-bg: rgba(15, 52, 96, 0.5);
--heading-color: #ffffff;
--tip-bg: rgba(15, 52, 96, 0.4);
}
/* Theme variables — Dark */
[data-theme="dark"] {
--indigo-darkest: #121212;
--indigo-dark: #1e1e1e;
--indigo-mid: #333333;
--coral: #e94560;
--coral-light: #ff6b81;
--coral-bg: rgba(233, 69, 96, 0.3);
--gold: #f5c518;
--text-primary: #e0e0e0;
--text-muted: #9e9e9e;
--nav-hover-bg: rgba(255, 255, 255, 0.08);
--heading-color: #ffffff;
--tip-bg: rgba(51, 51, 51, 0.6);
}
/* Theme variables — Light */
[data-theme="light"] {
--indigo-darkest: #f5f5f5;
--indigo-dark: #ffffff;
--indigo-mid: #dee2e6;
--coral: #d63851;
--coral-light: #e94560;
--coral-bg: rgba(214, 56, 81, 0.2);
--gold: #d4a017;
--text-primary: #212529;
--text-muted: #6c757d;
--nav-hover-bg: rgba(0, 0, 0, 0.05);
--heading-color: #212529;
--tip-bg: rgba(0, 0, 0, 0.03);
}
/* Theme variables — Ocean */
[data-theme="ocean"] {
--indigo-darkest: #e8f4f8;
--indigo-dark: #ffffff;
--indigo-mid: #b8d8e7;
--coral: #0077b6;
--coral-light: #0096c7;
--coral-bg: rgba(0, 119, 182, 0.15);
--gold: #e07a2f;
--text-primary: #1b3a4b;
--text-muted: #4a7c8f;
--nav-hover-bg: rgba(0, 119, 182, 0.08);
--heading-color: #023e58;
--tip-bg: rgba(0, 119, 182, 0.06);
}
/* Theme variables — Forest */
[data-theme="forest"] {
--indigo-darkest: #1a2e1a;
--indigo-dark: #1e3a1e;
--indigo-mid: #2d5a2d;
--coral: #e6a838;
--coral-light: #f0c060;
--coral-bg: rgba(230, 168, 56, 0.25);
--gold: #e6a838;
--text-primary: #d4e5d4;
--text-muted: #8aab8a;
--nav-hover-bg: rgba(45, 90, 45, 0.5);
--heading-color: #f0f5f0;
--tip-bg: rgba(45, 90, 45, 0.4);
} }
body { body {
@@ -42,12 +112,12 @@ body {
} }
.navbar-nav .nav-link:hover { .navbar-nav .nav-link:hover {
color: #ffffff; color: var(--heading-color);
background-color: rgba(15, 52, 96, 0.5); background-color: var(--nav-hover-bg);
} }
.navbar-nav .nav-link.active { .navbar-nav .nav-link.active {
color: #ffffff; color: var(--heading-color);
background-color: var(--indigo-mid); background-color: var(--indigo-mid);
} }
@@ -63,7 +133,7 @@ body {
} }
.hero h1 { .hero h1 {
color: #ffffff; color: var(--heading-color);
font-weight: 800; font-weight: 800;
} }
@@ -129,7 +199,7 @@ body {
} }
.tip-card { .tip-card {
background-color: rgba(15, 52, 96, 0.4); background-color: var(--tip-bg);
border-left: 3px solid var(--gold); border-left: 3px solid var(--gold);
border-radius: 4px; border-radius: 4px;
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
@@ -170,7 +240,7 @@ body {
} }
.game-grid td.cell-selected { .game-grid td.cell-selected {
background-color: rgba(233, 69, 96, 0.3); background-color: var(--coral-bg);
outline: 2px solid var(--coral); outline: 2px solid var(--coral);
outline-offset: -2px; outline-offset: -2px;
} }
@@ -195,11 +265,11 @@ body {
.card-header { .card-header {
background-color: var(--indigo-mid); background-color: var(--indigo-mid);
border-color: var(--indigo-mid); border-color: var(--indigo-mid);
color: #ffffff; color: var(--heading-color);
font-weight: 600; font-weight: 600;
} }
/* Form controls in dark theme */ /* Form controls */
.form-select, .form-select,
.form-control { .form-control {
background-color: var(--indigo-dark); background-color: var(--indigo-dark);
@@ -212,7 +282,7 @@ body {
background-color: var(--indigo-dark); background-color: var(--indigo-dark);
border-color: var(--coral); border-color: var(--coral);
color: var(--text-primary); color: var(--text-primary);
box-shadow: 0 0 0 0.2rem rgba(233, 69, 96, 0.25); box-shadow: 0 0 0 0.2rem var(--coral-bg);
} }
.form-range::-webkit-slider-thumb { .form-range::-webkit-slider-thumb {
@@ -222,3 +292,14 @@ body {
.form-range::-moz-range-thumb { .form-range::-moz-range-thumb {
background: var(--coral); background: var(--coral);
} }
/* Theme toggle */
#theme-toggle {
border-color: var(--indigo-mid);
color: var(--text-muted);
}
#theme-toggle:hover {
color: var(--coral);
border-color: var(--coral);
}
+40 -3
View File
@@ -6,6 +6,10 @@
let selectedCells = []; let selectedCells = [];
let feedbackTimeout = null; let feedbackTimeout = null;
function titleCase(word) {
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
}
// DOM refs // DOM refs
const setupDiv = document.getElementById("game-setup"); const setupDiv = document.getElementById("game-setup");
const boardDiv = document.getElementById("game-board"); const boardDiv = document.getElementById("game-board");
@@ -26,6 +30,7 @@
const finalTime = document.getElementById("final-time"); const finalTime = document.getElementById("final-time");
const playAgainBtn = document.getElementById("play-again-btn"); const playAgainBtn = document.getElementById("play-again-btn");
const inputArea = document.getElementById("input-area"); const inputArea = document.getElementById("input-area");
const playerNameInput = document.getElementById("player-name");
// Load categories // Load categories
async function loadCategories() { async function loadCategories() {
@@ -59,10 +64,16 @@
const categoryId = parseInt(categorySelect.value); const categoryId = parseInt(categorySelect.value);
const boardSize = parseInt(boardSizeInput.value); const boardSize = parseInt(boardSizeInput.value);
const wordCount = parseInt(wordCountInput.value); const wordCount = parseInt(wordCountInput.value);
const playerName = playerNameInput.value.trim();
const resp = await fetch("/api/game/new", { const resp = await fetch("/api/game/new", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ category_id: categoryId, board_size: boardSize, word_count: wordCount }), body: JSON.stringify({
category_id: categoryId,
board_size: boardSize,
word_count: wordCount,
player_name: playerName,
}),
}); });
if (!resp.ok) { if (!resp.ok) {
const errData = await resp.json().catch(function () { return {}; }); const errData = await resp.json().catch(function () { return {}; });
@@ -110,7 +121,7 @@
gameState.words.forEach(function (w) { gameState.words.forEach(function (w) {
const li = document.createElement("li"); const li = document.createElement("li");
li.className = "list-group-item"; li.className = "list-group-item";
li.textContent = w.text; li.textContent = titleCase(w.text);
li.dataset.word = w.text; li.dataset.word = w.text;
if (w.found) { if (w.found) {
li.classList.add("word-found"); li.classList.add("word-found");
@@ -255,12 +266,14 @@
if (result.correct) { if (result.correct) {
highlightFoundCells(result.cells); highlightFoundCells(result.cells);
renderWordList(); renderWordList();
showFeedback("Found: " + result.word + "!", "text-success"); showFeedback("Found: " + titleCase(result.word) + "!", "text-success");
} else { } else {
showFeedback("Not a match.", "text-danger"); showFeedback("Not a match.", "text-danger");
} }
if (result.status === "completed") { if (result.status === "completed") {
gameState.completed_at = result.completed_at;
gameState.player_name = result.player_name;
onGameComplete(result.completed_at); onGameComplete(result.completed_at);
} }
} catch (err) { } catch (err) {
@@ -294,6 +307,30 @@
finalTime.textContent = timerEl.textContent; finalTime.textContent = timerEl.textContent;
playAgainBtn.classList.remove("d-none"); playAgainBtn.classList.remove("d-none");
inputArea.classList.add("d-none"); inputArea.classList.add("d-none");
var startedAt = new Date(gameState.started_at);
var endedAt = new Date(completedAt);
var seconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
saveRecentGame({
game_id: gameState.id,
player_name: gameState.player_name || "Anonymous",
category: gameState.category,
time_display: timerEl.textContent,
time_seconds: seconds,
board_size: gameState.board_size,
word_count: gameState.words.length,
completed_at: completedAt,
});
}
function saveRecentGame(entry) {
var KEY = "ws-recent-games";
try {
var games = JSON.parse(localStorage.getItem(KEY) || "[]");
games.unshift(entry);
if (games.length > 20) games = games.slice(0, 20);
localStorage.setItem(KEY, JSON.stringify(games));
} catch (e) { /* localStorage unavailable */ }
} }
playAgainBtn.addEventListener("click", function () { playAgainBtn.addEventListener("click", function () {
+158
View File
@@ -0,0 +1,158 @@
(function () {
"use strict";
var loadingDiv = document.getElementById("scoreboard-loading");
var recentSection = document.getElementById("recent-games-section");
var recentTbody = document.querySelector("#recent-games-table tbody");
var popularSection = document.getElementById("popular-section");
var popularRow = document.getElementById("popular-row");
var leaderboardsSection = document.getElementById("leaderboards-section");
var leaderboardsContainer = document.getElementById("leaderboards-container");
var emptyDiv = document.getElementById("scoreboard-empty");
function formatTime(seconds) {
var mins = Math.floor(seconds / 60);
var secs = Math.floor(seconds % 60);
return mins + ":" + (secs < 10 ? "0" : "") + secs;
}
function formatDate(iso) {
return new Date(iso).toLocaleDateString();
}
function titleCase(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
function escapeHtml(str) {
var div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
// Recent games from localStorage
function getRecentGames() {
try {
return JSON.parse(localStorage.getItem("ws-recent-games") || "[]");
} catch (e) {
return [];
}
}
function renderRecentGames(games) {
recentTbody.innerHTML = "";
games.forEach(function (g) {
var tr = document.createElement("tr");
tr.innerHTML =
"<td>" + escapeHtml(titleCase(g.category)) + "</td>" +
"<td>" + escapeHtml(g.player_name) + "</td>" +
"<td>" + escapeHtml(g.time_display || formatTime(g.time_seconds)) + "</td>" +
"<td>" + g.board_size + "x" + g.board_size + "</td>" +
"<td>" + g.word_count + "</td>" +
"<td>" + formatDate(g.completed_at) + "</td>";
recentTbody.appendChild(tr);
});
}
function renderPopularCategories(categories) {
popularRow.innerHTML = "";
var active = categories.filter(function (c) { return c.completed_games > 0; });
active.forEach(function (c) {
var col = document.createElement("div");
col.className = "col-md-4 col-lg-3";
col.innerHTML =
'<div class="card text-center">' +
' <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>' +
' </div>' +
'</div>';
popularRow.appendChild(col);
});
}
function renderLeaderboards(leaderboards) {
leaderboardsContainer.innerHTML = "";
var names = Object.keys(leaderboards);
names.sort();
names.forEach(function (catName) {
var entries = leaderboards[catName];
var card = document.createElement("div");
card.className = "card mb-3";
var header = '<div class="card-header">' +
'<i class="bi bi-trophy"></i> ' + escapeHtml(titleCase(catName)) +
'</div>';
var rows = "";
entries.forEach(function (e, i) {
rows +=
"<tr>" +
"<td>" + (i + 1) + "</td>" +
"<td>" + escapeHtml(e.player_name) + "</td>" +
"<td>" + formatTime(e.time_seconds) + "</td>" +
"<td>" + e.board_size + "x" + e.board_size + "</td>" +
"<td>" + e.word_count + "</td>" +
"<td>" + formatDate(e.completed_at) + "</td>" +
"</tr>";
});
card.innerHTML = header +
'<div class="card-body p-0">' +
'<div class="table-responsive">' +
'<table class="table table-hover mb-0">' +
'<thead><tr>' +
'<th>#</th><th>Player</th><th>Time</th>' +
'<th>Board</th><th>Words</th><th>Date</th>' +
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table></div></div>';
leaderboardsContainer.appendChild(card);
});
}
async function loadScoreboard() {
try {
var resp = await fetch("/api/scoreboard");
if (!resp.ok) throw new Error("Failed to load");
var data = await resp.json();
loadingDiv.classList.add("d-none");
// Recent games from localStorage
var recent = getRecentGames();
if (recent.length > 0) {
renderRecentGames(recent);
recentSection.classList.remove("d-none");
}
// Popular categories
var hasCompleted = data.popular_categories.some(function (c) {
return c.completed_games > 0;
});
if (hasCompleted) {
renderPopularCategories(data.popular_categories);
popularSection.classList.remove("d-none");
}
// Leaderboards
var hasLeaderboards = Object.keys(data.leaderboards).length > 0;
if (hasLeaderboards) {
renderLeaderboards(data.leaderboards);
leaderboardsSection.classList.remove("d-none");
}
// Empty state
if (!hasCompleted && !hasLeaderboards && recent.length === 0) {
emptyDiv.classList.remove("d-none");
}
} catch (err) {
loadingDiv.innerHTML =
'<p class="text-danger">Failed to load scoreboard data.</p>';
}
}
loadScoreboard();
})();
+34
View File
@@ -0,0 +1,34 @@
(function () {
"use strict";
var themes = ["indigo", "dark", "light", "ocean", "forest"];
var icons = {
indigo: "bi-moon-stars",
dark: "bi-circle-half",
light: "bi-sun",
ocean: "bi-water",
forest: "bi-tree"
};
var lightThemes = { light: true, ocean: true };
var btn = document.getElementById("theme-toggle");
var current = localStorage.getItem("ws-theme") || "indigo";
function apply(theme) {
document.documentElement.setAttribute("data-theme", theme);
document.documentElement.setAttribute(
"data-bs-theme",
lightThemes[theme] ? "light" : "dark"
);
btn.querySelector("i").className = "bi " + icons[theme];
btn.title = theme.charAt(0).toUpperCase() + theme.slice(1) + " theme";
localStorage.setItem("ws-theme", theme);
current = theme;
}
apply(current);
btn.addEventListener("click", function () {
var idx = (themes.indexOf(current) + 1) % themes.length;
apply(themes[idx]);
});
})();
+19 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-bs-theme="dark"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -11,6 +11,13 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
rel="stylesheet"> rel="stylesheet">
<link rel="stylesheet" href="{{ static_url('css/theme.css') }}"> <link rel="stylesheet" href="{{ static_url('css/theme.css') }}">
<script>
(function() {
var t = localStorage.getItem("ws-theme") || "indigo";
document.documentElement.setAttribute("data-theme", t);
document.documentElement.setAttribute("data-bs-theme", (t === "light" || t === "ocean") ? "light" : "dark");
})();
</script>
</head> </head>
<body> <body>
{% block active_page %}{% end %} {% block active_page %}{% end %}
@@ -44,6 +51,12 @@
<i class="bi bi-question-circle"></i> How to Play <i class="bi bi-question-circle"></i> How to Play
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link {% if active_page == 'scoreboard' %}active{% end %}"
href="/scoreboard">
<i class="bi bi-trophy"></i> Scoreboard
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if active_page == 'about' %}active{% end %}" <a class="nav-link {% if active_page == 'about' %}active{% end %}"
href="/about"> href="/about">
@@ -51,6 +64,10 @@
</a> </a>
</li> </li>
</ul> </ul>
<button id="theme-toggle" class="btn btn-sm btn-outline-secondary ms-2"
title="Switch theme">
<i class="bi bi-moon-stars"></i>
</button>
</div> </div>
</div> </div>
</nav> </nav>
@@ -68,5 +85,6 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script src="{{ static_url('js/theme.js') }}"></script>
</body> </body>
</html> </html>
+8
View File
@@ -10,6 +10,14 @@
<h2 class="mb-4">New Game</h2> <h2 class="mb-4">New Game</h2>
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="player-name" class="form-label">Player Name</label>
<input type="text" id="player-name" class="form-control"
placeholder="Anonymous" maxlength="20"
autocomplete="off">
</div>
</div>
<div class="row g-3 align-items-end"> <div class="row g-3 align-items-end">
<div class="col-md-3"> <div class="col-md-3">
<label for="category-select" class="form-label">Category</label> <label for="category-select" class="form-label">Category</label>
+55
View File
@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block active_page %}{% set active_page = "scoreboard" %}{% end %}
{% block title %}Word Search — Scoreboard{% end %}
{% block content %}
<h2 class="mb-4"><i class="bi bi-trophy"></i> Scoreboard</h2>
<div id="scoreboard-loading" class="text-center py-4">
<div class="spinner-border text-secondary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div id="recent-games-section" class="d-none mb-4">
<h3 class="mb-3"><i class="bi bi-clock-history"></i> Your Recent Games</h3>
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0" id="recent-games-table">
<thead>
<tr>
<th>Category</th>
<th>Player</th>
<th>Time</th>
<th>Board</th>
<th>Words</th>
<th>Date</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<div id="popular-section" class="d-none mb-4">
<h3 class="mb-3"><i class="bi bi-fire"></i> Popular Categories</h3>
<div class="row g-3" id="popular-row"></div>
</div>
<div id="leaderboards-section" class="d-none">
<h3 class="mb-3"><i class="bi bi-list-ol"></i> Leaderboards</h3>
<div id="leaderboards-container"></div>
</div>
<div id="scoreboard-empty" class="d-none text-center py-4">
<p class="text-muted fs-5">No completed games yet. Be the first to finish a puzzle!</p>
<a href="/game" class="btn btn-accent">Start a Game</a>
</div>
<script src="{{ static_url('js/scoreboard.js') }}"></script>
{% end %}