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,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>