diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6634400 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Secret key for signing session cookies — use a long random string in production +SECRET_KEY=change-me-to-a-long-random-string + +# Email address of the admin user +ADMIN_EMAIL=admin@example.com + +# Base URL used to build magic links (no trailing slash) +SITE_URL=http://localhost:8000 + +# development | production +ENV=development + +# Path to the SQLite database file (inside the container) +DATABASE_PATH=/data/skinspins.db + +# SMTP email settings (leave SMTP_HOST empty to disable email and log links instead) +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=your@email.com +SMTP_PASSWORD=your-smtp-password +SMTP_FROM=noreply@example.com +# Use implicit SSL/TLS (port 465). Set SMTP_TLS=false when using SSL. +SMTP_SSL=false +# Use STARTTLS (port 587). Set to "false" when using SSL. +SMTP_TLS=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ea68b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +*.pyo +.env +data/ +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md index ea6e0ae..1be0e89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,5 +4,45 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What This Is -Skins&Pins is a social golf score tracking PWA. Multiple players in a group track scores per hole in real-time. Anyone in a group can enter or edit scores for any player. Built as a self-hosted Python app running in Docker Compose with a single PostgreSQL database and traefik as a Proxy. The PWA has optimizations for Mobile use. +Skins&Pins is a social golf score tracking PWA. Multiple players in a group track scores per hole in real-time. Anyone in a group can enter or edit scores for any player. Built as a self-hosted Python app running in Docker Compose with a single SQLite database and traefik as a Proxy. The PWA has optimizations for Mobile use. + +## Language + +All backend code must be written in Python. Do not introduce other backend languages. + +## Features + +- **Navigation:** Bottom navigation bar with large icons, optimized for thumb reach on mobile +- **Authentication:** Passwordless login via magic link (email weblink); in development the magic link is printed to the log instead of being emailed +- **Admin user:** Single admin user defined by email address in the `.env` file; has access to an additional Admin menu not visible to regular users +- **User invitations:** Admin invites users by email address via the Admin menu; only invited users can log in +- **Onboarding:** On first login, user sets their Name, Handicap, and Avatar +- **Profile:** Name, Handicap, and Avatar can be updated at any time via a Profile menu +- **Dashboard:** Landing page after login; shows the user's last played rounds/games with results and statistics derived from game data (e.g. scores, skins won, handicap trend, best/worst holes) +- **Game formats:** Architecture must support multiple formats. Only one format is implemented initially: Bingo Bango Bongo +- **Round length:** Players choose 9 or 18 holes when starting a game +- **Hole skipping:** Players can skip a hole; skipped holes are excluded from scoring +- **Score entry UI:** A swipe gesture toggles between the score entry view (current hole) and the score overview (all holes, running totals) + +## Game Format: Bingo Bango Bongo + +Three points are available per hole: +- **Bingo:** First player to get their ball on the green +- **Bango:** Player whose ball is closest to the pin once all balls are on the green; if multiple players are equidistant the point is split equally between them — Bango must support awarding fractional points to multiple players +- **Bongo:** First player to hole out + +Players play in order of furthest from the hole (standard golf rules). Any number of players can participate. Points are tallied across all 18 holes; highest total wins. + +## Design Principles + +- Simple and clean — prefer straightforward solutions over clever ones +- No unnecessary functions, abstractions, or helpers +- Only build what is needed for the current feature; do not design for hypothetical future requirements + +## Stack + +- **Backend:** FastAPI (async, ASGI via Uvicorn) +- **Database:** SQLite +- **Frontend:** HTMX + Alpine.js + Jinja2 templates (no build step, no npm) +- **Real-time:** WebSocket for live score updates across players in the same group diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..60ea61d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ +COPY static/ ./static/ + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..ffc39cc --- /dev/null +++ b/app/auth.py @@ -0,0 +1,93 @@ +import os +import secrets +import logging +from datetime import datetime, timedelta, timezone + +import aiosqlite + +from app.email import send_magic_link + +logger = logging.getLogger(__name__) + +SITE_URL = os.getenv("SITE_URL", "http://localhost:8000") +ENV = os.getenv("ENV", "development") +TOKEN_TTL_MINUTES = 15 +SESSION_TTL_DAYS = 30 + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _in(minutes: int = 0, days: int = 0) -> str: + delta = timedelta(minutes=minutes, days=days) + return (datetime.now(timezone.utc) + delta).isoformat() + + +def _expired(iso: str) -> bool: + return datetime.now(timezone.utc) > datetime.fromisoformat(iso) + + +async def create_magic_token(email: str, db: aiosqlite.Connection) -> str: + # Clean up old unused tokens for this email + await db.execute( + "DELETE FROM magic_link_tokens WHERE email = ? AND used_at IS NULL", + (email,), + ) + + token_id = secrets.token_urlsafe(16) + token = secrets.token_urlsafe(32) + expires_at = _in(minutes=TOKEN_TTL_MINUTES) + + await db.execute( + "INSERT INTO magic_link_tokens (id, email, token, expires_at) VALUES (?, ?, ?, ?)", + (token_id, email, token, expires_at), + ) + await db.commit() + + link = f"{SITE_URL}/auth/verify?token={token}" + await send_magic_link(email, link) + return token + + +async def verify_magic_token(token: str, db: aiosqlite.Connection): + """Returns the email if token is valid and unused, else None.""" + async with db.execute( + "SELECT email, expires_at, used_at FROM magic_link_tokens WHERE token = ?", + (token,), + ) as cursor: + row = await cursor.fetchone() + + if row is None: + return None + if row["used_at"] is not None: + return None + if _expired(row["expires_at"]): + return None + + # Mark token as used + await db.execute( + "UPDATE magic_link_tokens SET used_at = ? WHERE token = ?", + (_now(), token), + ) + await db.commit() + + return row["email"] + + +async def create_session(user_id: str, db: aiosqlite.Connection) -> str: + session_id = secrets.token_urlsafe(16) + token = secrets.token_urlsafe(32) + expires_at = _in(days=SESSION_TTL_DAYS) + + await db.execute( + "INSERT INTO sessions (id, user_id, token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)", + (session_id, user_id, token, expires_at, _now()), + ) + await db.commit() + return token + + +async def delete_session(token: str, db: aiosqlite.Connection): + await db.execute("DELETE FROM sessions WHERE token = ?", (token,)) + await db.commit() diff --git a/app/avatar.py b/app/avatar.py new file mode 100644 index 0000000..8c2b815 --- /dev/null +++ b/app/avatar.py @@ -0,0 +1,29 @@ +import os +from pathlib import Path + +from fastapi import UploadFile + +AVATARS_DIR = Path(os.getenv("DATABASE_PATH", "/data/skinspins.db")).parent / "avatars" +ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} +MAX_SIZE = 2 * 1024 * 1024 # 2 MB + + +async def save_avatar(file: UploadFile, user_id: str) -> tuple[str | None, str | None]: + """Returns (url_path, error). url_path is like /avatars/user_id.jpg""" + if file.content_type not in ALLOWED_TYPES: + return None, "Only JPEG, PNG, WebP or GIF images are allowed." + + data = await file.read() + if len(data) > MAX_SIZE: + return None, "Image must be smaller than 2 MB." + + ext = file.content_type.split("/")[-1].replace("jpeg", "jpg") + filename = f"{user_id}.{ext}" + dest = AVATARS_DIR / filename + + # Remove any previous avatar for this user + for old in AVATARS_DIR.glob(f"{user_id}.*"): + old.unlink(missing_ok=True) + + dest.write_bytes(data) + return f"/avatars/{filename}", None diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..793a5fb --- /dev/null +++ b/app/database.py @@ -0,0 +1,128 @@ +import os +import aiosqlite + +DATABASE_PATH = os.getenv("DATABASE_PATH", "/data/skinspins.db") + + +async def get_db(): + async with aiosqlite.connect(DATABASE_PATH) as db: + db.row_factory = aiosqlite.Row + yield db + + +async def init_db(): + async with aiosqlite.connect(DATABASE_PATH) as db: + await db.executescript(""" + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + name TEXT, + handicap REAL, + avatar TEXT, + is_onboarded INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS invitations ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + invited_by TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (invited_by) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS magic_link_tokens ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + token TEXT UNIQUE NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + token TEXT UNIQUE NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS courses ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + holes INTEGER NOT NULL DEFAULT 18, + location TEXT, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS course_holes ( + id TEXT PRIMARY KEY, + course_id TEXT NOT NULL, + hole_number INTEGER NOT NULL, + par INTEGER NOT NULL DEFAULT 4, + UNIQUE (course_id, hole_number), + FOREIGN KEY (course_id) REFERENCES courses(id) + ); + + CREATE TABLE IF NOT EXISTS course_tees ( + id TEXT PRIMARY KEY, + course_id TEXT NOT NULL, + name TEXT NOT NULL, + slope INTEGER NOT NULL, + rating REAL NOT NULL, + FOREIGN KEY (course_id) REFERENCES courses(id) + ); + + CREATE TABLE IF NOT EXISTS games ( + id TEXT PRIMARY KEY, + format TEXT NOT NULL DEFAULT 'bingo_bango_bongo', + holes_count INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'lobby', + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + course_id TEXT, + FOREIGN KEY (created_by) REFERENCES users(id), + FOREIGN KEY (course_id) REFERENCES courses(id) + ); + + CREATE TABLE IF NOT EXISTS game_players ( + id TEXT PRIMARY KEY, + game_id TEXT NOT NULL, + user_id TEXT NOT NULL, + handicap_snapshot REAL, + joined_at TEXT NOT NULL, + UNIQUE (game_id, user_id), + FOREIGN KEY (game_id) REFERENCES games(id), + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS hole_scores ( + id TEXT PRIMARY KEY, + game_id TEXT NOT NULL, + hole_number INTEGER NOT NULL, + user_id TEXT NOT NULL, + strokes INTEGER, + bingo INTEGER NOT NULL DEFAULT 0, + bango REAL NOT NULL DEFAULT 0.0, + bongo INTEGER NOT NULL DEFAULT 0, + updated_by TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (game_id, hole_number, user_id), + FOREIGN KEY (game_id) REFERENCES games(id), + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (updated_by) REFERENCES users(id) + ); + """) + await db.commit() + # Migrations for columns added after initial release + for migration in [ + "ALTER TABLE games ADD COLUMN course_id TEXT REFERENCES courses(id)", + "ALTER TABLE game_players ADD COLUMN tee_id TEXT REFERENCES course_tees(id)", + "ALTER TABLE course_holes ADD COLUMN stroke_index INTEGER", + ]: + try: + await db.execute(migration) + await db.commit() + except Exception: + pass # column already exists diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..706a692 --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,55 @@ +import os +from datetime import datetime, timezone + +from fastapi import Request, Depends +import aiosqlite + +from app.database import get_db + +ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "") +SESSION_COOKIE = "session" + + +class NeedsLogin(Exception): + pass + + +class NeedsOnboarding(Exception): + pass + + +class NeedsAdmin(Exception): + pass + + +async def get_current_user(request: Request, db: aiosqlite.Connection = Depends(get_db)): + token = request.cookies.get(SESSION_COOKIE) + if not token: + raise NeedsLogin() + + async with db.execute( + """ + SELECT u.id, u.email, u.name, u.handicap, u.avatar, u.is_onboarded + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.token = ? AND s.expires_at > ? + """, + (token, datetime.now(timezone.utc).isoformat()), + ) as cursor: + row = await cursor.fetchone() + + if row is None: + raise NeedsLogin() + + user = dict(row) + + if not user["is_onboarded"] and request.url.path not in ("/onboarding", "/logout"): + raise NeedsOnboarding() + + return user + + +async def require_admin(user: dict = Depends(get_current_user)): + if user["email"] != ADMIN_EMAIL: + raise NeedsAdmin() + return user diff --git a/app/email.py b/app/email.py new file mode 100644 index 0000000..db21332 --- /dev/null +++ b/app/email.py @@ -0,0 +1,96 @@ +"""Async email sending via SMTP. Falls back to logging if SMTP is not configured.""" +import logging +import os +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +import aiosmtplib + +logger = logging.getLogger(__name__) + +SMTP_HOST = os.getenv("SMTP_HOST", "") +SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) +SMTP_USER = os.getenv("SMTP_USER", "") +SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") +SMTP_FROM = os.getenv("SMTP_FROM", SMTP_USER) +SMTP_SSL = os.getenv("SMTP_SSL", "false").lower() == "true" +SMTP_TLS = os.getenv("SMTP_TLS", "true").lower() != "false" + +SITE_NAME = "Skins & Pins" + + +def _configured() -> bool: + return bool(SMTP_HOST and SMTP_USER and SMTP_FROM) + + +async def _send(to: str, subject: str, body_text: str, body_html: str) -> None: + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = f"{SITE_NAME} <{SMTP_FROM}>" + msg["To"] = to + msg.attach(MIMEText(body_text, "plain")) + msg.attach(MIMEText(body_html, "html")) + + await aiosmtplib.send( + msg, + hostname=SMTP_HOST, + port=SMTP_PORT, + username=SMTP_USER, + password=SMTP_PASSWORD, + use_tls=SMTP_SSL, + start_tls=(SMTP_TLS and not SMTP_SSL), + timeout=10, + ) + + +async def send_magic_link(to: str, link: str) -> None: + subject = f"Your {SITE_NAME} login link" + body_text = f"Click the link below to log in (expires in 15 minutes):\n\n{link}\n\nIf you didn't request this, you can ignore this email." + body_html = f""" +
+

⛳ {SITE_NAME}

+

Click the button below to log in. This link expires in 15 minutes.

+

+ + Log in to {SITE_NAME} + +

+

If the button doesn't work, copy this link:
{link}

+

If you didn't request this, you can ignore this email.

+
""" + + if _configured(): + try: + await _send(to, subject, body_text, body_html) + logger.info("Magic link email sent to %s", to) + except Exception as e: + logger.error("Failed to send magic link email to %s: %s", to, e) + else: + logger.info("Magic link for %s: %s", to, link) + + +async def send_invitation(to: str, invited_by_name: str, login_url: str) -> None: + subject = f"You've been invited to {SITE_NAME}" + body_text = f"{invited_by_name} has invited you to join {SITE_NAME}.\n\nLog in here:\n{login_url}" + body_html = f""" +
+

⛳ {SITE_NAME}

+

{invited_by_name} has invited you to join {SITE_NAME} — a golf score tracking app.

+

+ + Accept invitation + +

+

If the button doesn't work, copy this link:
{login_url}

+
""" + + if _configured(): + try: + await _send(to, subject, body_text, body_html) + logger.info("Invitation email sent to %s", to) + except Exception as e: + logger.error("Failed to send invitation email to %s: %s", to, e) + else: + logger.info("Invitation for %s — login at: %s", to, login_url) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..4a50b07 --- /dev/null +++ b/app/main.py @@ -0,0 +1,69 @@ +import logging +import os +from contextlib import asynccontextmanager +from pathlib import Path + +from dotenv import load_dotenv +load_dotenv() + +from fastapi import FastAPI, Request, Response +from fastapi.responses import RedirectResponse +from fastapi.staticfiles import StaticFiles +from starlette.middleware.sessions import SessionMiddleware + +from app.database import init_db +from app.dependencies import NeedsLogin, NeedsOnboarding, NeedsAdmin +from app.routers import auth, onboarding, dashboard, admin, profile, games, courses, users + +logging.basicConfig(level=logging.INFO) + +AVATARS_DIR = Path(os.getenv("DATABASE_PATH", "/data/skinspins.db")).parent / "avatars" +AVATARS_DIR.mkdir(parents=True, exist_ok=True) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + yield + + +app = FastAPI(lifespan=lifespan) + +app.add_middleware(SessionMiddleware, secret_key=os.getenv("SECRET_KEY", "dev-secret")) + +app.mount("/static", StaticFiles(directory="static"), name="static") +app.mount("/avatars", StaticFiles(directory=str(AVATARS_DIR)), name="avatars") + +app.include_router(auth.router) +app.include_router(onboarding.router) +app.include_router(dashboard.router) +app.include_router(admin.router) +app.include_router(profile.router) +app.include_router(games.router) +app.include_router(courses.router) +app.include_router(users.router) + + +@app.exception_handler(NeedsLogin) +async def needs_login_handler(request: Request, exc: NeedsLogin): + response = RedirectResponse("/login", status_code=302) + response.delete_cookie("session") + return response + + +@app.exception_handler(NeedsOnboarding) +async def needs_onboarding_handler(request: Request, exc: NeedsOnboarding): + return RedirectResponse("/onboarding", status_code=302) + + +@app.exception_handler(NeedsAdmin) +async def needs_admin_handler(request: Request, exc: NeedsAdmin): + return RedirectResponse("/", status_code=302) + + +@app.middleware("http") +async def security_headers(request: Request, call_next): + response: Response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + return response diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/admin.py b/app/routers/admin.py new file mode 100644 index 0000000..df5cbe8 --- /dev/null +++ b/app/routers/admin.py @@ -0,0 +1,271 @@ +import os +import secrets +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, Form, Request, UploadFile, File +from fastapi.responses import HTMLResponse, RedirectResponse +import aiosqlite + +from app.database import get_db +from app.dependencies import require_admin +from app.email import send_invitation +from app.templates_config import templates +from app.routers.onboarding import AVATARS +from app.avatar import save_avatar + +router = APIRouter() + + +async def _list_context(db: aiosqlite.Connection): + async with db.execute( + "SELECT id, email, created_at FROM invitations ORDER BY created_at DESC" + ) as c: + invitations = await c.fetchall() + async with db.execute( + "SELECT id, email, name, avatar, handicap, created_at FROM users ORDER BY created_at DESC" + ) as c: + users = await c.fetchall() + return invitations, users + + +@router.get("/admin", response_class=HTMLResponse) +async def admin_page(request: Request, user: dict = Depends(require_admin), + db: aiosqlite.Connection = Depends(get_db)): + invitations, users = await _list_context(db) + return templates.TemplateResponse( + "admin/admin.html", + {"request": request, "user": user, "invitations": invitations, "users": users}, + ) + + +@router.post("/admin/invite", response_class=HTMLResponse) +async def admin_invite(request: Request, email: str = Form(...), + user: dict = Depends(require_admin), + db: aiosqlite.Connection = Depends(get_db)): + email = email.strip().lower() + error = None + + async with db.execute("SELECT id FROM invitations WHERE email = ?", (email,)) as c: + if await c.fetchone(): + error = f"{email} has already been invited." + + if not error: + async with db.execute("SELECT id FROM users WHERE email = ?", (email,)) as c: + if await c.fetchone(): + error = f"{email} is already a registered user." + + if not error: + await db.execute( + "INSERT INTO invitations (id, email, invited_by, created_at) VALUES (?, ?, ?, ?)", + (secrets.token_urlsafe(16), email, user["id"], datetime.now(timezone.utc).isoformat()), + ) + await db.commit() + login_url = f"{os.getenv('SITE_URL', 'http://localhost:8000')}/login" + await send_invitation(email, user.get("name") or "Someone", login_url) + + invitations, users = await _list_context(db) + return templates.TemplateResponse( + "admin/admin.html", + {"request": request, "user": user, "invitations": invitations, "users": users, + "error": error, "success": None if error else f"Invitation sent to {email}."}, + ) + + +@router.post("/admin/invitations/{inv_id}/delete") +async def delete_invitation(inv_id: str, user: dict = Depends(require_admin), + db: aiosqlite.Connection = Depends(get_db)): + await db.execute("DELETE FROM invitations WHERE id = ?", (inv_id,)) + await db.commit() + return RedirectResponse("/admin?success=Invitation+deleted.", status_code=302) + + +@router.post("/admin/invitations/{inv_id}/resend") +async def resend_invitation(inv_id: str, user: dict = Depends(require_admin), + db: aiosqlite.Connection = Depends(get_db)): + async with db.execute("SELECT email FROM invitations WHERE id = ?", (inv_id,)) as c: + row = await c.fetchone() + if row: + login_url = f"{os.getenv('SITE_URL', 'http://localhost:8000')}/login" + await send_invitation(row["email"], user.get("name") or "Admin", login_url) + return RedirectResponse("/admin?success=Invitation+resent.", status_code=302) + + +@router.get("/admin/users/new", response_class=HTMLResponse) +async def new_user_page(request: Request, user: dict = Depends(require_admin)): + return templates.TemplateResponse( + "admin/new_user.html", + {"request": request, "user": user, "avatars": AVATARS}, + ) + + +@router.post("/admin/users/new", response_class=HTMLResponse) +async def new_user_submit(request: Request, + name: str = Form(...), email: str = Form(...), + handicap: float = Form(...), + avatar_emoji: str = Form(""), + avatar_file: UploadFile = File(None), + user: dict = Depends(require_admin), + db: aiosqlite.Connection = Depends(get_db)): + name = name.strip() + email = email.strip().lower() + errors = {} + if not name: + errors["name"] = "Name is required." + if not email: + errors["email"] = "Email is required." + if not (0.0 <= handicap <= 54.0): + errors["handicap"] = "Handicap must be between 0 and 54." + + if not errors: + async with db.execute("SELECT id FROM users WHERE email = ?", (email,)) as c: + if await c.fetchone(): + errors["email"] = f"{email} is already registered." + + uid = secrets.token_urlsafe(16) + avatar = None + if avatar_file and avatar_file.filename: + avatar, err = await save_avatar(avatar_file, uid) + if err: + errors["avatar"] = err + elif avatar_emoji in AVATARS: + avatar = avatar_emoji + + if errors: + return templates.TemplateResponse( + "admin/new_user.html", + {"request": request, "user": user, "avatars": AVATARS, "errors": errors, + "form": {"name": name, "email": email, "handicap": handicap}}, + status_code=422, + ) + + now = datetime.now(timezone.utc).isoformat() + await db.execute( + """INSERT INTO users (id, email, name, handicap, avatar, is_onboarded, created_at) + VALUES (?, ?, ?, ?, ?, 1, ?)""", + (uid, email, name, handicap, avatar, now), + ) + await db.commit() + return RedirectResponse("/admin?success=User+added.", status_code=302) + + +@router.get("/admin/users/{uid}/edit", response_class=HTMLResponse) +async def edit_user_page(uid: str, request: Request, user: dict = Depends(require_admin), + db: aiosqlite.Connection = Depends(get_db)): + async with db.execute( + "SELECT id, email, name, handicap, avatar FROM users WHERE id = ?", (uid,) + ) as c: + target = await c.fetchone() + if not target: + return RedirectResponse("/admin", status_code=302) + return templates.TemplateResponse( + "admin/edit_user.html", + {"request": request, "user": user, "target": dict(target), "avatars": AVATARS}, + ) + + +@router.post("/admin/users/{uid}/edit", response_class=HTMLResponse) +async def edit_user_submit(uid: str, request: Request, + name: str = Form(...), email: str = Form(...), + handicap: float = Form(...), + avatar_emoji: str = Form(""), + avatar_file: UploadFile = File(None), + user: dict = Depends(require_admin), + db: aiosqlite.Connection = Depends(get_db)): + async with db.execute( + "SELECT id, email, name, handicap, avatar FROM users WHERE id = ?", (uid,) + ) as c: + target = await c.fetchone() + if not target: + return RedirectResponse("/admin", status_code=302) + + errors = {} + name = name.strip() + email = email.strip().lower() + if not name: + errors["name"] = "Name is required." + if not email: + errors["email"] = "Email is required." + if not (0.0 <= handicap <= 54.0): + errors["handicap"] = "Handicap must be between 0 and 54." + + avatar = target["avatar"] + if avatar_file and avatar_file.filename: + avatar, err = await save_avatar(avatar_file, uid) + if err: + errors["avatar"] = err + elif avatar_emoji in AVATARS: + avatar = avatar_emoji + + if errors: + return templates.TemplateResponse( + "admin/edit_user.html", + {"request": request, "user": user, "target": dict(target), + "avatars": AVATARS, "errors": errors}, + ) + + await db.execute( + "UPDATE users SET name = ?, email = ?, handicap = ?, avatar = ? WHERE id = ?", + (name, email, handicap, avatar, uid), + ) + await db.commit() + return RedirectResponse("/admin?success=User+updated.", status_code=302) + + +@router.post("/admin/users/{uid}/delete") +async def delete_user(uid: str, user: dict = Depends(require_admin), + db: aiosqlite.Connection = Depends(get_db)): + # Don't allow deleting yourself + if uid == user["id"]: + return RedirectResponse("/admin?error=Cannot+delete+your+own+account.", status_code=302) + await db.execute("DELETE FROM sessions WHERE user_id = ?", (uid,)) + await db.execute("DELETE FROM game_players WHERE user_id = ?", (uid,)) + await db.execute("DELETE FROM hole_scores WHERE user_id = ?", (uid,)) + await db.execute("DELETE FROM users WHERE id = ?", (uid,)) + await db.commit() + return RedirectResponse("/admin?success=User+deleted.", status_code=302) + + +# ── Admin: Games ────────────────────────────────────────────────────────────── + +@router.get("/admin/games", response_class=HTMLResponse) +async def admin_games_page(request: Request, user: dict = Depends(require_admin), + db: aiosqlite.Connection = Depends(get_db)): + async with db.execute( + """SELECT g.id, g.holes_count, g.status, g.created_at, + c.name as course_name, u.name as creator_name + FROM games g + JOIN users u ON g.created_by = u.id + LEFT JOIN courses c ON c.id = g.course_id + ORDER BY g.created_at DESC""" + ) as cur: + games = [dict(r) for r in await cur.fetchall()] + + # Fetch all players grouped by game_id + async with db.execute( + """SELECT gp.game_id, u.id AS user_id, u.name, u.avatar + FROM game_players gp JOIN users u ON gp.user_id = u.id + ORDER BY gp.joined_at""" + ) as cur: + player_rows = await cur.fetchall() + + players_by_game: dict[str, list] = {} + for r in player_rows: + players_by_game.setdefault(r["game_id"], []).append( + {"id": r["user_id"], "name": r["name"], "avatar": r["avatar"]} + ) + + return templates.TemplateResponse( + "admin/games.html", + {"request": request, "user": user, "games": games, + "players_by_game": players_by_game}, + ) + + +@router.post("/admin/games/{gid}/delete") +async def admin_delete_game(gid: str, user: dict = Depends(require_admin), + db: aiosqlite.Connection = Depends(get_db)): + await db.execute("DELETE FROM hole_scores WHERE game_id = ?", (gid,)) + await db.execute("DELETE FROM game_players WHERE game_id = ?", (gid,)) + await db.execute("DELETE FROM games WHERE id = ?", (gid,)) + await db.commit() + return RedirectResponse("/admin/games?success=Game+deleted.", status_code=302) diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..b716ca4 --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,105 @@ +import os +import secrets +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse +import aiosqlite + +from app.database import get_db +from app.auth import create_magic_token, verify_magic_token, create_session, delete_session +from app.templates_config import templates + +router = APIRouter() + +ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "") +SESSION_COOKIE = "session" +SESSION_TTL_DAYS = 30 + + +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request): + if request.cookies.get(SESSION_COOKIE): + return RedirectResponse("/", status_code=302) + return templates.TemplateResponse("auth/login.html", {"request": request}) + + +@router.post("/login", response_class=HTMLResponse) +async def login_submit( + request: Request, + email: str = Form(...), + db: aiosqlite.Connection = Depends(get_db), +): + email = email.strip().lower() + + # Check email is admin or invited + if email != ADMIN_EMAIL: + async with db.execute( + "SELECT id FROM invitations WHERE email = ?", (email,) + ) as cursor: + invitation = await cursor.fetchone() + if invitation is None: + return templates.TemplateResponse( + "auth/login.html", + {"request": request, "error": "This email address has not been invited."}, + ) + + await create_magic_token(email, db) + + return templates.TemplateResponse("auth/check_email.html", {"request": request, "email": email}) + + +@router.get("/auth/verify", response_class=HTMLResponse) +async def auth_verify( + request: Request, + token: str, + db: aiosqlite.Connection = Depends(get_db), +): + email = await verify_magic_token(token, db) + + if email is None: + return templates.TemplateResponse( + "auth/login.html", + {"request": request, "error": "This link is invalid or has expired. Please request a new one."}, + ) + + # Get or create user + async with db.execute("SELECT id, is_onboarded FROM users WHERE email = ?", (email,)) as cursor: + user = await cursor.fetchone() + + if user is None: + user_id = secrets.token_urlsafe(16) + now = datetime.now(timezone.utc).isoformat() + await db.execute( + "INSERT INTO users (id, email, created_at) VALUES (?, ?, ?)", + (user_id, email, now), + ) + await db.execute("DELETE FROM invitations WHERE email = ?", (email,)) + await db.commit() + is_onboarded = False + else: + user_id = user["id"] + is_onboarded = bool(user["is_onboarded"]) + + session_token = await create_session(user_id, db) + + redirect_to = "/" if is_onboarded else "/onboarding" + response = RedirectResponse(redirect_to, status_code=302) + response.set_cookie( + SESSION_COOKIE, + session_token, + max_age=SESSION_TTL_DAYS * 86400, + httponly=True, + samesite="lax", + ) + return response + + +@router.get("/logout") +async def logout(request: Request, db: aiosqlite.Connection = Depends(get_db)): + token = request.cookies.get(SESSION_COOKIE) + if token: + await delete_session(token, db) + response = RedirectResponse("/login", status_code=302) + response.delete_cookie(SESSION_COOKIE) + return response diff --git a/app/routers/courses.py b/app/routers/courses.py new file mode 100644 index 0000000..3d7599b --- /dev/null +++ b/app/routers/courses.py @@ -0,0 +1,266 @@ +import secrets +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse +import aiosqlite + +from app.database import get_db +from app.dependencies import require_admin +from app.templates_config import templates + +router = APIRouter() + + +async def _get_course_detail(cid: str, db: aiosqlite.Connection): + """Returns (course_dict, holes_list, tees_list) or (None, [], []).""" + async with db.execute( + "SELECT id, name, holes, location FROM courses WHERE id = ?", (cid,) + ) as c: + course = await c.fetchone() + if not course: + return None, [], [] + async with db.execute( + "SELECT hole_number, par, stroke_index FROM course_holes WHERE course_id = ? ORDER BY hole_number", + (cid,), + ) as c: + holes = [dict(r) for r in await c.fetchall()] + async with db.execute( + "SELECT id, name, slope, rating FROM course_tees WHERE course_id = ? ORDER BY name", + (cid,), + ) as c: + tees = [dict(r) for r in await c.fetchall()] + return dict(course), holes, tees + + +# ── List ────────────────────────────────────────────────────────────────────── + +@router.get("/admin/courses", response_class=HTMLResponse) +async def courses_page(request: Request, user: dict = Depends(require_admin), + db: aiosqlite.Connection = Depends(get_db)): + async with db.execute( + "SELECT id, name, holes, location FROM courses ORDER BY name" + ) as c: + courses = [dict(r) for r in await c.fetchall()] + return templates.TemplateResponse( + "admin/courses.html", + {"request": request, "user": user, "courses": courses}, + ) + + +# ── Create ──────────────────────────────────────────────────────────────────── + +@router.get("/admin/courses/new", response_class=HTMLResponse) +async def new_course_page(request: Request, user: dict = Depends(require_admin)): + return templates.TemplateResponse( + "admin/new_course.html", {"request": request, "user": user} + ) + + +@router.post("/admin/courses/new") +async def create_course(request: Request, + name: str = Form(...), holes: int = Form(...), + location: str = Form(""), + user: dict = Depends(require_admin), + db: aiosqlite.Connection = Depends(get_db)): + form = await request.form() + name = name.strip() + location = location.strip() + errors = {} + if not name: + errors["name"] = "Name is required." + if holes not in (9, 18): + errors["holes"] = "Holes must be 9 or 18." + + hole_count = holes if holes in (9, 18) else 18 + pars = {} + stroke_indices = {} + for i in range(1, hole_count + 1): + raw = form.get(f"par_{i}", "4") + try: + p = int(raw) + if p < 3 or p > 5: + errors[f"par_{i}"] = f"Hole {i}: par must be 3–5." + else: + pars[i] = p + except (ValueError, TypeError): + errors[f"par_{i}"] = f"Hole {i}: invalid par." + raw_si = form.get(f"si_{i}", "") + if raw_si: + try: + si = int(raw_si) + if si < 1 or si > hole_count: + errors[f"si_{i}"] = f"Hole {i}: SI must be 1–{hole_count}." + else: + stroke_indices[i] = si + except (ValueError, TypeError): + errors[f"si_{i}"] = f"Hole {i}: invalid SI." + else: + stroke_indices[i] = None + + if errors: + return templates.TemplateResponse( + "admin/new_course.html", + {"request": request, "user": user, "errors": errors, + "form": {"name": name, "holes": holes, "location": location, + "pars": pars, "stroke_indices": stroke_indices}}, + status_code=422, + ) + + cid = secrets.token_urlsafe(10) + now = datetime.now(timezone.utc).isoformat() + await db.execute( + "INSERT INTO courses (id, name, holes, location, created_at) VALUES (?,?,?,?,?)", + (cid, name, holes, location or None, now), + ) + for hole_num, par in pars.items(): + await db.execute( + "INSERT INTO course_holes (id, course_id, hole_number, par, stroke_index) VALUES (?,?,?,?,?)", + (secrets.token_urlsafe(10), cid, hole_num, par, stroke_indices.get(hole_num)), + ) + await db.commit() + return RedirectResponse( + f"/admin/courses/{cid}/edit?success=Course+created.+Add+tees+below.", + status_code=302, + ) + + +# ── Edit ────────────────────────────────────────────────────────────────────── + +@router.get("/admin/courses/{cid}/edit", response_class=HTMLResponse) +async def edit_course_page(cid: str, request: Request, + user: dict = Depends(require_admin), + db: aiosqlite.Connection = Depends(get_db)): + course, holes, tees = await _get_course_detail(cid, db) + if not course: + return RedirectResponse("/admin/courses", status_code=302) + return templates.TemplateResponse( + "admin/edit_course.html", + {"request": request, "user": user, "course": course, "holes": holes, "tees": tees}, + ) + + +@router.post("/admin/courses/{cid}/edit") +async def edit_course_submit(cid: str, request: Request, + name: str = Form(...), holes: int = Form(...), + location: str = Form(""), + user: dict = Depends(require_admin), + db: aiosqlite.Connection = Depends(get_db)): + form = await request.form() + course, existing_holes, tees = await _get_course_detail(cid, db) + if not course: + return RedirectResponse("/admin/courses", status_code=302) + + name = name.strip() + location = location.strip() + errors = {} + if not name: + errors["name"] = "Name is required." + if holes not in (9, 18): + errors["holes"] = "Holes must be 9 or 18." + + hole_count = holes if holes in (9, 18) else 18 + pars = {} + stroke_indices = {} + for i in range(1, hole_count + 1): + raw = form.get(f"par_{i}", "4") + try: + p = int(raw) + if p < 3 or p > 5: + errors[f"par_{i}"] = f"Hole {i}: par must be 3–5." + else: + pars[i] = p + except (ValueError, TypeError): + errors[f"par_{i}"] = f"Hole {i}: invalid par." + raw_si = form.get(f"si_{i}", "") + if raw_si: + try: + si = int(raw_si) + if si < 1 or si > hole_count: + errors[f"si_{i}"] = f"Hole {i}: SI must be 1–{hole_count}." + else: + stroke_indices[i] = si + except (ValueError, TypeError): + errors[f"si_{i}"] = f"Hole {i}: invalid SI." + else: + stroke_indices[i] = None + + if errors: + return templates.TemplateResponse( + "admin/edit_course.html", + {"request": request, "user": user, "course": course, + "holes": existing_holes, "tees": tees, "errors": errors}, + status_code=422, + ) + + await db.execute( + "UPDATE courses SET name = ?, holes = ?, location = ? WHERE id = ?", + (name, holes, location or None, cid), + ) + for hole_num, par in pars.items(): + await db.execute( + """INSERT INTO course_holes (id, course_id, hole_number, par, stroke_index) VALUES (?,?,?,?,?) + ON CONFLICT(course_id, hole_number) DO UPDATE SET + par=excluded.par, stroke_index=excluded.stroke_index""", + (secrets.token_urlsafe(10), cid, hole_num, par, stroke_indices.get(hole_num)), + ) + # Remove holes beyond new count if holes was reduced + await db.execute( + "DELETE FROM course_holes WHERE course_id = ? AND hole_number > ?", (cid, holes) + ) + await db.commit() + return RedirectResponse(f"/admin/courses/{cid}/edit?success=Course+updated.", status_code=302) + + +# ── Delete course ───────────────────────────────────────────────────────────── + +@router.post("/admin/courses/{cid}/delete") +async def delete_course(cid: str, user: dict = Depends(require_admin), + db: aiosqlite.Connection = Depends(get_db)): + await db.execute("DELETE FROM course_holes WHERE course_id = ?", (cid,)) + await db.execute("DELETE FROM course_tees WHERE course_id = ?", (cid,)) + await db.execute("DELETE FROM courses WHERE id = ?", (cid,)) + await db.commit() + return RedirectResponse("/admin/courses?success=Course+deleted.", status_code=302) + + +# ── Tees ────────────────────────────────────────────────────────────────────── + +@router.post("/admin/courses/{cid}/tees/new") +async def add_tee(cid: str, name: str = Form(...), + slope: int = Form(...), rating: float = Form(...), + user: dict = Depends(require_admin), + db: aiosqlite.Connection = Depends(get_db)): + await db.execute( + "INSERT INTO course_tees (id, course_id, name, slope, rating) VALUES (?,?,?,?,?)", + (secrets.token_urlsafe(10), cid, name.strip(), slope, rating), + ) + await db.commit() + return RedirectResponse(f"/admin/courses/{cid}/edit?success=Tee+added.", status_code=302) + + +@router.post("/admin/courses/{cid}/tees/{tid}/edit") +async def edit_tee(cid: str, tid: str, name: str = Form(...), + slope: int = Form(...), rating: float = Form(...), + user: dict = Depends(require_admin), + db: aiosqlite.Connection = Depends(get_db)): + await db.execute( + "UPDATE course_tees SET name = ?, slope = ?, rating = ? WHERE id = ? AND course_id = ?", + (name.strip(), slope, rating, tid, cid), + ) + await db.commit() + return RedirectResponse(f"/admin/courses/{cid}/edit?success=Tee+updated.", status_code=302) + + +@router.post("/admin/courses/{cid}/tees/{tid}/delete") +async def delete_tee(cid: str, tid: str, user: dict = Depends(require_admin), + db: aiosqlite.Connection = Depends(get_db)): + # Nullify references in game_players before deleting + await db.execute( + "UPDATE game_players SET tee_id = NULL WHERE tee_id = ?", (tid,) + ) + await db.execute( + "DELETE FROM course_tees WHERE id = ? AND course_id = ?", (tid, cid) + ) + await db.commit() + return RedirectResponse(f"/admin/courses/{cid}/edit?success=Tee+deleted.", status_code=302) diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py new file mode 100644 index 0000000..9580f9a --- /dev/null +++ b/app/routers/dashboard.py @@ -0,0 +1,112 @@ +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +import aiosqlite + +from app.database import get_db +from app.dependencies import get_current_user +from app.templates_config import templates + +router = APIRouter() + + +@router.get("/", response_class=HTMLResponse) +async def dashboard(request: Request, user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(get_db)): + uid = user["id"] + + # Games played (completed) + async with db.execute( + """SELECT COUNT(*) FROM games + WHERE status = 'completed' + AND id IN (SELECT game_id FROM game_players WHERE user_id = ?)""", + (uid,), + ) as c: + games_played = (await c.fetchone())[0] + + # Victories: completed games where this user has the highest total points + # For each completed game the user played, rank players by total points + async with db.execute( + """SELECT gp.game_id, + SUM(CASE WHEN hs.user_id = ? THEN hs.bingo + hs.bango + hs.bongo ELSE 0 END) AS my_total, + MAX(hs.bingo + hs.bango + hs.bongo) AS top_hole_score + FROM game_players gp + JOIN games g ON g.id = gp.game_id + JOIN hole_scores hs ON hs.game_id = gp.game_id + WHERE gp.user_id = ? AND g.status = 'completed' + GROUP BY gp.game_id""", + (uid, uid), + ) as c: + game_rows = await c.fetchall() + + victories = 0 + for row in game_rows: + game_id = row["game_id"] + my_total = row["my_total"] + # Get the max total among all players in this game + async with db.execute( + """SELECT MAX(player_total) FROM ( + SELECT user_id, SUM(bingo + bango + bongo) AS player_total + FROM hole_scores WHERE game_id = ? GROUP BY user_id + )""", + (game_id,), + ) as c2: + max_total = (await c2.fetchone())[0] or 0 + if my_total >= max_total and my_total > 0: + victories += 1 + + # Recent completed games with results + async with db.execute( + """SELECT g.id, g.holes_count, g.created_at, g.format, c.name AS course_name + FROM games g + LEFT JOIN courses c ON c.id = g.course_id + JOIN game_players gp ON gp.game_id = g.id + WHERE gp.user_id = ? AND g.status = 'completed' + ORDER BY g.created_at DESC LIMIT 5""", + (uid,), + ) as c: + recent_games = [dict(r) for r in await c.fetchall()] + + # For each recent game, get player totals + for game in recent_games: + async with db.execute( + """SELECT u.id, u.name, u.avatar, + SUM(hs.bingo + hs.bango + hs.bongo) AS total + FROM hole_scores hs JOIN users u ON hs.user_id = u.id + WHERE hs.game_id = ? + GROUP BY hs.user_id + ORDER BY total DESC""", + (game["id"],), + ) as c: + game["results"] = [dict(r) for r in await c.fetchall()] + + # All active games (social stream) + async with db.execute( + """SELECT g.id, g.holes_count, g.created_at, g.format, c.name AS course_name + FROM games g + LEFT JOIN courses c ON c.id = g.course_id + WHERE g.status = 'active' + ORDER BY g.created_at DESC""", + ) as c: + active_games = [dict(r) for r in await c.fetchall()] + + for game in active_games: + async with db.execute( + """SELECT u.id, u.name, u.avatar, + COALESCE(SUM(hs.bingo + hs.bango + hs.bongo), 0) AS total + FROM game_players gp + JOIN users u ON u.id = gp.user_id + LEFT JOIN hole_scores hs ON hs.game_id = gp.game_id AND hs.user_id = u.id + WHERE gp.game_id = ? + GROUP BY u.id + ORDER BY total DESC""", + (game["id"],), + ) as c: + game["players"] = [dict(r) for r in await c.fetchall()] + game["is_participant"] = any(p["id"] == uid for p in game["players"]) + + return templates.TemplateResponse("dashboard/dashboard.html", { + "request": request, "user": user, + "games_played": games_played, "victories": victories, + "recent_games": recent_games, + "active_games": active_games, + }) diff --git a/app/routers/games.py b/app/routers/games.py new file mode 100644 index 0000000..70b47c5 --- /dev/null +++ b/app/routers/games.py @@ -0,0 +1,374 @@ +import json +import secrets +from datetime import datetime, timezone + +import aiosqlite +from fastapi import APIRouter, Depends, Form, Request, WebSocket, WebSocketDisconnect +from fastapi.responses import HTMLResponse, RedirectResponse + +from app.database import get_db, DATABASE_PATH +from app.dependencies import get_current_user +from app.templates_config import templates + +router = APIRouter() + +# game_id -> set of connected websockets +_connections: dict[str, set[WebSocket]] = {} + + +# ── Helpers ────────────────────────────────────────────────────────────────── + +async def _get_game(game_id: str, db: aiosqlite.Connection): + async with db.execute( + """SELECT g.*, c.name as course_name + FROM games g LEFT JOIN courses c ON c.id = g.course_id + WHERE g.id = ?""", + (game_id,), + ) as c: + return await c.fetchone() + + +async def _get_players(game_id: str, db: aiosqlite.Connection): + async with db.execute( + """SELECT u.id, u.name, u.avatar, gp.handicap_snapshot, + ct.slope, ct.rating as tee_rating, ct.name as tee_name + FROM game_players gp JOIN users u ON gp.user_id = u.id + LEFT JOIN course_tees ct ON ct.id = gp.tee_id + WHERE gp.game_id = ? ORDER BY gp.joined_at""", + (game_id,), + ) as c: + return [dict(r) for r in await c.fetchall()] + + +async def _get_scores(game_id: str, db: aiosqlite.Connection): + """Returns {hole_number: {user_id: score_row}}""" + async with db.execute( + "SELECT * FROM hole_scores WHERE game_id = ?", (game_id,) + ) as c: + rows = await c.fetchall() + scores: dict[int, dict] = {} + for r in rows: + h = r["hole_number"] + if h not in scores: + scores[h] = {} + scores[h][r["user_id"]] = dict(r) + return scores + + +async def _broadcast(game_id: str, message: dict): + dead = set() + for ws in _connections.get(game_id, set()): + try: + await ws.send_text(json.dumps(message)) + except Exception: + dead.add(ws) + _connections.get(game_id, set()).difference_update(dead) + + +def _totals(players: list, scores: dict) -> dict: + """Returns {user_id: {bingo, bango, bongo, total}}""" + totals = {p["id"]: {"bingo": 0, "bango": 0.0, "bongo": 0, "total": 0.0} for p in players} + for hole_scores in scores.values(): + for uid, s in hole_scores.items(): + if uid in totals: + totals[uid]["bingo"] += s["bingo"] + totals[uid]["bango"] += s["bango"] + totals[uid]["bongo"] += s["bongo"] + totals[uid]["total"] += s["bingo"] + s["bango"] + s["bongo"] + return totals + + +# ── Routes ──────────────────────────────────────────────────────────────────── + +@router.get("/games", response_class=HTMLResponse) +async def games_list(request: Request, user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(get_db)): + async with db.execute( + """SELECT g.id, g.holes_count, g.status, g.created_at, + u.name as creator_name, c.name as course_name + FROM games g JOIN users u ON g.created_by = u.id + LEFT JOIN courses c ON c.id = g.course_id + WHERE g.id IN (SELECT game_id FROM game_players WHERE user_id = ?) + ORDER BY g.created_at DESC LIMIT 20""", + (user["id"],), + ) as c: + games = [dict(r) for r in await c.fetchall()] + + # For completed games, find the winner (highest total points) + async with db.execute( + """SELECT hs.game_id, u.name, u.avatar, + SUM(hs.bingo + hs.bango + hs.bongo) AS total + FROM hole_scores hs JOIN users u ON hs.user_id = u.id + WHERE hs.game_id IN (SELECT id FROM games WHERE status = 'completed') + GROUP BY hs.game_id, hs.user_id + ORDER BY hs.game_id, total DESC""", + ) as c: + rows = await c.fetchall() + + # Keep only the top scorer per game + winners: dict[str, dict] = {} + for r in rows: + if r["game_id"] not in winners: + winners[r["game_id"]] = {"name": r["name"], "avatar": r["avatar"], "total": r["total"]} + + return templates.TemplateResponse( + "games/games.html", {"request": request, "user": user, "games": games, "winners": winners} + ) + + +@router.get("/games/new", response_class=HTMLResponse) +async def new_game_page(request: Request, user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(get_db)): + async with db.execute( + "SELECT id, name, avatar FROM users WHERE is_onboarded = 1 ORDER BY name" + ) as c: + all_users = [dict(r) for r in await c.fetchall()] + async with db.execute("SELECT id, name, holes, location FROM courses ORDER BY name") as c: + courses = [dict(r) for r in await c.fetchall()] + async with db.execute( + "SELECT id, course_id, name, slope, rating FROM course_tees ORDER BY course_id, name" + ) as c: + tee_rows = [dict(r) for r in await c.fetchall()] + + # Group tees by course_id for JSON embed in template + tees_by_course: dict[str, list] = {} + for t in tee_rows: + tees_by_course.setdefault(t["course_id"], []).append( + {"id": t["id"], "name": t["name"], "slope": t["slope"], "rating": t["rating"]} + ) + + return templates.TemplateResponse( + "games/new_game.html", + {"request": request, "user": user, "all_users": all_users, + "courses": courses, "tees_by_course": tees_by_course}, + ) + + +VALID_FORMATS = {"bingo_bango_bongo"} + +@router.post("/games/new") +async def create_game(request: Request, holes_count: int = Form(...), + course_id: str = Form(...), + format: str = Form("bingo_bango_bongo"), + user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(get_db)): + form = await request.form() + player_ids = form.getlist("players") + if format not in VALID_FORMATS: + format = "bingo_bango_bongo" + + if user["id"] not in player_ids: + player_ids.insert(0, user["id"]) + + async with db.execute("SELECT id, name, holes, location FROM courses ORDER BY name") as c: + courses = [dict(r) for r in await c.fetchall()] + + error = None + if len(player_ids) < 2: + error = "Select at least 2 players." + if not course_id or not any(c["id"] == course_id for c in courses): + error = error or "Select a course." + if not error: + async with db.execute( + "SELECT id FROM course_tees WHERE course_id = ? LIMIT 1", (course_id,) + ) as c: + has_tees = await c.fetchone() + if has_tees and any(not form.get(f"tee_{uid}") for uid in player_ids): + error = "All players must select a tee." + + if error: + async with db.execute( + "SELECT id, name, avatar FROM users WHERE is_onboarded = 1 ORDER BY name" + ) as c: + all_users = [dict(r) for r in await c.fetchall()] + async with db.execute( + "SELECT id, course_id, name, slope, rating FROM course_tees ORDER BY course_id, name" + ) as c: + tee_rows = [dict(r) for r in await c.fetchall()] + tees_by_course: dict[str, list] = {} + for t in tee_rows: + tees_by_course.setdefault(t["course_id"], []).append( + {"id": t["id"], "name": t["name"], "slope": t["slope"], "rating": t["rating"]} + ) + return templates.TemplateResponse( + "games/new_game.html", + {"request": request, "user": user, "all_users": all_users, + "courses": courses, "tees_by_course": tees_by_course, "error": error}, + ) + + game_id = secrets.token_urlsafe(10) + now = datetime.now(timezone.utc).isoformat() + + await db.execute( + "INSERT INTO games (id, format, holes_count, status, created_by, created_at, course_id) VALUES (?,?,?,?,?,?,?)", + (game_id, "bingo_bango_bongo", holes_count, "active", user["id"], now, course_id), + ) + for uid in player_ids: + async with db.execute("SELECT handicap FROM users WHERE id = ?", (uid,)) as c: + row = await c.fetchone() + hc = row["handicap"] if row else None + tee_id = form.get(f"tee_{uid}") or None + await db.execute( + "INSERT OR IGNORE INTO game_players (id, game_id, user_id, handicap_snapshot, tee_id, joined_at) VALUES (?,?,?,?,?,?)", + (secrets.token_urlsafe(10), game_id, uid, hc, tee_id, now), + ) + await db.commit() + + return RedirectResponse(f"/games/{game_id}", status_code=302) + + +@router.get("/games/{game_id}/summary", response_class=HTMLResponse) +async def game_summary(game_id: str, request: Request, user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(get_db)): + game = await _get_game(game_id, db) + if not game: + return RedirectResponse("/games", status_code=302) + + players = await _get_players(game_id, db) + scores = await _get_scores(game_id, db) + totals = _totals(players, scores) + holes = list(range(1, game["holes_count"] + 1)) + + hole_pars: dict[int, int] = {} + hole_stroke_indices: dict[int, int] = {} + if game["course_id"]: + async with db.execute( + "SELECT hole_number, par, stroke_index FROM course_holes WHERE course_id = ? ORDER BY hole_number", + (game["course_id"],), + ) as c: + for row in await c.fetchall(): + hole_pars[row["hole_number"]] = row["par"] + if row["stroke_index"] is not None: + hole_stroke_indices[row["hole_number"]] = row["stroke_index"] + + results = sorted(players, key=lambda p: totals.get(p["id"], {}).get("total", 0), reverse=True) + + return templates.TemplateResponse("games/summary.html", { + "request": request, "user": user, "game": dict(game), + "players": players, "scores": scores, "totals": totals, + "holes": holes, "hole_pars": hole_pars, + "results": results, + }) + + +@router.get("/games/{game_id}", response_class=HTMLResponse) +async def game_page(game_id: str, request: Request, user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(get_db)): + game = await _get_game(game_id, db) + if not game: + return RedirectResponse("/games", status_code=302) + + players = await _get_players(game_id, db) + scores = await _get_scores(game_id, db) + totals = _totals(players, scores) + holes = list(range(1, game["holes_count"] + 1)) + + # Load hole pars and stroke indices if the game has a course + hole_pars: dict[int, int] = {} + hole_stroke_indices: dict[int, int] = {} + if game["course_id"]: + async with db.execute( + "SELECT hole_number, par, stroke_index FROM course_holes WHERE course_id = ? ORDER BY hole_number", + (game["course_id"],), + ) as c: + for row in await c.fetchall(): + hole_pars[row["hole_number"]] = row["par"] + if row["stroke_index"] is not None: + hole_stroke_indices[row["hole_number"]] = row["stroke_index"] + + # Playing handicap per player: HI × (slope / 113) + (course_rating − par) + course_par = sum(hole_pars.values()) + playing_handicaps: dict[str, int | None] = {} + for p in players: + hc = p.get("handicap_snapshot") + slope = p.get("slope") + rating = p.get("tee_rating") + if hc is not None and slope and rating and course_par: + playing_handicaps[p["id"]] = round(hc * (slope / 113) + (rating - course_par)) + else: + playing_handicaps[p["id"]] = None + + return templates.TemplateResponse("games/game.html", { + "request": request, "user": user, "game": dict(game), + "players": players, "scores": scores, "totals": totals, + "holes": holes, "hole_pars": hole_pars, "hole_stroke_indices": hole_stroke_indices, + "playing_handicaps": playing_handicaps, + }) + + +@router.post("/games/{game_id}/score") +async def save_score(game_id: str, request: Request, user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(get_db)): + form = await request.form() + hole = int(form["hole"]) + now = datetime.now(timezone.utc).isoformat() + + game = await _get_game(game_id, db) + if not game: + return HTMLResponse("Not found", status_code=404) + + players = await _get_players(game_id, db) + player_ids = [p["id"] for p in players] + + # Determine Bingo (first on green) and Bongo (first to hole out) — single winner + bingo_winner = form.get("bingo", "") + bongo_winner = form.get("bongo", "") + + # Bango: may be multiple winners (split point) + bango_winners = form.getlist("bango") + + bango_value = round(1.0 / len(bango_winners), 4) if bango_winners else 0.0 + + for uid in player_ids: + bingo = 1 if uid == bingo_winner else 0 + bango = bango_value if uid in bango_winners else 0.0 + bongo = 1 if uid == bongo_winner else 0 + strokes_val = form.get(f"strokes_{uid}") + strokes = int(strokes_val) if strokes_val and strokes_val.isdigit() else None + + await db.execute( + """INSERT INTO hole_scores (id, game_id, hole_number, user_id, strokes, bingo, bango, bongo, updated_by, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(game_id, hole_number, user_id) DO UPDATE SET + strokes=excluded.strokes, bingo=excluded.bingo, bango=excluded.bango, bongo=excluded.bongo, + updated_by=excluded.updated_by, updated_at=excluded.updated_at""", + (secrets.token_urlsafe(10), game_id, hole, uid, strokes, bingo, bango, bongo, user["id"], now), + ) + await db.commit() + + # Broadcast updated scores to all connected players + scores = await _get_scores(game_id, db) + totals = _totals(players, scores) + hole_data = {uid: scores.get(hole, {}).get(uid, {}) for uid in player_ids} + + await _broadcast(game_id, { + "type": "score_update", + "hole": hole, + "scores": {uid: {"strokes": d.get("strokes"), "bingo": d.get("bingo", 0), + "bango": d.get("bango", 0.0), "bongo": d.get("bongo", 0)} + for uid, d in hole_data.items()}, + "totals": totals, + }) + + return HTMLResponse("", status_code=204) + + +@router.post("/games/{game_id}/finish") +async def finish_game(game_id: str, user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(get_db)): + await db.execute("UPDATE games SET status = 'completed' WHERE id = ?", (game_id,)) + await db.commit() + return RedirectResponse(f"/games/{game_id}", status_code=302) + + +# ── WebSocket ───────────────────────────────────────────────────────────────── + +@router.websocket("/games/{game_id}/ws") +async def game_ws(game_id: str, websocket: WebSocket): + await websocket.accept() + _connections.setdefault(game_id, set()).add(websocket) + try: + while True: + await websocket.receive_text() # keep alive + except WebSocketDisconnect: + _connections.get(game_id, set()).discard(websocket) diff --git a/app/routers/onboarding.py b/app/routers/onboarding.py new file mode 100644 index 0000000..bcb8a49 --- /dev/null +++ b/app/routers/onboarding.py @@ -0,0 +1,63 @@ +from fastapi import APIRouter, Depends, Form, Request, UploadFile, File +from fastapi.responses import HTMLResponse, RedirectResponse +import aiosqlite + +from app.database import get_db +from app.dependencies import get_current_user +from app.templates_config import templates +from app.avatar import save_avatar + +router = APIRouter() + +AVATARS = ["🏌️", "⛳", "🦅", "🐦", "🏆", "🎯", "🌟", "🔥", "💪", "🦁", "🐯", "🦊"] + + +@router.get("/onboarding", response_class=HTMLResponse) +async def onboarding_page(request: Request, user: dict = Depends(get_current_user)): + if user["is_onboarded"]: + return RedirectResponse("/", status_code=302) + return templates.TemplateResponse( + "onboarding/setup.html", {"request": request, "user": user, "avatars": AVATARS} + ) + + +@router.post("/onboarding", response_class=HTMLResponse) +async def onboarding_submit( + request: Request, + name: str = Form(...), + handicap: float = Form(...), + avatar_emoji: str = Form(""), + avatar_file: UploadFile = File(None), + user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(get_db), +): + errors = {} + name = name.strip() + if not name: + errors["name"] = "Name is required." + if not (0.0 <= handicap <= 54.0): + errors["handicap"] = "Handicap must be between 0 and 54." + + avatar = None + if avatar_file and avatar_file.filename: + avatar, err = await save_avatar(avatar_file, user["id"]) + if err: + errors["avatar"] = err + elif avatar_emoji in AVATARS: + avatar = avatar_emoji + else: + errors["avatar"] = "Please upload a photo or select an emoji." + + if errors: + return templates.TemplateResponse( + "onboarding/setup.html", + {"request": request, "user": user, "avatars": AVATARS, "errors": errors, + "values": {"name": name, "handicap": handicap, "avatar_emoji": avatar_emoji}}, + ) + + await db.execute( + "UPDATE users SET name = ?, handicap = ?, avatar = ?, is_onboarded = 1 WHERE id = ?", + (name, handicap, avatar, user["id"]), + ) + await db.commit() + return RedirectResponse("/", status_code=302) diff --git a/app/routers/profile.py b/app/routers/profile.py new file mode 100644 index 0000000..8cc2b18 --- /dev/null +++ b/app/routers/profile.py @@ -0,0 +1,62 @@ +from fastapi import APIRouter, Depends, Form, Request, UploadFile, File +from fastapi.responses import HTMLResponse +import aiosqlite + +from app.database import get_db +from app.dependencies import get_current_user +from app.routers.onboarding import AVATARS +from app.templates_config import templates +from app.avatar import save_avatar + +router = APIRouter() + + +@router.get("/profile", response_class=HTMLResponse) +async def profile_page(request: Request, user: dict = Depends(get_current_user)): + return templates.TemplateResponse( + "profile/profile.html", {"request": request, "user": user, "avatars": AVATARS} + ) + + +@router.post("/profile", response_class=HTMLResponse) +async def profile_submit( + request: Request, + name: str = Form(...), + handicap: float = Form(...), + avatar_emoji: str = Form(""), + avatar_file: UploadFile = File(None), + user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(get_db), +): + errors = {} + name = name.strip() + if not name: + errors["name"] = "Name is required." + if not (0.0 <= handicap <= 54.0): + errors["handicap"] = "Handicap must be between 0 and 54." + + avatar = user["avatar"] # keep existing if nothing new provided + if avatar_file and avatar_file.filename: + avatar, err = await save_avatar(avatar_file, user["id"]) + if err: + errors["avatar"] = err + elif avatar_emoji in AVATARS: + avatar = avatar_emoji + + if errors: + return templates.TemplateResponse( + "profile/profile.html", + {"request": request, "user": user, "avatars": AVATARS, "errors": errors}, + ) + + await db.execute( + "UPDATE users SET name = ?, handicap = ?, avatar = ? WHERE id = ?", + (name, handicap, avatar, user["id"]), + ) + await db.commit() + + return templates.TemplateResponse( + "profile/profile.html", + {"request": request, "user": {**user, "name": name, "handicap": handicap, "avatar": avatar}, + "avatars": AVATARS, "success": "Profile updated."}, + ) diff --git a/app/routers/users.py b/app/routers/users.py new file mode 100644 index 0000000..dd2394e --- /dev/null +++ b/app/routers/users.py @@ -0,0 +1,66 @@ +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +import aiosqlite + +from app.database import get_db +from app.dependencies import get_current_user +from app.templates_config import templates + +router = APIRouter() + + +@router.get("/users/{user_id}", response_class=HTMLResponse) +async def user_profile(user_id: str, request: Request, + user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(get_db)): + async with db.execute( + "SELECT id, name, handicap, avatar FROM users WHERE id = ? AND is_onboarded = 1", + (user_id,), + ) as c: + profile = await c.fetchone() + if not profile: + from fastapi.responses import RedirectResponse + return RedirectResponse("/", status_code=302) + + uid = user_id + + async with db.execute( + """SELECT COUNT(*) FROM games + WHERE status = 'completed' + AND id IN (SELECT game_id FROM game_players WHERE user_id = ?)""", + (uid,), + ) as c: + games_played = (await c.fetchone())[0] + + async with db.execute( + """SELECT gp.game_id, + SUM(CASE WHEN hs.user_id = ? THEN hs.bingo + hs.bango + hs.bongo ELSE 0 END) AS my_total + FROM game_players gp + JOIN games g ON g.id = gp.game_id + JOIN hole_scores hs ON hs.game_id = gp.game_id + WHERE gp.user_id = ? AND g.status = 'completed' + GROUP BY gp.game_id""", + (uid, uid), + ) as c: + game_rows = await c.fetchall() + + victories = 0 + for row in game_rows: + async with db.execute( + """SELECT MAX(player_total) FROM ( + SELECT user_id, SUM(bingo + bango + bongo) AS player_total + FROM hole_scores WHERE game_id = ? GROUP BY user_id + )""", + (row["game_id"],), + ) as c2: + max_total = (await c2.fetchone())[0] or 0 + if row["my_total"] >= max_total and row["my_total"] > 0: + victories += 1 + + win_pct = round((victories / games_played) * 100) if games_played > 0 else None + + return templates.TemplateResponse( + "users/profile.html", + {"request": request, "user": user, "profile": dict(profile), + "games_played": games_played, "victories": victories, "win_pct": win_pct}, + ) diff --git a/app/templates/admin/admin.html b/app/templates/admin/admin.html new file mode 100644 index 0000000..fbfb799 --- /dev/null +++ b/app/templates/admin/admin.html @@ -0,0 +1,108 @@ +{% extends "base.html" %} +{% block title %}Admin – Skins & Pins{% endblock %} + +{% block content %} +
+ + +
+ Users + Courses + Games +
+ + {% if request.query_params.get('error') %} +
{{ request.query_params.get('error') }}
+ {% endif %} + {% if request.query_params.get('success') %} +
{{ request.query_params.get('success') }}
+ {% endif %} + {% if error %}
{{ error }}
{% endif %} + {% if success %}
{{ success }}
{% endif %} + +
+

Invite a player

+
+
+ + +
+ +
+
+ +
+

Pending invitations

+ {% if invitations %} + + + + + + {% for inv in invitations %} + + + + + + {% endfor %} + +
EmailInvited
{{ inv.email }}{{ inv.created_at[:10] }} +
+ +
+
+ +
+
+ {% else %} +

No pending invitations.

+ {% endif %} +
+ +
+
+

Registered users

+ + Add user +
+ {% if users %} + + + + + + {% for u in users %} + + + + + + {% endfor %} + +
UserHcp
+ {% if u.avatar and u.avatar.startswith('/') %} + + {% else %} + {{ u.avatar or '👤' }} + {% endif %} + {% if u.name %}{{ u.name }}{% else %}(not set up){% endif %} +
{{ u.email }}
+
{{ u.handicap if u.handicap is not none else '—' }} + Edit + {% if u.id != user.id %} +
+ +
+ {% endif %} +
+ {% else %} +

No users yet.

+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/admin/courses.html b/app/templates/admin/courses.html new file mode 100644 index 0000000..a1c175b --- /dev/null +++ b/app/templates/admin/courses.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% block title %}Courses – Skins & Pins{% endblock %} + +{% block content %} +
+ + +
+ Users + Courses + Games +
+ + {% if request.query_params.get('success') %} +
{{ request.query_params.get('success') }}
+ {% endif %} + +
+ + New course +
+ + {% if courses %} +
+ + + + + + {% for c in courses %} + + + + + + + {% endfor %} + +
CourseHolesLocation
{{ c.name }}{{ c.holes }}{{ c.location or '—' }} + Edit +
+ +
+
+
+ {% else %} +
+
+

No courses yet.

+ Add a course +
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/admin/edit_course.html b/app/templates/admin/edit_course.html new file mode 100644 index 0000000..f2fbcec --- /dev/null +++ b/app/templates/admin/edit_course.html @@ -0,0 +1,149 @@ +{% extends "base.html" %} +{% block title %}Edit Course – Skins & Pins{% endblock %} + +{% block content %} +
+ + + {% if request.query_params.get('success') %} +
{{ request.query_params.get('success') }}
+ {% endif %} + + {# ── Course info + hole pars ── #} +
+
+

Course info

+ +
+ + {% if errors and errors.name %}
{{ errors.name }}
{% endif %} + +
+ +
+ + +
+ +
+ +
+ + +
+
+
+ +
+ {% set course_par = holes | sum(attribute='par') %} +

+ Hole details — Course par: {{ course_par }} +

+
+ {% for h in holes %} +
+
{{ h.hole_number }}
+ + +
+ {% endfor %} +
+
+ + +
+ + {# ── Tees ── #} +
+

Tees

+ + {% if tees %} +
+ {% for t in tees %} +
+ +
+ {{ t.name }} + Slope {{ t.slope }} + Rating {{ t.rating }} + + +
+ +
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ {% endfor %} +
+ {% else %} +

No tees yet. Add at least one tee so players can start a game on this course.

+ {% endif %} + + {# Add tee form #} +

Add tee

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+{% endblock %} diff --git a/app/templates/admin/edit_user.html b/app/templates/admin/edit_user.html new file mode 100644 index 0000000..a8d2528 --- /dev/null +++ b/app/templates/admin/edit_user.html @@ -0,0 +1,84 @@ +{% extends "base.html" %} +{% block title %}Edit User – Skins & Pins{% endblock %} + +{% block extra_head %} + + + +{% endblock %} + +{% block content %} +
+ + +
+ +
+
+
+ + {% if errors and errors.name %}
{{ errors.name }}
{% endif %} + +
+ +
+ + {% if errors and errors.email %}
{{ errors.email }}
{% endif %} + +
+ +
+ + {% if errors and errors.handicap %}
{{ errors.handicap }}
{% endif %} + +
+ +
+ + {% if errors and errors.avatar %}
{{ errors.avatar }}
{% endif %} + +
+ + +
+ +
+
+ {% for a in avatars %} +
+ + +
+ {% endfor %} +
+
+ +
+
+ + Tap to change +
+ +
+
+
+ + +
+ + {% include "partials/crop_modal.html" %} +
+
+{% endblock %} diff --git a/app/templates/admin/games.html b/app/templates/admin/games.html new file mode 100644 index 0000000..1031024 --- /dev/null +++ b/app/templates/admin/games.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}Games – Admin – Skins & Pins{% endblock %} + +{% block content %} +
+ + +
+ Users + Courses + Games +
+ + {% if request.query_params.get('success') %} +
{{ request.query_params.get('success') }}
+ {% endif %} + + {% if games %} +
+ {% for g in games %} + {% set plist = players_by_game.get(g.id, []) %} +
+ +
+
+ {{ g.course_name or 'Unknown course' }} + {{ g.status }} +
+
+ {{ g.holes_count }} holes · {{ g.created_at[:10] }} · by {{ g.creator_name }} +
+
+ {% for p in plist %} + {% if p.avatar and p.avatar.startswith('/') %} + + {% else %} + {{ p.avatar or '👤' }} + {% endif %} + {{ p.name }}{% if not loop.last %}, {% endif %} + {% endfor %} +
+
+ +
+ Edit scores +
+ +
+
+ +
+ {% endfor %} +
+ {% else %} +
+
+

No games yet.

+
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/admin/new_course.html b/app/templates/admin/new_course.html new file mode 100644 index 0000000..936d0da --- /dev/null +++ b/app/templates/admin/new_course.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} +{% block title %}New Course – Skins & Pins{% endblock %} + +{% block content %} +
+ + +
+
+
+ + {% if errors and errors.name %}
{{ errors.name }}
{% endif %} + +
+ +
+ + +
+ +
+ + {% if errors and errors.holes %}
{{ errors.holes }}
{% endif %} +
+ + +
+
+
+ +
+

+ Hole details + + — Course par: + +

+
+ +
+
+ + +
+
+{% endblock %} diff --git a/app/templates/admin/new_user.html b/app/templates/admin/new_user.html new file mode 100644 index 0000000..ce118d9 --- /dev/null +++ b/app/templates/admin/new_user.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} +{% block title %}Add User – Skins & Pins{% endblock %} + +{% block extra_head %} + + + +{% endblock %} + +{% block content %} +
+ + +
+ +
+
+
+ + {% if errors and errors.name %}
{{ errors.name }}
{% endif %} + +
+ +
+ + {% if errors and errors.email %}
{{ errors.email }}
{% endif %} + +
+ +
+ + {% if errors and errors.handicap %}
{{ errors.handicap }}
{% endif %} + +
+ +
+ + {% if errors and errors.avatar %}
{{ errors.avatar }}
{% endif %} + +
+ + +
+ +
+
+ {% for a in avatars %} +
+ + +
+ {% endfor %} +
+
+ +
+
+ + Tap to change +
+ +
+
+
+ + +
+ + {% include "partials/crop_modal.html" %} +
+
+{% endblock %} diff --git a/app/templates/auth/check_email.html b/app/templates/auth/check_email.html new file mode 100644 index 0000000..db89e69 --- /dev/null +++ b/app/templates/auth/check_email.html @@ -0,0 +1,28 @@ + + + + + + + + + Check your email – Skins & Pins + + +
+ +

Check your email

+

We sent a login link to {{ email }}

+ +
+

+ The link expires in 15 minutes.
You can close this tab. +

+
+ + + ← Use a different email + +
+ + diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..361b582 --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,33 @@ + + + + + + + + + Login – Skins & Pins + + +
+ +

Skins & Pins

+

Enter your email to get a login link

+ +
+ {% if error %} +
{{ error }}
+ {% endif %} + +
+
+ + +
+ +
+
+
+ + diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..857850b --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,103 @@ + + + + + + + + + + + + {% block title %}Skins & Pins{% endblock %} + + + {% block extra_head %}{% endblock %} + + + + {# PWA install banner #} + + + {% block content %}{% endblock %} + + {% if user is defined %} + {% include "nav.html" %} + {% endif %} + + + + diff --git a/app/templates/dashboard/dashboard.html b/app/templates/dashboard/dashboard.html new file mode 100644 index 0000000..ad2161b --- /dev/null +++ b/app/templates/dashboard/dashboard.html @@ -0,0 +1,105 @@ +{% extends "base.html" %} +{% block title %}Dashboard – Skins & Pins{% endblock %} + +{% block content %} +
+ + + {% if games_played > 0 %} + {# Stats row #} +
+
+
{{ games_played }}
+
Games played
+
+
+
{{ victories }}
+
Victories
+
+
+
+ {{ ((victories / games_played) * 100) | round | int }}% +
+
Win rate
+
+
+ {% endif %} + + {# Social stream: active games #} + {% if active_games %} +

Live games

+ {% for game in active_games %} +
+
+
{{ game.course_name or '—' }}
+ {% if game.is_participant %} + ▶ Play + {% endif %} +
+
+ {{ game.format | replace('_', ' ') | title }} · {{ game.holes_count }} holes · {{ game.created_at[:10] }} +
+ {% for p in game.players %} +
+ {{ loop.index }} + + {% if p.avatar and p.avatar.startswith('/') %} + + {% else %} + {{ p.avatar or '👤' }} + {% endif %} + + {{ p.name }} + {{ p.total | round(1) }} pts +
+ {% endfor %} +
+ {% endfor %} + {% endif %} + + {# Recent games #} + {% if recent_games %} +

Recent games

+ {% for game in recent_games %} +
+
{{ game.course_name or '—' }}
+
+ {{ game.format | replace('_', ' ') | title }} · {{ game.holes_count }} holes · {{ game.created_at[:10] }} +
+ {% for r in game.results %} +
+ {% if loop.first %}🏆{% else %}{{ loop.index }}{% endif %} + + {% if r.avatar and r.avatar.startswith('/') %} + + {% else %} + {{ r.avatar or '👤' }} + {% endif %} + + {{ r.name }} + {{ r.total | round(1) }} pts +
+ {% endfor %} +
+ {% endfor %} + {% endif %} + + {% if not games_played and not active_games %} +
+
+

No games played yet.

+ Start a game +
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/games/game.html b/app/templates/games/game.html new file mode 100644 index 0000000..f1dce3e --- /dev/null +++ b/app/templates/games/game.html @@ -0,0 +1,442 @@ +{% extends "base.html" %} +{% block title %}Game – Skins & Pins{% endblock %} + + +{% block content %} +
+ + {# ── Header ── #} +
+
+ +
+
+
+ + +
+
+ +
+
+ + {# ── Swipeable panels ── #} +
+ + {# Score entry panel #} +
+
+ + {# Skip hole #} +
+ +
+ +
+ +
+ +
+
+

Hole {{ '{{ currentHole }}' }} skipped

+
+ + {% if game.status == 'active' %} + + {% endif %} + +
+
+ + {# Scorecard panel #} +
+
+ {# Scorecard sub-toggle #} +
+ + +
+ + {# Strokes scorecard #} + + + + + {% if hole_pars %}{% endif %} + {% for p in players %} + + {% endfor %} + + + + {% for h in holes %} + + + {% if hole_pars %}{% endif %} + {% for p in players %} + + {% endfor %} + + {% endfor %} + + + + + {% if hole_pars %}{% endif %} + {% for p in players %} + + {% endfor %} + + +
HolePar + {% if p.avatar and p.avatar.startswith('/') %} + + {% else %} + {{ p.avatar or '👤' }} + {% endif %}
+ {{ p.name.split()[0] }} + {% set ph = playing_handicaps.get(p.id) %} + {% if ph is not none %}
HCP {{ ph }}{% endif %} +
{{ h }}{{ hole_pars.get(h, '—') }} + +
Total{{ hole_pars.values() | sum }}
+ + {# Points scorecard #} + + + + + {% for p in players %} + + {% endfor %} + + + + {% for h in holes %} + + + {% for p in players %} + + {% endfor %} + + {% endfor %} + + + + + {% for p in players %} + + {% endfor %} + + +
Hole + {% if p.avatar and p.avatar.startswith('/') %} + + {% else %} + {{ p.avatar or '👤' }} + {% endif %}
+ {{ p.name.split()[0] }} + {% set ph = playing_handicaps.get(p.id) %} + {% if ph is not none %}
HCP {{ ph }}{% endif %} +
{{ h }} + +
Total
+ + {% if game.status == 'active' %} + + {% endif %} + +
+
+ +
{# end swipe-container #} + + {# ── View toggle ── #} +
+ + +
+ + +
+ + +{% endblock %} diff --git a/app/templates/games/games.html b/app/templates/games/games.html new file mode 100644 index 0000000..6b055d7 --- /dev/null +++ b/app/templates/games/games.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}Games – Skins & Pins{% endblock %} + +{% block content %} +
+ + + {% if games %} + {% for g in games %} + +
+
+
{{ g.course_name or 'Bingo Bango Bongo' }}
+
{{ g.holes_count }} holes · {{ g.created_at[:10] }}
+
+
+ {{ g.status }} + {% if g.status == 'completed' and g.id in winners %} + {% set w = winners[g.id] %} +
+ {% if w.avatar and w.avatar.startswith('/') %}{% else %}{{ w.avatar or '👤' }}{% endif %} {{ w.name }} +
+ {% endif %} +
+
+
+ {% endfor %} + {% else %} +
+
+

No games yet.

+ Start a game +
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/games/new_game.html b/app/templates/games/new_game.html new file mode 100644 index 0000000..7feea50 --- /dev/null +++ b/app/templates/games/new_game.html @@ -0,0 +1,141 @@ +{% extends "base.html" %} +{% block title %}New Game – Skins & Pins{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ + + + {% if error %}
{{ error }}
{% endif %} + +
+ + {# ── Step 1: Format, course, holes, players ── #} +
+
+ +
+ +
+ +
+
+ +
+ + {% if courses %} + + {% else %} +

No courses available. Ask the admin to add courses first.

+ + {% endif %} +
+ +
+ +
+ + +
+
+ +
+ +
+ {% for u in all_users %} + + {% endfor %} +
+ +
+
+ + + +
+ + {# ── Step 2: Tee selection ── #} +
+
+

Select tees

+ {% for u in all_users %} +
+ + +
+ {% endfor %} +
+ +
+ + +
+
+ +
+
+{% endblock %} diff --git a/app/templates/games/summary.html b/app/templates/games/summary.html new file mode 100644 index 0000000..9e211fe --- /dev/null +++ b/app/templates/games/summary.html @@ -0,0 +1,154 @@ +{% extends "base.html" %} +{% block title %}{{ game.course_name or 'Game' }} – Skins & Pins{% endblock %} + +{% block content %} +
+ + + {# Leaderboard #} +
+ {% for p in results %} + {% set t = totals.get(p.id, {}) %} +
+ {% if loop.first %}🏆{% else %}{{ loop.index }}{% endif %} + + {% if p.avatar and p.avatar.startswith('/') %} + + {% else %} + {{ p.avatar or '👤' }} + {% endif %} + + {{ p.name }} + {{ t.total | round(1) }} pts + + B{{ t.bingo | int }}/{{ t.bango | round(1) }}/{{ t.bongo | int }} + +
+ {% endfor %} +
+ + {# Scorecard toggle #} +
+ + +
+ + {# Strokes scorecard #} + + + + + {% if hole_pars %}{% endif %} + {% for p in players %} + + {% endfor %} + + + + {% for h in holes %} + + + {% if hole_pars %}{% endif %} + {% for p in players %} + {% set s = scores.get(h, {}).get(p.id, {}) %} + {% set strokes = s.strokes if s else 0 %} + {% set par = hole_pars.get(h, 0) %} + + {% endfor %} + + {% endfor %} + + + + + {% if hole_pars %}{% endif %} + {% for p in players %} + {% set stroke_total = namespace(val=0) %} + {% for h in holes %} + {% set s = scores.get(h, {}).get(p.id, {}) %} + {% if s and s.strokes %}{% set stroke_total.val = stroke_total.val + s.strokes %}{% endif %} + {% endfor %} + + {% endfor %} + + +
HolePar + {% if p.avatar and p.avatar.startswith('/') %} + + {% else %} + {{ p.avatar or '👤' }} + {% endif %}
+ {{ p.name.split()[0] }} +
{{ h }}{{ hole_pars.get(h, '—') }} + {% if strokes %} + {% if par %} + {% set diff = strokes - par %} + {% if strokes == 1 %} + {{ strokes }} + {% elif diff <= -2 %} + {{ strokes }} + {% elif diff == -1 %} + {{ strokes }} + {% elif diff == 1 %} + {{ strokes }} + {% elif diff >= 2 %} + {{ strokes }} + {% else %} + {{ strokes }} + {% endif %} + {% else %} + {{ strokes }} + {% endif %} + {% else %} + — + {% endif %} +
Total{{ hole_pars.values() | sum }}{{ stroke_total.val or '—' }}
+ + {# Points scorecard #} + + + + + {% for p in players %} + + {% endfor %} + + + + {% for h in holes %} + + + {% for p in players %} + {% set s = scores.get(h, {}).get(p.id, {}) %} + {% set pts = (s.bingo + s.bango + s.bongo) if s else 0 %} + + {% endfor %} + + {% endfor %} + + + + + {% for p in players %} + + {% endfor %} + + +
Hole + {% if p.avatar and p.avatar.startswith('/') %} + + {% else %} + {{ p.avatar or '👤' }} + {% endif %}
+ {{ p.name.split()[0] }} +
{{ h }}{{ pts | round(1) if pts else '—' }}
Total{{ totals.get(p.id, {}).get('total', 0) | round(1) }}
+ +
+{% endblock %} diff --git a/app/templates/nav.html b/app/templates/nav.html new file mode 100644 index 0000000..3399112 --- /dev/null +++ b/app/templates/nav.html @@ -0,0 +1,48 @@ +{% set path = request.url.path %} + diff --git a/app/templates/onboarding/setup.html b/app/templates/onboarding/setup.html new file mode 100644 index 0000000..dddcbbf --- /dev/null +++ b/app/templates/onboarding/setup.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} +{% block title %}Welcome – Skins & Pins{% endblock %} + +{% block extra_head %} + + + +{% endblock %} + +{% block content %} +
+ + +
+
+
+
+ + {% if errors and errors.name %}
{{ errors.name }}
{% endif %} + +
+ +
+ + {% if errors and errors.handicap %}
{{ errors.handicap }}
{% endif %} + +
+ +
+ + {% if errors and errors.avatar %}
{{ errors.avatar }}
{% endif %} + +
+ + +
+ +
+
+ {% for a in avatars %} +
+ + +
+ {% endfor %} +
+
+ +
+
+ + Tap to change +
+ +

JPEG, PNG, WebP or GIF · max 2 MB

+
+
+
+ + +
+ + {% include "partials/crop_modal.html" %} +
+{% endblock %} diff --git a/app/templates/partials/crop_modal.html b/app/templates/partials/crop_modal.html new file mode 100644 index 0000000..2fcb2aa --- /dev/null +++ b/app/templates/partials/crop_modal.html @@ -0,0 +1,17 @@ +{# Crop modal — requires avatarUpload() Alpine component on a parent element #} +
+
+
+ Position your photo + +
+
+ +
+

Pinch or scroll to zoom · drag to reposition

+
+ + +
+
+
diff --git a/app/templates/profile/profile.html b/app/templates/profile/profile.html new file mode 100644 index 0000000..6024f66 --- /dev/null +++ b/app/templates/profile/profile.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} +{% block title %}Profile – Skins & Pins{% endblock %} + +{% block extra_head %} + + + +{% endblock %} + +{% block content %} +
+ + + {% if success %}
{{ success }}
{% endif %} + +
+
+
+
+ + {% if errors and errors.name %}
{{ errors.name }}
{% endif %} + +
+ +
+ + {% if errors and errors.handicap %}
{{ errors.handicap }}
{% endif %} + +
+ +
+ + {% if errors and errors.avatar %}
{{ errors.avatar }}
{% endif %} + +
+ + +
+ +
+
+ {% for a in avatars %} +
+ + +
+ {% endfor %} +
+
+ +
+
+ + Tap to change +
+ +

JPEG, PNG, WebP or GIF · max 2 MB

+
+
+
+ + +
+ + {% include "partials/crop_modal.html" %} +
+ +
+ Log out +
+
+{% endblock %} diff --git a/app/templates/users/profile.html b/app/templates/users/profile.html new file mode 100644 index 0000000..8a973f7 --- /dev/null +++ b/app/templates/users/profile.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}{{ profile.name }} – Skins & Pins{% endblock %} + +{% block content %} +
+ + +
+ {% if profile.avatar and profile.avatar.startswith('/') %} + {{ profile.name }} + {% else %} +
{{ profile.avatar or '👤' }}
+ {% endif %} + +
+

{{ profile.name }}

+
+ Handicap {{ profile.handicap if profile.handicap is not none else '—' }} +
+
+
+ + {% if games_played > 0 %} +
+
+
{{ games_played }}
+
Games played
+
+
+
{{ victories }}
+
Victories
+
+
+
{{ win_pct }}%
+
Win rate
+
+
+ {% endif %} +
+{% endblock %} diff --git a/app/templates_config.py b/app/templates_config.py new file mode 100644 index 0000000..64d8ae0 --- /dev/null +++ b/app/templates_config.py @@ -0,0 +1,5 @@ +import os +from fastapi.templating import Jinja2Templates + +templates = Jinja2Templates(directory="app/templates") +templates.env.globals["admin_email"] = os.getenv("ADMIN_EMAIL", "") diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..2a46c1c --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,20 @@ +services: + app: + build: . + ports: + - "8000:8000" + volumes: + - data:/data + - ./app:/app/app + - ./static:/app/static + env_file: + - .env + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + networks: + - internal + +volumes: + data: + +networks: + internal: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9dea85f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + app: + build: . + restart: unless-stopped + volumes: + - data:/data + env_file: + - .env + labels: + - "traefik.enable=true" + - "traefik.http.routers.skinspins.rule=Host(`${APP_DOMAIN}`)" + - "traefik.http.routers.skinspins.entrypoints=websecure" + - "traefik.http.routers.skinspins.tls.certresolver=letsencrypt" + - "traefik.http.services.skinspins.loadbalancer.server.port=8000" + networks: + - traefik + - internal + +volumes: + data: + +networks: + traefik: + external: true + internal: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5cc0a99 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.115.5 +uvicorn[standard]==0.32.1 +aiosqlite==0.20.0 +jinja2==3.1.4 +python-multipart==0.0.12 +python-dotenv==1.0.1 +itsdangerous==2.2.0 +aiosmtplib==3.0.1 diff --git a/static/avatar-crop.js b/static/avatar-crop.js new file mode 100644 index 0000000..148fe7a --- /dev/null +++ b/static/avatar-crop.js @@ -0,0 +1,72 @@ +function avatarUpload(formAction, redirectTo) { + return { + tab: 'emoji', + showModal: false, + previewUrl: null, + croppedBlob: null, + cropper: null, + + openFile(e) { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + this.previewUrl = ev.target.result; + this.showModal = true; + this.$nextTick(() => this._initCropper()); + }; + reader.readAsDataURL(file); + // Clear the input so re-selecting the same file triggers change again + e.target.value = ''; + }, + + _initCropper() { + if (this.cropper) { this.cropper.destroy(); this.cropper = null; } + const img = this.$refs.cropImg; + this.cropper = new Cropper(img, { + aspectRatio: 1, + viewMode: 1, + dragMode: 'move', + autoCropArea: 1, + cropBoxMovable: false, + cropBoxResizable: false, + toggleDragModeOnDblclick: false, + background: false, + }); + }, + + confirmCrop() { + const canvas = this.cropper.getCroppedCanvas({ width: 256, height: 256 }); + canvas.toBlob((blob) => { + this.croppedBlob = blob; + this.previewUrl = canvas.toDataURL('image/jpeg'); + this.showModal = false; + this.cropper.destroy(); + this.cropper = null; + }, 'image/jpeg', 0.88); + }, + + cancelCrop() { + this.showModal = false; + if (this.cropper) { this.cropper.destroy(); this.cropper = null; } + if (!this.croppedBlob) this.previewUrl = null; + }, + + async submitForm(e) { + e.preventDefault(); + const form = e.target; + const fd = new FormData(form); + + if (this.tab === 'upload' && this.croppedBlob) { + fd.delete('avatar_file'); + fd.set('avatar_file', this.croppedBlob, 'avatar.jpg'); + fd.set('avatar_emoji', ''); + } else if (this.tab === 'emoji') { + fd.delete('avatar_file'); + } + + const resp = await fetch(formAction, { method: 'POST', body: fd, redirect: 'follow' }); + window.location.href = resp.url || redirectTo; + }, + }; +} diff --git a/static/icons/icon-192.png b/static/icons/icon-192.png new file mode 100644 index 0000000..7f4e832 Binary files /dev/null and b/static/icons/icon-192.png differ diff --git a/static/icons/icon-512.png b/static/icons/icon-512.png new file mode 100644 index 0000000..4efacea Binary files /dev/null and b/static/icons/icon-512.png differ diff --git a/static/icons/icon.svg b/static/icons/icon.svg new file mode 100644 index 0000000..a979fc6 --- /dev/null +++ b/static/icons/icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..3c8f8b8 --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "Skins & Pins", + "short_name": "Skins&Pins", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#1a1a2e", + "theme_color": "#2d6a4f", + "icons": [ + { + "src": "/static/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..6572262 --- /dev/null +++ b/static/style.css @@ -0,0 +1,967 @@ +:root { + --green: #2d6a4f; + --green-light: #40916c; + --green-dark: #1b4332; + --bg: #1a1a2e; + --surface: #16213e; + --surface2: #0f3460; + --text: #e8f5e9; + --text-muted: #a8c5a0; + --accent: #52b788; + --danger: #e63946; + --nav-height: 64px; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; + -webkit-tap-highlight-color: transparent; +} + +html, body { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); + font-size: 16px; +} + +body { + padding-bottom: calc(var(--nav-height) + env(safe-area-inset-bottom)); +} + +/* ── Layout ── */ +.page { + max-width: 480px; + margin: 0 auto; + padding: 1.5rem 1rem; +} + +/* ── PWA Install Banner ── */ +#pwa-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 200; + background: var(--green-dark); + border-bottom: 1px solid var(--green-light); + padding: 0.65rem 1rem; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + font-size: 0.85rem; + color: var(--text); +} +#pwa-banner-android, +#pwa-banner-ios { + width: 100%; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; +} +.pwa-banner-actions { display: flex; gap: 0.4rem; flex-shrink: 0; } +.pwa-install-btn { + padding: 0.35rem 0.75rem; + border-radius: 0.4rem; + border: 1px solid var(--green-light); + background: transparent; + color: var(--text); + font-size: 0.8rem; + cursor: pointer; +} +.pwa-install-primary { + background: var(--accent); + border-color: var(--accent); + color: #000; + font-weight: 600; +} + +/* ── Bottom Nav ── */ +.bottom-nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: calc(var(--nav-height) + env(safe-area-inset-bottom)); + padding-bottom: env(safe-area-inset-bottom); + background: var(--surface); + border-top: 1px solid var(--surface2); + display: flex; + justify-content: space-around; + align-items: center; + z-index: 100; +} + +.nav-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + flex: 1; + height: var(--nav-height); + text-decoration: none; + color: var(--text-muted); + font-size: 0.65rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + transition: color 0.15s; + min-width: 44px; +} + +.nav-item:hover, +.nav-item.active { + color: var(--accent); +} + +.nav-icon { + font-size: 1.75rem; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; +} + +.nav-icon svg { + width: 26px; + height: 26px; +} + +.nav-avatar-img { + width: 28px; + height: 28px; + border-radius: 50%; + object-fit: cover; +} + +/* ── Cards ── */ +.card { + background: var(--surface); + border-radius: 12px; + padding: 1.25rem; + margin-bottom: 1rem; +} + +/* ── Forms ── */ +.form-group { + margin-bottom: 1.25rem; +} + +label { + display: block; + font-size: 0.85rem; + color: var(--text-muted); + margin-bottom: 0.4rem; + font-weight: 500; +} + +input[type="text"], +input[type="email"], +input[type="number"] { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid var(--surface2); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font-size: 1rem; + outline: none; + transition: border-color 0.15s; +} + +input:focus { + border-color: var(--accent); +} + +/* ── Buttons ── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.85rem 1.5rem; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + text-decoration: none; + transition: opacity 0.15s; + width: 100%; +} + +.btn:active { opacity: 0.8; } + +.btn-primary { + background: var(--green); + color: #fff; +} + +.btn-danger { + background: var(--danger); + color: #fff; +} + +.btn-ghost { + background: transparent; + color: var(--accent); + border: 1px solid var(--accent); +} + +/* ── Alerts ── */ +.alert { + padding: 0.75rem 1rem; + border-radius: 8px; + margin-bottom: 1rem; + font-size: 0.9rem; +} + +.alert-error { background: rgba(230,57,70,0.15); color: #ff8fa3; border: 1px solid rgba(230,57,70,0.3); } +.alert-success { background: rgba(82,183,136,0.15); color: var(--accent); border: 1px solid rgba(82,183,136,0.3); } + +/* ── Avatar picker ── */ +.avatar-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 0.5rem; +} + +.avatar-option input[type="radio"] { + display: none; +} + +.avatar-option label { + display: flex; + align-items: center; + justify-content: center; + font-size: 1.75rem; + width: 100%; + aspect-ratio: 1; + border-radius: 8px; + border: 2px solid transparent; + background: var(--bg); + cursor: pointer; + transition: border-color 0.15s; +} + +.avatar-option input[type="radio"]:checked + label { + border-color: var(--accent); + background: rgba(82,183,136,0.15); +} + +/* ── Avatar tabs ── */ +.avatar-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 0.25rem; +} + +.avatar-tab { + flex: 1; + padding: 0.5rem; + border: 1px solid var(--surface2); + border-radius: 6px; + background: var(--bg); + color: var(--text-muted); + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} + +.avatar-tab.active { + border-color: var(--accent); + color: var(--accent); +} + +.file-input { + width: 100%; + padding: 0.65rem; + border: 1px dashed var(--surface2); + border-radius: 8px; + background: var(--bg); + color: var(--text-muted); + font-size: 0.9rem; + cursor: pointer; +} + +.file-input::-webkit-file-upload-button { + background: var(--green); + color: #fff; + border: none; + padding: 0.4rem 0.75rem; + border-radius: 4px; + cursor: pointer; + margin-right: 0.75rem; +} + +.avatar-preview { + width: 64px; + height: 64px; + border-radius: 50%; + object-fit: cover; + border: 2px solid var(--accent); +} + +/* ── Page header ── */ +.page-header { + margin-bottom: 1.5rem; +} + +.page-header h1 { + font-size: 1.5rem; + font-weight: 700; +} + +.page-header p { + color: var(--text-muted); + font-size: 0.9rem; + margin-top: 0.25rem; +} + +/* ── Empty state ── */ +.empty-state { + text-align: center; + padding: 3rem 1rem; + color: var(--text-muted); +} + +.empty-state .empty-icon { + font-size: 3rem; + margin-bottom: 1rem; +} + +/* ── Table ── */ +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.data-table th { + text-align: left; + color: var(--text-muted); + padding: 0.5rem 0; + border-bottom: 1px solid var(--surface2); + font-weight: 500; +} + +.data-table td { + padding: 0.6rem 0; + border-bottom: 1px solid rgba(255,255,255,0.05); +} + +/* ── Login page ── */ +.login-container { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem 1.5rem; +} + +.login-logo { + font-size: 3rem; + margin-bottom: 0.5rem; +} + +.login-title { + font-size: 1.75rem; + font-weight: 700; + margin-bottom: 0.25rem; +} + +.login-subtitle { + color: var(--text-muted); + margin-bottom: 2rem; + font-size: 0.9rem; +} + +.login-card { + width: 100%; + max-width: 360px; +} + +/* ── Game card (list) ── */ +.game-card { transition: opacity 0.15s; } +.game-card:active { opacity: 0.7; } +.status-badge { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + padding: 0.2rem 0.5rem; + border-radius: 4px; + letter-spacing: 0.05em; +} +.status-active { background: rgba(82,183,136,0.2); color: var(--accent); } +.status-completed { background: rgba(255,255,255,0.08); color: var(--text-muted); } +.status-lobby { background: rgba(255,200,80,0.15); color: #ffc850; } + +/* ── New game form ── */ +.radio-card { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + padding: 0.75rem; + border: 2px solid var(--surface2); + border-radius: 10px; + cursor: pointer; + font-size: 1rem; + font-weight: 600; + transition: border-color 0.15s; +} +.radio-card input { display: none; } +.radio-card:has(input:checked) { border-color: var(--accent); color: var(--accent); } + +.player-list { display: flex; flex-direction: column; gap: 0.5rem; } +.player-option { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.65rem 0.75rem; + border: 2px solid var(--surface2); + border-radius: 10px; + cursor: pointer; + transition: border-color 0.15s; +} +.player-option:has(input:checked) { border-color: var(--accent); } +.player-option input { display: none; } +.player-avatar { font-size: 1.5rem; } +.player-name { flex: 1; font-weight: 500; } + +/* ── Game screen ── */ +.game-header { + padding: 0.75rem 1rem 0.5rem; + background: var(--surface); + border-bottom: 1px solid var(--surface2); + flex-shrink: 0; +} + +.hole-label { + font-size: 1rem; + font-weight: 700; +} + +.icon-btn { + background: none; + border: none; + color: var(--text); + font-size: 1.75rem; + padding: 0 0.5rem; + cursor: pointer; + line-height: 1; + opacity: 0.8; +} +.icon-btn:disabled { opacity: 0.2; cursor: default; } + +.swipe-container { + flex: 1; + overflow: hidden; + position: relative; +} + +.swipe-panel { + position: absolute; + inset: 0; + opacity: 0; + pointer-events: none; + transform: translateX(30px); + transition: opacity 0.2s, transform 0.2s; +} +.swipe-panel.active { + opacity: 1; + pointer-events: all; + transform: translateX(0); +} + +.panel-scroll { + height: 100%; + overflow-y: auto; + padding: 1rem; + -webkit-overflow-scrolling: touch; +} + +/* ── Score sections ── */ +.score-section { + margin-bottom: 1.25rem; +} + +.score-label { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.6rem; +} + +.score-badge { + font-size: 0.65rem; + font-weight: 800; + letter-spacing: 0.08em; + padding: 0.2rem 0.5rem; + border-radius: 4px; +} +.bingo-badge { background: rgba(255,190,50,0.2); color: #ffbe32; } +.bango-badge { background: rgba(82,183,136,0.2); color: var(--accent); } +.bongo-badge { background: rgba(120,120,255,0.2); color: #9090ff; } + +.score-desc { color: var(--text-muted); font-size: 0.82rem; } + +.player-buttons { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.player-btn { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.55rem 0.85rem; + border: 2px solid var(--surface2); + border-radius: 10px; + background: var(--surface); + color: var(--text); + cursor: pointer; + font-size: 0.9rem; + transition: border-color 0.15s, background 0.15s; +} +.player-btn.selected { + border-color: var(--accent); + background: rgba(82,183,136,0.15); +} +.pbtn-avatar { font-size: 1.2rem; } +.pbtn-name { font-weight: 500; } +.pbtn-pts { color: var(--accent); font-weight: 700; font-size: 0.8rem; } + +.save-btn { margin-top: 0.5rem; } +.save-btn.saved:not(.dirty) { background: var(--green-dark); opacity: 0.8; } + +.btn-skip { + background: none; + border: 1px solid var(--surface2); + color: var(--text-muted); + border-radius: 6px; + padding: 0.3rem 0.75rem; + font-size: 0.8rem; + cursor: pointer; +} +.btn-skip.skipped { border-color: var(--danger); color: #ff8fa3; } + +/* ── Scorecard table ── */ +.scorecard { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} +.scorecard th { + text-align: center; + padding: 0.4rem 0.3rem; + border-bottom: 1px solid var(--surface2); + color: var(--text-muted); + font-weight: 600; +} +.scorecard th:first-child { text-align: left; } +.scorecard td { + text-align: center; + padding: 0.45rem 0.3rem; + border-bottom: 1px solid rgba(255,255,255,0.04); +} +.hole-num { text-align: left; color: var(--text-muted); font-weight: 600; } +.score-cell { font-weight: 500; } +.hole-skipped td { opacity: 0.35; } +.totals-row td { + font-weight: 700; + color: var(--accent); + padding-top: 0.6rem; + border-top: 1px solid var(--surface2); +} + +/* ── View toggle ── */ +.view-toggle { + display: flex; + border-top: 1px solid var(--surface2); + background: var(--surface); + flex-shrink: 0; +} +.view-toggle button { + flex: 1; + padding: 0.6rem; + background: none; + border: none; + color: var(--text-muted); + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: color 0.15s, border-color 0.15s; +} +.view-toggle button.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +/* ── Avatar crop modal ── */ +[x-cloak] { display: none !important; } + +.crop-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.8); + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.crop-modal { + background: var(--surface); + border-radius: 14px; + width: 100%; + max-width: 380px; + overflow: hidden; +} + +.crop-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.85rem 1rem; + border-bottom: 1px solid var(--surface2); + font-weight: 600; +} + +.crop-container { + width: 100%; + aspect-ratio: 1; + background: #000; + overflow: hidden; +} + +/* Circular crop preview overlay */ +.crop-container .cropper-view-box, +.crop-container .cropper-face { + border-radius: 50%; +} + +.crop-hint { + text-align: center; + color: var(--text-muted); + font-size: 0.78rem; + padding: 0.5rem 1rem; +} + +.crop-modal-actions { + display: flex; + gap: 0.75rem; + padding: 0.75rem 1rem 1rem; +} + +/* ── File input button ── */ +.file-input-label { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.65rem 1rem; + border: 1px dashed var(--surface2); + border-radius: 8px; + background: var(--bg); + color: var(--text-muted); + font-size: 0.9rem; + cursor: pointer; + width: 100%; + transition: border-color 0.15s, color 0.15s; +} +.file-input-label:hover { border-color: var(--accent); color: var(--accent); } + +/* ── Per-player score card ── */ +.player-score-card { + display: flex; + flex-direction: column; + gap: 0.55rem; + background: var(--surface); + border-radius: 12px; + padding: 0.75rem 0.85rem; + margin-bottom: 0.6rem; +} + +.psc-name { + font-weight: 700; + font-size: 1rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.psc-controls { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.drag-handle { + color: var(--text-muted); + font-size: 1.1rem; + cursor: grab; + margin-right: 0.5rem; + user-select: none; + touch-action: none; +} +.drag-handle:active { cursor: grabbing; } +/* ── Golf score markers ── */ +.score-hio, .score-eagle, .score-birdie, .score-bogey, .score-double-bogey { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.6rem; + height: 1.6rem; + font-weight: 600; +} +.score-hio { + background: gold; + color: #000; + border-radius: 50%; +} +.score-eagle { + border: 2px solid #3b82f6; + border-radius: 50%; + outline: 2px solid #3b82f6; + outline-offset: 2px; + color: #3b82f6; +} +.score-birdie { + border: 2px solid var(--green-light); + border-radius: 50%; + color: var(--green-light); +} +.score-bogey { + border: 2px solid var(--danger); + color: var(--danger); +} +.score-double-bogey { + border: 2px solid #888; + outline: 2px solid #888; + outline-offset: 2px; + color: #888; +} + +.drag-source { opacity: 0.4; } +.drag-over { outline: 2px solid var(--accent); border-radius: 0.65rem; } + +.stroke-controls { + display: inline-flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: stretch; + flex-shrink: 0; + background: var(--bg); + border: 1px solid var(--surface2); + border-radius: 8px; + overflow: hidden; + vertical-align: middle; +} + +.stroke-btn { + width: 2.8rem; + min-height: 3.2rem; + border: none; + background: none; + color: var(--text); + font-size: 1.25rem; + font-weight: 700; + cursor: pointer; + user-select: none; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.1s; +} +.stroke-btn:active { background: var(--surface2); } + +.stroke-val { + min-width: 2.4rem; + min-height: 3.2rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + font-weight: 700; + user-select: none; + flex-shrink: 0; +} + +.bbb-btn { + height: 3.2rem; + padding: 0 0.75rem; + border-radius: 8px; + border: 2px solid transparent; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.03em; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + background: var(--bg); + color: var(--text-muted); + white-space: nowrap; + flex-shrink: 0; +} +.bbb-btn:active { opacity: 0.7; } + +.bingo-btn.active { background: rgba(255,190,50,0.15); border-color: #ffbe32; color: #ffbe32; } +.bango-btn.active { background: rgba(82,183,136,0.15); border-color: var(--accent); color: var(--accent); } +.bongo-btn.active { background: rgba(120,120,255,0.15); border-color: #9090ff; color: #9090ff; } + +.bingo-btn { border-color: rgba(255,190,50,0.3); } +.bango-btn { border-color: rgba(82,183,136,0.3); } +.bongo-btn { border-color: rgba(120,120,255,0.3); } + +/* ── Scorecard sub-toggle ── */ +.scorecard-toggle { + display: flex; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.scorecard-toggle button { + flex: 1; + padding: 0.45rem; + background: var(--bg); + border: 1px solid var(--surface2); + border-radius: 6px; + color: var(--text-muted); + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} + +.scorecard-toggle button.active { + border-color: var(--accent); + color: var(--accent); +} + +/* ── Admin sub-navigation ── */ +.admin-subnav { + display: flex; + gap: 0.5rem; + margin-bottom: 1.25rem; +} +.admin-subnav-item { + padding: 0.4rem 1rem; + border-radius: 2rem; + font-size: 0.85rem; + font-weight: 500; + text-decoration: none; + color: var(--text-muted); + border: 1px solid var(--border); + transition: background 0.15s, color 0.15s; +} +.admin-subnav-item.active, +.admin-subnav-item:hover { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +/* ── Hole par grid (course admin) ── */ +.hole-pars-grid { + display: grid; + grid-template-columns: repeat(9, 1fr); + gap: 0.4rem; +} +.hole-par-cell { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; +} +.hole-par-label { + font-size: 0.7rem; + color: var(--text-muted); + font-weight: 600; +} +.hole-par-select { + width: 100%; + padding: 0.35rem 0.1rem; + text-align: center; + border-radius: 0.4rem; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + font-size: 0.9rem; +} +/* ── Table action buttons ── */ +.btn-table-action { + display: inline-block; + padding: 0.25rem 0.6rem; + border-radius: 5px; + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + text-decoration: none; + border: 1px solid var(--surface2); + background: var(--bg); + color: var(--text-muted); + margin-left: 0.25rem; +} +.btn-table-action.danger { border-color: rgba(230,57,70,0.4); color: #ff8fa3; } + +/* ── Dashboard stats ── */ +.stats-row { + display: flex; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.stat-card { + flex: 1; + background: var(--surface); + border-radius: 12px; + padding: 1rem 0.75rem; + text-align: center; +} + +.stat-value { + font-size: 1.75rem; + font-weight: 800; + color: var(--accent); + line-height: 1; +} + +.stat-label { + font-size: 0.7rem; + color: var(--text-muted); + margin-top: 0.3rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* ── Game result card ── */ +.game-result-card { + margin-bottom: 0.75rem; +} + +.result-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0; + font-size: 0.9rem; +} + +.result-winner { color: var(--accent); font-weight: 600; } +.result-rank { width: 1.4rem; text-align: center; font-size: 0.85rem; color: var(--text-muted); } +.result-winner .result-rank { color: var(--accent); } +.result-avatar { font-size: 1.1rem; line-height: 1; } +.result-name { flex: 1; color: var(--text); font-weight: 500; } +.result-winner .result-name { color: var(--accent); } +.result-pts { font-weight: 600; font-size: 0.85rem; color: var(--text); } +.result-winner .result-pts { color: var(--accent); } diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..4d64511 --- /dev/null +++ b/static/sw.js @@ -0,0 +1,27 @@ +const CACHE = "skinspins-v1"; +const PRECACHE = ["/static/style.css", "/static/manifest.json"]; + +self.addEventListener("install", (e) => { + e.waitUntil(caches.open(CACHE).then((c) => c.addAll(PRECACHE))); + self.skipWaiting(); +}); + +self.addEventListener("activate", (e) => { + e.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (e) => { + // Network-first for navigation; cache-first for static assets + if (e.request.mode === "navigate") { + e.respondWith(fetch(e.request).catch(() => caches.match(e.request))); + } else { + e.respondWith( + caches.match(e.request).then((cached) => cached || fetch(e.request)) + ); + } +});