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
+108
View File
@@ -0,0 +1,108 @@
{% extends "base.html" %}
{% block title %}Admin Skins &amp; Pins{% endblock %}
{% block content %}
<div class="page">
<div class="page-header">
<h1>⚙️ Admin</h1>
</div>
<div class="admin-subnav">
<a href="/admin" class="admin-subnav-item active">Users</a>
<a href="/admin/courses" class="admin-subnav-item">Courses</a>
<a href="/admin/games" class="admin-subnav-item">Games</a>
</div>
{% if request.query_params.get('error') %}
<div class="alert alert-error">{{ request.query_params.get('error') }}</div>
{% endif %}
{% if request.query_params.get('success') %}
<div class="alert alert-success">{{ request.query_params.get('success') }}</div>
{% endif %}
{% if error %}<div class="alert alert-error">{{ error }}</div>{% endif %}
{% if success %}<div class="alert alert-success">{{ success }}</div>{% endif %}
<div class="card">
<h2 style="font-size: 1rem; margin-bottom: 1rem; color: var(--text-muted);">Invite a player</h2>
<form method="post" action="/admin/invite">
<div class="form-group">
<label for="email">Email address</label>
<input type="email" id="email" name="email" placeholder="player@example.com"
autocomplete="off" autocapitalize="none" required>
</div>
<button type="submit" class="btn btn-primary">Send invitation</button>
</form>
</div>
<div class="card">
<h2 style="font-size: 1rem; margin-bottom: 1rem; color: var(--text-muted);">Pending invitations</h2>
{% if invitations %}
<table class="data-table">
<thead>
<tr><th>Email</th><th>Invited</th><th></th></tr>
</thead>
<tbody>
{% for inv in invitations %}
<tr>
<td>{{ inv.email }}</td>
<td style="color: var(--text-muted);">{{ inv.created_at[:10] }}</td>
<td style="white-space:nowrap;">
<form method="post" action="/admin/invitations/{{ inv.id }}/resend" style="display:inline;">
<button type="submit" class="btn-table-action">Resend</button>
</form>
<form method="post" action="/admin/invitations/{{ inv.id }}/delete" style="display:inline;"
onsubmit="return confirm('Delete invitation for {{ inv.email }}?')">
<button type="submit" class="btn-table-action danger">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color: var(--text-muted); font-size: 0.9rem;">No pending invitations.</p>
{% endif %}
</div>
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
<h2 style="font-size: 1rem; color: var(--text-muted); margin:0;">Registered users</h2>
<a href="/admin/users/new" class="btn btn-primary" style="width:auto; padding:0.4rem 0.85rem; font-size:0.85rem;">+ Add user</a>
</div>
{% if users %}
<table class="data-table">
<thead>
<tr><th>User</th><th>Hcp</th><th></th></tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>
{% if u.avatar and u.avatar.startswith('/') %}
<img src="{{ u.avatar }}" style="width:1.4rem;height:1.4rem;border-radius:50%;object-fit:cover;vertical-align:middle;margin-right:0.3rem;">
{% else %}
{{ u.avatar or '👤' }}
{% endif %}
{% if u.name %}<a href="/users/{{ u.id }}" style="color:inherit; text-decoration:none;">{{ u.name }}</a>{% else %}(not set up){% endif %}
<div style="font-size:0.75rem; color:var(--text-muted);">{{ u.email }}</div>
</td>
<td style="color:var(--text-muted);">{{ u.handicap if u.handicap is not none else '—' }}</td>
<td style="white-space:nowrap;">
<a href="/admin/users/{{ u.id }}/edit" class="btn-table-action">Edit</a>
{% if u.id != user.id %}
<form method="post" action="/admin/users/{{ u.id }}/delete" style="display:inline;"
onsubmit="return confirm('Delete {{ u.name or u.email }}?')">
<button type="submit" class="btn-table-action danger">Delete</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color: var(--text-muted); font-size: 0.9rem;">No users yet.</p>
{% endif %}
</div>
</div>
{% endblock %}
+56
View File
@@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block title %}Courses Skins &amp; Pins{% endblock %}
{% block content %}
<div class="page">
<div class="page-header">
<h1>⚙️ Admin</h1>
</div>
<div class="admin-subnav">
<a href="/admin" class="admin-subnav-item">Users</a>
<a href="/admin/courses" class="admin-subnav-item active">Courses</a>
<a href="/admin/games" class="admin-subnav-item">Games</a>
</div>
{% if request.query_params.get('success') %}
<div class="alert alert-success">{{ request.query_params.get('success') }}</div>
{% endif %}
<div style="display:flex; justify-content:flex-end; margin-bottom:1rem;">
<a href="/admin/courses/new" class="btn btn-primary" style="width:auto; padding:0.5rem 1rem;">+ New course</a>
</div>
{% if courses %}
<div class="card" style="padding:0;">
<table class="data-table" style="margin:0;">
<thead>
<tr><th>Course</th><th>Holes</th><th>Location</th><th></th></tr>
</thead>
<tbody>
{% for c in courses %}
<tr>
<td style="font-weight:500;">{{ c.name }}</td>
<td style="color:var(--text-muted);">{{ c.holes }}</td>
<td style="color:var(--text-muted);">{{ c.location or '—' }}</td>
<td style="white-space:nowrap;">
<a href="/admin/courses/{{ c.id }}/edit" class="btn-table-action">Edit</a>
<form method="post" action="/admin/courses/{{ c.id }}/delete" style="display:inline;"
onsubmit="return confirm('Delete {{ c.name }}? This cannot be undone.')">
<button type="submit" class="btn-table-action danger">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon"></div>
<p>No courses yet.</p>
<a href="/admin/courses/new" class="btn btn-primary" style="margin-top:1rem; max-width:200px;">Add a course</a>
</div>
{% endif %}
</div>
{% endblock %}
+149
View File
@@ -0,0 +1,149 @@
{% extends "base.html" %}
{% block title %}Edit Course Skins &amp; Pins{% endblock %}
{% block content %}
<div class="page">
<div class="page-header">
<a href="/admin/courses" style="color:var(--text-muted); font-size:0.9rem;">← Courses</a>
<h1 style="margin-top:0.25rem;">{{ course.name }}</h1>
</div>
{% if request.query_params.get('success') %}
<div class="alert alert-success">{{ request.query_params.get('success') }}</div>
{% endif %}
{# ── Course info + hole pars ── #}
<form method="post" action="/admin/courses/{{ course.id }}/edit">
<div class="card">
<h2 style="font-size:0.95rem; color:var(--text-muted); margin-bottom:1rem;">Course info</h2>
<div class="form-group">
<label for="name">Course name</label>
{% if errors and errors.name %}<div class="alert alert-error">{{ errors.name }}</div>{% endif %}
<input type="text" id="name" name="name" value="{{ course.name }}" required>
</div>
<div class="form-group">
<label for="location">Location <span style="color:var(--text-muted); font-size:0.8rem;">(optional)</span></label>
<input type="text" id="location" name="location" value="{{ course.location or '' }}"
placeholder="e.g. Augusta, Georgia">
</div>
<div class="form-group">
<label>Holes</label>
<div style="display:flex; gap:0.75rem;">
<label class="radio-card">
<input type="radio" name="holes" value="9" {% if course.holes == 9 %}checked{% endif %}>
<span>9 holes</span>
</label>
<label class="radio-card">
<input type="radio" name="holes" value="18" {% if course.holes == 18 %}checked{% endif %}>
<span>18 holes</span>
</label>
</div>
</div>
</div>
<div class="card">
{% set course_par = holes | sum(attribute='par') %}
<h2 style="font-size:0.95rem; color:var(--text-muted); margin-bottom:0.75rem;">
Hole details <span style="font-weight:400;">— Course par: <strong>{{ course_par }}</strong></span>
</h2>
<div class="hole-pars-grid">
{% for h in holes %}
<div class="hole-par-cell">
<div class="hole-par-label">{{ h.hole_number }}</div>
<select name="par_{{ h.hole_number }}" class="hole-par-select">
<option value="3" {% if h.par == 3 %}selected{% endif %}>3</option>
<option value="4" {% if h.par == 4 %}selected{% endif %}>4</option>
<option value="5" {% if h.par == 5 %}selected{% endif %}>5</option>
</select>
<select name="si_{{ h.hole_number }}" class="hole-par-select">
<option value=""></option>
{% for s in range(1, course.holes + 1) %}
<option value="{{ s }}" {% if h.stroke_index == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</div>
{% endfor %}
</div>
</div>
<button type="submit" class="btn btn-primary" style="margin-bottom:1.5rem;">Save changes</button>
</form>
{# ── Tees ── #}
<div class="card">
<h2 style="font-size:0.95rem; color:var(--text-muted); margin-bottom:1rem;">Tees</h2>
{% if tees %}
<div style="margin-bottom:1.25rem;">
{% for t in tees %}
<div x-data="{ editing: false }" style="border-bottom:1px solid var(--border); padding:0.6rem 0;">
<div x-show="!editing" style="display:flex; align-items:center; gap:0.75rem; flex-wrap:wrap;">
<span style="font-weight:500; min-width:5rem;">{{ t.name }}</span>
<span style="color:var(--text-muted); font-size:0.85rem;">Slope {{ t.slope }}</span>
<span style="color:var(--text-muted); font-size:0.85rem;">Rating {{ t.rating }}</span>
<span style="margin-left:auto; display:flex; gap:0.4rem;">
<button type="button" @click="editing = true" class="btn-table-action">Edit</button>
<form method="post" action="/admin/courses/{{ course.id }}/tees/{{ t.id }}/delete"
style="display:inline;"
onsubmit="return confirm('Delete {{ t.name }} tee?')">
<button type="submit" class="btn-table-action danger">Delete</button>
</form>
</span>
</div>
<form x-show="editing" method="post"
action="/admin/courses/{{ course.id }}/tees/{{ t.id }}/edit"
style="display:flex; gap:0.5rem; align-items:flex-end; flex-wrap:wrap; padding-top:0.4rem;">
<div class="form-group" style="margin:0; flex:1; min-width:7rem;">
<label style="font-size:0.8rem;">Name</label>
<input type="text" name="name" value="{{ t.name }}" required style="padding:0.4rem 0.6rem;">
</div>
<div class="form-group" style="margin:0; width:6rem;">
<label style="font-size:0.8rem;">Slope</label>
<input type="number" name="slope" value="{{ t.slope }}" min="55" max="155" required style="padding:0.4rem 0.6rem;">
</div>
<div class="form-group" style="margin:0; width:7rem;">
<label style="font-size:0.8rem;">Rating</label>
<input type="number" step="0.1" name="rating" value="{{ t.rating }}" min="60" max="80" required style="padding:0.4rem 0.6rem;">
</div>
<div style="display:flex; gap:0.4rem; padding-bottom:0.1rem;">
<button type="submit" class="btn btn-primary" style="padding:0.4rem 0.75rem; font-size:0.85rem;">Save</button>
<button type="button" @click="editing = false"
style="padding:0.4rem 0.75rem; font-size:0.85rem; background:none; border:1px solid var(--border); border-radius:0.4rem; color:var(--text-muted); cursor:pointer;">Cancel</button>
</div>
</form>
</div>
{% endfor %}
</div>
{% else %}
<p style="color:var(--text-muted); font-size:0.9rem; margin-bottom:1rem;">No tees yet. Add at least one tee so players can start a game on this course.</p>
{% endif %}
{# Add tee form #}
<h3 style="font-size:0.85rem; color:var(--text-muted); margin-bottom:0.75rem; text-transform:uppercase; letter-spacing:0.05em;">Add tee</h3>
<form method="post" action="/admin/courses/{{ course.id }}/tees/new"
style="display:flex; gap:0.5rem; align-items:flex-end; flex-wrap:wrap;">
<div class="form-group" style="margin:0; flex:1; min-width:7rem;">
<label style="font-size:0.8rem;">Name</label>
<input type="text" name="name" placeholder="e.g. White" required style="padding:0.4rem 0.6rem;">
</div>
<div class="form-group" style="margin:0; width:6rem;">
<label style="font-size:0.8rem;">Slope</label>
<input type="number" name="slope" placeholder="113" min="55" max="155" required style="padding:0.4rem 0.6rem;">
</div>
<div class="form-group" style="margin:0; width:7rem;">
<label style="font-size:0.8rem;">Rating</label>
<input type="number" step="0.1" name="rating" placeholder="72.0" min="60" max="80" required style="padding:0.4rem 0.6rem;">
</div>
<div style="padding-bottom:0.1rem;">
<button type="submit" class="btn btn-primary" style="padding:0.4rem 0.75rem; font-size:0.85rem;">Add tee</button>
</div>
</form>
</div>
</div>
{% endblock %}
+84
View File
@@ -0,0 +1,84 @@
{% extends "base.html" %}
{% block title %}Edit User Skins &amp; Pins{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="https://unpkg.com/cropperjs@1.6.2/dist/cropper.min.css">
<script src="https://unpkg.com/cropperjs@1.6.2/dist/cropper.min.js"></script>
<script src="/static/avatar-crop.js"></script>
{% endblock %}
{% block content %}
<div class="page">
<div class="page-header">
<a href="/admin" style="color:var(--text-muted); font-size:0.9rem;">← Admin</a>
<h1 style="margin-top:0.25rem;">Edit user</h1>
</div>
<div x-data="avatarUpload('/admin/users/{{ target.id }}/edit', '/admin')"
x-init="tab = '{{ 'upload' if target.avatar and target.avatar.startswith('/') else 'emoji' }}';
previewUrl = '{{ target.avatar if target.avatar and target.avatar.startswith('/') else '' }}' || null;">
<form action="/admin/users/{{ target.id }}/edit" method="post" enctype="multipart/form-data"
@submit="submitForm($event)">
<div class="card">
<div class="form-group">
<label for="name">Name</label>
{% if errors and errors.name %}<div class="alert alert-error">{{ errors.name }}</div>{% endif %}
<input type="text" id="name" name="name" value="{{ target.name or '' }}" required>
</div>
<div class="form-group">
<label for="email">Email</label>
{% if errors and errors.email %}<div class="alert alert-error">{{ errors.email }}</div>{% endif %}
<input type="email" id="email" name="email" value="{{ target.email }}" required>
</div>
<div class="form-group">
<label for="handicap">Handicap (054)</label>
{% if errors and errors.handicap %}<div class="alert alert-error">{{ errors.handicap }}</div>{% endif %}
<input type="number" id="handicap" name="handicap" min="0" max="54" step="0.1"
value="{{ target.handicap or '' }}" required>
</div>
<div class="form-group">
<label>Avatar</label>
{% if errors and errors.avatar %}<div class="alert alert-error">{{ errors.avatar }}</div>{% endif %}
<div class="avatar-tabs">
<button type="button" class="avatar-tab" :class="{ active: tab === 'emoji' }" @click="tab = 'emoji'">Emoji</button>
<button type="button" class="avatar-tab" :class="{ active: tab === 'upload' }" @click="tab = 'upload'">Photo</button>
</div>
<div x-show="tab === 'emoji'">
<div class="avatar-grid" style="margin-top: 0.75rem;">
{% for a in avatars %}
<div class="avatar-option">
<input type="radio" name="avatar_emoji" id="avatar_{{ loop.index }}" value="{{ a }}"
{% if target.avatar == a %}checked{% endif %}>
<label for="avatar_{{ loop.index }}">{{ a }}</label>
</div>
{% endfor %}
</div>
</div>
<div x-show="tab === 'upload'" style="margin-top: 0.75rem;">
<div x-show="previewUrl" style="margin-bottom: 0.75rem; display:flex; align-items:center; gap:1rem;">
<img :src="previewUrl" class="avatar-preview">
<span style="color:var(--text-muted); font-size:0.85rem;">Tap to change</span>
</div>
<label class="file-input-label">
<span x-text="previewUrl ? 'Choose different photo' : 'Choose photo'"></span>
<input type="file" name="avatar_file" accept="image/jpeg,image/png,image/webp,image/gif"
style="display:none" @change="openFile($event)">
</label>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">Save changes</button>
</form>
{% include "partials/crop_modal.html" %}
</div>
</div>
{% endblock %}
+64
View File
@@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}Games Admin Skins &amp; Pins{% endblock %}
{% block content %}
<div class="page">
<div class="page-header">
<h1>⚙️ Admin</h1>
</div>
<div class="admin-subnav">
<a href="/admin" class="admin-subnav-item">Users</a>
<a href="/admin/courses" class="admin-subnav-item">Courses</a>
<a href="/admin/games" class="admin-subnav-item active">Games</a>
</div>
{% if request.query_params.get('success') %}
<div class="alert alert-success">{{ request.query_params.get('success') }}</div>
{% endif %}
{% if games %}
<div class="card" style="padding:0; overflow:hidden;">
{% for g in games %}
{% set plist = players_by_game.get(g.id, []) %}
<div style="padding:0.85rem 1rem; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:0.75rem; flex-wrap:wrap;">
<div style="flex:1; min-width:0;">
<div style="font-weight:500; color:var(--text);">
{{ g.course_name or 'Unknown course' }}
<span class="status-badge status-{{ g.status }}" style="margin-left:0.4rem;">{{ g.status }}</span>
</div>
<div style="font-size:0.8rem; color:var(--text-muted); margin-top:0.2rem;">
{{ g.holes_count }} holes · {{ g.created_at[:10] }} · by {{ g.creator_name }}
</div>
<div style="font-size:0.8rem; color:var(--text-muted); margin-top:0.15rem;">
{% for p in plist %}
{% if p.avatar and p.avatar.startswith('/') %}
<img src="{{ p.avatar }}" style="width:1rem;height:1rem;border-radius:50%;object-fit:cover;vertical-align:middle;">
{% else %}
{{ p.avatar or '👤' }}
{% endif %}
<a href="/users/{{ p.id }}" style="color:inherit; text-decoration:none;">{{ p.name }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
</div>
<div style="display:flex; gap:0.4rem; flex-shrink:0;">
<a href="/games/{{ g.id }}" class="btn-table-action">Edit scores</a>
<form method="post" action="/admin/games/{{ g.id }}/delete" style="display:inline;"
onsubmit="return confirm('Delete this game? All scores will be lost.')">
<button type="submit" class="btn-table-action danger">Delete</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon"></div>
<p>No games yet.</p>
</div>
{% endif %}
</div>
{% endblock %}
+77
View File
@@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block title %}New Course Skins &amp; Pins{% endblock %}
{% block content %}
<div class="page">
<div class="page-header">
<a href="/admin/courses" style="color:var(--text-muted); font-size:0.9rem;">← Courses</a>
<h1 style="margin-top:0.25rem;">New course</h1>
</div>
<form method="post" action="/admin/courses/new"
x-data="{
holes: {{ (form.holes if form else 18)|int }},
initPars: {{ form.pars | tojson if (form and form.pars) else '{}' }},
initSIs: {{ form.stroke_indices | tojson if (form and form.stroke_indices) else '{}' }}
}">
<div class="card">
<div class="form-group">
<label for="name">Course name</label>
{% if errors and errors.name %}<div class="alert alert-error">{{ errors.name }}</div>{% endif %}
<input type="text" id="name" name="name"
value="{{ form.name if form else '' }}" placeholder="e.g. Augusta National" required>
</div>
<div class="form-group">
<label for="location">Location <span style="color:var(--text-muted); font-size:0.8rem;">(optional)</span></label>
<input type="text" id="location" name="location"
value="{{ form.location if form else '' }}" placeholder="e.g. Augusta, Georgia">
</div>
<div class="form-group">
<label>Holes</label>
{% if errors and errors.holes %}<div class="alert alert-error">{{ errors.holes }}</div>{% endif %}
<div style="display:flex; gap:0.75rem;">
<label class="radio-card">
<input type="radio" name="holes" value="9" x-model.number="holes">
<span>9 holes</span>
</label>
<label class="radio-card">
<input type="radio" name="holes" value="18" x-model.number="holes">
<span>18 holes</span>
</label>
</div>
</div>
</div>
<div class="card">
<h2 style="font-size:0.95rem; color:var(--text-muted); margin-bottom:0.75rem;">
Hole details
<span style="font-weight:400; font-size:0.85rem;">
— Course par: <strong x-text="Array.from({length: holes}, (_, i) => parseInt(document.querySelector('[name=par_' + (i+1) + ']')?.value || 4)).reduce((a,b)=>a+b,0)"></strong>
</span>
</h2>
<div class="hole-pars-grid">
<template x-for="n in Array.from({length: holes}, (_, i) => i + 1)" :key="n">
<div class="hole-par-cell">
<div class="hole-par-label" x-text="n"></div>
<select :name="'par_' + n" class="hole-par-select">
<option value="3" :selected="(initPars[String(n)] || 4) == 3">3</option>
<option value="4" :selected="(initPars[String(n)] || 4) == 4">4</option>
<option value="5" :selected="(initPars[String(n)] || 4) == 5">5</option>
</select>
<select :name="'si_' + n" class="hole-par-select">
<option value=""></option>
<template x-for="s in Array.from({length: holes}, (_, i) => i + 1)" :key="s">
<option :value="s" :selected="(initSIs[String(n)] || null) == s" x-text="s"></option>
</template>
</select>
</div>
</template>
</div>
</div>
<button type="submit" class="btn btn-primary">Create course</button>
</form>
</div>
{% endblock %}
+81
View File
@@ -0,0 +1,81 @@
{% extends "base.html" %}
{% block title %}Add User Skins &amp; Pins{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="https://unpkg.com/cropperjs@1.6.2/dist/cropper.min.css">
<script src="https://unpkg.com/cropperjs@1.6.2/dist/cropper.min.js"></script>
<script src="/static/avatar-crop.js"></script>
{% endblock %}
{% block content %}
<div class="page">
<div class="page-header">
<a href="/admin" style="color:var(--text-muted); font-size:0.9rem;">← Admin</a>
<h1 style="margin-top:0.25rem;">Add user</h1>
</div>
<div x-data="avatarUpload('/admin/users/new', '/admin')">
<form action="/admin/users/new" method="post" enctype="multipart/form-data"
@submit="submitForm($event)">
<div class="card">
<div class="form-group">
<label for="name">Name</label>
{% if errors and errors.name %}<div class="alert alert-error">{{ errors.name }}</div>{% endif %}
<input type="text" id="name" name="name" value="{{ form.name if form else '' }}" required>
</div>
<div class="form-group">
<label for="email">Email</label>
{% if errors and errors.email %}<div class="alert alert-error">{{ errors.email }}</div>{% endif %}
<input type="email" id="email" name="email" value="{{ form.email if form else '' }}" required>
</div>
<div class="form-group">
<label for="handicap">Handicap (054)</label>
{% if errors and errors.handicap %}<div class="alert alert-error">{{ errors.handicap }}</div>{% endif %}
<input type="number" id="handicap" name="handicap" min="0" max="54" step="0.1"
value="{{ form.handicap if form else '' }}" required>
</div>
<div class="form-group">
<label>Avatar</label>
{% if errors and errors.avatar %}<div class="alert alert-error">{{ errors.avatar }}</div>{% endif %}
<div class="avatar-tabs">
<button type="button" class="avatar-tab" :class="{ active: tab === 'emoji' }" @click="tab = 'emoji'">Emoji</button>
<button type="button" class="avatar-tab" :class="{ active: tab === 'upload' }" @click="tab = 'upload'">Photo</button>
</div>
<div x-show="tab === 'emoji'">
<div class="avatar-grid" style="margin-top: 0.75rem;">
{% for a in avatars %}
<div class="avatar-option">
<input type="radio" name="avatar_emoji" id="avatar_{{ loop.index }}" value="{{ a }}">
<label for="avatar_{{ loop.index }}">{{ a }}</label>
</div>
{% endfor %}
</div>
</div>
<div x-show="tab === 'upload'" style="margin-top: 0.75rem;">
<div x-show="previewUrl" style="margin-bottom: 0.75rem; display:flex; align-items:center; gap:1rem;">
<img :src="previewUrl" class="avatar-preview">
<span style="color:var(--text-muted); font-size:0.85rem;">Tap to change</span>
</div>
<label class="file-input-label">
<span x-text="previewUrl ? 'Choose different photo' : 'Choose photo'"></span>
<input type="file" name="avatar_file" accept="image/jpeg,image/png,image/webp,image/gif"
style="display:none" @change="openFile($event)">
</label>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">Add user</button>
</form>
{% include "partials/crop_modal.html" %}
</div>
</div>
{% endblock %}