Files
SkinsPins/app/templates/games/game.html
T
Rolf 3a1da1482c 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>
2026-03-20 10:16:46 +01:00

443 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Game Skins &amp; 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 %}