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:
co-authored by
Claude Opus 4.6
parent
cd1dc9200b
commit
9319f08875
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 () {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
Reference in New Issue
Block a user