Add Skins format, GolfCourseAPI import, and UX improvements
- Add Skins game format with optional carryover, mirroring BBB scoring pattern
- Add GolfCourseAPI integration for course search and import (httpx)
- Unwrap {"course": {}} API response envelope on import
- Add searchable course combobox and player filter to new game form
- Redirect to summary page after finishing a game
- Fix auth to allow registered users without a pending invitation
- Fix hardcoded BBB format in create_game; store carryover flag in DB
- Add game summary/scorecard view for completed games
- Add live games social stream to dashboard
- Add course name + subtitle to recent game cards
- Reorder nav: Home → Games → Admin → Profile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,10 @@ ENV=development
|
|||||||
# Path to the SQLite database file (inside the container)
|
# Path to the SQLite database file (inside the container)
|
||||||
DATABASE_PATH=/data/skinspins.db
|
DATABASE_PATH=/data/skinspins.db
|
||||||
|
|
||||||
|
# GolfCourseAPI key — get one at https://www.golfcourseapi.com
|
||||||
|
# Leave empty to disable the course search/import feature
|
||||||
|
GOLF_COURSE_API_KEY=
|
||||||
|
|
||||||
# SMTP email settings (leave SMTP_HOST empty to disable email and log links instead)
|
# SMTP email settings (leave SMTP_HOST empty to disable email and log links instead)
|
||||||
SMTP_HOST=smtp.example.com
|
SMTP_HOST=smtp.example.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ async def init_db():
|
|||||||
"ALTER TABLE games ADD COLUMN course_id TEXT REFERENCES courses(id)",
|
"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 game_players ADD COLUMN tee_id TEXT REFERENCES course_tees(id)",
|
||||||
"ALTER TABLE course_holes ADD COLUMN stroke_index INTEGER",
|
"ALTER TABLE course_holes ADD COLUMN stroke_index INTEGER",
|
||||||
|
"ALTER TABLE games ADD COLUMN carryover INTEGER NOT NULL DEFAULT 1",
|
||||||
]:
|
]:
|
||||||
try:
|
try:
|
||||||
await db.execute(migration)
|
await db.execute(migration)
|
||||||
|
|||||||
+4
-1
@@ -32,8 +32,11 @@ async def login_submit(
|
|||||||
):
|
):
|
||||||
email = email.strip().lower()
|
email = email.strip().lower()
|
||||||
|
|
||||||
# Check email is admin or invited
|
# Check email is admin, already registered, or has a pending invitation
|
||||||
if email != ADMIN_EMAIL:
|
if email != ADMIN_EMAIL:
|
||||||
|
async with db.execute("SELECT id FROM users WHERE email = ?", (email,)) as cursor:
|
||||||
|
existing_user = await cursor.fetchone()
|
||||||
|
if existing_user is None:
|
||||||
async with db.execute(
|
async with db.execute(
|
||||||
"SELECT id FROM invitations WHERE email = ?", (email,)
|
"SELECT id FROM invitations WHERE email = ?", (email,)
|
||||||
) as cursor:
|
) as cursor:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, Form, Request
|
from fastapi import APIRouter, Depends, Form, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
@@ -9,6 +11,21 @@ from app.database import get_db
|
|||||||
from app.dependencies import require_admin
|
from app.dependencies import require_admin
|
||||||
from app.templates_config import templates
|
from app.templates_config import templates
|
||||||
|
|
||||||
|
GOLF_COURSE_API_KEY = os.getenv("GOLF_COURSE_API_KEY", "")
|
||||||
|
_GOLF_API_BASE = "https://api.golfcourseapi.com/v1"
|
||||||
|
|
||||||
|
|
||||||
|
async def _golf_api(path: str, params: dict = None):
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.get(
|
||||||
|
f"{_GOLF_API_BASE}{path}",
|
||||||
|
headers={"Authorization": f"Key {GOLF_COURSE_API_KEY}"},
|
||||||
|
params=params,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -125,6 +142,99 @@ async def create_course(request: Request,
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── API Search & Import ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/admin/courses/search", response_class=HTMLResponse)
|
||||||
|
async def search_courses_page(request: Request, user: dict = Depends(require_admin),
|
||||||
|
q: str = ""):
|
||||||
|
results, error = [], None
|
||||||
|
if q and GOLF_COURSE_API_KEY:
|
||||||
|
try:
|
||||||
|
data = await _golf_api("/search", {"search_query": q})
|
||||||
|
results = data.get("courses", [])
|
||||||
|
except Exception:
|
||||||
|
error = "Search failed. Check your API key or try again."
|
||||||
|
return templates.TemplateResponse("admin/course_search.html", {
|
||||||
|
"request": request, "user": user, "q": q, "results": results,
|
||||||
|
"error": error, "api_configured": bool(GOLF_COURSE_API_KEY),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/courses/import/{api_id}")
|
||||||
|
async def import_course(api_id: int, request: Request,
|
||||||
|
user: dict = Depends(require_admin),
|
||||||
|
db: aiosqlite.Connection = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
data = await _golf_api(f"/courses/{api_id}")
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/admin/courses/search?error=Import+failed.", status_code=302)
|
||||||
|
|
||||||
|
# Unwrap {"course": {...}} envelope if present
|
||||||
|
data = data.get("course", data)
|
||||||
|
|
||||||
|
# Course name
|
||||||
|
club = data.get("club_name", "").strip()
|
||||||
|
cname = data.get("course_name", "").strip()
|
||||||
|
name = club if (not cname or club == cname) else f"{club} – {cname}"
|
||||||
|
|
||||||
|
# Location
|
||||||
|
loc = data.get("location", {})
|
||||||
|
if loc.get("state") and loc.get("country") == "United States":
|
||||||
|
location = f"{loc.get('city', '')}, {loc['state']}"
|
||||||
|
elif loc.get("city") and loc.get("country"):
|
||||||
|
location = f"{loc['city']}, {loc['country']}"
|
||||||
|
else:
|
||||||
|
location = (loc.get("address") or "").strip()
|
||||||
|
|
||||||
|
# Determine hole count from tees
|
||||||
|
tees_male = data.get("tees", {}).get("male") or []
|
||||||
|
tees_female = data.get("tees", {}).get("female") or []
|
||||||
|
all_tees = tees_male + tees_female
|
||||||
|
holes_count = 18
|
||||||
|
for t in all_tees:
|
||||||
|
n = t.get("number_of_holes")
|
||||||
|
if n in (9, 18):
|
||||||
|
holes_count = n
|
||||||
|
break
|
||||||
|
|
||||||
|
# Hole par + stroke index from first tee that has hole data
|
||||||
|
hole_pars: dict[int, int] = {}
|
||||||
|
hole_sis: dict[int, int] = {}
|
||||||
|
for t in all_tees:
|
||||||
|
hole_data = t.get("holes") or []
|
||||||
|
if hole_data:
|
||||||
|
for i, h in enumerate(hole_data[:holes_count], 1):
|
||||||
|
hole_pars[i] = h.get("par", 4)
|
||||||
|
if h.get("handicap"):
|
||||||
|
hole_sis[i] = h["handicap"]
|
||||||
|
break
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
cid = secrets.token_urlsafe(10)
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO courses (id, name, holes, location, created_at) VALUES (?,?,?,?,?)",
|
||||||
|
(cid, name, holes_count, location or None, now),
|
||||||
|
)
|
||||||
|
for i in range(1, holes_count + 1):
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO course_holes (id, course_id, hole_number, par, stroke_index) VALUES (?,?,?,?,?)",
|
||||||
|
(secrets.token_urlsafe(10), cid, i, hole_pars.get(i, 4), hole_sis.get(i)),
|
||||||
|
)
|
||||||
|
for t in tees_male:
|
||||||
|
tname = (t.get("tee_name") or "").strip()
|
||||||
|
slope = t.get("slope_rating")
|
||||||
|
rating = t.get("course_rating")
|
||||||
|
if tname and slope and rating:
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO course_tees (id, course_id, name, slope, rating) VALUES (?,?,?,?,?)",
|
||||||
|
(secrets.token_urlsafe(10), cid, tname, slope, rating),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/admin/courses/{cid}/edit?success=Course+imported+successfully.", status_code=302
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Edit ──────────────────────────────────────────────────────────────────────
|
# ── Edit ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/admin/courses/{cid}/edit", response_class=HTMLResponse)
|
@router.get("/admin/courses/{cid}/edit", response_class=HTMLResponse)
|
||||||
|
|||||||
+55
-7
@@ -65,6 +65,29 @@ async def _broadcast(game_id: str, message: dict):
|
|||||||
_connections.get(game_id, set()).difference_update(dead)
|
_connections.get(game_id, set()).difference_update(dead)
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_skins(players: list, scores: dict, holes: list, carryover: bool = True):
|
||||||
|
"""Returns (skins_per_player, hole_results, unclaimed_pot)"""
|
||||||
|
skins = {p["id"]: 0 for p in players}
|
||||||
|
hole_results = {}
|
||||||
|
pot = 0
|
||||||
|
for hole in holes:
|
||||||
|
scored = {uid: s["strokes"] for uid, s in scores.get(hole, {}).items() if s.get("strokes")}
|
||||||
|
if not scored:
|
||||||
|
continue
|
||||||
|
pot += 1
|
||||||
|
min_score = min(scored.values())
|
||||||
|
winners = [uid for uid, s in scored.items() if s == min_score]
|
||||||
|
if len(winners) == 1:
|
||||||
|
skins[winners[0]] += pot
|
||||||
|
hole_results[hole] = {"winner": winners[0], "pot": pot, "min_score": min_score}
|
||||||
|
pot = 0
|
||||||
|
else:
|
||||||
|
hole_results[hole] = {"winner": None, "pot": pot, "min_score": min_score}
|
||||||
|
if not carryover:
|
||||||
|
pot = 0
|
||||||
|
return skins, hole_results, pot
|
||||||
|
|
||||||
|
|
||||||
def _totals(players: list, scores: dict) -> dict:
|
def _totals(players: list, scores: dict) -> dict:
|
||||||
"""Returns {user_id: {bingo, bango, bongo, total}}"""
|
"""Returns {user_id: {bingo, bango, bongo, total}}"""
|
||||||
totals = {p["id"]: {"bingo": 0, "bango": 0.0, "bongo": 0, "total": 0.0} for p in players}
|
totals = {p["id"]: {"bingo": 0, "bango": 0.0, "bongo": 0, "total": 0.0} for p in players}
|
||||||
@@ -84,7 +107,7 @@ def _totals(players: list, scores: dict) -> dict:
|
|||||||
async def games_list(request: Request, user: dict = Depends(get_current_user),
|
async def games_list(request: Request, user: dict = Depends(get_current_user),
|
||||||
db: aiosqlite.Connection = Depends(get_db)):
|
db: aiosqlite.Connection = Depends(get_db)):
|
||||||
async with db.execute(
|
async with db.execute(
|
||||||
"""SELECT g.id, g.holes_count, g.status, g.created_at,
|
"""SELECT g.id, g.holes_count, g.status, g.created_at, g.format, g.carryover,
|
||||||
u.name as creator_name, c.name as course_name
|
u.name as creator_name, c.name as course_name
|
||||||
FROM games g JOIN users u ON g.created_by = u.id
|
FROM games g JOIN users u ON g.created_by = u.id
|
||||||
LEFT JOIN courses c ON c.id = g.course_id
|
LEFT JOIN courses c ON c.id = g.course_id
|
||||||
@@ -94,12 +117,12 @@ async def games_list(request: Request, user: dict = Depends(get_current_user),
|
|||||||
) as c:
|
) as c:
|
||||||
games = [dict(r) for r in await c.fetchall()]
|
games = [dict(r) for r in await c.fetchall()]
|
||||||
|
|
||||||
# For completed games, find the winner (highest total points)
|
# For completed BBB games, find the winner (highest total points)
|
||||||
async with db.execute(
|
async with db.execute(
|
||||||
"""SELECT hs.game_id, u.name, u.avatar,
|
"""SELECT hs.game_id, u.name, u.avatar,
|
||||||
SUM(hs.bingo + hs.bango + hs.bongo) AS total
|
SUM(hs.bingo + hs.bango + hs.bongo) AS total
|
||||||
FROM hole_scores hs JOIN users u ON hs.user_id = u.id
|
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')
|
WHERE hs.game_id IN (SELECT id FROM games WHERE status = 'completed' AND format = 'bingo_bango_bongo')
|
||||||
GROUP BY hs.game_id, hs.user_id
|
GROUP BY hs.game_id, hs.user_id
|
||||||
ORDER BY hs.game_id, total DESC""",
|
ORDER BY hs.game_id, total DESC""",
|
||||||
) as c:
|
) as c:
|
||||||
@@ -111,6 +134,21 @@ async def games_list(request: Request, user: dict = Depends(get_current_user),
|
|||||||
if r["game_id"] not in winners:
|
if r["game_id"] not in winners:
|
||||||
winners[r["game_id"]] = {"name": r["name"], "avatar": r["avatar"], "total": r["total"]}
|
winners[r["game_id"]] = {"name": r["name"], "avatar": r["avatar"], "total": r["total"]}
|
||||||
|
|
||||||
|
# For completed skins games, calculate winner via skins algorithm
|
||||||
|
for g in games:
|
||||||
|
if g["status"] == "completed" and g["format"] == "skins":
|
||||||
|
gid = g["id"]
|
||||||
|
players_g = await _get_players(gid, db)
|
||||||
|
scores_g = await _get_scores(gid, db)
|
||||||
|
holes_g = list(range(1, g["holes_count"] + 1))
|
||||||
|
skins_g, _, _ = _calc_skins(players_g, scores_g, holes_g, bool(g["carryover"]))
|
||||||
|
if skins_g:
|
||||||
|
winner_id = max(skins_g, key=skins_g.get)
|
||||||
|
if skins_g[winner_id] > 0:
|
||||||
|
wp = next((p for p in players_g if p["id"] == winner_id), None)
|
||||||
|
if wp:
|
||||||
|
winners[gid] = {"name": wp["name"], "avatar": wp["avatar"], "total": skins_g[winner_id]}
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"games/games.html", {"request": request, "user": user, "games": games, "winners": winners}
|
"games/games.html", {"request": request, "user": user, "games": games, "winners": winners}
|
||||||
)
|
)
|
||||||
@@ -144,7 +182,7 @@ async def new_game_page(request: Request, user: dict = Depends(get_current_user)
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
VALID_FORMATS = {"bingo_bango_bongo"}
|
VALID_FORMATS = {"bingo_bango_bongo", "skins"}
|
||||||
|
|
||||||
@router.post("/games/new")
|
@router.post("/games/new")
|
||||||
async def create_game(request: Request, holes_count: int = Form(...),
|
async def create_game(request: Request, holes_count: int = Form(...),
|
||||||
@@ -198,10 +236,11 @@ async def create_game(request: Request, holes_count: int = Form(...),
|
|||||||
|
|
||||||
game_id = secrets.token_urlsafe(10)
|
game_id = secrets.token_urlsafe(10)
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
carryover = 1 if form.get("carryover") == "1" else 0
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"INSERT INTO games (id, format, holes_count, status, created_by, created_at, course_id) VALUES (?,?,?,?,?,?,?)",
|
"INSERT INTO games (id, format, holes_count, status, created_by, created_at, course_id, carryover) VALUES (?,?,?,?,?,?,?,?)",
|
||||||
(game_id, "bingo_bango_bongo", holes_count, "active", user["id"], now, course_id),
|
(game_id, format, holes_count, "active", user["id"], now, course_id, carryover),
|
||||||
)
|
)
|
||||||
for uid in player_ids:
|
for uid in player_ids:
|
||||||
async with db.execute("SELECT handicap FROM users WHERE id = ?", (uid,)) as c:
|
async with db.execute("SELECT handicap FROM users WHERE id = ?", (uid,)) as c:
|
||||||
@@ -241,6 +280,12 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_
|
|||||||
if row["stroke_index"] is not None:
|
if row["stroke_index"] is not None:
|
||||||
hole_stroke_indices[row["hole_number"]] = row["stroke_index"]
|
hole_stroke_indices[row["hole_number"]] = row["stroke_index"]
|
||||||
|
|
||||||
|
skins_won, skins_hole_results, unclaimed = None, None, 0
|
||||||
|
if game["format"] == "skins":
|
||||||
|
carryover = bool(game["carryover"])
|
||||||
|
skins_won, skins_hole_results, unclaimed = _calc_skins(players, scores, holes, carryover)
|
||||||
|
results = sorted(players, key=lambda p: skins_won.get(p["id"], 0), reverse=True)
|
||||||
|
else:
|
||||||
results = sorted(players, key=lambda p: totals.get(p["id"], {}).get("total", 0), reverse=True)
|
results = sorted(players, key=lambda p: totals.get(p["id"], {}).get("total", 0), reverse=True)
|
||||||
|
|
||||||
return templates.TemplateResponse("games/summary.html", {
|
return templates.TemplateResponse("games/summary.html", {
|
||||||
@@ -248,6 +293,8 @@ async def game_summary(game_id: str, request: Request, user: dict = Depends(get_
|
|||||||
"players": players, "scores": scores, "totals": totals,
|
"players": players, "scores": scores, "totals": totals,
|
||||||
"holes": holes, "hole_pars": hole_pars,
|
"holes": holes, "hole_pars": hole_pars,
|
||||||
"results": results,
|
"results": results,
|
||||||
|
"skins_won": skins_won, "skins_hole_results": skins_hole_results, "unclaimed": unclaimed,
|
||||||
|
"carryover": bool(game["carryover"]),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -293,6 +340,7 @@ async def game_page(game_id: str, request: Request, user: dict = Depends(get_cur
|
|||||||
"players": players, "scores": scores, "totals": totals,
|
"players": players, "scores": scores, "totals": totals,
|
||||||
"holes": holes, "hole_pars": hole_pars, "hole_stroke_indices": hole_stroke_indices,
|
"holes": holes, "hole_pars": hole_pars, "hole_stroke_indices": hole_stroke_indices,
|
||||||
"playing_handicaps": playing_handicaps,
|
"playing_handicaps": playing_handicaps,
|
||||||
|
"carryover": bool(game["carryover"]),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -358,7 +406,7 @@ async def finish_game(game_id: str, user: dict = Depends(get_current_user),
|
|||||||
db: aiosqlite.Connection = Depends(get_db)):
|
db: aiosqlite.Connection = Depends(get_db)):
|
||||||
await db.execute("UPDATE games SET status = 'completed' WHERE id = ?", (game_id,))
|
await db.execute("UPDATE games SET status = 'completed' WHERE id = ?", (game_id,))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return RedirectResponse(f"/games/{game_id}", status_code=302)
|
return RedirectResponse(f"/games/{game_id}/summary", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
# ── WebSocket ─────────────────────────────────────────────────────────────────
|
# ── WebSocket ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Search 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>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:0.5rem; justify-content:flex-end; margin-bottom:1rem;">
|
||||||
|
<a href="/admin/courses" class="btn" style="width:auto; padding:0.5rem 1rem; background:var(--surface2); color:var(--text);">← Courses</a>
|
||||||
|
<a href="/admin/courses/new" class="btn btn-primary" style="width:auto; padding:0.5rem 1rem;">+ Manual</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not api_configured %}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<strong>GOLF_COURSE_API_KEY is not configured.</strong>
|
||||||
|
Add it to your <code>.env</code> file to enable course search.
|
||||||
|
Get a key at <a href="https://www.golfcourseapi.com" target="_blank" style="color:inherit;">golfcourseapi.com</a>.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<form method="get" action="/admin/courses/search" style="display:flex; gap:0.5rem;">
|
||||||
|
<input type="text" name="q" value="{{ q }}" placeholder="Search by course or club name…"
|
||||||
|
style="flex:1;" {% if not api_configured %}disabled{% endif %} autofocus>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width:auto; padding:0.65rem 1.25rem;"
|
||||||
|
{% if not api_configured %}disabled{% endif %}>Search</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-error" style="margin-top:0.75rem;">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if q and results is not none %}
|
||||||
|
{% if results %}
|
||||||
|
<p style="font-size:0.85rem; color:var(--text-muted); margin:0.75rem 0 0.4rem;">
|
||||||
|
{{ results | length }} result{{ 's' if results | length != 1 else '' }} for "<strong>{{ q }}</strong>"
|
||||||
|
</p>
|
||||||
|
<div class="card" style="padding:0;">
|
||||||
|
<table class="data-table" style="margin:0;">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Club</th><th>Course</th><th>Location</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in results %}
|
||||||
|
{% set loc = c.location or {} %}
|
||||||
|
{% if loc.state and loc.country == 'United States' %}
|
||||||
|
{% set location_str = (loc.city or '') + ', ' + loc.state %}
|
||||||
|
{% elif loc.city and loc.country %}
|
||||||
|
{% set location_str = loc.city + ', ' + loc.country %}
|
||||||
|
{% else %}
|
||||||
|
{% set location_str = '' %}
|
||||||
|
{% endif %}
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:500;">{{ c.club_name or '—' }}</td>
|
||||||
|
<td style="color:var(--text-muted);">
|
||||||
|
{% if c.course_name and c.course_name != c.club_name %}{{ c.course_name }}{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="color:var(--text-muted);">{{ location_str or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/courses/import/{{ c.id }}">
|
||||||
|
<button type="submit" class="btn-table-action">Import</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state" style="padding:2rem 0;">
|
||||||
|
<div class="empty-icon">🔍</div>
|
||||||
|
<p>No courses found for "<strong>{{ q }}</strong>".</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -17,7 +17,8 @@
|
|||||||
<div class="alert alert-success">{{ request.query_params.get('success') }}</div>
|
<div class="alert alert-success">{{ request.query_params.get('success') }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div style="display:flex; justify-content:flex-end; margin-bottom:1rem;">
|
<div style="display:flex; gap:0.5rem; justify-content:flex-end; margin-bottom:1rem;">
|
||||||
|
<a href="/admin/courses/search" class="btn" style="width:auto; padding:0.5rem 1rem; background:var(--surface2); color:var(--text);">Search & Import</a>
|
||||||
<a href="/admin/courses/new" class="btn btn-primary" style="width:auto; padding:0.5rem 1rem;">+ New course</a>
|
<a href="/admin/courses/new" class="btn btn-primary" style="width:auto; padding:0.5rem 1rem;">+ New course</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<button @click="view === 'score' ? prevHole() : cardView = 'strokes'" class="icon-btn"
|
<button @click="view === 'score' ? prevHole() : cardView = 'strokes'" class="icon-btn"
|
||||||
:disabled="view === 'score' ? currentHole <= 1 : cardView === 'strokes'">‹</button>
|
:disabled="view === 'score' ? currentHole <= 1 : cardView === 'strokes'">‹</button>
|
||||||
<div style="text-align:center; flex:1;">
|
<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 class="hole-label" x-text="view === 'score' ? 'Hole ' + currentHole + ' / {{ game.holes_count }}' : (cardView === 'strokes' ? 'Strokes' : (GAME_FORMAT === 'skins' ? 'Skins' : 'Points'))"></div>
|
||||||
<div style="font-size:0.72rem; color:var(--text-muted); margin-top:0.1rem;">
|
<div style="font-size:0.72rem; color:var(--text-muted); margin-top:0.1rem;">
|
||||||
<template x-if="view === 'score'">
|
<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>
|
<span x-text="(HOLE_PARS[currentHole] ? 'Par ' + HOLE_PARS[currentHole] : '') + (HOLE_PARS[currentHole] && HOLE_SIS[currentHole] ? ' · ' : '') + (HOLE_SIS[currentHole] ? 'SI ' + HOLE_SIS[currentHole] : '')"></span>
|
||||||
@@ -24,8 +24,8 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button @click="view === 'score' ? nextHole() : cardView = 'points'" class="icon-btn"
|
<button @click="view === 'score' ? nextHole() : cardView = (GAME_FORMAT === 'skins' ? 'skins' : 'points')" class="icon-btn"
|
||||||
:disabled="view === 'score' ? currentHole >= {{ game.holes_count }} : cardView === 'points'">›</button>
|
:disabled="view === 'score' ? currentHole >= {{ game.holes_count }} : cardView !== 'strokes'">›</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -73,6 +73,8 @@
|
|||||||
<button type="button" class="stroke-btn stroke-plus"
|
<button type="button" class="stroke-btn stroke-plus"
|
||||||
@click="adjustStrokes(currentHole, p.id, 1)">+</button>
|
@click="adjustStrokes(currentHole, p.id, 1)">+</button>
|
||||||
</span>
|
</span>
|
||||||
|
<template x-if="GAME_FORMAT === 'bingo_bango_bongo'">
|
||||||
|
<span>
|
||||||
<button type="button" class="bbb-btn bingo-btn"
|
<button type="button" class="bbb-btn bingo-btn"
|
||||||
:class="{ active: getScore(currentHole, p.id).bingo }"
|
:class="{ active: getScore(currentHole, p.id).bingo }"
|
||||||
@click="setBingo(currentHole, p.id)">Bingo</button>
|
@click="setBingo(currentHole, p.id)">Bingo</button>
|
||||||
@@ -82,6 +84,8 @@
|
|||||||
<button type="button" class="bbb-btn bongo-btn"
|
<button type="button" class="bbb-btn bongo-btn"
|
||||||
:class="{ active: getScore(currentHole, p.id).bongo }"
|
:class="{ active: getScore(currentHole, p.id).bongo }"
|
||||||
@click="setBongo(currentHole, p.id)">Bongo</button>
|
@click="setBongo(currentHole, p.id)">Bongo</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -109,7 +113,12 @@
|
|||||||
{# Scorecard sub-toggle #}
|
{# Scorecard sub-toggle #}
|
||||||
<div class="scorecard-toggle">
|
<div class="scorecard-toggle">
|
||||||
<button @click="cardView = 'strokes'" :class="{ active: cardView === 'strokes' }">Strokes</button>
|
<button @click="cardView = 'strokes'" :class="{ active: cardView === 'strokes' }">Strokes</button>
|
||||||
|
<template x-if="GAME_FORMAT === 'bingo_bango_bongo'">
|
||||||
<button @click="cardView = 'points'" :class="{ active: cardView === 'points' }">Points</button>
|
<button @click="cardView = 'points'" :class="{ active: cardView === 'points' }">Points</button>
|
||||||
|
</template>
|
||||||
|
<template x-if="GAME_FORMAT === 'skins'">
|
||||||
|
<button @click="cardView = 'skins'" :class="{ active: cardView === 'skins' }">Skins</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Strokes scorecard #}
|
{# Strokes scorecard #}
|
||||||
@@ -198,6 +207,52 @@
|
|||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{# Skins scorecard #}
|
||||||
|
<table class="scorecard" x-show="cardView === 'skins'">
|
||||||
|
<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 %}
|
||||||
|
<th style="color:var(--text-muted);">Skin</th>
|
||||||
|
</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 }}'), isSkinWinner({{ h }}, '{{ p.id }}') ? 'skin-winner' : '']"></span>
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td class="score-cell" x-text="skinHoleLabel({{ h }})"></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="totals-row">
|
||||||
|
<td>Skins</td>
|
||||||
|
{% if hole_pars %}<td></td>{% endif %}
|
||||||
|
{% for p in players %}
|
||||||
|
<td x-text="calcSkins().skins['{{ p.id }}'] || 0"></td>
|
||||||
|
{% endfor %}
|
||||||
|
<td x-text="calcSkins().unclaimed ? '(' + calcSkins().unclaimed + ')' : ''"></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
{% if game.status == 'active' %}
|
{% if game.status == 'active' %}
|
||||||
<template x-if="allHolesDone()">
|
<template x-if="allHolesDone()">
|
||||||
<form method="post" action="/games/{{ game.id }}/finish" style="margin-top: 1rem;">
|
<form method="post" action="/games/{{ game.id }}/finish" style="margin-top: 1rem;">
|
||||||
@@ -223,6 +278,8 @@
|
|||||||
<script>
|
<script>
|
||||||
const GAME_ID = "{{ game.id }}";
|
const GAME_ID = "{{ game.id }}";
|
||||||
const GAME_HOLES = {{ game.holes_count }};
|
const GAME_HOLES = {{ game.holes_count }};
|
||||||
|
const GAME_FORMAT = "{{ game.format }}";
|
||||||
|
const SKINS_CARRYOVER = {{ carryover | tojson }};
|
||||||
const HOLE_PARS = {{ hole_pars | tojson }};
|
const HOLE_PARS = {{ hole_pars | tojson }};
|
||||||
const HOLE_SIS = {{ hole_stroke_indices | tojson }};
|
const HOLE_SIS = {{ hole_stroke_indices | tojson }};
|
||||||
const PLAYERS_DATA = {{ players | tojson }};
|
const PLAYERS_DATA = {{ players | tojson }};
|
||||||
@@ -422,6 +479,44 @@ function gameApp() {
|
|||||||
|
|
||||||
touchDragEnd() { this.dropPlayer(this.dragOver ?? this.dragSrc); },
|
touchDragEnd() { this.dropPlayer(this.dragOver ?? this.dragSrc); },
|
||||||
|
|
||||||
|
calcSkins() {
|
||||||
|
const skins = {};
|
||||||
|
PLAYERS_DATA.forEach(p => skins[p.id] = 0);
|
||||||
|
const holes = {};
|
||||||
|
let pot = 0;
|
||||||
|
for (let hole = 1; hole <= GAME_HOLES; hole++) {
|
||||||
|
if (this.isSkipped(hole)) continue;
|
||||||
|
const holeScores = this.scores[hole] || {};
|
||||||
|
const scored = PLAYERS_DATA
|
||||||
|
.map(p => ({ id: p.id, strokes: holeScores[p.id]?.strokes || 0 }))
|
||||||
|
.filter(x => x.strokes > 0);
|
||||||
|
if (scored.length === 0) continue;
|
||||||
|
pot += 1;
|
||||||
|
const min = Math.min(...scored.map(x => x.strokes));
|
||||||
|
const winners = scored.filter(x => x.strokes === min);
|
||||||
|
if (winners.length === 1) {
|
||||||
|
skins[winners[0].id] += pot;
|
||||||
|
holes[hole] = { winner: winners[0].id, pot };
|
||||||
|
pot = 0;
|
||||||
|
} else {
|
||||||
|
holes[hole] = { winner: null, pot };
|
||||||
|
if (!SKINS_CARRYOVER) pot = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { skins, holes, unclaimed: pot };
|
||||||
|
},
|
||||||
|
|
||||||
|
isSkinWinner(hole, uid) {
|
||||||
|
return this.calcSkins().holes[hole]?.winner === uid;
|
||||||
|
},
|
||||||
|
|
||||||
|
skinHoleLabel(hole) {
|
||||||
|
const h = this.calcSkins().holes[hole];
|
||||||
|
if (!h) return '';
|
||||||
|
if (h.winner) return h.pot > 1 ? '✓ ' + h.pot : '✓';
|
||||||
|
return '→ ' + h.pot;
|
||||||
|
},
|
||||||
|
|
||||||
touchStart(e) {
|
touchStart(e) {
|
||||||
if (e.target.closest('.drag-handle')) return;
|
if (e.target.closest('.drag-handle')) return;
|
||||||
this.touchStartX = e.changedTouches[0].screenX;
|
this.touchStartX = e.changedTouches[0].screenX;
|
||||||
|
|||||||
@@ -4,7 +4,13 @@
|
|||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<script>
|
<script>
|
||||||
const TEES_BY_COURSE = {{ tees_by_course | tojson }};
|
const TEES_BY_COURSE = {{ tees_by_course | tojson }};
|
||||||
|
const COURSES_DATA = {{ courses | tojson }};
|
||||||
</script>
|
</script>
|
||||||
|
<style>
|
||||||
|
.picker-item { padding:0.65rem 0.75rem; cursor:pointer; border-bottom:1px solid var(--border); }
|
||||||
|
.picker-item:last-child { border-bottom:none; }
|
||||||
|
.picker-item.selected { background:var(--surface2); }
|
||||||
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -12,7 +18,26 @@
|
|||||||
x-data="{
|
x-data="{
|
||||||
step: 1,
|
step: 1,
|
||||||
courseId: '',
|
courseId: '',
|
||||||
|
courseLabel: '',
|
||||||
|
courseOpen: false,
|
||||||
|
courseSearch: '',
|
||||||
|
format: 'bingo_bango_bongo',
|
||||||
selectedPlayers: ['{{ user.id }}'],
|
selectedPlayers: ['{{ user.id }}'],
|
||||||
|
playerSearch: '',
|
||||||
|
filteredCourses() {
|
||||||
|
const q = this.courseSearch.toLowerCase();
|
||||||
|
if (!q) return COURSES_DATA;
|
||||||
|
return COURSES_DATA.filter(c =>
|
||||||
|
c.name.toLowerCase().includes(q) ||
|
||||||
|
(c.location || '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
selectCourse(c) {
|
||||||
|
this.courseId = c.id;
|
||||||
|
this.courseLabel = c.name + (c.location ? ' · ' + c.location : '') + ' (' + c.holes + ' holes)';
|
||||||
|
this.courseOpen = false;
|
||||||
|
this.courseSearch = '';
|
||||||
|
},
|
||||||
togglePlayer(id) {
|
togglePlayer(id) {
|
||||||
const idx = this.selectedPlayers.indexOf(id);
|
const idx = this.selectedPlayers.indexOf(id);
|
||||||
if (idx === -1) this.selectedPlayers.push(id);
|
if (idx === -1) this.selectedPlayers.push(id);
|
||||||
@@ -38,23 +63,65 @@
|
|||||||
<label>Format</label>
|
<label>Format</label>
|
||||||
<div style="display:flex; gap:0.75rem; flex-wrap:wrap;">
|
<div style="display:flex; gap:0.75rem; flex-wrap:wrap;">
|
||||||
<label class="radio-card" style="flex:1;">
|
<label class="radio-card" style="flex:1;">
|
||||||
<input type="radio" name="format" value="bingo_bango_bongo" checked required>
|
<input type="radio" name="format" value="bingo_bango_bongo" checked required
|
||||||
|
@change="format = $event.target.value">
|
||||||
<span>Bingo Bango Bongo</span>
|
<span>Bingo Bango Bongo</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="radio-card" style="flex:1;">
|
||||||
|
<input type="radio" name="format" value="skins"
|
||||||
|
@change="format = $event.target.value">
|
||||||
|
<span>Skins</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div x-show="format === 'skins'" style="margin-top:0.75rem;">
|
||||||
|
<label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
|
||||||
|
<input type="checkbox" name="carryover" value="1" checked style="width:auto;">
|
||||||
|
<span style="font-weight:400; color:var(--text-muted);">Carryover (ties carry to next hole)</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
{# ── Course picker ── #}
|
||||||
<label for="course_id">Course</label>
|
<div class="form-group" @click.outside="courseOpen = false; courseSearch = ''">
|
||||||
|
<label>Course</label>
|
||||||
{% if courses %}
|
{% if courses %}
|
||||||
<select id="course_id" name="course_id" required
|
<input type="hidden" name="course_id" :value="courseId">
|
||||||
@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;">
|
<button type="button"
|
||||||
<option value="" disabled selected>Select a course…</option>
|
@click="courseOpen = !courseOpen; if (courseOpen) $nextTick(() => $refs.courseSearch && $refs.courseSearch.focus())"
|
||||||
{% for c in courses %}
|
style="width:100%; display:flex; align-items:center; gap:0.5rem; padding:0.65rem 0.75rem; border-radius:0.5rem; border:1px solid var(--border); background:var(--surface); color:var(--text); font-size:1rem; cursor:pointer; text-align:left;">
|
||||||
<option value="{{ c.id }}">{{ c.name }}{% if c.location %} · {{ c.location }}{% endif %} ({{ c.holes }} holes)</option>
|
<span style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"
|
||||||
{% endfor %}
|
:style="courseId ? '' : 'color:var(--text-muted)'"
|
||||||
</select>
|
x-text="courseLabel || 'Select a course…'"></span>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="flex-shrink:0; transition:transform 0.15s;"
|
||||||
|
:style="courseOpen ? 'transform:rotate(180deg)' : ''">
|
||||||
|
<path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div x-show="courseOpen"
|
||||||
|
style="border:1px solid var(--border); border-radius:0.5rem; background:var(--surface); margin-top:0.25rem; overflow:hidden; box-shadow:0 4px 16px rgba(0,0,0,0.15);">
|
||||||
|
<div style="padding:0.5rem; border-bottom:1px solid var(--border);">
|
||||||
|
<input type="text" x-model="courseSearch" x-ref="courseSearch"
|
||||||
|
placeholder="Search by name or location…"
|
||||||
|
@keydown.escape="courseOpen = false; courseSearch = ''"
|
||||||
|
style="width:100%; padding:0.5rem 0.65rem; border-radius:0.4rem; border:1px solid var(--border); background:var(--surface2); color:var(--text); font-size:0.95rem; box-sizing:border-box;">
|
||||||
|
</div>
|
||||||
|
<div style="max-height:14rem; overflow-y:auto; -webkit-overflow-scrolling:touch;">
|
||||||
|
<template x-for="c in filteredCourses()" :key="c.id">
|
||||||
|
<div class="picker-item" @click="selectCourse(c)" :class="courseId === c.id ? 'selected' : ''">
|
||||||
|
<div x-text="c.name" style="font-weight:500;"></div>
|
||||||
|
<div x-text="(c.location ? c.location + ' · ' : '') + c.holes + ' holes'"
|
||||||
|
style="font-size:0.8rem; color:var(--text-muted);"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div x-show="filteredCourses().length === 0"
|
||||||
|
style="padding:0.75rem; text-align:center; color:var(--text-muted); font-size:0.9rem;">
|
||||||
|
No courses found
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p style="color:var(--text-muted); font-size:0.9rem;">No courses available. Ask the admin to add courses first.</p>
|
<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="">
|
<input type="hidden" name="course_id" value="">
|
||||||
@@ -75,11 +142,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ── Player picker ── #}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Players</label>
|
<label>Players</label>
|
||||||
|
<input type="text" x-model="playerSearch" placeholder="Filter players…"
|
||||||
|
style="width:100%; padding:0.5rem 0.65rem; border-radius:0.4rem; border:1px solid var(--border); background:var(--surface2); color:var(--text); font-size:0.95rem; margin-bottom:0.5rem; box-sizing:border-box;">
|
||||||
<div class="player-list">
|
<div class="player-list">
|
||||||
{% for u in all_users %}
|
{% for u in all_users %}
|
||||||
<label class="player-option">
|
<label class="player-option"
|
||||||
|
data-name="{{ u.name | lower }}"
|
||||||
|
x-show="{% if u.id == user.id %}true{% else %}!playerSearch || $el.dataset.name.includes(playerSearch.toLowerCase()){% endif %}">
|
||||||
<input type="checkbox" name="players" value="{{ u.id }}"
|
<input type="checkbox" name="players" value="{{ u.id }}"
|
||||||
{% if u.id == user.id %}checked disabled{% endif %}
|
{% if u.id == user.id %}checked disabled{% endif %}
|
||||||
@change="togglePlayer('{{ u.id }}')"
|
@change="togglePlayer('{{ u.id }}')"
|
||||||
@@ -98,6 +170,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="players" value="{{ user.id }}">
|
<input type="hidden" name="players" value="{{ user.id }}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" x-show="hasTees()" class="btn btn-primary" @click="step = 2">Next →</button>
|
<button type="button" x-show="hasTees()" class="btn btn-primary" @click="step = 2">Next →</button>
|
||||||
|
|||||||
@@ -15,7 +15,14 @@
|
|||||||
{# Leaderboard #}
|
{# Leaderboard #}
|
||||||
<div class="card" style="padding:0.75rem 1rem;">
|
<div class="card" style="padding:0.75rem 1rem;">
|
||||||
{% for p in results %}
|
{% for p in results %}
|
||||||
|
{% if game.format == 'skins' %}
|
||||||
|
{% set score_val = skins_won.get(p.id, 0) %}
|
||||||
|
{% set score_label = score_val | string + ' skin' + ('s' if score_val != 1 else '') %}
|
||||||
|
{% else %}
|
||||||
{% set t = totals.get(p.id, {}) %}
|
{% set t = totals.get(p.id, {}) %}
|
||||||
|
{% set score_val = t.total %}
|
||||||
|
{% set score_label = t.total | round(1) | string + ' pts' %}
|
||||||
|
{% endif %}
|
||||||
<div class="result-row {% if loop.first %}result-winner{% endif %}" style="padding:0.4rem 0;">
|
<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-rank">{% if loop.first %}🏆{% else %}{{ loop.index }}{% endif %}</span>
|
||||||
<span class="result-avatar">
|
<span class="result-avatar">
|
||||||
@@ -26,18 +33,29 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<a href="/users/{{ p.id }}" style="flex:1; color:inherit; text-decoration:none;">{{ p.name }}</a>
|
<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 class="result-pts">{{ score_label }}</span>
|
||||||
|
{% if game.format == 'bingo_bango_bongo' %}
|
||||||
<span style="font-size:0.75rem; color:var(--text-muted); margin-left:0.5rem;">
|
<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 }}
|
B{{ t.bingo | int }}/{{ t.bango | round(1) }}/{{ t.bongo | int }}
|
||||||
</span>
|
</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% if game.format == 'skins' and unclaimed %}
|
||||||
|
<div style="font-size:0.8rem; color:var(--text-muted); margin-top:0.5rem; text-align:center;">
|
||||||
|
{{ unclaimed }} skin{{ 's' if unclaimed != 1 else '' }} unclaimed
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Scorecard toggle #}
|
{# Scorecard toggle #}
|
||||||
<div class="scorecard-toggle" style="margin-top:1rem;">
|
<div class="scorecard-toggle" style="margin-top:1rem;">
|
||||||
<button @click="view = 'strokes'" :class="{ active: view === 'strokes' }">Strokes</button>
|
<button @click="view = 'strokes'" :class="{ active: view === 'strokes' }">Strokes</button>
|
||||||
|
{% if game.format == 'skins' %}
|
||||||
|
<button @click="view = 'skins'" :class="{ active: view === 'skins' }">Skins</button>
|
||||||
|
{% else %}
|
||||||
<button @click="view = 'points'" :class="{ active: view === 'points' }">Points</button>
|
<button @click="view = 'points'" :class="{ active: view === 'points' }">Points</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Strokes scorecard #}
|
{# Strokes scorecard #}
|
||||||
@@ -150,5 +168,72 @@
|
|||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{# Skins scorecard #}
|
||||||
|
{% if game.format == 'skins' %}
|
||||||
|
<table class="scorecard" x-show="view === 'skins'">
|
||||||
|
<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 %}
|
||||||
|
<th style="color:var(--text-muted);">Skin</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for h in holes %}
|
||||||
|
{% set hr = skins_hole_results.get(h) %}
|
||||||
|
<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 %}
|
||||||
|
{% set is_winner = hr and hr.winner == p.id %}
|
||||||
|
{% if par %}
|
||||||
|
{% set diff = strokes - par %}
|
||||||
|
{% if strokes == 1 %}<span class="score-hio{% if is_winner %} skin-winner{% endif %}">{{ strokes }}</span>
|
||||||
|
{% elif diff <= -2 %}<span class="score-eagle{% if is_winner %} skin-winner{% endif %}">{{ strokes }}</span>
|
||||||
|
{% elif diff == -1 %}<span class="score-birdie{% if is_winner %} skin-winner{% endif %}">{{ strokes }}</span>
|
||||||
|
{% elif diff == 1 %}<span class="score-bogey{% if is_winner %} skin-winner{% endif %}">{{ strokes }}</span>
|
||||||
|
{% elif diff >= 2 %}<span class="score-double-bogey{% if is_winner %} skin-winner{% endif %}">{{ strokes }}</span>
|
||||||
|
{% else %}<span{% if is_winner %} class="skin-winner"{% endif %}>{{ strokes }}</span>{% endif %}
|
||||||
|
{% else %}<span{% if is_winner %} class="skin-winner"{% endif %}>{{ strokes }}</span>{% endif %}
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td class="score-cell">
|
||||||
|
{% if hr %}
|
||||||
|
{% if hr.winner %}✓{% if hr.pot > 1 %} {{ hr.pot }}{% endif %}
|
||||||
|
{% else %}→ {{ hr.pot }}{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="totals-row">
|
||||||
|
<td>Skins</td>
|
||||||
|
{% if hole_pars %}<td></td>{% endif %}
|
||||||
|
{% for p in players %}
|
||||||
|
<td>{{ skins_won.get(p.id, 0) }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td>{% if unclaimed %}({{ unclaimed }}){% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ python-multipart==0.0.12
|
|||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
aiosmtplib==3.0.1
|
aiosmtplib==3.0.1
|
||||||
|
httpx==0.28.1
|
||||||
|
|||||||
@@ -752,6 +752,7 @@ input:focus {
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
|
.skin-winner { font-weight: 700; color: var(--accent); }
|
||||||
|
|
||||||
.drag-source { opacity: 0.4; }
|
.drag-source { opacity: 0.4; }
|
||||||
.drag-over { outline: 2px solid var(--accent); border-radius: 0.65rem; }
|
.drag-over { outline: 2px solid var(--accent); border-radius: 0.65rem; }
|
||||||
|
|||||||
Reference in New Issue
Block a user