c51c872c33
- Add Skins game format with optional carryover, mirroring BBB scoring pattern
- Add GolfCourseAPI integration for course search and import (httpx)
- Unwrap {"course": {}} API response envelope on import
- Add searchable course combobox and player filter to new game form
- Redirect to summary page after finishing a game
- Fix auth to allow registered users without a pending invitation
- Fix hardcoded BBB format in create_game; store carryover flag in DB
- Add game summary/scorecard view for completed games
- Add live games social stream to dashboard
- Add course name + subtitle to recent game cards
- Reorder nav: Home → Games → Admin → Profile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
215 lines
10 KiB
HTML
215 lines
10 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}New Game – Skins & Pins{% endblock %}
|
||
|
||
{% block extra_head %}
|
||
<script>
|
||
const TEES_BY_COURSE = {{ tees_by_course | tojson }};
|
||
const COURSES_DATA = {{ courses | tojson }};
|
||
</script>
|
||
<style>
|
||
.picker-item { padding:0.65rem 0.75rem; cursor:pointer; border-bottom:1px solid var(--border); }
|
||
.picker-item:last-child { border-bottom:none; }
|
||
.picker-item.selected { background:var(--surface2); }
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="page"
|
||
x-data="{
|
||
step: 1,
|
||
courseId: '',
|
||
courseLabel: '',
|
||
courseOpen: false,
|
||
courseSearch: '',
|
||
format: 'bingo_bango_bongo',
|
||
selectedPlayers: ['{{ user.id }}'],
|
||
playerSearch: '',
|
||
filteredCourses() {
|
||
const q = this.courseSearch.toLowerCase();
|
||
if (!q) return COURSES_DATA;
|
||
return COURSES_DATA.filter(c =>
|
||
c.name.toLowerCase().includes(q) ||
|
||
(c.location || '').toLowerCase().includes(q)
|
||
);
|
||
},
|
||
selectCourse(c) {
|
||
this.courseId = c.id;
|
||
this.courseLabel = c.name + (c.location ? ' · ' + c.location : '') + ' (' + c.holes + ' holes)';
|
||
this.courseOpen = false;
|
||
this.courseSearch = '';
|
||
},
|
||
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
|
||
@change="format = $event.target.value">
|
||
<span>Bingo Bango Bongo</span>
|
||
</label>
|
||
<label class="radio-card" style="flex:1;">
|
||
<input type="radio" name="format" value="skins"
|
||
@change="format = $event.target.value">
|
||
<span>Skins</span>
|
||
</label>
|
||
</div>
|
||
<div x-show="format === 'skins'" style="margin-top:0.75rem;">
|
||
<label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
|
||
<input type="checkbox" name="carryover" value="1" checked style="width:auto;">
|
||
<span style="font-weight:400; color:var(--text-muted);">Carryover (ties carry to next hole)</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{# ── Course picker ── #}
|
||
<div class="form-group" @click.outside="courseOpen = false; courseSearch = ''">
|
||
<label>Course</label>
|
||
{% if courses %}
|
||
<input type="hidden" name="course_id" :value="courseId">
|
||
|
||
<button type="button"
|
||
@click="courseOpen = !courseOpen; if (courseOpen) $nextTick(() => $refs.courseSearch && $refs.courseSearch.focus())"
|
||
style="width:100%; display:flex; align-items:center; gap:0.5rem; padding:0.65rem 0.75rem; border-radius:0.5rem; border:1px solid var(--border); background:var(--surface); color:var(--text); font-size:1rem; cursor:pointer; text-align:left;">
|
||
<span style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"
|
||
:style="courseId ? '' : 'color:var(--text-muted)'"
|
||
x-text="courseLabel || 'Select a course…'"></span>
|
||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="flex-shrink:0; transition:transform 0.15s;"
|
||
:style="courseOpen ? 'transform:rotate(180deg)' : ''">
|
||
<path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||
</svg>
|
||
</button>
|
||
|
||
<div x-show="courseOpen"
|
||
style="border:1px solid var(--border); border-radius:0.5rem; background:var(--surface); margin-top:0.25rem; overflow:hidden; box-shadow:0 4px 16px rgba(0,0,0,0.15);">
|
||
<div style="padding:0.5rem; border-bottom:1px solid var(--border);">
|
||
<input type="text" x-model="courseSearch" x-ref="courseSearch"
|
||
placeholder="Search by name or location…"
|
||
@keydown.escape="courseOpen = false; courseSearch = ''"
|
||
style="width:100%; padding:0.5rem 0.65rem; border-radius:0.4rem; border:1px solid var(--border); background:var(--surface2); color:var(--text); font-size:0.95rem; box-sizing:border-box;">
|
||
</div>
|
||
<div style="max-height:14rem; overflow-y:auto; -webkit-overflow-scrolling:touch;">
|
||
<template x-for="c in filteredCourses()" :key="c.id">
|
||
<div class="picker-item" @click="selectCourse(c)" :class="courseId === c.id ? 'selected' : ''">
|
||
<div x-text="c.name" style="font-weight:500;"></div>
|
||
<div x-text="(c.location ? c.location + ' · ' : '') + c.holes + ' holes'"
|
||
style="font-size:0.8rem; color:var(--text-muted);"></div>
|
||
</div>
|
||
</template>
|
||
<div x-show="filteredCourses().length === 0"
|
||
style="padding:0.75rem; text-align:center; color:var(--text-muted); font-size:0.9rem;">
|
||
No courses found
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{% 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>
|
||
|
||
{# ── Player picker ── #}
|
||
<div class="form-group">
|
||
<label>Players</label>
|
||
<input type="text" x-model="playerSearch" placeholder="Filter players…"
|
||
style="width:100%; padding:0.5rem 0.65rem; border-radius:0.4rem; border:1px solid var(--border); background:var(--surface2); color:var(--text); font-size:0.95rem; margin-bottom:0.5rem; box-sizing:border-box;">
|
||
<div class="player-list">
|
||
{% for u in all_users %}
|
||
<label class="player-option"
|
||
data-name="{{ u.name | lower }}"
|
||
x-show="{% if u.id == user.id %}true{% else %}!playerSearch || $el.dataset.name.includes(playerSearch.toLowerCase()){% endif %}">
|
||
<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 %}
|