Compare commits

..

4 commits
sso ... trunk

Author SHA1 Message Date
d64e93674e Refactored styles. 2026-03-11 12:26:33 -05:00
d73a74965d Added datepicker fix. 2026-03-11 10:55:16 -05:00
6d4c49a223 Added autozoom fix. 2026-03-11 10:22:54 -05:00
374316944e Refactored inline styles. 2026-03-11 10:04:30 -05:00
11 changed files with 108 additions and 82 deletions

View file

@ -37,6 +37,7 @@
history.pushState(null, '', path) history.pushState(null, '', path)
route = path route = path
mobileNavOpen = false mobileNavOpen = false
window.scrollTo(0, 0)
} }
async function checkVersion() { async function checkVersion() {

View file

@ -1,4 +1,4 @@
import { db } from './db.js' import { db, clearSession } from './db.js'
async function getToken() { async function getToken() {
const session = await db.session.get(1) const session = await db.session.get(1)
@ -17,7 +17,7 @@ export async function apiFetch(path, options = {}) {
const res = await fetch(path, { ...options, headers }) const res = await fetch(path, { ...options, headers })
if (res.status === 401) { if (res.status === 401) {
await db.session.clear() await clearSession()
window.location.pathname = '/login' window.location.pathname = '/login'
throw new Error('unauthorized') throw new Error('unauthorized')
} }

View file

@ -66,6 +66,9 @@ a:hover { color: var(--c-accent-h); }
/* Cards */ /* Cards */
.card { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 1.25rem; } .card { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 1.25rem; }
.card + .card, .card + form, form + .card, form + form { margin-top: 1.5rem; }
.card-title { font-size: 0.95rem; font-weight: 700; margin-bottom: 1rem; }
.card-hint { font-size: 0.78rem; color: var(--c-muted); }
/* Stats */ /* Stats */
.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } .stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
@ -104,8 +107,12 @@ input, select, textarea {
transition: border-color var(--transition); transition: border-color var(--transition);
} }
input[type="checkbox"] { width: auto; } input[type="checkbox"] { width: auto; }
input[type="date"], input[type="time"], input[type="datetime-local"] { -webkit-appearance: none; appearance: none; min-height: 2.35rem; }
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--c-accent); } input:focus, select:focus, textarea:focus { outline: none; border-color: var(--c-accent); }
input::placeholder { color: var(--c-muted); } input::placeholder { color: var(--c-muted); }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.form-grid-3 { display: grid; grid-template-columns: 1fr 1fr auto; gap: 1rem; align-items: end; }
.form-grid .full { grid-column: 1 / -1; }
.checkbox-label { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; cursor: pointer; } .checkbox-label { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; cursor: pointer; }
.checkbox-label-sm { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.82rem; cursor: pointer; color: var(--c-text); } .checkbox-label-sm { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.82rem; cursor: pointer; color: var(--c-text); }
@ -132,6 +139,7 @@ tr:hover td { background: rgba(255,255,255,0.02); }
font-size: 0.72rem; font-weight: 600; font-size: 0.72rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em; text-transform: uppercase; letter-spacing: 0.04em;
} }
* + .badge { margin-left: 0.3rem; }
.badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); } .badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); }
.badge-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); } .badge-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); }
.badge-registered { background: rgba(14,165,233,0.15); color: #0ea5e9; } .badge-registered { background: rgba(14,165,233,0.15); color: #0ea5e9; }
@ -237,6 +245,7 @@ tr:hover td { background: rgba(255,255,255,0.02); }
td { display: inline; padding: 0; border: none; } td { display: inline; padding: 0; border: none; }
td:empty { display: none; } td:empty { display: none; }
/* Forms */ /* Forms — 16px prevents iOS auto-zoom on focus */
.form-grid { grid-template-columns: 1fr !important; } input, select, textarea { font-size: 16px; }
.form-grid, .form-grid-3 { grid-template-columns: 1fr !important; }
} }

View file

@ -160,7 +160,7 @@
<p class="text-muted" style="font-size:0.85rem;margin-top:2rem"> <p class="text-muted" style="font-size:0.85rem;margin-top:2rem">
Welcome, <strong style="color:var(--c-text)">{session?.user?.preferred_name}</strong> Welcome, <strong style="color:var(--c-text)">{session?.user?.preferred_name}</strong>
· {#each roles as r}<span class="badge badge-role" style="margin-right:0.25rem">{r}</span>{/each} · {#each roles as r}<span class="badge badge-role">{r}</span>{/each}
</p> </p>
</div> </div>

View file

@ -101,7 +101,7 @@
{#if showAdd && canCreate} {#if showAdd && canCreate}
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addDept}> <form onsubmit={addDept}>
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr auto;gap:1rem;align-items:end"> <div class="form-grid-3">
<div class="form-group" style="margin-bottom:0"> <div class="form-group" style="margin-bottom:0">
<label for="d-name">Name *</label> <label for="d-name">Name *</label>
<input id="d-name" bind:value={newName} required placeholder="e.g. Security" /> <input id="d-name" bind:value={newName} required placeholder="e.g. Security" />
@ -112,7 +112,7 @@
</div> </div>
<div class="form-group" style="margin-bottom:0"> <div class="form-group" style="margin-bottom:0">
<label for="d-color">Color</label> <label for="d-color">Color</label>
<input id="d-color" type="color" bind:value={newColor} style="width:60px;padding:0.2rem;height:2.3rem;cursor:pointer" /> <input id="d-color" type="color" bind:value={newColor} class="color-input" />
</div> </div>
</div> </div>
<div class="actions" style="margin-top:1rem"> <div class="actions" style="margin-top:1rem">
@ -191,6 +191,7 @@
</div> </div>
<style> <style>
.color-input { width: 60px; padding: 0.2rem; height: 2.3rem; cursor: pointer; }
@media (max-width: 640px) { @media (max-width: 640px) {
.td-name { width: 100%; } .td-name { width: 100%; }
.td-desc { width: 100%; } .td-desc { width: 100%; }

View file

@ -3,11 +3,13 @@
import { api } from '../api.js' import { api } from '../api.js'
import { saveSession } from '../db.js' import { saveSession } from '../db.js'
let { onlogin, error: initialError = '' } = $props() let { onlogin, error: externalError = '' } = $props()
let email = $state('') let email = $state('')
let password = $state('') let password = $state('')
let error = $state(initialError) let error = $state('')
$effect(() => { if (externalError) error = externalError })
let loading = $state(false) let loading = $state(false)
let ssoEnabled = $state(false) let ssoEnabled = $state(false)
let ssoLoading = $state(false) let ssoLoading = $state(false)

View file

@ -248,7 +248,7 @@
{#if showAdd && canManage} {#if showAdd && canManage}
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addParticipant}> <form onsubmit={addParticipant}>
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:1rem"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
<label for="p-name">Preferred Name</label> <label for="p-name">Preferred Name</label>
<input id="p-name" bind:value={newName} placeholder="Preferred name" /> <input id="p-name" bind:value={newName} placeholder="Preferred name" />

View file

@ -275,7 +275,7 @@
{#if showAdd && canManage} {#if showAdd && canManage}
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addShift}> <form onsubmit={addShift}>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
<label for="s-dept">Department *</label> <label for="s-dept">Department *</label>
<select id="s-dept" bind:value={newDeptID} required> <select id="s-dept" bind:value={newDeptID} required>
@ -380,10 +380,11 @@
<span class="board-cap">{assigned.length}</span> <span class="board-cap">{assigned.length}</span>
{/if} {/if}
{#if hasConflict} {#if hasConflict}
<span class="badge badge-lead" style="margin-left:0.3rem">⚠ conflict</span> <span class="badge badge-lead">⚠ conflict</span>
{/if} {/if}
</div> </div>
{#if canManage}
<div class="board-shift-actions"> <div class="board-shift-actions">
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(shift)}>Edit</button> <button class="btn btn-ghost btn-sm" onclick={() => startEdit(shift)}>Edit</button>
<button class="btn btn-ghost btn-sm" title="Move up" <button class="btn btn-ghost btn-sm" title="Move up"
@ -392,6 +393,7 @@
onclick={() => reorder(shift.id, 1, rows)}>↓</button> onclick={() => reorder(shift.id, 1, rows)}>↓</button>
<button class="btn btn-danger btn-sm" onclick={() => deleteShift(shift)}>Delete</button> <button class="btn btn-danger btn-sm" onclick={() => deleteShift(shift)}>Delete</button>
</div> </div>
{/if}
</div> </div>
<!-- Assigned volunteers --> <!-- Assigned volunteers -->
@ -406,13 +408,14 @@
{#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])} {#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])}
<span title="Scheduling conflict" style="color:var(--c-warn)"></span> <span title="Scheduling conflict" style="color:var(--c-warn)"></span>
{/if} {/if}
<button class="board-vol-remove" onclick={() => unassign(shift.id, volunteer.id)} title="Remove">×</button> {#if canManage}<button class="board-vol-remove" onclick={() => unassign(shift.id, volunteer.id)} title="Remove">×</button>{/if}
</div> </div>
{/each} {/each}
</div> </div>
{/if} {/if}
<!-- Assign volunteer --> <!-- Assign volunteer -->
{#if canManage}
{#if assigningShiftID === shift.id} {#if assigningShiftID === shift.id}
<div class="board-assign-row"> <div class="board-assign-row">
<select bind:value={assignVolID} style="width:auto"> <select bind:value={assignVolID} style="width:auto">
@ -436,6 +439,7 @@
<button class="board-add-vol" onclick={() => startAssign(shift.id)}>+ Assign volunteer</button> <button class="board-add-vol" onclick={() => startAssign(shift.id)}>+ Assign volunteer</button>
{/if} {/if}
{/if} {/if}
{/if}
</div> </div>
{/each} {/each}
{/each} {/each}

View file

@ -7,6 +7,7 @@
let saving = $state(false) let saving = $state(false)
let savingEvent = $state(false) let savingEvent = $state(false)
let testing = $state(false) let testing = $state(false)
let resetting = $state(false)
let error = $state('') let error = $state('')
let success = $state('') let success = $state('')
@ -130,7 +131,9 @@
} }
async function resetModel(label, fn) { async function resetModel(label, fn) {
if (resetting) return
if (!confirm(`PERMANENTLY DELETE all ${label}? This cannot be undone.`)) return if (!confirm(`PERMANENTLY DELETE all ${label}? This cannot be undone.`)) return
resetting = true
error = '' error = ''
success = '' success = ''
try { try {
@ -138,6 +141,8 @@
success = `Deleted ${result.deleted} ${label}.` success = `Deleted ${result.deleted} ${label}.`
} catch (err) { } catch (err) {
error = err.message error = err.message
} finally {
resetting = false
} }
} }
@ -173,14 +178,14 @@
<div class="text-muted">Loading…</div> <div class="text-muted">Loading…</div>
{:else} {:else}
<form onsubmit={saveEvent}> <form onsubmit={saveEvent}>
<div class="card" style="margin-bottom:1.5rem"> <div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Event</h2> <h2 class="card-title">Event</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem"> <div class="form-grid">
<div class="form-group" style="grid-column:1/-1"> <div class="form-group full">
<label for="e-name">Event Name *</label> <label for="e-name">Event Name *</label>
<input id="e-name" bind:value={eventName} required placeholder="My Event 2026" /> <input id="e-name" bind:value={eventName} required placeholder="My Event 2026" />
</div> </div>
<div class="form-group" style="grid-column:1/-1"> <div class="form-group full">
<label for="e-venue">Venue</label> <label for="e-venue">Venue</label>
<input id="e-venue" bind:value={eventVenue} placeholder="Location name" /> <input id="e-venue" bind:value={eventVenue} placeholder="Location name" />
</div> </div>
@ -192,7 +197,7 @@
<label for="e-end">End Date *</label> <label for="e-end">End Date *</label>
<input id="e-end" type="date" bind:value={eventEndDate} required /> <input id="e-end" type="date" bind:value={eventEndDate} required />
</div> </div>
<div class="form-group" style="grid-column:1/-1"> <div class="form-group full">
<label for="e-tz">Timezone</label> <label for="e-tz">Timezone</label>
<input id="e-tz" bind:value={eventTimezone} placeholder="America/Chicago" list="tz-list" /> <input id="e-tz" bind:value={eventTimezone} placeholder="America/Chicago" list="tz-list" />
<datalist id="tz-list"> <datalist id="tz-list">
@ -211,11 +216,11 @@
</form> </form>
<form onsubmit={save}> <form onsubmit={save}>
<div class="card" style="margin-bottom:1.5rem"> <div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">SMTP Email</h2> <h2 class="card-title">SMTP Email</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem"> <div class="form-grid">
<div class="form-group" style="grid-column:1"> <div class="form-group">
<label for="s-host">SMTP Host</label> <label for="s-host">SMTP Host</label>
<input id="s-host" bind:value={smtpHost} placeholder="smtp.fastmail.com" /> <input id="s-host" bind:value={smtpHost} placeholder="smtp.fastmail.com" />
</div> </div>
@ -243,22 +248,21 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="s-url">Base URL <span class="text-muted" style="font-weight:400">(for kiosk links in emails)</span></label> <label for="s-url">Base URL <span class="card-hint" style="font-weight:400">(for kiosk links in emails)</span></label>
<input id="s-url" bind:value={baseURL} placeholder="https://events.example.com" /> <input id="s-url" bind:value={baseURL} placeholder="https://events.example.com" />
</div> </div>
<!-- Discourse SSO --> <h2 class="card-title" style="margin-top:1.5rem">Discourse SSO</h2>
<h2 style="font-size:0.95rem;font-weight:700;margin:1.5rem 0 1rem">Discourse SSO</h2> <p class="card-hint" style="margin-bottom:1rem">
<p class="text-muted" style="font-size:0.78rem;margin-bottom:1rem">
Enable DiscourseConnect SSO so users can log in with their Discourse account. Enable DiscourseConnect SSO so users can log in with their Discourse account.
Set the same secret in your Discourse admin under Connect &gt; discourse connect secret. Set the same secret in your Discourse admin under Connect &gt; discourse connect secret.
</p> </p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem"> <div class="form-grid">
<div class="form-group" style="grid-column:1/-1"> <div class="form-group full">
<label for="sso-url">Discourse URL</label> <label for="sso-url">Discourse URL</label>
<input id="sso-url" bind:value={discourseSSOUrl} placeholder="https://forum.example.com" /> <input id="sso-url" bind:value={discourseSSOUrl} placeholder="https://forum.example.com" />
</div> </div>
<div class="form-group" style="grid-column:1/-1"> <div class="form-group full">
<label for="sso-secret">SSO Secret</label> <label for="sso-secret">SSO Secret</label>
<input id="sso-secret" type="password" bind:value={discourseSSOSecret} <input id="sso-secret" type="password" bind:value={discourseSSOSecret}
placeholder="Leave blank to keep existing" autocomplete="new-password" /> placeholder="Leave blank to keep existing" autocomplete="new-password" />
@ -274,8 +278,8 @@
</form> </form>
<!-- Test email --> <!-- Test email -->
<div class="card" style="margin-bottom:1.5rem"> <div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Test Email</h2> <h2 class="card-title">Test Email</h2>
<div style="display:flex;gap:0.5rem;align-items:flex-end"> <div style="display:flex;gap:0.5rem;align-items:flex-end">
<div class="form-group" style="flex:1;margin-bottom:0"> <div class="form-group" style="flex:1;margin-bottom:0">
<label for="s-test">Send to</label> <label for="s-test">Send to</label>
@ -288,8 +292,8 @@
</div> </div>
<!-- Volunteer Signup --> <!-- Volunteer Signup -->
<div class="card" style="margin-bottom:1.5rem"> <div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Volunteer Signup</h2> <h2 class="card-title">Volunteer Signup</h2>
<div class="form-group"> <div class="form-group">
<label for="s-note-label">Note Field Label</label> <label for="s-note-label">Note Field Label</label>
<input id="s-note-label" bind:value={noteLabel} placeholder="Additional note" /> <input id="s-note-label" bind:value={noteLabel} placeholder="Additional note" />
@ -298,14 +302,14 @@
<input type="checkbox" bind:checked={noteRequired} /> <input type="checkbox" bind:checked={noteRequired} />
Note field is required Note field is required
</label> </label>
<p class="text-muted" style="font-size:0.78rem;margin-top:0.75rem"> <p class="card-hint" style="margin-top:0.75rem">
Signup form: <a href="/volunteer-signup" target="_blank" style="color:var(--c-accent)">/volunteer-signup</a> Signup form: <a href="/volunteer-signup" target="_blank" style="color:var(--c-accent)">/volunteer-signup</a>
</p> </p>
</div> </div>
<!-- Shift Signups --> <!-- Shift Signups -->
<div class="card" style="margin-bottom:1.5rem"> <div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Shift Signups</h2> <h2 class="card-title">Shift Signups</h2>
<div style="display:flex;align-items:center;gap:1rem"> <div style="display:flex;align-items:center;gap:1rem">
<span style="font-size:0.875rem"> <span style="font-size:0.875rem">
Status: <strong>{shiftSignupsOpen ? 'Open' : 'Closed'}</strong> Status: <strong>{shiftSignupsOpen ? 'Open' : 'Closed'}</strong>
@ -320,7 +324,7 @@
</button> </button>
</div> </div>
{#if !shiftSignupsOpen} {#if !shiftSignupsOpen}
<p class="text-muted" style="font-size:0.78rem;margin-top:0.75rem"> <p class="card-hint" style="margin-top:0.75rem">
Opening signups will email all confirmed volunteers their shift signup links. Opening signups will email all confirmed volunteers their shift signup links.
</p> </p>
{/if} {/if}
@ -328,24 +332,24 @@
<!-- Data Management --> <!-- Data Management -->
<div class="card"> <div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:0.5rem">Data Management</h2> <h2 class="card-title" style="margin-bottom:0.5rem">Data Management</h2>
<p class="text-muted" style="font-size:0.78rem;margin-bottom:1rem"> <p class="card-hint" style="margin-bottom:1rem">
Permanently delete all records of a given type. This cannot be undone. Permanently delete all records of a given type. This cannot be undone.
</p> </p>
<div style="display:flex;flex-wrap:wrap;gap:0.5rem"> <div style="display:flex;flex-wrap:wrap;gap:0.5rem">
<button class="btn btn-danger" onclick={() => resetModel('tickets', api.settings.resetTickets)}> <button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('tickets', api.settings.resetTickets)}>
Delete All Tickets Delete All Tickets
</button> </button>
<button class="btn btn-danger" onclick={() => resetModel('volunteers', api.settings.resetVolunteers)}> <button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('volunteers', api.settings.resetVolunteers)}>
Delete All Volunteers Delete All Volunteers
</button> </button>
<button class="btn btn-danger" onclick={() => resetModel('shifts', api.settings.resetShifts)}> <button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('shifts', api.settings.resetShifts)}>
Delete All Shifts Delete All Shifts
</button> </button>
<button class="btn btn-danger" onclick={() => resetModel('departments', api.settings.resetDepartments)}> <button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('departments', api.settings.resetDepartments)}>
Delete All Departments Delete All Departments
</button> </button>
<button class="btn btn-danger" onclick={() => resetModel('volunteer shift assignments', api.settings.resetVolunteerShifts)}> <button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('volunteer shift assignments', api.settings.resetVolunteerShifts)}>
Delete All Shift Assignments Delete All Shift Assignments
</button> </button>
</div> </div>

View file

@ -149,7 +149,7 @@
{#if showAdd} {#if showAdd}
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addUser}> <form onsubmit={addUser}>
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem"> <div class="form-grid-3">
<div class="form-group"> <div class="form-group">
<label for="u-email">Email *</label> <label for="u-email">Email *</label>
<input id="u-email" type="email" bind:value={newEmail} required placeholder="email@example.com" autocomplete="off" /> <input id="u-email" type="email" bind:value={newEmail} required placeholder="email@example.com" autocomplete="off" />
@ -268,11 +268,11 @@
<td class="td-name"> <td class="td-name">
<strong>{u.preferred_name || u.email}</strong> <strong>{u.preferred_name || u.email}</strong>
{#if u.id === me} {#if u.id === me}
<span class="badge badge-role" style="margin-left:0.4rem">you</span> <span class="badge badge-role">you</span>
{/if} {/if}
<br><span class="text-muted" style="font-size:0.8rem">{u.email}</span> <br><span class="text-muted" style="font-size:0.8rem">{u.email}</span>
</td> </td>
<td>{#each u.roles ?? [] as r}<span class="badge badge-role" style="margin-right:0.25rem">{roleLabel(r)}</span>{/each}</td> <td>{#each u.roles ?? [] as r}<span class="badge badge-role">{roleLabel(r)}</span>{/each}</td>
<td class="text-muted">{deptNamesFor(u.department_ids || [])}</td> <td class="text-muted">{deptNamesFor(u.department_ids || [])}</td>
<td class="td-actions"> <td class="td-actions">
<div class="actions"> <div class="actions">

View file

@ -24,6 +24,7 @@
let editIsLead = $state(false) let editIsLead = $state(false)
let editNote = $state('') let editNote = $state('')
let saving = $state(false) let saving = $state(false)
let confirmingID = $state(null)
const roles = $derived(session?.user?.roles ?? []) const roles = $derived(session?.user?.roles ?? [])
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
@ -76,11 +77,15 @@
} }
async function confirmVolunteer(v) { async function confirmVolunteer(v) {
if (confirmingID) return
confirmingID = v.id
try { try {
const updated = await api.volunteers.confirm(v.id) const updated = await api.volunteers.confirm(v.id)
await db.volunteers.put(updated) await db.volunteers.put(updated)
} catch (err) { } catch (err) {
error = err.message error = err.message
} finally {
confirmingID = null
} }
} }
@ -181,7 +186,7 @@
{#if showAdd && canManage} {#if showAdd && canManage}
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addVolunteer}> <form onsubmit={addVolunteer}>
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:1rem"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
<label for="v-name">Preferred Name *</label> <label for="v-name">Preferred Name *</label>
<input id="v-name" bind:value={newName} required placeholder="What they go by" /> <input id="v-name" bind:value={newName} required placeholder="What they go by" />
@ -301,12 +306,12 @@
<td class="td-name"> <td class="td-name">
<strong>{v.name}</strong> <strong>{v.name}</strong>
{#if v.is_lead} {#if v.is_lead}
<span class="badge badge-lead" style="margin-left:0.4rem">Co-Lead</span> <span class="badge badge-lead">Co-Lead</span>
{/if} {/if}
{#if !v.participant_id} {#if !v.participant_id}
<span class="badge badge-unchecked" style="margin-left:0.4rem" title="Not linked to a participant">No ticket</span> <span class="badge badge-unchecked" title="Not linked to a participant">No ticket</span>
{:else if !participantHasTickets(v.participant_id)} {:else if !participantHasTickets(v.participant_id)}
<span class="badge badge-partial" style="margin-left:0.4rem" title="No ticket on file">No ticket</span> <span class="badge badge-partial" title="No ticket on file">No ticket</span>
{/if} {/if}
{#if participant?.ticket_name && participant.ticket_name !== v.name} {#if participant?.ticket_name && participant.ticket_name !== v.name}
<div class="text-muted" style="font-size:0.78rem">Ticket: {participant.ticket_name}</div> <div class="text-muted" style="font-size:0.78rem">Ticket: {participant.ticket_name}</div>
@ -349,7 +354,7 @@
{#if canManage} {#if canManage}
<td class="td-actions"> <td class="td-actions">
{#if canConfirm && v.email_confirmed && !v.confirmed} {#if canConfirm && v.email_confirmed && !v.confirmed}
<button class="btn btn-primary btn-sm" onclick={() => confirmVolunteer(v)}>Confirm</button> <button class="btn btn-primary btn-sm" onclick={() => confirmVolunteer(v)} disabled={confirmingID === v.id}>Confirm</button>
{/if} {/if}
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(v)}>Edit</button> <button class="btn btn-ghost btn-sm" onclick={() => startEdit(v)}>Edit</button>
<button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button> <button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button>