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,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 %}
|
||||
Reference in New Issue
Block a user