First crack at the app. Still lots of bugs to squash.
This commit is contained in:
177
src/routes/admin/PhotoReview.svelte
Normal file
177
src/routes/admin/PhotoReview.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user