Switched to path routing. Added data management.

This commit is contained in:
Pen Anderson 2026-03-03 19:55:35 -06:00
parent 8dc5d3ed01
commit 4bba0ed3a0
14 changed files with 256 additions and 46 deletions

View file

@ -23,16 +23,22 @@
let session = $state(null)
let loading = $state(true)
let route = $state(window.location.hash || '#/')
let route = $state(window.location.pathname)
let updateAvailable = $state(false)
let mobileNavOpen = $state(false)
// Check if this is a public page (no auth needed)
const kioskToken = $derived(window.location.hash.match(/^#\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
const isVolunteerSignup = $derived(window.location.hash.startsWith('#/volunteer-signup'))
const isConfirmEmail = $derived(window.location.hash.startsWith('#/confirm/'))
const kioskToken = $derived(route.match(/^\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
const isVolunteerSignup = $derived(route.startsWith('/volunteer-signup'))
const isConfirmEmail = $derived(route.startsWith('/confirm/'))
const isPublicPage = $derived(!!kioskToken || isVolunteerSignup || isConfirmEmail)
function navigate(path) {
history.pushState(null, '', path)
route = path
mobileNavOpen = false
}
async function checkVersion() {
try {
const res = await fetch('/api/version')
@ -56,8 +62,8 @@
startSSE()
startSyncLoop()
}
window.addEventListener('hashchange', () => {
route = window.location.hash || '#/'
window.addEventListener('popstate', () => {
route = window.location.pathname
mobileNavOpen = false
})
@ -67,17 +73,17 @@
function onLogin(s) {
session = s
window.location.hash = '#/'
navigate('/')
syncPull().then(() => { startSSE(); startSyncLoop() })
}
async function onLogout() {
await clearSession()
session = null
window.location.hash = '#/login'
navigate('/login')
}
const path = $derived(route.replace(/^#/, '') || '/')
const path = $derived(route || '/')
const role = $derived(session?.user?.role ?? '')
</script>
@ -107,7 +113,7 @@
{#if mobileNavOpen}
<div class="nav-overlay" onclick={() => mobileNavOpen = false} onkeydown={() => {}}></div>
{/if}
<Nav {session} {onLogout} active={path} open={mobileNavOpen} />
<Nav {session} {onLogout} {navigate} active={path} open={mobileNavOpen} />
<div class="main">
<header class="mobile-header">
<button class="hamburger" onclick={() => mobileNavOpen = !mobileNavOpen} aria-label="Menu">

View file

@ -18,7 +18,7 @@ export async function apiFetch(path, options = {}) {
const res = await fetch(path, { ...options, headers })
if (res.status === 401) {
await db.session.clear()
window.location.hash = '#/login'
window.location.pathname = '/login'
throw new Error('unauthorized')
}
return res
@ -110,6 +110,11 @@ export const api = {
update: (data) => apiJSON('/api/settings', { method: 'PUT', body: JSON.stringify(data) }),
testEmail: (to) => apiJSON('/api/settings/test-email', { method: 'POST', body: JSON.stringify({ to }) }),
toggleShiftSignups: (open) => apiJSON('/api/settings/shift-signups', { method: 'POST', body: JSON.stringify({ open }) }),
resetAttendees: () => apiJSON('/api/settings/reset-attendees', { method: 'POST' }),
resetVolunteers: () => apiJSON('/api/settings/reset-volunteers', { method: 'POST' }),
resetShifts: () => apiJSON('/api/settings/reset-shifts', { method: 'POST' }),
resetDepartments: () => apiJSON('/api/settings/reset-departments', { method: 'POST' }),
resetVolunteerShifts: () => apiJSON('/api/settings/reset-volunteer-shifts', { method: 'POST' }),
},
signup: {
config: () => kioskFetch('/api/public/signup-config'),

View file

@ -1,7 +1,7 @@
<script>
import { LayoutDashboard, ClipboardCheck, Heart, Hexagon, Clock, CalendarDays, Upload, Users, Settings, LogOut } from 'lucide-svelte'
let { session, active, onLogout, open = false } = $props()
let { session, active, onLogout, navigate, open = false } = $props()
const role = $derived(session?.user?.role ?? '')
@ -9,45 +9,45 @@
const links = $derived.by(() => {
if (role === 'ticketing') return [
{ href: '#/attendees', label: 'Attendees', icon: ClipboardCheck },
{ href: '#/import', label: 'Import', icon: Upload },
{ href: '/attendees', label: 'Attendees', icon: ClipboardCheck },
{ href: '/import', label: 'Import', icon: Upload },
]
if (role === 'volunteer_lead') return [
{ href: '#/', label: 'Schedule', icon: CalendarDays },
{ href: '#/volunteers', label: 'Volunteers', icon: Heart },
{ href: '#/departments', label: 'Departments', icon: Hexagon },
{ href: '/', label: 'Schedule', icon: CalendarDays },
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
{ href: '/departments', label: 'Departments', icon: Hexagon },
]
if (role === 'coordinator') return [
{ href: '#/', label: 'Dashboard', icon: LayoutDashboard },
{ href: '#/schedule', label: 'Schedule', icon: CalendarDays },
{ href: '#/volunteers', label: 'Volunteers', icon: Heart },
{ href: '#/departments', label: 'Departments', icon: Hexagon },
{ href: '#/shifts', label: 'Shifts', icon: Clock },
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/schedule', label: 'Schedule', icon: CalendarDays },
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
{ href: '/departments', label: 'Departments', icon: Hexagon },
{ href: '/shifts', label: 'Shifts', icon: Clock },
]
return [
{ href: '#/', label: 'Dashboard', icon: LayoutDashboard },
{ href: '#/attendees', label: 'Attendees', icon: ClipboardCheck },
{ href: '#/volunteers', label: 'Volunteers', icon: Heart },
{ href: '#/departments', label: 'Departments', icon: Hexagon },
{ href: '#/shifts', label: 'Shifts', icon: Clock },
{ href: '#/schedule', label: 'Schedule', icon: CalendarDays },
{ href: '#/import', label: 'Import', icon: Upload },
{ href: '#/users', label: 'Users', icon: Users },
{ href: '#/settings', label: 'Settings', icon: Settings },
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/attendees', label: 'Attendees', icon: ClipboardCheck },
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
{ href: '/departments', label: 'Departments', icon: Hexagon },
{ href: '/shifts', label: 'Shifts', icon: Clock },
{ href: '/schedule', label: 'Schedule', icon: CalendarDays },
{ href: '/import', label: 'Import', icon: Upload },
{ href: '/users', label: 'Users', icon: Users },
{ href: '/settings', label: 'Settings', icon: Settings },
]
})
function isActive(href) {
const p = href.replace(/^#/, '')
if (p === '/') return active === '/' || active === ''
return active.startsWith(p)
if (href === '/') return active === '/' || active === ''
return active.startsWith(href)
}
</script>
<nav class="sidebar" class:open>
<div class="sidebar-brand">Turn<span>pike</span></div>
{#each links as link}
<a href={link.href} class="nav-link" class:active={isActive(link.href)}>
<a href={link.href} class="nav-link" class:active={isActive(link.href)}
onclick={(e) => { e.preventDefault(); navigate(link.href) }}>
<link.icon {...iconProps} />
{link.label}
</a>

View file

@ -7,7 +7,7 @@
let error = $state('')
onMount(async () => {
const match = window.location.hash.match(/^#\/confirm\/(.+)/)
const match = window.location.pathname.match(/^\/confirm\/(.+)/)
const token = match?.[1] ?? ''
if (!token) {
status = 'invalid'

View file

@ -2,8 +2,7 @@
import { onMount } from 'svelte'
import { api } from '../api.js'
// Token comes from the URL hash: /#/v/TOKEN
const token = $derived(window.location.hash.match(/^#\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
const token = $derived(window.location.pathname.match(/^\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
let state = $state(null) // { volunteer, shifts, available }
let loading = $state(true)

View file

@ -84,6 +84,18 @@
}
}
async function resetModel(label, fn) {
if (!confirm(`PERMANENTLY DELETE all ${label}? This cannot be undone.`)) return
error = ''
success = ''
try {
const result = await fn()
success = `Deleted ${result.deleted} ${label}.`
} catch (err) {
error = err.message
}
}
async function sendTest() {
if (!testEmail) return
testing = true
@ -186,12 +198,12 @@
Note field is required
</label>
<p class="text-muted" style="font-size:0.78rem;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>
</div>
<!-- Shift Signups -->
<div class="card">
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Shift Signups</h2>
<div style="display:flex;align-items:center;gap:1rem">
<span style="font-size:0.875rem">
@ -212,5 +224,30 @@
</p>
{/if}
</div>
<!-- Data Management -->
<div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:0.5rem">Data Management</h2>
<p class="text-muted" style="font-size:0.78rem;margin-bottom:1rem">
Permanently delete all records of a given type. This cannot be undone.
</p>
<div style="display:flex;flex-wrap:wrap;gap:0.5rem">
<button class="btn btn-danger" onclick={() => resetModel('attendees', api.settings.resetAttendees)}>
Delete All Attendees
</button>
<button class="btn btn-danger" onclick={() => resetModel('volunteers', api.settings.resetVolunteers)}>
Delete All Volunteers
</button>
<button class="btn btn-danger" onclick={() => resetModel('shifts', api.settings.resetShifts)}>
Delete All Shifts
</button>
<button class="btn btn-danger" onclick={() => resetModel('departments', api.settings.resetDepartments)}>
Delete All Departments
</button>
<button class="btn btn-danger" onclick={() => resetModel('volunteer shift assignments', api.settings.resetVolunteerShifts)}>
Delete All Shift Assignments
</button>
</div>
</div>
{/if}
</div>