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,81 @@
<script lang="ts">
import {push} from 'svelte-spa-router'
import {auth} from '../../lib/stores/auth.svelte'
import {apiGetAllHunts} from '../../lib/api/index'
import type {HuntResponse} from '../../lib/api/types'
import StatusBadge from '../../lib/components/StatusBadge.svelte'
import LoadingSpinner from '../../lib/components/LoadingSpinner.svelte'
$effect(() => {
if (!auth.isLoggedIn) push('/login')
if (!auth.isAdmin) push('/')
})
let hunts = $state<HuntResponse[]>([])
let loading = $state(true)
let error = $state('')
$effect(() => {
apiGetAllHunts()
.then(h => { hunts = h })
.catch(e => { error = e instanceof Error ? e.message : 'Failed to load' })
.finally(() => { loading = false })
})
function huntStatus(h: HuntResponse): 'ONGOING' | 'UNSTARTED' | 'CLOSED' {
if (h.isTerminated) return 'CLOSED'
const now = Date.now()
if (now < new Date(h.startDateTime).getTime()) return 'UNSTARTED'
if (now > new Date(h.endDateTime).getTime()) return 'CLOSED'
return 'ONGOING'
}
function formatDate(dt: string) {
return new Date(dt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
</script>
<div class="p-4 pb-6">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold">Hunts</h1>
<button class="btn btn-primary btn-sm" onclick={() => push('/admin/hunt/create')}>
+ New Hunt
</button>
</div>
{#if loading}
<LoadingSpinner />
{:else if error}
<div class="alert alert-error">{error}</div>
{:else if hunts.length === 0}
<div class="text-center py-16 text-base-content/50">
<p class="text-4xl mb-3">📋</p>
<p class="font-medium">No hunts yet</p>
<button class="btn btn-primary mt-4" onclick={() => push('/admin/hunt/create')}>Create First Hunt</button>
</div>
{:else}
<div class="flex flex-col gap-3">
{#each hunts as hunt}
<div class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body p-4 gap-2">
<div class="flex items-start justify-between gap-2">
<h2 class="font-semibold">{hunt.title}</h2>
<StatusBadge status={huntStatus(hunt)} />
</div>
<p class="text-xs text-base-content/50">
{formatDate(hunt.startDateTime)} {formatDate(hunt.endDateTime)}
</p>
<div class="card-actions justify-end gap-2 mt-1">
<button class="btn btn-outline btn-xs" onclick={() => push(`/admin/hunt/${hunt.id}/review`)}>
Review Photos
</button>
<button class="btn btn-primary btn-xs" onclick={() => push(`/admin/hunt/${hunt.id}`)}>
Manage
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import {push} from 'svelte-spa-router'
import {auth} from '../../lib/stores/auth.svelte'
import {apiCreateHunt} from '../../lib/api/index'
import DateTimePicker from '../../lib/components/DateTimePicker.svelte'
$effect(() => {
if (!auth.isLoggedIn) push('/login')
if (!auth.isAdmin) push('/')
})
let title = $state('')
let startDateTime = $state('')
let endDateTime = $state('')
let loading = $state(false)
let error = $state('')
async function handleCreate() {
if (!title) return
loading = true
error = ''
try {
const hunt = await apiCreateHunt(title, startDateTime, endDateTime)
push(`/admin/hunt/${hunt.id}`)
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Failed to create hunt'
} finally {
loading = false
}
}
</script>
<div class="p-4">
<div class="flex items-center gap-3 mb-6">
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => push('/admin')}>←</button>
<h1 class="text-xl font-bold">New Hunt</h1>
</div>
{#if error}
<div class="alert alert-error mb-4 text-sm">{error}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleCreate() }} class="flex flex-col gap-4">
<label class="form-control">
<div class="label"><span class="label-text font-medium">Hunt Title</span></div>
<input
type="text"
placeholder="e.g. Summer Scavenger Hunt 2026"
class="input input-bordered"
bind:value={title}
required
/>
</label>
<fieldset class="form-control">
<legend class="label"><span class="label-text font-medium">Start</span></legend>
<DateTimePicker bind:value={startDateTime} defaultTimeIndex={36} />
</fieldset>
<fieldset class="form-control">
<legend class="label"><span class="label-text font-medium">End</span></legend>
<DateTimePicker bind:value={endDateTime} defaultTimeIndex={68} />
</fieldset>
<button type="submit" class="btn btn-primary mt-2" disabled={loading}>
{#if loading}<span class="loading loading-spinner loading-sm"></span>{/if}
Create Hunt
</button>
</form>
</div>

View File

@@ -0,0 +1,253 @@
<script lang="ts">
import {push} from 'svelte-spa-router'
import {auth} from '../../lib/stores/auth.svelte'
import {
apiAddItem,
apiCreateTeam,
apiDeleteItem,
apiGetHunt,
apiGetItems,
apiListTeams,
apiUpdateItem
} from '../../lib/api/index'
import type {HuntResponse, ItemResponse, TeamResponse} from '../../lib/api/types'
import StatusBadge from '../../lib/components/StatusBadge.svelte'
import LoadingSpinner from '../../lib/components/LoadingSpinner.svelte'
let { params }: { params: { huntId: string } } = $props()
$effect(() => {
if (!auth.isLoggedIn) push('/login')
if (!auth.isAdmin) push('/')
})
let hunt = $state<HuntResponse | null>(null)
let items = $state<ItemResponse[]>([])
let teams = $state<TeamResponse[]>([])
let loading = $state(true)
let error = $state('')
let newItemName = $state('')
let newItemPoints = $state(10)
let addingItem = $state(false)
let newTeamName = $state('')
let addingTeam = $state(false)
let editingItemId = $state<string | null>(null)
let editName = $state('')
let editPoints = $state(0)
let savingItem = $state(false)
function startEdit(item: ItemResponse) {
editingItemId = item.id
editName = item.name
editPoints = item.points
}
function cancelEdit() {
editingItemId = null
}
let deletingItemId = $state<string | null>(null)
async function deleteItem(item: ItemResponse) {
if (!confirm(`Delete "${item.name}"? This cannot be undone.`)) return
deletingItemId = item.id
try {
await apiDeleteItem(params.huntId, item.id)
items = items.filter(i => i.id !== item.id)
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Failed to delete item'
} finally {
deletingItemId = null
}
}
async function saveItem(item: ItemResponse) {
if (!editName.trim() || editPoints < 1) return
savingItem = true
try {
const updated = await apiUpdateItem(params.huntId, item.id, editName.trim(), editPoints)
items = items.map(i => i.id === updated.id ? updated : i)
editingItemId = null
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Failed to update item'
} finally {
savingItem = false
}
}
$effect(() => {
const { huntId } = params
Promise.all([apiGetHunt(huntId), apiGetItems(huntId), apiListTeams(huntId)])
.then(([h, i, t]) => { hunt = h; items = i; teams = t })
.catch(e => { error = e instanceof Error ? e.message : 'Failed to load' })
.finally(() => { loading = false })
})
async function addItem() {
if (!newItemName || newItemPoints < 1) return
addingItem = true
try {
const item = await apiAddItem(params.huntId, newItemName, newItemPoints)
items = [...items, item]
newItemName = ''
newItemPoints = 10
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Failed to add item'
} finally {
addingItem = false
}
}
async function addTeam() {
if (!newTeamName) return
addingTeam = true
try {
await apiCreateTeam(params.huntId, newTeamName)
teams = await apiListTeams(params.huntId)
newTeamName = ''
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Failed to create team'
} finally {
addingTeam = false
}
}
function huntStatus(h: HuntResponse): 'ONGOING' | 'UNSTARTED' | 'CLOSED' {
if (h.isTerminated) return 'CLOSED'
const now = Date.now()
if (now < new Date(h.startDateTime).getTime()) return 'UNSTARTED'
if (now > new Date(h.endDateTime).getTime()) return 'CLOSED'
return 'ONGOING'
}
</script>
<div class="p-4 pb-6">
<div class="flex items-center gap-3 mb-4">
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => push('/admin')}>←</button>
<div>
{#if hunt}
<h1 class="text-lg font-bold leading-tight">{hunt.title}</h1>
<StatusBadge status={huntStatus(hunt)} />
{/if}
</div>
</div>
{#if loading}
<LoadingSpinner />
{:else}
{#if error}
<div class="alert alert-error mb-4 text-sm">{error}</div>
{/if}
<!-- Items -->
<section class="mb-6">
<h2 class="font-bold text-base mb-3">Items ({items.length})</h2>
<form onsubmit={(e) => { e.preventDefault(); addItem() }} class="flex gap-2 mb-3">
<input
type="text"
placeholder="Item name"
class="input input-bordered input-sm flex-1"
bind:value={newItemName}
required
/>
<input
type="number"
placeholder="pts"
class="input input-bordered input-sm w-20"
bind:value={newItemPoints}
min="1"
required
/>
<button type="submit" class="btn btn-primary btn-sm" disabled={addingItem}>
{#if addingItem}<span class="loading loading-spinner loading-xs"></span>{:else}Add{/if}
</button>
</form>
<div class="flex flex-col gap-2">
{#each items as item}
<div class="bg-base-100 rounded-xl shadow-sm border border-base-200 px-4 py-3">
{#if editingItemId === item.id}
<form onsubmit={(e) => { e.preventDefault(); saveItem(item) }} class="flex gap-2 items-center">
<input
type="text"
class="input input-bordered input-sm flex-1"
bind:value={editName}
required
disabled={savingItem}
/>
<input
type="number"
class="input input-bordered input-sm w-20"
bind:value={editPoints}
min="1"
required
disabled={savingItem}
/>
<button type="submit" class="btn btn-primary btn-sm" disabled={savingItem}>
{#if savingItem}<span class="loading loading-spinner loading-xs"></span>{:else}Save{/if}
</button>
<button type="button" class="btn btn-ghost btn-sm" onclick={cancelEdit} disabled={savingItem}></button>
</form>
{:else}
<div class="flex items-center justify-between">
<span class="font-medium">{item.name}</span>
<div class="flex items-center gap-2">
<span class="badge badge-outline">{item.points} pts</span>
<button class="btn btn-ghost btn-xs" onclick={() => startEdit(item)} disabled={deletingItemId === item.id}>Edit</button>
<button class="btn btn-ghost btn-xs text-error" onclick={() => deleteItem(item)} disabled={deletingItemId === item.id}>
{#if deletingItemId === item.id}<span class="loading loading-spinner loading-xs"></span>{:else}Delete{/if}
</button>
</div>
</div>
{/if}
</div>
{/each}
{#if items.length === 0}
<p class="text-base-content/50 text-sm text-center py-4">No items yet — add one above</p>
{/if}
</div>
</section>
<!-- Teams -->
<section>
<h2 class="font-bold text-base mb-3">Teams ({teams.length})</h2>
<form onsubmit={(e) => { e.preventDefault(); addTeam() }} class="flex gap-2 mb-3">
<input
type="text"
placeholder="Team name"
class="input input-bordered input-sm flex-1"
bind:value={newTeamName}
required
/>
<button type="submit" class="btn btn-secondary btn-sm" disabled={addingTeam}>
{#if addingTeam}<span class="loading loading-spinner loading-xs"></span>{:else}Add{/if}
</button>
</form>
<div class="flex flex-col gap-2">
{#each teams as team}
<div class="flex items-center justify-between bg-base-100 rounded-xl px-4 py-3 shadow-sm border border-base-200">
<span class="font-medium">{team.name}</span>
<button class="btn btn-ghost btn-xs" onclick={() => push(`/admin/hunt/${params.huntId}/review?teamId=${team.id}`)}>
Review →
</button>
</div>
{/each}
{#if teams.length === 0}
<p class="text-base-content/50 text-sm text-center py-4">No teams yet — add one above</p>
{/if}
</div>
</section>
<div class="mt-6">
<button class="btn btn-outline w-full" onclick={() => push(`/admin/hunt/${params.huntId}/review`)}>
Review All Photos →
</button>
</div>
{/if}
</div>

View File

@@ -0,0 +1,177 @@
<script lang="ts">
import {push, router} from 'svelte-spa-router'
import {auth} from '../../lib/stores/auth.svelte'
import {apiGetHunt, apiGetItemPhotos, apiGetItems, apiListTeams, apiReviewPhoto} from '../../lib/api/index'
import type {HuntResponse, ItemResponse, PhotoResponse, TeamResponse} from '../../lib/api/types'
import StatusBadge from '../../lib/components/StatusBadge.svelte'
import AuthImage from '../../lib/components/AuthImage.svelte'
import LoadingSpinner from '../../lib/components/LoadingSpinner.svelte'
let { params }: { params: { huntId: string } } = $props()
$effect(() => {
if (!auth.isLoggedIn) push('/login')
if (!auth.isAdmin) push('/')
})
let hunt = $state<HuntResponse | null>(null)
let teams = $state<TeamResponse[]>([])
let items = $state<ItemResponse[]>([])
let selectedTeam = $state<TeamResponse | null>(null)
let selectedItem = $state<ItemResponse | null>(null)
let photos = $state<PhotoResponse[]>([])
let loading = $state(true)
let photosLoading = $state(false)
let error = $state('')
let reviewing = $state<string | null>(null)
$effect(() => {
const { huntId } = params
Promise.all([apiGetHunt(huntId), apiListTeams(huntId), apiGetItems(huntId)])
.then(([h, t, i]) => {
hunt = h
teams = t
items = i
// Pre-select team from query string if provided
const qs = new URLSearchParams(router.querystring ?? '')
const preTeam = qs.get('teamId')
if (preTeam) {
selectedTeam = t.find(x => x.id === preTeam) ?? null
}
})
.catch(e => { error = e instanceof Error ? e.message : 'Failed to load' })
.finally(() => { loading = false })
})
$effect(() => {
if (selectedTeam && selectedItem) {
photosLoading = true
photos = []
apiGetItemPhotos(params.huntId, selectedTeam.id, selectedItem.id)
.then(p => { photos = p })
.catch(e => { error = e instanceof Error ? e.message : 'Failed to load photos' })
.finally(() => { photosLoading = false })
}
})
async function review(photo: PhotoResponse, status: 'APPROVED' | 'REJECTED') {
reviewing = photo.id
try {
await apiReviewPhoto(photo.id, status)
photos = photos.map(p => p.id === photo.id ? { ...p, photoStatus: status } : p)
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Failed to review photo'
} finally {
reviewing = null
}
}
const activePhotos = $derived(photos.filter(p => p.photoStatus !== 'REMOVED'))
</script>
<div class="p-4 pb-6">
<div class="flex items-center gap-3 mb-4">
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => push(`/admin/hunt/${params.huntId}`)}>←</button>
<h1 class="text-lg font-bold">{hunt?.title ?? 'Photo Review'}</h1>
</div>
{#if error}
<div class="alert alert-error mb-4 text-sm">{error}</div>
{/if}
{#if loading}
<LoadingSpinner />
{:else}
<!-- Team selector -->
<div class="mb-4">
<p class="text-sm font-medium text-base-content/60 mb-2">Select Team</p>
<div class="flex flex-wrap gap-2">
{#each teams as team}
<button
class="btn btn-sm"
class:btn-primary={selectedTeam?.id === team.id}
class:btn-outline={selectedTeam?.id !== team.id}
onclick={() => { selectedTeam = team; selectedItem = null; photos = [] }}
>
{team.name}
</button>
{/each}
</div>
</div>
{#if selectedTeam}
<!-- Item selector -->
<div class="mb-4">
<p class="text-sm font-medium text-base-content/60 mb-2">Select Item</p>
<div class="flex flex-wrap gap-2">
{#each items as item}
<button
class="btn btn-sm"
class:btn-secondary={selectedItem?.id === item.id}
class:btn-outline={selectedItem?.id !== item.id}
onclick={() => { selectedItem = item }}
>
{item.name}
</button>
{/each}
</div>
</div>
{/if}
{#if selectedTeam && selectedItem}
{#if photosLoading}
<LoadingSpinner />
{:else if activePhotos.length === 0}
<div class="text-center py-10 text-base-content/50">
<p>No photos submitted for this item</p>
</div>
{:else}
<div class="flex flex-col gap-4">
{#each activePhotos as photo}
<div class="card bg-base-100 shadow-sm border border-base-200 overflow-hidden">
<AuthImage photoId={photo.id} version="LARGE" alt={photo.hunterName} class="w-full h-56 object-cover" />
<div class="p-3">
<div class="flex items-center justify-between mb-3">
<div>
<p class="font-semibold">{photo.hunterName}</p>
<p class="text-xs text-base-content/50">
{new Date(photo.photoUploadDateTime).toLocaleString()}
</p>
</div>
<StatusBadge status={photo.photoStatus} />
</div>
{#if photo.photoStatus === 'SUBMITTED'}
<div class="flex gap-2">
<button
class="btn btn-success btn-sm flex-1"
onclick={() => review(photo, 'APPROVED')}
disabled={reviewing === photo.id}
>
{#if reviewing === photo.id}<span class="loading loading-spinner loading-xs"></span>{/if}
Approve
</button>
<button
class="btn btn-error btn-sm flex-1"
onclick={() => review(photo, 'REJECTED')}
disabled={reviewing === photo.id}
>
Reject
</button>
</div>
{:else if photo.photoStatus === 'APPROVED'}
<button class="btn btn-outline btn-error btn-sm w-full" onclick={() => review(photo, 'REJECTED')} disabled={reviewing === photo.id}>
Revoke Approval
</button>
{:else if photo.photoStatus === 'REJECTED'}
<button class="btn btn-outline btn-success btn-sm w-full" onclick={() => review(photo, 'APPROVED')} disabled={reviewing === photo.id}>
Approve Instead
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{/if}
{/if}
</div>