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:
@@ -0,0 +1,108 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin – Skins & 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 %}
|
||||
@@ -0,0 +1,56 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Courses – Skins & 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 %}
|
||||
@@ -0,0 +1,149 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Edit Course – Skins & 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 %}
|
||||
@@ -0,0 +1,84 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Edit User – Skins & 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 (0–54)</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 %}
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Games – Admin – Skins & 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 %}
|
||||
@@ -0,0 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}New Course – Skins & 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 %}
|
||||
@@ -0,0 +1,81 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Add User – Skins & 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 (0–54)</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 %}
|
||||
Reference in New Issue
Block a user