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
+141
View File
@@ -0,0 +1,141 @@
{% extends "base.html" %}
{% block title %}New Game Skins &amp; 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 %}