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
+105
View File
@@ -0,0 +1,105 @@
{% extends "base.html" %}
{% block title %}Dashboard Skins &amp; Pins{% endblock %}
{% block content %}
<div class="page">
<div class="page-header">
<h1>
{% if user.avatar and user.avatar.startswith('/') %}
<img src="{{ user.avatar }}" alt="avatar" style="width:2rem; height:2rem; border-radius:50%; object-fit:cover; vertical-align:middle;">
{% elif user.avatar %}
{{ user.avatar }}
{% endif %}
<a href="/users/{{ user.id }}" style="color:inherit; text-decoration:none;">{{ user.name }}</a>
</h1>
<p>Handicap {{ user.handicap }}</p>
</div>
{% if games_played > 0 %}
{# Stats row #}
<div class="stats-row">
<div class="stat-card">
<div class="stat-value">{{ games_played }}</div>
<div class="stat-label">Games played</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ victories }}</div>
<div class="stat-label">Victories</div>
</div>
<div class="stat-card">
<div class="stat-value">
{{ ((victories / games_played) * 100) | round | int }}%
</div>
<div class="stat-label">Win rate</div>
</div>
</div>
{% endif %}
{# Social stream: active games #}
{% if active_games %}
<h2 style="font-size:0.85rem; color:var(--text-muted); margin-bottom:0.6rem; text-transform:uppercase; letter-spacing:0.06em;">Live games</h2>
{% for game in active_games %}
<div class="card game-result-card" style="cursor:pointer;" onclick="window.location='/games/{{ game.id }}/summary'">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:0.15rem;">
<div style="font-weight:600;">{{ game.course_name or '—' }}</div>
{% if game.is_participant %}
<a href="/games/{{ game.id }}" style="font-size:0.75rem; color:var(--accent); text-decoration:none; font-weight:600;" onclick="event.stopPropagation()">▶ Play</a>
{% endif %}
</div>
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.5rem;">
{{ game.format | replace('_', ' ') | title }} · {{ game.holes_count }} holes · {{ game.created_at[:10] }}
</div>
{% for p in game.players %}
<div class="result-row">
<span class="result-rank">{{ loop.index }}</span>
<span class="result-avatar">
{% if p.avatar and p.avatar.startswith('/') %}
<img src="{{ p.avatar }}" style="width:1.3rem;height:1.3rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
{% else %}
{{ p.avatar or '👤' }}
{% endif %}
</span>
<span class="result-name">{{ p.name }}</span>
<span class="result-pts">{{ p.total | round(1) }} pts</span>
</div>
{% endfor %}
</div>
{% endfor %}
{% endif %}
{# Recent games #}
{% if recent_games %}
<h2 style="font-size:0.85rem; color:var(--text-muted); margin-bottom:0.6rem; text-transform:uppercase; letter-spacing:0.06em;">Recent games</h2>
{% for game in recent_games %}
<div class="card game-result-card" style="cursor:pointer;" onclick="window.location='/games/{{ game.id }}/summary'">
<div style="font-weight:600; margin-bottom:0.15rem;">{{ game.course_name or '—' }}</div>
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.5rem;">
{{ game.format | replace('_', ' ') | title }} · {{ game.holes_count }} holes · {{ game.created_at[:10] }}
</div>
{% for r in game.results %}
<div class="result-row {% if loop.first %}result-winner{% endif %}">
<span class="result-rank">{% if loop.first %}🏆{% else %}{{ loop.index }}{% endif %}</span>
<span class="result-avatar">
{% if r.avatar and r.avatar.startswith('/') %}
<img src="{{ r.avatar }}" style="width:1.3rem;height:1.3rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
{% else %}
{{ r.avatar or '👤' }}
{% endif %}
</span>
<span class="result-name">{{ r.name }}</span>
<span class="result-pts">{{ r.total | round(1) }} pts</span>
</div>
{% endfor %}
</div>
{% endfor %}
{% endif %}
{% if not games_played and not active_games %}
<div class="empty-state">
<div class="empty-icon"></div>
<p>No games played yet.</p>
<a href="/games/new" class="btn btn-primary" style="margin-top:1rem; max-width:200px;">Start a game</a>
</div>
{% endif %}
</div>
{% endblock %}