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:
Rolf
2026-03-20 10:16:46 +01:00
parent 4b31b98f89
commit 3a1da1482c
51 changed files with 4964 additions and 1 deletions
+154
View File
@@ -0,0 +1,154 @@
{% extends "base.html" %}
{% block title %}{{ game.course_name or 'Game' }} Skins &amp; 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 %}