First crack at the app. Still lots of bugs to squash.

This commit is contained in:
2026-05-17 23:30:08 -05:00
parent cfd936e5fa
commit 435481549c
35 changed files with 4029 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import {apiGetPhotoBlob} from '../api/index'
let { photoId, version = 'MEDIUM', alt = 'Photo', class: cls = '' }: {
photoId: string
version?: 'ORIGINAL' | 'LARGE' | 'MEDIUM' | 'SMALL'
alt?: string
class?: string
} = $props()
let src = $state<string | null>(null)
let error = $state(false)
$effect(() => {
let objectUrl: string | null = null
let cancelled = false
error = false
src = null
async function load(attempt = 0) {
try {
const blob = await apiGetPhotoBlob(photoId, version)
if (cancelled) return
if (objectUrl) URL.revokeObjectURL(objectUrl)
objectUrl = URL.createObjectURL(blob)
src = objectUrl
} catch {
if (cancelled) return
if (attempt < 3) {
await new Promise(r => setTimeout(r, 1500 * (attempt + 1)))
if (!cancelled) load(attempt + 1)
} else {
error = true
}
}
}
load()
return () => {
cancelled = true
if (objectUrl) URL.revokeObjectURL(objectUrl)
}
})
</script>
{#if error}
<div class="bg-base-300 flex items-center justify-center {cls}">
<span class="text-base-content/40 text-sm">No image</span>
</div>
{:else if src}
<img {src} {alt} class={cls} />
{:else}
<div class="bg-base-300 animate-pulse {cls}"></div>
{/if}

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import {push, router} from 'svelte-spa-router'
function isActive(path: string) {
return router.location === path || router.location.startsWith(path + '/')
}
const huntId = $derived(
(router.params as Record<string, string> | null)?.huntId ?? null
)
</script>
<div class="btm-nav btm-nav-sm bg-base-100 border-t border-base-300 fixed bottom-0 left-0 right-0 z-30">
<button class:active={isActive('/')} onclick={() => push('/')}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<span class="btm-nav-label text-xs">Hunts</span>
</button>
{#if huntId}
<button
class:active={router.location.includes('/leaderboard')}
onclick={() => push(`/hunt/${huntId}/leaderboard`)}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<span class="btm-nav-label text-xs">Scores</span>
</button>
{/if}
</div>

View File

@@ -0,0 +1,88 @@
<script lang="ts">
let {
value = $bindable(''),
defaultTimeIndex = 36,
}: {
value?: string
defaultTimeIndex?: number
} = $props()
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
]
const currentYear = new Date().getFullYear()
const years = Array.from({ length: 5 }, (_, i) => currentYear + i)
const timeOptions = Array.from({ length: 96 }, (_, i) => {
const hour = Math.floor(i / 4)
const minute = (i % 4) * 15
const ampm = hour < 12 ? 'AM' : 'PM'
const displayHour = hour % 12 === 0 ? 12 : hour % 12
return { value: i, label: `${displayHour}:${minute.toString().padStart(2, '0')} ${ampm}` }
})
const today = new Date()
let month = $state(today.getMonth())
let day = $state(today.getDate())
let year = $state(today.getFullYear())
let timeIndex = $state(defaultTimeIndex)
const daysInMonth = $derived(new Date(year, month + 1, 0).getDate())
$effect(() => {
if (day > daysInMonth) day = daysInMonth
})
$effect(() => {
const pad = (n: number) => n.toString().padStart(2, '0')
const hour = Math.floor(timeIndex / 4)
const minute = (timeIndex % 4) * 15
value = new Date(`${year}-${pad(month + 1)}-${pad(day)}T${pad(hour)}:${pad(minute)}`).toISOString()
})
</script>
<div class="rounded-2xl border border-base-300 bg-base-100 overflow-hidden shadow-sm">
<!-- Date section -->
<div class="px-4 pt-4 pb-3 border-b border-base-200">
<p class="flex items-center gap-1.5 text-primary text-xs font-bold uppercase tracking-widest mb-2.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd" />
</svg>
Date
</p>
<div class="flex gap-2">
<select class="select select-sm bg-base-200 border-0 flex-1 font-medium" bind:value={month}>
{#each months as m, i}
<option value={i}>{m}</option>
{/each}
</select>
<select class="select select-sm bg-base-200 border-0 w-[4.5rem] font-medium" bind:value={day}>
{#each Array.from({ length: daysInMonth }, (_, i) => i + 1) as d}
<option value={d}>{d}</option>
{/each}
</select>
<select class="select select-sm bg-base-200 border-0 w-24 font-medium" bind:value={year}>
{#each years as y}
<option value={y}>{y}</option>
{/each}
</select>
</div>
</div>
<!-- Time section -->
<div class="px-4 pt-3 pb-4">
<p class="flex items-center gap-1.5 text-secondary text-xs font-bold uppercase tracking-widest mb-2.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd" />
</svg>
Time
</p>
<select class="select select-sm bg-base-200 border-0 w-full font-medium" bind:value={timeIndex}>
{#each timeOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
</div>

View File

@@ -0,0 +1,3 @@
<div class="flex justify-center items-center py-16">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
type Status = 'NOT_FOUND' | 'SUBMITTED' | 'APPROVED' | 'REJECTED' | 'REMOVED'
| 'UNSTARTED' | 'ONGOING' | 'CLOSED'
let { status }: { status: Status } = $props()
const config: Record<Status, { label: string; cls: string }> = {
NOT_FOUND: { label: 'Not Found', cls: 'badge-ghost' },
SUBMITTED: { label: 'Submitted', cls: 'badge-warning' },
APPROVED: { label: 'Approved', cls: 'badge-success' },
REJECTED: { label: 'Rejected', cls: 'badge-error' },
REMOVED: { label: 'Removed', cls: 'badge-ghost' },
UNSTARTED: { label: 'Upcoming', cls: 'badge-info' },
ONGOING: { label: 'Active', cls: 'badge-success' },
CLOSED: { label: 'Closed', cls: 'badge-ghost' },
}
const { label, cls } = $derived(config[status] ?? { label: status, cls: 'badge-ghost' })
</script>
<span class="badge {cls} font-medium px-3">{label}</span>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import {push} from 'svelte-spa-router'
import {auth, clearTokens} from '../stores/auth.svelte'
import {apiLogout} from '../api/index'
let { title = 'Scavenger Hunt' }: { title?: string } = $props()
async function handleLogout() {
try {
if (auth.refreshToken) await apiLogout(auth.refreshToken)
} finally {
clearTokens()
push('/login')
}
}
</script>
<div class="navbar bg-primary text-primary-content shadow-sm sticky top-0 z-30">
<div class="navbar-start">
<span class="text-lg font-bold tracking-tight">{title}</span>
</div>
<div class="navbar-end">
{#if auth.isLoggedIn}
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-circle" aria-label="Account menu">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</button>
<ul class="dropdown-content menu p-2 shadow bg-base-100 text-base-content rounded-box w-48 mt-2">
{#if auth.name}
<li class="menu-title px-3 py-1 text-xs font-semibold text-base-content/50 truncate">{auth.name}</li>
{/if}
{#if auth.isAdmin}
<li><button onclick={() => push('/admin')}>Admin Dashboard</button></li>
{/if}
<li><button onclick={handleLogout}>Log out</button></li>
</ul>
</div>
{/if}
</div>
</div>