Add full SkinsPins application
- FastAPI + SQLite + Alpine.js + HTMX PWA for Bingo Bango Bongo golf scoring - Passwordless magic link auth with email (aiosmtplib) and admin invite system - Real-time score entry via WebSocket with drag-and-drop player ordering - Course management with per-hole par and stroke index - Scorecard with golf score markers (birdie, eagle, bogey, etc.) - Two-step new game form with tee selection per player - Dashboard with stats, live games social stream, and recent game history - Game summary view (read-only scorecard with leaderboard) - User profile pages with win stats - PWA install support with generated PNG icons - Admin panel for users, courses, games, and invitations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
.DS_Store
|
||||||
@@ -4,5 +4,45 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## What This Is
|
## 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
|
||||||
|
|
||||||
|
|||||||
+11
@@ -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"]
|
||||||
+93
@@ -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()
|
||||||
@@ -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
|
||||||
+128
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"""
|
||||||
|
<div style="font-family:sans-serif;max-width:480px;margin:auto;">
|
||||||
|
<h2 style="color:#2d6a4f;">⛳ {SITE_NAME}</h2>
|
||||||
|
<p>Click the button below to log in. This link expires in 15 minutes.</p>
|
||||||
|
<p style="margin:2rem 0;">
|
||||||
|
<a href="{link}"
|
||||||
|
style="background:#2d6a4f;color:#fff;padding:0.75rem 1.5rem;border-radius:0.5rem;text-decoration:none;font-weight:600;">
|
||||||
|
Log in to {SITE_NAME}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="color:#888;font-size:0.85rem;">If the button doesn't work, copy this link:<br>{link}</p>
|
||||||
|
<p style="color:#888;font-size:0.85rem;">If you didn't request this, you can ignore this email.</p>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
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"""
|
||||||
|
<div style="font-family:sans-serif;max-width:480px;margin:auto;">
|
||||||
|
<h2 style="color:#2d6a4f;">⛳ {SITE_NAME}</h2>
|
||||||
|
<p><strong>{invited_by_name}</strong> has invited you to join {SITE_NAME} — a golf score tracking app.</p>
|
||||||
|
<p style="margin:2rem 0;">
|
||||||
|
<a href="{login_url}"
|
||||||
|
style="background:#2d6a4f;color:#fff;padding:0.75rem 1.5rem;border-radius:0.5rem;text-decoration:none;font-weight:600;">
|
||||||
|
Accept invitation
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="color:#888;font-size:0.85rem;">If the button doesn't work, copy this link:<br>{login_url}</p>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
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)
|
||||||
+69
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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."},
|
||||||
|
)
|
||||||
@@ -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},
|
||||||
|
)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Admin – Skins & Pins{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>⚙️ Admin</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-subnav">
|
||||||
|
<a href="/admin" class="admin-subnav-item active">Users</a>
|
||||||
|
<a href="/admin/courses" class="admin-subnav-item">Courses</a>
|
||||||
|
<a href="/admin/games" class="admin-subnav-item">Games</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if request.query_params.get('error') %}
|
||||||
|
<div class="alert alert-error">{{ request.query_params.get('error') }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if request.query_params.get('success') %}
|
||||||
|
<div class="alert alert-success">{{ request.query_params.get('success') }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if error %}<div class="alert alert-error">{{ error }}</div>{% endif %}
|
||||||
|
{% if success %}<div class="alert alert-success">{{ success }}</div>{% endif %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="font-size: 1rem; margin-bottom: 1rem; color: var(--text-muted);">Invite a player</h2>
|
||||||
|
<form method="post" action="/admin/invite">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email address</label>
|
||||||
|
<input type="email" id="email" name="email" placeholder="player@example.com"
|
||||||
|
autocomplete="off" autocapitalize="none" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Send invitation</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="font-size: 1rem; margin-bottom: 1rem; color: var(--text-muted);">Pending invitations</h2>
|
||||||
|
{% if invitations %}
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Email</th><th>Invited</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for inv in invitations %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ inv.email }}</td>
|
||||||
|
<td style="color: var(--text-muted);">{{ inv.created_at[:10] }}</td>
|
||||||
|
<td style="white-space:nowrap;">
|
||||||
|
<form method="post" action="/admin/invitations/{{ inv.id }}/resend" style="display:inline;">
|
||||||
|
<button type="submit" class="btn-table-action">Resend</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/invitations/{{ inv.id }}/delete" style="display:inline;"
|
||||||
|
onsubmit="return confirm('Delete invitation for {{ inv.email }}?')">
|
||||||
|
<button type="submit" class="btn-table-action danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.9rem;">No pending invitations.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
|
||||||
|
<h2 style="font-size: 1rem; color: var(--text-muted); margin:0;">Registered users</h2>
|
||||||
|
<a href="/admin/users/new" class="btn btn-primary" style="width:auto; padding:0.4rem 0.85rem; font-size:0.85rem;">+ Add user</a>
|
||||||
|
</div>
|
||||||
|
{% if users %}
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>User</th><th>Hcp</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in users %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if u.avatar and u.avatar.startswith('/') %}
|
||||||
|
<img src="{{ u.avatar }}" style="width:1.4rem;height:1.4rem;border-radius:50%;object-fit:cover;vertical-align:middle;margin-right:0.3rem;">
|
||||||
|
{% else %}
|
||||||
|
{{ u.avatar or '👤' }}
|
||||||
|
{% endif %}
|
||||||
|
{% if u.name %}<a href="/users/{{ u.id }}" style="color:inherit; text-decoration:none;">{{ u.name }}</a>{% else %}(not set up){% endif %}
|
||||||
|
<div style="font-size:0.75rem; color:var(--text-muted);">{{ u.email }}</div>
|
||||||
|
</td>
|
||||||
|
<td style="color:var(--text-muted);">{{ u.handicap if u.handicap is not none else '—' }}</td>
|
||||||
|
<td style="white-space:nowrap;">
|
||||||
|
<a href="/admin/users/{{ u.id }}/edit" class="btn-table-action">Edit</a>
|
||||||
|
{% if u.id != user.id %}
|
||||||
|
<form method="post" action="/admin/users/{{ u.id }}/delete" style="display:inline;"
|
||||||
|
onsubmit="return confirm('Delete {{ u.name or u.email }}?')">
|
||||||
|
<button type="submit" class="btn-table-action danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.9rem;">No users yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Courses – Skins & Pins{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>⚙️ Admin</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-subnav">
|
||||||
|
<a href="/admin" class="admin-subnav-item">Users</a>
|
||||||
|
<a href="/admin/courses" class="admin-subnav-item active">Courses</a>
|
||||||
|
<a href="/admin/games" class="admin-subnav-item">Games</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if request.query_params.get('success') %}
|
||||||
|
<div class="alert alert-success">{{ request.query_params.get('success') }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:flex-end; margin-bottom:1rem;">
|
||||||
|
<a href="/admin/courses/new" class="btn btn-primary" style="width:auto; padding:0.5rem 1rem;">+ New course</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if courses %}
|
||||||
|
<div class="card" style="padding:0;">
|
||||||
|
<table class="data-table" style="margin:0;">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Course</th><th>Holes</th><th>Location</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in courses %}
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:500;">{{ c.name }}</td>
|
||||||
|
<td style="color:var(--text-muted);">{{ c.holes }}</td>
|
||||||
|
<td style="color:var(--text-muted);">{{ c.location or '—' }}</td>
|
||||||
|
<td style="white-space:nowrap;">
|
||||||
|
<a href="/admin/courses/{{ c.id }}/edit" class="btn-table-action">Edit</a>
|
||||||
|
<form method="post" action="/admin/courses/{{ c.id }}/delete" style="display:inline;"
|
||||||
|
onsubmit="return confirm('Delete {{ c.name }}? This cannot be undone.')">
|
||||||
|
<button type="submit" class="btn-table-action danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">⛳</div>
|
||||||
|
<p>No courses yet.</p>
|
||||||
|
<a href="/admin/courses/new" class="btn btn-primary" style="margin-top:1rem; max-width:200px;">Add a course</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Edit Course – Skins & Pins{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<a href="/admin/courses" style="color:var(--text-muted); font-size:0.9rem;">← Courses</a>
|
||||||
|
<h1 style="margin-top:0.25rem;">{{ course.name }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if request.query_params.get('success') %}
|
||||||
|
<div class="alert alert-success">{{ request.query_params.get('success') }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Course info + hole pars ── #}
|
||||||
|
<form method="post" action="/admin/courses/{{ course.id }}/edit">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="font-size:0.95rem; color:var(--text-muted); margin-bottom:1rem;">Course info</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Course name</label>
|
||||||
|
{% if errors and errors.name %}<div class="alert alert-error">{{ errors.name }}</div>{% endif %}
|
||||||
|
<input type="text" id="name" name="name" value="{{ course.name }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="location">Location <span style="color:var(--text-muted); font-size:0.8rem;">(optional)</span></label>
|
||||||
|
<input type="text" id="location" name="location" value="{{ course.location or '' }}"
|
||||||
|
placeholder="e.g. Augusta, Georgia">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Holes</label>
|
||||||
|
<div style="display:flex; gap:0.75rem;">
|
||||||
|
<label class="radio-card">
|
||||||
|
<input type="radio" name="holes" value="9" {% if course.holes == 9 %}checked{% endif %}>
|
||||||
|
<span>9 holes</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio-card">
|
||||||
|
<input type="radio" name="holes" value="18" {% if course.holes == 18 %}checked{% endif %}>
|
||||||
|
<span>18 holes</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
{% set course_par = holes | sum(attribute='par') %}
|
||||||
|
<h2 style="font-size:0.95rem; color:var(--text-muted); margin-bottom:0.75rem;">
|
||||||
|
Hole details <span style="font-weight:400;">— Course par: <strong>{{ course_par }}</strong></span>
|
||||||
|
</h2>
|
||||||
|
<div class="hole-pars-grid">
|
||||||
|
{% for h in holes %}
|
||||||
|
<div class="hole-par-cell">
|
||||||
|
<div class="hole-par-label">{{ h.hole_number }}</div>
|
||||||
|
<select name="par_{{ h.hole_number }}" class="hole-par-select">
|
||||||
|
<option value="3" {% if h.par == 3 %}selected{% endif %}>3</option>
|
||||||
|
<option value="4" {% if h.par == 4 %}selected{% endif %}>4</option>
|
||||||
|
<option value="5" {% if h.par == 5 %}selected{% endif %}>5</option>
|
||||||
|
</select>
|
||||||
|
<select name="si_{{ h.hole_number }}" class="hole-par-select">
|
||||||
|
<option value="">—</option>
|
||||||
|
{% for s in range(1, course.holes + 1) %}
|
||||||
|
<option value="{{ s }}" {% if h.stroke_index == s %}selected{% endif %}>{{ s }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" style="margin-bottom:1.5rem;">Save changes</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# ── Tees ── #}
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="font-size:0.95rem; color:var(--text-muted); margin-bottom:1rem;">Tees</h2>
|
||||||
|
|
||||||
|
{% if tees %}
|
||||||
|
<div style="margin-bottom:1.25rem;">
|
||||||
|
{% for t in tees %}
|
||||||
|
<div x-data="{ editing: false }" style="border-bottom:1px solid var(--border); padding:0.6rem 0;">
|
||||||
|
|
||||||
|
<div x-show="!editing" style="display:flex; align-items:center; gap:0.75rem; flex-wrap:wrap;">
|
||||||
|
<span style="font-weight:500; min-width:5rem;">{{ t.name }}</span>
|
||||||
|
<span style="color:var(--text-muted); font-size:0.85rem;">Slope {{ t.slope }}</span>
|
||||||
|
<span style="color:var(--text-muted); font-size:0.85rem;">Rating {{ t.rating }}</span>
|
||||||
|
<span style="margin-left:auto; display:flex; gap:0.4rem;">
|
||||||
|
<button type="button" @click="editing = true" class="btn-table-action">Edit</button>
|
||||||
|
<form method="post" action="/admin/courses/{{ course.id }}/tees/{{ t.id }}/delete"
|
||||||
|
style="display:inline;"
|
||||||
|
onsubmit="return confirm('Delete {{ t.name }} tee?')">
|
||||||
|
<button type="submit" class="btn-table-action danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form x-show="editing" method="post"
|
||||||
|
action="/admin/courses/{{ course.id }}/tees/{{ t.id }}/edit"
|
||||||
|
style="display:flex; gap:0.5rem; align-items:flex-end; flex-wrap:wrap; padding-top:0.4rem;">
|
||||||
|
<div class="form-group" style="margin:0; flex:1; min-width:7rem;">
|
||||||
|
<label style="font-size:0.8rem;">Name</label>
|
||||||
|
<input type="text" name="name" value="{{ t.name }}" required style="padding:0.4rem 0.6rem;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0; width:6rem;">
|
||||||
|
<label style="font-size:0.8rem;">Slope</label>
|
||||||
|
<input type="number" name="slope" value="{{ t.slope }}" min="55" max="155" required style="padding:0.4rem 0.6rem;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0; width:7rem;">
|
||||||
|
<label style="font-size:0.8rem;">Rating</label>
|
||||||
|
<input type="number" step="0.1" name="rating" value="{{ t.rating }}" min="60" max="80" required style="padding:0.4rem 0.6rem;">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:0.4rem; padding-bottom:0.1rem;">
|
||||||
|
<button type="submit" class="btn btn-primary" style="padding:0.4rem 0.75rem; font-size:0.85rem;">Save</button>
|
||||||
|
<button type="button" @click="editing = false"
|
||||||
|
style="padding:0.4rem 0.75rem; font-size:0.85rem; background:none; border:1px solid var(--border); border-radius:0.4rem; color:var(--text-muted); cursor:pointer;">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p style="color:var(--text-muted); font-size:0.9rem; margin-bottom:1rem;">No tees yet. Add at least one tee so players can start a game on this course.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Add tee form #}
|
||||||
|
<h3 style="font-size:0.85rem; color:var(--text-muted); margin-bottom:0.75rem; text-transform:uppercase; letter-spacing:0.05em;">Add tee</h3>
|
||||||
|
<form method="post" action="/admin/courses/{{ course.id }}/tees/new"
|
||||||
|
style="display:flex; gap:0.5rem; align-items:flex-end; flex-wrap:wrap;">
|
||||||
|
<div class="form-group" style="margin:0; flex:1; min-width:7rem;">
|
||||||
|
<label style="font-size:0.8rem;">Name</label>
|
||||||
|
<input type="text" name="name" placeholder="e.g. White" required style="padding:0.4rem 0.6rem;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0; width:6rem;">
|
||||||
|
<label style="font-size:0.8rem;">Slope</label>
|
||||||
|
<input type="number" name="slope" placeholder="113" min="55" max="155" required style="padding:0.4rem 0.6rem;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0; width:7rem;">
|
||||||
|
<label style="font-size:0.8rem;">Rating</label>
|
||||||
|
<input type="number" step="0.1" name="rating" placeholder="72.0" min="60" max="80" required style="padding:0.4rem 0.6rem;">
|
||||||
|
</div>
|
||||||
|
<div style="padding-bottom:0.1rem;">
|
||||||
|
<button type="submit" class="btn btn-primary" style="padding:0.4rem 0.75rem; font-size:0.85rem;">Add tee</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Edit User – Skins & Pins{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/cropperjs@1.6.2/dist/cropper.min.css">
|
||||||
|
<script src="https://unpkg.com/cropperjs@1.6.2/dist/cropper.min.js"></script>
|
||||||
|
<script src="/static/avatar-crop.js"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<a href="/admin" style="color:var(--text-muted); font-size:0.9rem;">← Admin</a>
|
||||||
|
<h1 style="margin-top:0.25rem;">Edit user</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-data="avatarUpload('/admin/users/{{ target.id }}/edit', '/admin')"
|
||||||
|
x-init="tab = '{{ 'upload' if target.avatar and target.avatar.startswith('/') else 'emoji' }}';
|
||||||
|
previewUrl = '{{ target.avatar if target.avatar and target.avatar.startswith('/') else '' }}' || null;">
|
||||||
|
|
||||||
|
<form action="/admin/users/{{ target.id }}/edit" method="post" enctype="multipart/form-data"
|
||||||
|
@submit="submitForm($event)">
|
||||||
|
<div class="card">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
{% if errors and errors.name %}<div class="alert alert-error">{{ errors.name }}</div>{% endif %}
|
||||||
|
<input type="text" id="name" name="name" value="{{ target.name or '' }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
{% if errors and errors.email %}<div class="alert alert-error">{{ errors.email }}</div>{% endif %}
|
||||||
|
<input type="email" id="email" name="email" value="{{ target.email }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="handicap">Handicap (0–54)</label>
|
||||||
|
{% if errors and errors.handicap %}<div class="alert alert-error">{{ errors.handicap }}</div>{% endif %}
|
||||||
|
<input type="number" id="handicap" name="handicap" min="0" max="54" step="0.1"
|
||||||
|
value="{{ target.handicap or '' }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Avatar</label>
|
||||||
|
{% if errors and errors.avatar %}<div class="alert alert-error">{{ errors.avatar }}</div>{% endif %}
|
||||||
|
|
||||||
|
<div class="avatar-tabs">
|
||||||
|
<button type="button" class="avatar-tab" :class="{ active: tab === 'emoji' }" @click="tab = 'emoji'">Emoji</button>
|
||||||
|
<button type="button" class="avatar-tab" :class="{ active: tab === 'upload' }" @click="tab = 'upload'">Photo</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="tab === 'emoji'">
|
||||||
|
<div class="avatar-grid" style="margin-top: 0.75rem;">
|
||||||
|
{% for a in avatars %}
|
||||||
|
<div class="avatar-option">
|
||||||
|
<input type="radio" name="avatar_emoji" id="avatar_{{ loop.index }}" value="{{ a }}"
|
||||||
|
{% if target.avatar == a %}checked{% endif %}>
|
||||||
|
<label for="avatar_{{ loop.index }}">{{ a }}</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="tab === 'upload'" style="margin-top: 0.75rem;">
|
||||||
|
<div x-show="previewUrl" style="margin-bottom: 0.75rem; display:flex; align-items:center; gap:1rem;">
|
||||||
|
<img :src="previewUrl" class="avatar-preview">
|
||||||
|
<span style="color:var(--text-muted); font-size:0.85rem;">Tap to change</span>
|
||||||
|
</div>
|
||||||
|
<label class="file-input-label">
|
||||||
|
<span x-text="previewUrl ? 'Choose different photo' : 'Choose photo'"></span>
|
||||||
|
<input type="file" name="avatar_file" accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
style="display:none" @change="openFile($event)">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Save changes</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% include "partials/crop_modal.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Games – Admin – Skins & Pins{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>⚙️ Admin</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-subnav">
|
||||||
|
<a href="/admin" class="admin-subnav-item">Users</a>
|
||||||
|
<a href="/admin/courses" class="admin-subnav-item">Courses</a>
|
||||||
|
<a href="/admin/games" class="admin-subnav-item active">Games</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if request.query_params.get('success') %}
|
||||||
|
<div class="alert alert-success">{{ request.query_params.get('success') }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if games %}
|
||||||
|
<div class="card" style="padding:0; overflow:hidden;">
|
||||||
|
{% for g in games %}
|
||||||
|
{% set plist = players_by_game.get(g.id, []) %}
|
||||||
|
<div style="padding:0.85rem 1rem; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:0.75rem; flex-wrap:wrap;">
|
||||||
|
|
||||||
|
<div style="flex:1; min-width:0;">
|
||||||
|
<div style="font-weight:500; color:var(--text);">
|
||||||
|
{{ g.course_name or 'Unknown course' }}
|
||||||
|
<span class="status-badge status-{{ g.status }}" style="margin-left:0.4rem;">{{ g.status }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.8rem; color:var(--text-muted); margin-top:0.2rem;">
|
||||||
|
{{ g.holes_count }} holes · {{ g.created_at[:10] }} · by {{ g.creator_name }}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.8rem; color:var(--text-muted); margin-top:0.15rem;">
|
||||||
|
{% for p in plist %}
|
||||||
|
{% if p.avatar and p.avatar.startswith('/') %}
|
||||||
|
<img src="{{ p.avatar }}" style="width:1rem;height:1rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
|
||||||
|
{% else %}
|
||||||
|
{{ p.avatar or '👤' }}
|
||||||
|
{% endif %}
|
||||||
|
<a href="/users/{{ p.id }}" style="color:inherit; text-decoration:none;">{{ p.name }}</a>{% if not loop.last %}, {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:0.4rem; flex-shrink:0;">
|
||||||
|
<a href="/games/{{ g.id }}" class="btn-table-action">Edit scores</a>
|
||||||
|
<form method="post" action="/admin/games/{{ g.id }}/delete" style="display:inline;"
|
||||||
|
onsubmit="return confirm('Delete this game? All scores will be lost.')">
|
||||||
|
<button type="submit" class="btn-table-action danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">⛳</div>
|
||||||
|
<p>No games yet.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}New Course – Skins & Pins{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<a href="/admin/courses" style="color:var(--text-muted); font-size:0.9rem;">← Courses</a>
|
||||||
|
<h1 style="margin-top:0.25rem;">New course</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/courses/new"
|
||||||
|
x-data="{
|
||||||
|
holes: {{ (form.holes if form else 18)|int }},
|
||||||
|
initPars: {{ form.pars | tojson if (form and form.pars) else '{}' }},
|
||||||
|
initSIs: {{ form.stroke_indices | tojson if (form and form.stroke_indices) else '{}' }}
|
||||||
|
}">
|
||||||
|
<div class="card">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Course name</label>
|
||||||
|
{% if errors and errors.name %}<div class="alert alert-error">{{ errors.name }}</div>{% endif %}
|
||||||
|
<input type="text" id="name" name="name"
|
||||||
|
value="{{ form.name if form else '' }}" placeholder="e.g. Augusta National" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="location">Location <span style="color:var(--text-muted); font-size:0.8rem;">(optional)</span></label>
|
||||||
|
<input type="text" id="location" name="location"
|
||||||
|
value="{{ form.location if form else '' }}" placeholder="e.g. Augusta, Georgia">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Holes</label>
|
||||||
|
{% if errors and errors.holes %}<div class="alert alert-error">{{ errors.holes }}</div>{% endif %}
|
||||||
|
<div style="display:flex; gap:0.75rem;">
|
||||||
|
<label class="radio-card">
|
||||||
|
<input type="radio" name="holes" value="9" x-model.number="holes">
|
||||||
|
<span>9 holes</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio-card">
|
||||||
|
<input type="radio" name="holes" value="18" x-model.number="holes">
|
||||||
|
<span>18 holes</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="font-size:0.95rem; color:var(--text-muted); margin-bottom:0.75rem;">
|
||||||
|
Hole details
|
||||||
|
<span style="font-weight:400; font-size:0.85rem;">
|
||||||
|
— Course par: <strong x-text="Array.from({length: holes}, (_, i) => parseInt(document.querySelector('[name=par_' + (i+1) + ']')?.value || 4)).reduce((a,b)=>a+b,0)"></strong>
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div class="hole-pars-grid">
|
||||||
|
<template x-for="n in Array.from({length: holes}, (_, i) => i + 1)" :key="n">
|
||||||
|
<div class="hole-par-cell">
|
||||||
|
<div class="hole-par-label" x-text="n"></div>
|
||||||
|
<select :name="'par_' + n" class="hole-par-select">
|
||||||
|
<option value="3" :selected="(initPars[String(n)] || 4) == 3">3</option>
|
||||||
|
<option value="4" :selected="(initPars[String(n)] || 4) == 4">4</option>
|
||||||
|
<option value="5" :selected="(initPars[String(n)] || 4) == 5">5</option>
|
||||||
|
</select>
|
||||||
|
<select :name="'si_' + n" class="hole-par-select">
|
||||||
|
<option value="">—</option>
|
||||||
|
<template x-for="s in Array.from({length: holes}, (_, i) => i + 1)" :key="s">
|
||||||
|
<option :value="s" :selected="(initSIs[String(n)] || null) == s" x-text="s"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Create course</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Add User – Skins & Pins{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/cropperjs@1.6.2/dist/cropper.min.css">
|
||||||
|
<script src="https://unpkg.com/cropperjs@1.6.2/dist/cropper.min.js"></script>
|
||||||
|
<script src="/static/avatar-crop.js"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<a href="/admin" style="color:var(--text-muted); font-size:0.9rem;">← Admin</a>
|
||||||
|
<h1 style="margin-top:0.25rem;">Add user</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-data="avatarUpload('/admin/users/new', '/admin')">
|
||||||
|
|
||||||
|
<form action="/admin/users/new" method="post" enctype="multipart/form-data"
|
||||||
|
@submit="submitForm($event)">
|
||||||
|
<div class="card">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
{% if errors and errors.name %}<div class="alert alert-error">{{ errors.name }}</div>{% endif %}
|
||||||
|
<input type="text" id="name" name="name" value="{{ form.name if form else '' }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
{% if errors and errors.email %}<div class="alert alert-error">{{ errors.email }}</div>{% endif %}
|
||||||
|
<input type="email" id="email" name="email" value="{{ form.email if form else '' }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="handicap">Handicap (0–54)</label>
|
||||||
|
{% if errors and errors.handicap %}<div class="alert alert-error">{{ errors.handicap }}</div>{% endif %}
|
||||||
|
<input type="number" id="handicap" name="handicap" min="0" max="54" step="0.1"
|
||||||
|
value="{{ form.handicap if form else '' }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Avatar</label>
|
||||||
|
{% if errors and errors.avatar %}<div class="alert alert-error">{{ errors.avatar }}</div>{% endif %}
|
||||||
|
|
||||||
|
<div class="avatar-tabs">
|
||||||
|
<button type="button" class="avatar-tab" :class="{ active: tab === 'emoji' }" @click="tab = 'emoji'">Emoji</button>
|
||||||
|
<button type="button" class="avatar-tab" :class="{ active: tab === 'upload' }" @click="tab = 'upload'">Photo</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="tab === 'emoji'">
|
||||||
|
<div class="avatar-grid" style="margin-top: 0.75rem;">
|
||||||
|
{% for a in avatars %}
|
||||||
|
<div class="avatar-option">
|
||||||
|
<input type="radio" name="avatar_emoji" id="avatar_{{ loop.index }}" value="{{ a }}">
|
||||||
|
<label for="avatar_{{ loop.index }}">{{ a }}</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="tab === 'upload'" style="margin-top: 0.75rem;">
|
||||||
|
<div x-show="previewUrl" style="margin-bottom: 0.75rem; display:flex; align-items:center; gap:1rem;">
|
||||||
|
<img :src="previewUrl" class="avatar-preview">
|
||||||
|
<span style="color:var(--text-muted); font-size:0.85rem;">Tap to change</span>
|
||||||
|
</div>
|
||||||
|
<label class="file-input-label">
|
||||||
|
<span x-text="previewUrl ? 'Choose different photo' : 'Choose photo'"></span>
|
||||||
|
<input type="file" name="avatar_file" accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
style="display:none" @change="openFile($event)">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Add user</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% include "partials/crop_modal.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#2d6a4f">
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<title>Check your email – Skins & Pins</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-logo">📬</div>
|
||||||
|
<h1 class="login-title">Check your email</h1>
|
||||||
|
<p class="login-subtitle">We sent a login link to <strong>{{ email }}</strong></p>
|
||||||
|
|
||||||
|
<div class="login-card card">
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.9rem; text-align: center;">
|
||||||
|
The link expires in 15 minutes.<br>You can close this tab.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/login" style="color: var(--text-muted); font-size: 0.85rem; margin-top: 1.5rem;">
|
||||||
|
← Use a different email
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#2d6a4f">
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<title>Login – Skins & Pins</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-logo">⛳</div>
|
||||||
|
<h1 class="login-title">Skins & Pins</h1>
|
||||||
|
<p class="login-subtitle">Enter your email to get a login link</p>
|
||||||
|
|
||||||
|
<div class="login-card card">
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email address</label>
|
||||||
|
<input type="email" id="email" name="email" placeholder="you@example.com"
|
||||||
|
autocomplete="email" autocapitalize="none" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Send login link</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#2d6a4f">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<title>{% block title %}Skins & Pins{% endblock %}</title>
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js" defer></script>
|
||||||
|
<script src="https://unpkg.com/alpinejs@3.14.3/dist/cdn.min.js" defer></script>
|
||||||
|
{% block extra_head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{# PWA install banner #}
|
||||||
|
<div id="pwa-banner" style="display:none;">
|
||||||
|
<div id="pwa-banner-android" style="display:none;">
|
||||||
|
<span>Install Skins & Pins for the best experience</span>
|
||||||
|
<div class="pwa-banner-actions">
|
||||||
|
<button class="pwa-install-btn" onclick="pwaDismiss()">Not now</button>
|
||||||
|
<button class="pwa-install-btn pwa-install-primary" onclick="pwaInstall()">Install</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="pwa-banner-ios" style="display:none;">
|
||||||
|
<span>Install: tap <strong>Share</strong> then <strong>Add to Home Screen</strong></span>
|
||||||
|
<button class="pwa-install-btn" onclick="pwaDismiss()">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
|
{% if user is defined %}
|
||||||
|
{% include "nav.html" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/static/sw.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
// PWA install prompt
|
||||||
|
let deferredPrompt = null;
|
||||||
|
const banner = document.getElementById('pwa-banner');
|
||||||
|
const androidBanner = document.getElementById('pwa-banner-android');
|
||||||
|
const iosBanner = document.getElementById('pwa-banner-ios');
|
||||||
|
|
||||||
|
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|
||||||
|
|| navigator.standalone === true;
|
||||||
|
const isDismissed = localStorage.getItem('pwa-dismissed') === '1';
|
||||||
|
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent) && !window.MSStream;
|
||||||
|
const isAndroid = /android/i.test(navigator.userAgent);
|
||||||
|
|
||||||
|
function showBanner() {
|
||||||
|
banner.style.display = 'flex';
|
||||||
|
document.body.style.paddingTop = banner.offsetHeight + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStandalone && !isDismissed) {
|
||||||
|
if (isIos) {
|
||||||
|
showBanner();
|
||||||
|
iosBanner.style.display = 'flex';
|
||||||
|
} else if (isAndroid) {
|
||||||
|
// Try native install prompt first
|
||||||
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
deferredPrompt = e;
|
||||||
|
showBanner();
|
||||||
|
androidBanner.style.display = 'flex';
|
||||||
|
});
|
||||||
|
// Fallback: if prompt doesn't fire (e.g. HTTP), show manual instructions
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!deferredPrompt && banner.style.display === 'none') {
|
||||||
|
const btn = androidBanner.querySelector('.pwa-install-primary');
|
||||||
|
btn.textContent = 'How?';
|
||||||
|
btn.onclick = () => alert('Tap ⋮ in Chrome, then "Add to Home screen" or "Install app".');
|
||||||
|
showBanner();
|
||||||
|
androidBanner.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pwaInstall() {
|
||||||
|
if (!deferredPrompt) return;
|
||||||
|
deferredPrompt.prompt();
|
||||||
|
deferredPrompt.userChoice.then(() => {
|
||||||
|
deferredPrompt = null;
|
||||||
|
banner.style.display = 'none';
|
||||||
|
localStorage.setItem('pwa-dismissed', '1');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pwaDismiss() {
|
||||||
|
banner.style.display = 'none';
|
||||||
|
localStorage.setItem('pwa-dismissed', '1');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Dashboard – Skins & Pins{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>
|
||||||
|
{% if user.avatar and user.avatar.startswith('/') %}
|
||||||
|
<img src="{{ user.avatar }}" alt="avatar" style="width:2rem; height:2rem; border-radius:50%; object-fit:cover; vertical-align:middle;">
|
||||||
|
{% elif user.avatar %}
|
||||||
|
{{ user.avatar }}
|
||||||
|
{% endif %}
|
||||||
|
<a href="/users/{{ user.id }}" style="color:inherit; text-decoration:none;">{{ user.name }}</a>
|
||||||
|
</h1>
|
||||||
|
<p>Handicap {{ user.handicap }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if games_played > 0 %}
|
||||||
|
{# Stats row #}
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ games_played }}</div>
|
||||||
|
<div class="stat-label">Games played</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ victories }}</div>
|
||||||
|
<div class="stat-label">Victories</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">
|
||||||
|
{{ ((victories / games_played) * 100) | round | int }}%
|
||||||
|
</div>
|
||||||
|
<div class="stat-label">Win rate</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Social stream: active games #}
|
||||||
|
{% if active_games %}
|
||||||
|
<h2 style="font-size:0.85rem; color:var(--text-muted); margin-bottom:0.6rem; text-transform:uppercase; letter-spacing:0.06em;">Live games</h2>
|
||||||
|
{% for game in active_games %}
|
||||||
|
<div class="card game-result-card" style="cursor:pointer;" onclick="window.location='/games/{{ game.id }}/summary'">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:0.15rem;">
|
||||||
|
<div style="font-weight:600;">{{ game.course_name or '—' }}</div>
|
||||||
|
{% if game.is_participant %}
|
||||||
|
<a href="/games/{{ game.id }}" style="font-size:0.75rem; color:var(--accent); text-decoration:none; font-weight:600;" onclick="event.stopPropagation()">▶ Play</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.5rem;">
|
||||||
|
{{ game.format | replace('_', ' ') | title }} · {{ game.holes_count }} holes · {{ game.created_at[:10] }}
|
||||||
|
</div>
|
||||||
|
{% for p in game.players %}
|
||||||
|
<div class="result-row">
|
||||||
|
<span class="result-rank">{{ loop.index }}</span>
|
||||||
|
<span class="result-avatar">
|
||||||
|
{% if p.avatar and p.avatar.startswith('/') %}
|
||||||
|
<img src="{{ p.avatar }}" style="width:1.3rem;height:1.3rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
|
||||||
|
{% else %}
|
||||||
|
{{ p.avatar or '👤' }}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="result-name">{{ p.name }}</span>
|
||||||
|
<span class="result-pts">{{ p.total | round(1) }} pts</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Recent games #}
|
||||||
|
{% if recent_games %}
|
||||||
|
<h2 style="font-size:0.85rem; color:var(--text-muted); margin-bottom:0.6rem; text-transform:uppercase; letter-spacing:0.06em;">Recent games</h2>
|
||||||
|
{% for game in recent_games %}
|
||||||
|
<div class="card game-result-card" style="cursor:pointer;" onclick="window.location='/games/{{ game.id }}/summary'">
|
||||||
|
<div style="font-weight:600; margin-bottom:0.15rem;">{{ game.course_name or '—' }}</div>
|
||||||
|
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.5rem;">
|
||||||
|
{{ game.format | replace('_', ' ') | title }} · {{ game.holes_count }} holes · {{ game.created_at[:10] }}
|
||||||
|
</div>
|
||||||
|
{% for r in game.results %}
|
||||||
|
<div class="result-row {% if loop.first %}result-winner{% endif %}">
|
||||||
|
<span class="result-rank">{% if loop.first %}🏆{% else %}{{ loop.index }}{% endif %}</span>
|
||||||
|
<span class="result-avatar">
|
||||||
|
{% if r.avatar and r.avatar.startswith('/') %}
|
||||||
|
<img src="{{ r.avatar }}" style="width:1.3rem;height:1.3rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
|
||||||
|
{% else %}
|
||||||
|
{{ r.avatar or '👤' }}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="result-name">{{ r.name }}</span>
|
||||||
|
<span class="result-pts">{{ r.total | round(1) }} pts</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not games_played and not active_games %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">⛳</div>
|
||||||
|
<p>No games played yet.</p>
|
||||||
|
<a href="/games/new" class="btn btn-primary" style="margin-top:1rem; max-width:200px;">Start a game</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Game – Skins & Pins{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div id="game-app"
|
||||||
|
x-data="gameApp()"
|
||||||
|
x-init="init()"
|
||||||
|
style="height: calc(100vh - 64px); display:flex; flex-direction:column; overflow:hidden;">
|
||||||
|
|
||||||
|
{# ── Header ── #}
|
||||||
|
<div class="game-header">
|
||||||
|
<div style="display:flex; align-items:center; gap:0.75rem;">
|
||||||
|
<button @click="view === 'score' ? prevHole() : cardView = 'strokes'" class="icon-btn"
|
||||||
|
:disabled="view === 'score' ? currentHole <= 1 : cardView === 'strokes'">‹</button>
|
||||||
|
<div style="text-align:center; flex:1;">
|
||||||
|
<div class="hole-label" x-text="view === 'score' ? 'Hole ' + currentHole + ' / {{ game.holes_count }}' : (cardView === 'strokes' ? 'Strokes' : 'Points')"></div>
|
||||||
|
<div style="font-size:0.72rem; color:var(--text-muted); margin-top:0.1rem;">
|
||||||
|
<template x-if="view === 'score'">
|
||||||
|
<span x-text="(HOLE_PARS[currentHole] ? 'Par ' + HOLE_PARS[currentHole] : '') + (HOLE_PARS[currentHole] && HOLE_SIS[currentHole] ? ' · ' : '') + (HOLE_SIS[currentHole] ? 'SI ' + HOLE_SIS[currentHole] : '')"></span>
|
||||||
|
</template>
|
||||||
|
<template x-if="view === 'card'">
|
||||||
|
<span>{{ game.course_name or '' }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button @click="view === 'score' ? nextHole() : cardView = 'points'" class="icon-btn"
|
||||||
|
:disabled="view === 'score' ? currentHole >= {{ game.holes_count }} : cardView === 'points'">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Swipeable panels ── #}
|
||||||
|
<div class="swipe-container"
|
||||||
|
@touchstart="touchStart($event)"
|
||||||
|
@touchend="touchEnd($event)">
|
||||||
|
|
||||||
|
{# Score entry panel #}
|
||||||
|
<div class="swipe-panel" :class="{ active: view === 'score' }">
|
||||||
|
<div class="panel-scroll">
|
||||||
|
|
||||||
|
{# Skip hole #}
|
||||||
|
<div style="display:flex; justify-content:flex-end; padding: 0 0 0.75rem;">
|
||||||
|
<button class="btn-skip"
|
||||||
|
:class="{ skipped: isSkipped(currentHole) }"
|
||||||
|
@click="toggleSkip(currentHole)">
|
||||||
|
<span x-text="isSkipped(currentHole) ? '↩ Unskip hole' : 'Skip hole'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="!isSkipped(currentHole)" id="player-list">
|
||||||
|
<template x-for="(p, idx) in players" :key="p.id">
|
||||||
|
<div class="player-score-card"
|
||||||
|
draggable="true"
|
||||||
|
:class="{ 'drag-source': dragSrc === idx, 'drag-over': dragOver === idx }"
|
||||||
|
@dragstart="dragSrc = idx; $event.dataTransfer.effectAllowed = 'move'"
|
||||||
|
@dragenter.prevent="dragOver = idx"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop.prevent="dropPlayer(idx)"
|
||||||
|
@dragend="endDrag()">
|
||||||
|
<div class="psc-name">
|
||||||
|
<span class="drag-handle"
|
||||||
|
@touchstart.stop="dragSrc = idx; touchStartX = null"
|
||||||
|
@touchmove.stop.prevent="touchDragMove($event)"
|
||||||
|
@touchend.stop="touchDragEnd()">⠿</span>
|
||||||
|
<span x-text="p.name"></span>
|
||||||
|
</div>
|
||||||
|
<div class="psc-controls">
|
||||||
|
<span class="stroke-controls">
|
||||||
|
<button type="button" class="stroke-btn stroke-minus"
|
||||||
|
@click="adjustStrokes(currentHole, p.id, -1)">−</button>
|
||||||
|
<span class="stroke-val"
|
||||||
|
x-text="getScore(currentHole, p.id).strokes || '—'"></span>
|
||||||
|
<button type="button" class="stroke-btn stroke-plus"
|
||||||
|
@click="adjustStrokes(currentHole, p.id, 1)">+</button>
|
||||||
|
</span>
|
||||||
|
<button type="button" class="bbb-btn bingo-btn"
|
||||||
|
:class="{ active: getScore(currentHole, p.id).bingo }"
|
||||||
|
@click="setBingo(currentHole, p.id)">Bingo</button>
|
||||||
|
<button type="button" class="bbb-btn bango-btn"
|
||||||
|
:class="{ active: getScore(currentHole, p.id).bango > 0 }"
|
||||||
|
@click="toggleBango(currentHole, p.id)">Bango</button>
|
||||||
|
<button type="button" class="bbb-btn bongo-btn"
|
||||||
|
:class="{ active: getScore(currentHole, p.id).bongo }"
|
||||||
|
@click="setBongo(currentHole, p.id)">Bongo</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="isSkipped(currentHole)" class="empty-state" style="padding: 2rem 0;">
|
||||||
|
<div class="empty-icon">⏭</div>
|
||||||
|
<p>Hole {{ '{{ currentHole }}' }} skipped</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if game.status == 'active' %}
|
||||||
|
<template x-if="allHolesDone()">
|
||||||
|
<form method="post" action="/games/{{ game.id }}/finish" style="margin-top: 1rem;">
|
||||||
|
<button type="submit" class="btn btn-primary">Finish game</button>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Scorecard panel #}
|
||||||
|
<div class="swipe-panel" :class="{ active: view === 'card' }">
|
||||||
|
<div class="panel-scroll">
|
||||||
|
{# Scorecard sub-toggle #}
|
||||||
|
<div class="scorecard-toggle">
|
||||||
|
<button @click="cardView = 'strokes'" :class="{ active: cardView === 'strokes' }">Strokes</button>
|
||||||
|
<button @click="cardView = 'points'" :class="{ active: cardView === 'points' }">Points</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Strokes scorecard #}
|
||||||
|
<table class="scorecard" x-show="cardView === 'strokes'">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Hole</th>
|
||||||
|
{% if hole_pars %}<th style="color:var(--text-muted);">Par</th>{% endif %}
|
||||||
|
{% for p in players %}
|
||||||
|
<th>
|
||||||
|
{% if p.avatar and p.avatar.startswith('/') %}
|
||||||
|
<img src="{{ p.avatar }}" style="width:1.3rem;height:1.3rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
|
||||||
|
{% else %}
|
||||||
|
{{ p.avatar or '👤' }}
|
||||||
|
{% endif %}<br>
|
||||||
|
<a href="/users/{{ p.id }}" style="font-size:0.7rem; color:inherit; text-decoration:none;">{{ p.name.split()[0] }}</a>
|
||||||
|
{% set ph = playing_handicaps.get(p.id) %}
|
||||||
|
{% if ph is not none %}<br><span style="font-size:0.65rem; color:var(--text-muted);">HCP {{ ph }}</span>{% endif %}
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for h in holes %}
|
||||||
|
<tr :class="{ 'hole-skipped': isSkipped({{ h }}) }">
|
||||||
|
<td class="hole-num">{{ h }}</td>
|
||||||
|
{% if hole_pars %}<td class="score-cell" style="color:var(--text-muted);">{{ hole_pars.get(h, '—') }}</td>{% endif %}
|
||||||
|
{% for p in players %}
|
||||||
|
<td class="score-cell">
|
||||||
|
<span x-text="holeStrokesStr({{ h }}, '{{ p.id }}')"
|
||||||
|
:class="holeScoreClass({{ h }}, '{{ p.id }}')"></span>
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="totals-row">
|
||||||
|
<td>Total</td>
|
||||||
|
{% if hole_pars %}<td style="color:var(--text-muted);">{{ hole_pars.values() | sum }}</td>{% endif %}
|
||||||
|
{% for p in players %}
|
||||||
|
<td x-text="Object.values(scores).reduce((sum, h) => sum + (h['{{ p.id }}']?.strokes || 0), 0) || '—'"></td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{# Points scorecard #}
|
||||||
|
<table class="scorecard" x-show="cardView === 'points'">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Hole</th>
|
||||||
|
{% for p in players %}
|
||||||
|
<th>
|
||||||
|
{% if p.avatar and p.avatar.startswith('/') %}
|
||||||
|
<img src="{{ p.avatar }}" style="width:1.3rem;height:1.3rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
|
||||||
|
{% else %}
|
||||||
|
{{ p.avatar or '👤' }}
|
||||||
|
{% endif %}<br>
|
||||||
|
<a href="/users/{{ p.id }}" style="font-size:0.7rem; color:inherit; text-decoration:none;">{{ p.name.split()[0] }}</a>
|
||||||
|
{% set ph = playing_handicaps.get(p.id) %}
|
||||||
|
{% if ph is not none %}<br><span style="font-size:0.65rem; color:var(--text-muted);">HCP {{ ph }}</span>{% endif %}
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for h in holes %}
|
||||||
|
<tr :class="{ 'hole-skipped': isSkipped({{ h }}) }">
|
||||||
|
<td class="hole-num">{{ h }}</td>
|
||||||
|
{% for p in players %}
|
||||||
|
<td class="score-cell">
|
||||||
|
<span x-text="holeTotalStr({{ h }}, '{{ p.id }}')"></span>
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="totals-row">
|
||||||
|
<td>Total</td>
|
||||||
|
{% for p in players %}
|
||||||
|
<td x-text="fmt(totals['{{ p.id }}']?.total ?? 0)"></td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if game.status == 'active' %}
|
||||||
|
<template x-if="allHolesDone()">
|
||||||
|
<form method="post" action="/games/{{ game.id }}/finish" style="margin-top: 1rem;">
|
||||||
|
<button type="submit" class="btn btn-primary">Finish game</button>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>{# end swipe-container #}
|
||||||
|
|
||||||
|
{# ── View toggle ── #}
|
||||||
|
<div class="view-toggle">
|
||||||
|
<button @click="view = 'score'" :class="{ active: view === 'score' }">Score</button>
|
||||||
|
<button @click="if (view === 'score') saveHole(currentHole); view = 'card'" :class="{ active: view === 'card' }">Card</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const GAME_ID = "{{ game.id }}";
|
||||||
|
const GAME_HOLES = {{ game.holes_count }};
|
||||||
|
const HOLE_PARS = {{ hole_pars | tojson }};
|
||||||
|
const HOLE_SIS = {{ hole_stroke_indices | tojson }};
|
||||||
|
const PLAYERS_DATA = {{ players | tojson }};
|
||||||
|
|
||||||
|
// Initial scores from server
|
||||||
|
const initialScores = {{ scores | tojson }};
|
||||||
|
const initialTotals = {{ totals | tojson }};
|
||||||
|
|
||||||
|
function gameApp() {
|
||||||
|
return {
|
||||||
|
view: 'score',
|
||||||
|
cardView: 'strokes',
|
||||||
|
currentHole: 1,
|
||||||
|
players: [],
|
||||||
|
scores: {}, // { hole: { uid: {strokes,bingo,bango,bongo} } }
|
||||||
|
totals: {},
|
||||||
|
skipped: new Set(),
|
||||||
|
ws: null,
|
||||||
|
touchStartX: 0,
|
||||||
|
dragSrc: null,
|
||||||
|
dragOver: null,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.players = PLAYERS_DATA;
|
||||||
|
|
||||||
|
// Load initial scores from server
|
||||||
|
this.totals = initialTotals;
|
||||||
|
for (const [hole, playerScores] of Object.entries(initialScores)) {
|
||||||
|
const h = parseInt(hole);
|
||||||
|
this.scores[h] = {};
|
||||||
|
for (const [uid, s] of Object.entries(playerScores)) {
|
||||||
|
this.scores[h][uid] = { strokes: s.strokes ?? 0, bingo: s.bingo, bango: s.bango, bongo: s.bongo };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Jump to the last scored hole for active games
|
||||||
|
const scoredHoles = Object.keys(initialScores).map(Number).filter(h => Object.keys(initialScores[h]).length > 0);
|
||||||
|
if (scoredHoles.length > 0) this.currentHole = Math.max(...scoredHoles);
|
||||||
|
|
||||||
|
this.connectWs();
|
||||||
|
},
|
||||||
|
|
||||||
|
connectWs() {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
this.ws = new WebSocket(`${proto}://${location.host}/games/${GAME_ID}/ws`);
|
||||||
|
this.ws.onmessage = (e) => {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (msg.type === 'score_update') {
|
||||||
|
const h = msg.hole;
|
||||||
|
if (!this.scores[h]) this.scores[h] = {};
|
||||||
|
for (const [uid, s] of Object.entries(msg.scores)) {
|
||||||
|
this.scores[h][uid] = s;
|
||||||
|
}
|
||||||
|
this.totals = msg.totals;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.ws.onclose = () => setTimeout(() => this.connectWs(), 2000);
|
||||||
|
},
|
||||||
|
|
||||||
|
getScore(hole, uid) {
|
||||||
|
return this.scores[hole]?.[uid] ?? { strokes: 0, bingo: 0, bango: 0.0, bongo: 0 };
|
||||||
|
},
|
||||||
|
|
||||||
|
_ensurePlayer(hole, uid) {
|
||||||
|
if (!this.scores[hole]) this.scores[hole] = {};
|
||||||
|
if (!this.scores[hole][uid]) this.scores[hole][uid] = { strokes: 0, bingo: 0, bango: 0.0, bongo: 0 };
|
||||||
|
},
|
||||||
|
|
||||||
|
adjustStrokes(hole, uid, delta) {
|
||||||
|
this._ensurePlayer(hole, uid);
|
||||||
|
const cur = this.scores[hole][uid].strokes;
|
||||||
|
if (!cur) {
|
||||||
|
// First press: start at par for this hole
|
||||||
|
this.scores[hole][uid].strokes = HOLE_PARS[hole] || 4;
|
||||||
|
} else {
|
||||||
|
this.scores[hole][uid].strokes = Math.max(1, cur + delta);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setBingo(hole, uid) {
|
||||||
|
this._ensurePlayer(hole, uid);
|
||||||
|
const current = this.scores[hole][uid].bingo;
|
||||||
|
for (const p in this.scores[hole]) this.scores[hole][p].bingo = 0;
|
||||||
|
this.scores[hole][uid].bingo = current ? 0 : 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleBango(hole, uid) {
|
||||||
|
this._ensurePlayer(hole, uid);
|
||||||
|
this.scores[hole][uid].bango = this.scores[hole][uid].bango > 0 ? 0 : 1;
|
||||||
|
// Recalculate split
|
||||||
|
const winners = Object.entries(this.scores[hole]).filter(([, s]) => s.bango > 0).map(([u]) => u);
|
||||||
|
const share = winners.length ? Math.round(10000 / winners.length) / 10000 : 0;
|
||||||
|
for (const p in this.scores[hole]) this.scores[hole][p].bango = winners.includes(p) ? share : 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
setBongo(hole, uid) {
|
||||||
|
this._ensurePlayer(hole, uid);
|
||||||
|
const current = this.scores[hole][uid].bongo;
|
||||||
|
for (const p in this.scores[hole]) this.scores[hole][p].bongo = 0;
|
||||||
|
this.scores[hole][uid].bongo = current ? 0 : 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
saveHole(hole) {
|
||||||
|
const holeScores = this.scores[hole] ?? {};
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('hole', hole);
|
||||||
|
|
||||||
|
for (const [uid, s] of Object.entries(holeScores)) {
|
||||||
|
if (s.bingo) params.set('bingo', uid);
|
||||||
|
if (s.bongo) params.set('bongo', uid);
|
||||||
|
if (s.strokes) params.append('strokes_' + uid, s.strokes);
|
||||||
|
}
|
||||||
|
const bangoWinners = Object.entries(holeScores).filter(([, s]) => s.bango > 0).map(([uid]) => uid);
|
||||||
|
for (const uid of bangoWinners) params.append('bango', uid);
|
||||||
|
|
||||||
|
fetch(`/games/${GAME_ID}/score`, { method: 'POST', body: params });
|
||||||
|
},
|
||||||
|
|
||||||
|
holeScoreClass(hole, uid) {
|
||||||
|
const s = this.getScore(hole, uid);
|
||||||
|
if (!s.strokes || this.isSkipped(hole)) return '';
|
||||||
|
const par = HOLE_PARS[hole];
|
||||||
|
if (!par) return '';
|
||||||
|
if (s.strokes === 1) return 'score-hio';
|
||||||
|
const diff = s.strokes - par;
|
||||||
|
if (diff <= -2) return 'score-eagle';
|
||||||
|
if (diff === -1) return 'score-birdie';
|
||||||
|
if (diff === 1) return 'score-bogey';
|
||||||
|
if (diff >= 2) return 'score-double-bogey';
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
|
||||||
|
holeStrokesStr(hole, uid) {
|
||||||
|
if (this.isSkipped(hole)) return '—';
|
||||||
|
const s = this.getScore(hole, uid);
|
||||||
|
return s.strokes ? s.strokes : '';
|
||||||
|
},
|
||||||
|
|
||||||
|
holeTotalStr(hole, uid) {
|
||||||
|
if (this.isSkipped(hole)) return '—';
|
||||||
|
const holeScores = this.scores[hole];
|
||||||
|
if (!holeScores || Object.keys(holeScores).length === 0) return '';
|
||||||
|
const s = this.getScore(hole, uid);
|
||||||
|
const t = s.bingo + s.bango + s.bongo;
|
||||||
|
return this.fmt(t);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleSkip(hole) {
|
||||||
|
if (this.skipped.has(hole)) {
|
||||||
|
this.skipped.delete(hole);
|
||||||
|
} else {
|
||||||
|
this.skipped.add(hole);
|
||||||
|
}
|
||||||
|
// Alpine reactivity workaround for Set
|
||||||
|
this.skipped = new Set(this.skipped);
|
||||||
|
},
|
||||||
|
|
||||||
|
isSkipped(hole) {
|
||||||
|
return this.skipped.has(hole);
|
||||||
|
},
|
||||||
|
|
||||||
|
allHolesDone() {
|
||||||
|
for (let h = 1; h <= GAME_HOLES; h++) {
|
||||||
|
if (this.isSkipped(h)) continue;
|
||||||
|
const holeScores = this.scores[h];
|
||||||
|
if (!holeScores || Object.keys(holeScores).length === 0) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
prevHole() { if (this.currentHole > 1) { this.saveHole(this.currentHole); this.currentHole--; } },
|
||||||
|
nextHole() { if (this.currentHole < GAME_HOLES) { this.saveHole(this.currentHole); this.currentHole++; } },
|
||||||
|
|
||||||
|
fmt(v) {
|
||||||
|
return Number.isInteger(v) || v % 1 === 0 ? v.toFixed(0) : v.toFixed(1);
|
||||||
|
},
|
||||||
|
|
||||||
|
dropPlayer(targetIdx) {
|
||||||
|
if (this.dragSrc === null || targetIdx === null || this.dragSrc === targetIdx) {
|
||||||
|
this.dragSrc = null; this.dragOver = null; return;
|
||||||
|
}
|
||||||
|
const moved = this.players.splice(this.dragSrc, 1)[0];
|
||||||
|
this.players.splice(targetIdx, 0, moved);
|
||||||
|
this.dragSrc = null; this.dragOver = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
endDrag() { this.dragSrc = null; this.dragOver = null; },
|
||||||
|
|
||||||
|
touchDragMove(evt) {
|
||||||
|
const touch = evt.touches[0];
|
||||||
|
const el = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||||
|
const card = el?.closest('.player-score-card');
|
||||||
|
if (!card) return;
|
||||||
|
const cards = [...document.getElementById('player-list').querySelectorAll('.player-score-card')];
|
||||||
|
const idx = cards.indexOf(card);
|
||||||
|
if (idx !== -1) this.dragOver = idx;
|
||||||
|
},
|
||||||
|
|
||||||
|
touchDragEnd() { this.dropPlayer(this.dragOver ?? this.dragSrc); },
|
||||||
|
|
||||||
|
touchStart(e) {
|
||||||
|
if (e.target.closest('.drag-handle')) return;
|
||||||
|
this.touchStartX = e.changedTouches[0].screenX;
|
||||||
|
},
|
||||||
|
touchEnd(e) {
|
||||||
|
if (!this.touchStartX) return;
|
||||||
|
const dx = e.changedTouches[0].screenX - this.touchStartX;
|
||||||
|
this.touchStartX = 0;
|
||||||
|
if (Math.abs(dx) > 60) {
|
||||||
|
const next = dx < 0 ? 'card' : 'score';
|
||||||
|
if (next === 'card' && this.view === 'score') this.saveHole(this.currentHole);
|
||||||
|
this.view = next;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Games – Skins & Pins{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<h1>Games</h1>
|
||||||
|
<a href="/games/new" class="btn btn-primary" style="width:auto; padding: 0.5rem 1rem;">+ New</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if games %}
|
||||||
|
{% for g in games %}
|
||||||
|
<a href="/games/{{ g.id }}{% if g.status == 'completed' %}/summary{% endif %}" class="card game-card" style="display:block; text-decoration:none;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600; color:var(--text);">{{ g.course_name or 'Bingo Bango Bongo' }}</div>
|
||||||
|
<div style="color:var(--text-muted); font-size:0.85rem;">{{ g.holes_count }} holes · {{ g.created_at[:10] }}</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right;">
|
||||||
|
<span class="status-badge status-{{ g.status }}">{{ g.status }}</span>
|
||||||
|
{% if g.status == 'completed' and g.id in winners %}
|
||||||
|
{% set w = winners[g.id] %}
|
||||||
|
<div style="font-size:0.78rem; color:var(--accent); margin-top:0.3rem;">
|
||||||
|
{% if w.avatar and w.avatar.startswith('/') %}<img src="{{ w.avatar }}" style="width:1rem;height:1rem;border-radius:50%;object-fit:cover;vertical-align:middle;">{% else %}{{ w.avatar or '👤' }}{% endif %} {{ w.name }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">⛳</div>
|
||||||
|
<p>No games yet.</p>
|
||||||
|
<a href="/games/new" class="btn btn-primary" style="margin-top:1rem; max-width:200px;">Start a game</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}New Game – Skins & Pins{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<script>
|
||||||
|
const TEES_BY_COURSE = {{ tees_by_course | tojson }};
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page"
|
||||||
|
x-data="{
|
||||||
|
step: 1,
|
||||||
|
courseId: '',
|
||||||
|
selectedPlayers: ['{{ user.id }}'],
|
||||||
|
togglePlayer(id) {
|
||||||
|
const idx = this.selectedPlayers.indexOf(id);
|
||||||
|
if (idx === -1) this.selectedPlayers.push(id);
|
||||||
|
else if (id !== '{{ user.id }}') this.selectedPlayers.splice(idx, 1);
|
||||||
|
},
|
||||||
|
hasTees() { return (TEES_BY_COURSE[this.courseId] || []).length > 0; },
|
||||||
|
tees() { return TEES_BY_COURSE[this.courseId] || []; }
|
||||||
|
}">
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>New Game</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}<div class="alert alert-error">{{ error }}</div>{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/games/new">
|
||||||
|
|
||||||
|
{# ── Step 1: Format, course, holes, players ── #}
|
||||||
|
<div x-show="step === 1">
|
||||||
|
<div class="card">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Format</label>
|
||||||
|
<div style="display:flex; gap:0.75rem; flex-wrap:wrap;">
|
||||||
|
<label class="radio-card" style="flex:1;">
|
||||||
|
<input type="radio" name="format" value="bingo_bango_bongo" checked required>
|
||||||
|
<span>Bingo Bango Bongo</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="course_id">Course</label>
|
||||||
|
{% if courses %}
|
||||||
|
<select id="course_id" name="course_id" required
|
||||||
|
@change="courseId = $event.target.value"
|
||||||
|
style="width:100%; padding:0.65rem 0.75rem; border-radius:0.5rem; border:1px solid var(--border); background:var(--surface); color:var(--text); font-size:1rem;">
|
||||||
|
<option value="" disabled selected>Select a course…</option>
|
||||||
|
{% for c in courses %}
|
||||||
|
<option value="{{ c.id }}">{{ c.name }}{% if c.location %} · {{ c.location }}{% endif %} ({{ c.holes }} holes)</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% else %}
|
||||||
|
<p style="color:var(--text-muted); font-size:0.9rem;">No courses available. Ask the admin to add courses first.</p>
|
||||||
|
<input type="hidden" name="course_id" value="">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Holes</label>
|
||||||
|
<div style="display:flex; gap:0.75rem;">
|
||||||
|
<label class="radio-card">
|
||||||
|
<input type="radio" name="holes_count" value="9" required>
|
||||||
|
<span>9 holes</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio-card">
|
||||||
|
<input type="radio" name="holes_count" value="18">
|
||||||
|
<span>18 holes</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Players</label>
|
||||||
|
<div class="player-list">
|
||||||
|
{% for u in all_users %}
|
||||||
|
<label class="player-option">
|
||||||
|
<input type="checkbox" name="players" value="{{ u.id }}"
|
||||||
|
{% if u.id == user.id %}checked disabled{% endif %}
|
||||||
|
@change="togglePlayer('{{ u.id }}')"
|
||||||
|
:checked="selectedPlayers.includes('{{ u.id }}')">
|
||||||
|
<span class="player-avatar">
|
||||||
|
{% if u.avatar and u.avatar.startswith('/') %}
|
||||||
|
<img src="{{ u.avatar }}" style="width:1.3rem;height:1.3rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
|
||||||
|
{% else %}
|
||||||
|
{{ u.avatar or '👤' }}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="player-name">{{ u.name }}</span>
|
||||||
|
{% if u.id == user.id %}<span style="color:var(--text-muted); font-size:0.75rem;">you</span>{% endif %}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="players" value="{{ user.id }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" x-show="hasTees()" class="btn btn-primary" @click="step = 2">Next →</button>
|
||||||
|
<button type="submit" x-show="!hasTees()" class="btn btn-primary">Start game ⛳</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Step 2: Tee selection ── #}
|
||||||
|
<div x-show="step === 2" style="display:none;">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="font-size:0.95rem; color:var(--text-muted); margin-bottom:1rem;">Select tees</h2>
|
||||||
|
{% for u in all_users %}
|
||||||
|
<div class="form-group" x-show="selectedPlayers.includes('{{ u.id }}')">
|
||||||
|
<label>
|
||||||
|
{% if u.avatar and u.avatar.startswith('/') %}
|
||||||
|
<img src="{{ u.avatar }}" style="width:1.1rem;height:1.1rem;border-radius:50%;object-fit:cover;vertical-align:middle;margin-right:0.3rem;">
|
||||||
|
{% else %}
|
||||||
|
{{ u.avatar or '👤' }}
|
||||||
|
{% endif %}
|
||||||
|
{{ u.name }}
|
||||||
|
</label>
|
||||||
|
<select name="tee_{{ u.id }}"
|
||||||
|
style="width:100%; padding:0.65rem 0.75rem; border-radius:0.5rem; border:1px solid var(--border); background:var(--surface); color:var(--text); font-size:1rem;">
|
||||||
|
<option value="" disabled selected>Select tee…</option>
|
||||||
|
<template x-for="t in tees()" :key="t.id">
|
||||||
|
<option :value="t.id" x-text="t.name + ' (Slope ' + t.slope + ' · Rating ' + t.rating + ')'"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:0.75rem;">
|
||||||
|
<button type="button" @click="step = 1"
|
||||||
|
style="flex:1; padding:0.85rem; border-radius:0.65rem; border:1px solid var(--border); background:var(--surface2); color:var(--text); font-size:1rem; cursor:pointer;">← Back</button>
|
||||||
|
<button type="submit" class="btn btn-primary" style="flex:2;">Start game ⛳</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ game.course_name or 'Game' }} – Skins & Pins{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page" x-data="{ view: 'strokes' }">
|
||||||
|
<div class="page-header" style="display:flex; align-items:center; justify-content:space-between;">
|
||||||
|
<a href="javascript:history.back()" style="color:var(--text-muted); font-size:0.9rem;">← Back</a>
|
||||||
|
<div style="text-align:center;">
|
||||||
|
<div style="font-weight:600;">{{ game.course_name or 'Bingo Bango Bongo' }}</div>
|
||||||
|
<div style="font-size:0.8rem; color:var(--text-muted);">{{ game.holes_count }} holes · {{ game.created_at[:10] }}</div>
|
||||||
|
</div>
|
||||||
|
<div style="width:3rem;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Leaderboard #}
|
||||||
|
<div class="card" style="padding:0.75rem 1rem;">
|
||||||
|
{% for p in results %}
|
||||||
|
{% set t = totals.get(p.id, {}) %}
|
||||||
|
<div class="result-row {% if loop.first %}result-winner{% endif %}" style="padding:0.4rem 0;">
|
||||||
|
<span class="result-rank">{% if loop.first %}🏆{% else %}{{ loop.index }}{% endif %}</span>
|
||||||
|
<span class="result-avatar">
|
||||||
|
{% if p.avatar and p.avatar.startswith('/') %}
|
||||||
|
<img src="{{ p.avatar }}" style="width:1.4rem;height:1.4rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
|
||||||
|
{% else %}
|
||||||
|
{{ p.avatar or '👤' }}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<a href="/users/{{ p.id }}" style="flex:1; color:inherit; text-decoration:none;">{{ p.name }}</a>
|
||||||
|
<span class="result-pts">{{ t.total | round(1) }} pts</span>
|
||||||
|
<span style="font-size:0.75rem; color:var(--text-muted); margin-left:0.5rem;">
|
||||||
|
B{{ t.bingo | int }}/{{ t.bango | round(1) }}/{{ t.bongo | int }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Scorecard toggle #}
|
||||||
|
<div class="scorecard-toggle" style="margin-top:1rem;">
|
||||||
|
<button @click="view = 'strokes'" :class="{ active: view === 'strokes' }">Strokes</button>
|
||||||
|
<button @click="view = 'points'" :class="{ active: view === 'points' }">Points</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Strokes scorecard #}
|
||||||
|
<table class="scorecard" x-show="view === 'strokes'">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Hole</th>
|
||||||
|
{% if hole_pars %}<th style="color:var(--text-muted);">Par</th>{% endif %}
|
||||||
|
{% for p in players %}
|
||||||
|
<th>
|
||||||
|
{% if p.avatar and p.avatar.startswith('/') %}
|
||||||
|
<img src="{{ p.avatar }}" style="width:1.3rem;height:1.3rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
|
||||||
|
{% else %}
|
||||||
|
{{ p.avatar or '👤' }}
|
||||||
|
{% endif %}<br>
|
||||||
|
<a href="/users/{{ p.id }}" style="font-size:0.7rem; color:inherit; text-decoration:none;">{{ p.name.split()[0] }}</a>
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for h in holes %}
|
||||||
|
<tr>
|
||||||
|
<td class="hole-num">{{ h }}</td>
|
||||||
|
{% if hole_pars %}<td class="score-cell" style="color:var(--text-muted);">{{ hole_pars.get(h, '—') }}</td>{% 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) %}
|
||||||
|
<td class="score-cell">
|
||||||
|
{% if strokes %}
|
||||||
|
{% if par %}
|
||||||
|
{% set diff = strokes - par %}
|
||||||
|
{% if strokes == 1 %}
|
||||||
|
<span class="score-hio">{{ strokes }}</span>
|
||||||
|
{% elif diff <= -2 %}
|
||||||
|
<span class="score-eagle">{{ strokes }}</span>
|
||||||
|
{% elif diff == -1 %}
|
||||||
|
<span class="score-birdie">{{ strokes }}</span>
|
||||||
|
{% elif diff == 1 %}
|
||||||
|
<span class="score-bogey">{{ strokes }}</span>
|
||||||
|
{% elif diff >= 2 %}
|
||||||
|
<span class="score-double-bogey">{{ strokes }}</span>
|
||||||
|
{% else %}
|
||||||
|
{{ strokes }}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{{ strokes }}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
—
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="totals-row">
|
||||||
|
<td>Total</td>
|
||||||
|
{% if hole_pars %}<td style="color:var(--text-muted);">{{ hole_pars.values() | sum }}</td>{% 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 %}
|
||||||
|
<td>{{ stroke_total.val or '—' }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{# Points scorecard #}
|
||||||
|
<table class="scorecard" x-show="view === 'points'">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Hole</th>
|
||||||
|
{% for p in players %}
|
||||||
|
<th>
|
||||||
|
{% if p.avatar and p.avatar.startswith('/') %}
|
||||||
|
<img src="{{ p.avatar }}" style="width:1.3rem;height:1.3rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
|
||||||
|
{% else %}
|
||||||
|
{{ p.avatar or '👤' }}
|
||||||
|
{% endif %}<br>
|
||||||
|
<a href="/users/{{ p.id }}" style="font-size:0.7rem; color:inherit; text-decoration:none;">{{ p.name.split()[0] }}</a>
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for h in holes %}
|
||||||
|
<tr>
|
||||||
|
<td class="hole-num">{{ h }}</td>
|
||||||
|
{% for p in players %}
|
||||||
|
{% set s = scores.get(h, {}).get(p.id, {}) %}
|
||||||
|
{% set pts = (s.bingo + s.bango + s.bongo) if s else 0 %}
|
||||||
|
<td class="score-cell">{{ pts | round(1) if pts else '—' }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="totals-row">
|
||||||
|
<td>Total</td>
|
||||||
|
{% for p in players %}
|
||||||
|
<td>{{ totals.get(p.id, {}).get('total', 0) | round(1) }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{% set path = request.url.path %}
|
||||||
|
<nav class="bottom-nav">
|
||||||
|
<a href="/" class="nav-item {% if path == '/' %}active{% endif %}">
|
||||||
|
<span class="nav-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 9.5L12 3l9 6.5V20a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9.5z"/>
|
||||||
|
<path d="M9 21V12h6v9"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span>Home</span>
|
||||||
|
</a>
|
||||||
|
<a href="/games" class="nav-item {% if path.startswith('/games') %}active{% endif %}">
|
||||||
|
<span class="nav-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="12" y1="3" x2="12" y2="21"/>
|
||||||
|
<path d="M12 3l7 5-7 5"/>
|
||||||
|
<ellipse cx="12" cy="20" rx="4" ry="1.5"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span>Games</span>
|
||||||
|
</a>
|
||||||
|
{% if user.email == admin_email %}
|
||||||
|
<a href="/admin" class="nav-item {% if path.startswith('/admin') %}active{% endif %}">
|
||||||
|
<span class="nav-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span>Admin</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/profile" class="nav-item {% if path == '/profile' %}active{% endif %}">
|
||||||
|
<span class="nav-icon nav-avatar">
|
||||||
|
{% if user.avatar and user.avatar.startswith('/') %}
|
||||||
|
<img src="{{ user.avatar }}" alt="avatar" class="nav-avatar-img">
|
||||||
|
{% elif user.avatar %}
|
||||||
|
{{ user.avatar }}
|
||||||
|
{% else %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="8" r="4"/>
|
||||||
|
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span>Profile</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Welcome – Skins & Pins{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/cropperjs@1.6.2/dist/cropper.min.css">
|
||||||
|
<script src="https://unpkg.com/cropperjs@1.6.2/dist/cropper.min.js"></script>
|
||||||
|
<script src="/static/avatar-crop.js"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Welcome! 🏌️</h1>
|
||||||
|
<p>Set up your profile before you start playing.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-data="avatarUpload('/onboarding', '/')" x-init="tab = 'emoji'">
|
||||||
|
<form action="/onboarding" method="post" enctype="multipart/form-data"
|
||||||
|
@submit="submitForm($event)">
|
||||||
|
<div class="card">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Your name</label>
|
||||||
|
{% if errors and errors.name %}<div class="alert alert-error">{{ errors.name }}</div>{% endif %}
|
||||||
|
<input type="text" id="name" name="name" placeholder="e.g. Tiger"
|
||||||
|
value="{{ values.name if values else '' }}" autocomplete="given-name" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="handicap">Handicap (0–54)</label>
|
||||||
|
{% if errors and errors.handicap %}<div class="alert alert-error">{{ errors.handicap }}</div>{% endif %}
|
||||||
|
<input type="number" id="handicap" name="handicap" min="0" max="54" step="0.1"
|
||||||
|
placeholder="e.g. 18" value="{{ values.handicap if values else '' }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Avatar</label>
|
||||||
|
{% if errors and errors.avatar %}<div class="alert alert-error">{{ errors.avatar }}</div>{% endif %}
|
||||||
|
|
||||||
|
<div class="avatar-tabs">
|
||||||
|
<button type="button" class="avatar-tab" :class="{ active: tab === 'emoji' }" @click="tab = 'emoji'">Emoji</button>
|
||||||
|
<button type="button" class="avatar-tab" :class="{ active: tab === 'upload' }" @click="tab = 'upload'">Photo</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="tab === 'emoji'">
|
||||||
|
<div class="avatar-grid" style="margin-top: 0.75rem;">
|
||||||
|
{% for a in avatars %}
|
||||||
|
<div class="avatar-option">
|
||||||
|
<input type="radio" name="avatar_emoji" id="avatar_{{ loop.index }}" value="{{ a }}"
|
||||||
|
{% if loop.first %}checked{% endif %}>
|
||||||
|
<label for="avatar_{{ loop.index }}">{{ a }}</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="tab === 'upload'" style="margin-top: 0.75rem;">
|
||||||
|
<div x-show="previewUrl" style="margin-bottom: 0.75rem; display:flex; align-items:center; gap:1rem;">
|
||||||
|
<img :src="previewUrl" class="avatar-preview">
|
||||||
|
<span style="color:var(--text-muted); font-size:0.85rem;">Tap to change</span>
|
||||||
|
</div>
|
||||||
|
<label class="file-input-label">
|
||||||
|
<span x-text="previewUrl ? 'Choose different photo' : 'Choose photo'"></span>
|
||||||
|
<input type="file" name="avatar_file" accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
style="display:none" @change="openFile($event)">
|
||||||
|
</label>
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.8rem; margin-top: 0.4rem;">JPEG, PNG, WebP or GIF · max 2 MB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Save & continue</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% include "partials/crop_modal.html" %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{# Crop modal — requires avatarUpload() Alpine component on a parent element #}
|
||||||
|
<div x-show="showModal" x-cloak class="crop-overlay" @click.self="cancelCrop()">
|
||||||
|
<div class="crop-modal">
|
||||||
|
<div class="crop-modal-header">
|
||||||
|
<span>Position your photo</span>
|
||||||
|
<button type="button" class="icon-btn" @click="cancelCrop()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="crop-container">
|
||||||
|
<img x-ref="cropImg" :src="previewUrl" style="max-width:100%;">
|
||||||
|
</div>
|
||||||
|
<p class="crop-hint">Pinch or scroll to zoom · drag to reposition</p>
|
||||||
|
<div class="crop-modal-actions">
|
||||||
|
<button type="button" class="btn btn-ghost" style="width:auto;" @click="cancelCrop()">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" style="width:auto;" @click="confirmCrop()">Use photo</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Profile – Skins & Pins{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/cropperjs@1.6.2/dist/cropper.min.css">
|
||||||
|
<script src="https://unpkg.com/cropperjs@1.6.2/dist/cropper.min.js"></script>
|
||||||
|
<script src="/static/avatar-crop.js"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Profile</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if success %}<div class="alert alert-success">{{ success }}</div>{% endif %}
|
||||||
|
|
||||||
|
<div x-data="avatarUpload('/profile', '/profile')"
|
||||||
|
x-init="tab = '{{ 'upload' if user.avatar and user.avatar.startswith('/') else 'emoji' }}';
|
||||||
|
previewUrl = '{{ user.avatar if user.avatar and user.avatar.startswith('/') else '' }}' || null;">
|
||||||
|
<form action="/profile" method="post" enctype="multipart/form-data"
|
||||||
|
@submit="submitForm($event)">
|
||||||
|
<div class="card">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
{% if errors and errors.name %}<div class="alert alert-error">{{ errors.name }}</div>{% endif %}
|
||||||
|
<input type="text" id="name" name="name" value="{{ user.name or '' }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="handicap">Handicap (0–54)</label>
|
||||||
|
{% if errors and errors.handicap %}<div class="alert alert-error">{{ errors.handicap }}</div>{% endif %}
|
||||||
|
<input type="number" id="handicap" name="handicap" min="0" max="54" step="0.1"
|
||||||
|
value="{{ user.handicap or '' }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Avatar</label>
|
||||||
|
{% if errors and errors.avatar %}<div class="alert alert-error">{{ errors.avatar }}</div>{% endif %}
|
||||||
|
|
||||||
|
<div class="avatar-tabs">
|
||||||
|
<button type="button" class="avatar-tab" :class="{ active: tab === 'emoji' }" @click="tab = 'emoji'">Emoji</button>
|
||||||
|
<button type="button" class="avatar-tab" :class="{ active: tab === 'upload' }" @click="tab = 'upload'">Photo</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="tab === 'emoji'">
|
||||||
|
<div class="avatar-grid" style="margin-top: 0.75rem;">
|
||||||
|
{% for a in avatars %}
|
||||||
|
<div class="avatar-option">
|
||||||
|
<input type="radio" name="avatar_emoji" id="avatar_{{ loop.index }}" value="{{ a }}"
|
||||||
|
{% if user.avatar == a %}checked{% endif %}>
|
||||||
|
<label for="avatar_{{ loop.index }}">{{ a }}</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="tab === 'upload'" style="margin-top: 0.75rem;">
|
||||||
|
<div x-show="previewUrl" style="margin-bottom: 0.75rem; display:flex; align-items:center; gap:1rem;">
|
||||||
|
<img :src="previewUrl" class="avatar-preview">
|
||||||
|
<span style="color:var(--text-muted); font-size:0.85rem;">Tap to change</span>
|
||||||
|
</div>
|
||||||
|
<label class="file-input-label">
|
||||||
|
<span x-text="previewUrl ? 'Choose different photo' : 'Choose photo'"></span>
|
||||||
|
<input type="file" name="avatar_file" accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
style="display:none" @change="openFile($event)">
|
||||||
|
</label>
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.8rem; margin-top: 0.4rem;">JPEG, PNG, WebP or GIF · max 2 MB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Save changes</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% include "partials/crop_modal.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 1.5rem;">
|
||||||
|
<a href="/logout" class="btn btn-ghost">Log out</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ profile.name }} – Skins & Pins{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<a href="javascript:history.back()" style="color:var(--text-muted); font-size:0.9rem;">← Back</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="display:flex; flex-direction:column; align-items:center; gap:1rem; padding:2rem 1rem;">
|
||||||
|
{% if profile.avatar and profile.avatar.startswith('/') %}
|
||||||
|
<img src="{{ profile.avatar }}" alt="{{ profile.name }}"
|
||||||
|
style="width:6rem; height:6rem; border-radius:50%; object-fit:cover; border:3px solid var(--accent);">
|
||||||
|
{% else %}
|
||||||
|
<div style="font-size:4rem; line-height:1;">{{ profile.avatar or '👤' }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="text-align:center;">
|
||||||
|
<h1 style="font-size:1.4rem; margin-bottom:0.25rem;">{{ profile.name }}</h1>
|
||||||
|
<div style="color:var(--text-muted); font-size:0.95rem;">
|
||||||
|
Handicap <strong style="color:var(--text);">{{ profile.handicap if profile.handicap is not none else '—' }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if games_played > 0 %}
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ games_played }}</div>
|
||||||
|
<div class="stat-label">Games played</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ victories }}</div>
|
||||||
|
<div class="stat-label">Victories</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ win_pct }}%</div>
|
||||||
|
<div class="stat-label">Win rate</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -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", "")
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="96" fill="#1b4332"/>
|
||||||
|
<!-- Flag stick -->
|
||||||
|
<rect x="246" y="80" width="20" height="300" rx="4" fill="#e8f5e9"/>
|
||||||
|
<!-- Flag -->
|
||||||
|
<polygon points="266,80 390,140 266,200" fill="#40916c"/>
|
||||||
|
<!-- Golf ball -->
|
||||||
|
<circle cx="256" cy="420" r="60" fill="#52b788"/>
|
||||||
|
<!-- Ball dimple hints -->
|
||||||
|
<circle cx="236" cy="408" r="8" fill="#2d6a4f" opacity="0.4"/>
|
||||||
|
<circle cx="270" cy="400" r="8" fill="#2d6a4f" opacity="0.4"/>
|
||||||
|
<circle cx="248" cy="432" r="8" fill="#2d6a4f" opacity="0.4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 594 B |
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
@@ -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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user