Lists all unreviewed photos for a hunt

This commit is contained in:
2026-05-19 10:02:04 -05:00
parent ef8ab903ee
commit 2c7cb00745
2 changed files with 86 additions and 23 deletions

View File

@@ -104,6 +104,9 @@ export function photoUrl(photoId: string, version: 'ORIGINAL' | 'LARGE' | 'MEDIU
export const apiReviewPhoto = (photoId: string, status: 'SUBMITTED' | 'APPROVED' | 'REJECTED' | 'REMOVED') => export const apiReviewPhoto = (photoId: string, status: 'SUBMITTED' | 'APPROVED' | 'REJECTED' | 'REMOVED') =>
client.patch<void>(`/admin/photo/${photoId}`, { status }) client.patch<void>(`/admin/photo/${photoId}`, { status })
export const apiGetUnreviewedPhotos = (huntId: string) =>
client.get<PhotoResponse[]>(`/admin/hunt/${huntId}/photo/unreviewed`)
// ── Stats ───────────────────────────────────────────────────────────────────── // ── Stats ─────────────────────────────────────────────────────────────────────
export const apiGetTeamLeaderboard = (huntId: string) => export const apiGetTeamLeaderboard = (huntId: string) =>

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {push} from 'svelte-spa-router' import {push} from 'svelte-spa-router'
import {auth} from '../../lib/stores/auth.svelte' import {auth} from '../../lib/stores/auth.svelte'
import {apiGetHunt, apiGetItemPhotos, apiGetItems, apiListTeams, apiReviewPhoto} from '../../lib/api/index' import {apiGetHunt, apiGetItemPhotos, apiGetItems, apiGetUnreviewedPhotos, apiListTeams, apiReviewPhoto} from '../../lib/api/index'
import {parseUTC} from '../../lib/utils' import {parseUTC} from '../../lib/utils'
import type {HuntResponse, ItemResponse, PhotoResponse, TeamResponse} from '../../lib/api/types' import type {HuntResponse, ItemResponse, PhotoResponse, TeamResponse} from '../../lib/api/types'
import StatusBadge from '../../lib/components/StatusBadge.svelte' import StatusBadge from '../../lib/components/StatusBadge.svelte'
@@ -15,26 +15,42 @@
if (!auth.isAdmin) push('/') if (!auth.isAdmin) push('/')
}) })
type PhotoWithTeam = PhotoResponse & { teamName: string } type PhotoWithContext = PhotoResponse & { teamName: string; itemName: string }
let hunt = $state<HuntResponse | null>(null) let hunt = $state<HuntResponse | null>(null)
let teams = $state<TeamResponse[]>([]) let teams = $state<TeamResponse[]>([])
let items = $state<ItemResponse[]>([]) let items = $state<ItemResponse[]>([])
let selectedItem = $state<ItemResponse | null>(null) let selectedItem = $state<ItemResponse | null>(null)
let photos = $state<PhotoWithTeam[]>([]) let photos = $state<PhotoWithContext[]>([])
let unreviewedPhotos = $state<PhotoWithContext[]>([])
let loading = $state(true) let loading = $state(true)
let photosLoading = $state(false) let photosLoading = $state(false)
let error = $state('') let error = $state('')
let reviewing = $state<string | null>(null) let reviewing = $state<string | null>(null)
let expandedPhoto = $state<PhotoWithTeam | null>(null) let expandedPhoto = $state<PhotoWithContext | null>(null)
$effect(() => { $effect(() => {
const { huntId } = params const { huntId } = params
Promise.all([apiGetHunt(huntId), apiListTeams(huntId), apiGetItems(huntId)]) Promise.all([apiGetHunt(huntId), apiListTeams(huntId), apiGetItems(huntId), apiGetUnreviewedPhotos(huntId)])
.then(([h, t, i]) => { .then(async ([h, t, i, unreviewed]) => {
hunt = h hunt = h
teams = t teams = t
items = i items = i
// Build photoId → context map from all item+team combos in parallel
const rows = await Promise.all(
i.flatMap(item =>
t.map(team =>
apiGetItemPhotos(huntId, team.id, item.id)
.then(ps => ps.map(p => [p.id, { itemName: item.name, teamName: team.name }] as [string, { itemName: string; teamName: string }]))
.catch(() => [] as [string, { itemName: string; teamName: string }][])
)
)
)
const contextMap = new Map(rows.flat())
unreviewedPhotos = unreviewed.map(p => {
const ctx = contextMap.get(p.id) ?? { itemName: 'Unknown', teamName: 'Unknown' }
return { ...p, ...ctx }
})
}) })
.catch(e => { error = e instanceof Error ? e.message : 'Failed to load' }) .catch(e => { error = e instanceof Error ? e.message : 'Failed to load' })
.finally(() => { loading = false }) .finally(() => { loading = false })
@@ -47,7 +63,7 @@
Promise.all( Promise.all(
teams.map(team => teams.map(team =>
apiGetItemPhotos(params.huntId, team.id, selectedItem!.id) apiGetItemPhotos(params.huntId, team.id, selectedItem!.id)
.then(ps => ps.map(p => ({ ...p, teamName: team.name }))) .then(ps => ps.map(p => ({ ...p, teamName: team.name, itemName: selectedItem!.name })))
) )
) )
.then(results => { photos = results.flat() }) .then(results => { photos = results.flat() })
@@ -56,12 +72,13 @@
} }
}) })
async function review(photo: PhotoWithTeam, status: 'APPROVED' | 'REJECTED' | 'REMOVED') { async function review(photo: PhotoWithContext, status: 'APPROVED' | 'REJECTED' | 'REMOVED') {
reviewing = photo.id reviewing = photo.id
try { try {
await apiReviewPhoto(photo.id, status) await apiReviewPhoto(photo.id, status)
const updated = { ...photo, photoStatus: status } as PhotoWithTeam const updated = { ...photo, photoStatus: status } as PhotoWithContext
photos = photos.map(p => p.id === photo.id ? updated : p) photos = photos.map(p => p.id === photo.id ? updated : p)
unreviewedPhotos = unreviewedPhotos.map(p => p.id === photo.id ? updated : p)
if (expandedPhoto?.id === photo.id) expandedPhoto = updated if (expandedPhoto?.id === photo.id) expandedPhoto = updated
} catch (e: unknown) { } catch (e: unknown) {
error = e instanceof Error ? e.message : 'Failed to review photo' error = e instanceof Error ? e.message : 'Failed to review photo'
@@ -71,6 +88,7 @@
} }
const activePhotos = $derived(photos.filter(p => p.photoStatus !== 'REMOVED')) const activePhotos = $derived(photos.filter(p => p.photoStatus !== 'REMOVED'))
const activeUnreviewed = $derived(unreviewedPhotos.filter(p => p.photoStatus === 'SUBMITTED'))
</script> </script>
<!-- Lightbox --> <!-- Lightbox -->
@@ -83,6 +101,7 @@
> >
<div class="flex items-center justify-between px-4 py-3 shrink-0"> <div class="flex items-center justify-between px-4 py-3 shrink-0">
<div> <div>
<p class="text-xs font-semibold uppercase tracking-wide text-white/40">{photo.itemName}</p>
<p class="font-semibold text-white">{photo.hunterName} <span class="text-white/50 font-normal">· {photo.teamName}</span></p> <p class="font-semibold text-white">{photo.hunterName} <span class="text-white/50 font-normal">· {photo.teamName}</span></p>
<p class="text-xs text-white/40">{parseUTC(photo.photoUploadDateTime).toLocaleString()}</p> <p class="text-xs text-white/40">{parseUTC(photo.photoUploadDateTime).toLocaleString()}</p>
</div> </div>
@@ -148,14 +167,20 @@
{:else} {:else}
<!-- Item selector --> <!-- Item selector -->
<div class="mb-4"> <div class="mb-4">
<p class="text-sm font-medium text-base-content/60 mb-2">Select Item</p> <p class="text-sm font-medium text-base-content/60 mb-2">
{#if selectedItem}
Filtering by item — tap again to see all unreviewed
{:else}
Unreviewed ({activeUnreviewed.length})
{/if}
</p>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each items as item} {#each items as item}
<button <button
class="btn btn-sm" class="btn btn-sm"
class:btn-secondary={selectedItem?.id === item.id} class:btn-secondary={selectedItem?.id === item.id}
class:btn-outline={selectedItem?.id !== item.id} class:btn-outline={selectedItem?.id !== item.id}
onclick={() => { selectedItem = item }} onclick={() => { selectedItem = selectedItem?.id === item.id ? null : item }}
> >
{item.name} {item.name}
</button> </button>
@@ -164,6 +189,7 @@
</div> </div>
{#if selectedItem} {#if selectedItem}
<!-- Per-item photo view -->
{#if photosLoading} {#if photosLoading}
<LoadingSpinner /> <LoadingSpinner />
{:else if activePhotos.length === 0} {:else if activePhotos.length === 0}
@@ -172,7 +198,7 @@
</div> </div>
{:else} {:else}
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
{#each activePhotos as photo} {#each activePhotos as photo (photo.id)}
<div class="card bg-base-100 shadow-sm border border-base-200 overflow-hidden"> <div class="card bg-base-100 shadow-sm border border-base-200 overflow-hidden">
<button class="w-full cursor-zoom-in" onclick={() => expandedPhoto = photo}> <button class="w-full cursor-zoom-in" onclick={() => expandedPhoto = photo}>
<AuthImage photoId={photo.id} version="SMALL" alt={photo.hunterName} class="w-full h-56 object-cover" /> <AuthImage photoId={photo.id} version="SMALL" alt={photo.hunterName} class="w-full h-56 object-cover" />
@@ -189,19 +215,11 @@
</div> </div>
{#if photo.photoStatus === 'SUBMITTED'} {#if photo.photoStatus === 'SUBMITTED'}
<div class="flex gap-2 mb-2"> <div class="flex gap-2 mb-2">
<button <button class="btn btn-success btn-sm flex-1" onclick={() => review(photo, 'APPROVED')} disabled={reviewing === photo.id}>
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} {#if reviewing === photo.id}<span class="loading loading-spinner loading-xs"></span>{/if}
Approve Approve
</button> </button>
<button <button class="btn btn-error btn-sm flex-1" onclick={() => review(photo, 'REJECTED')} disabled={reviewing === photo.id}>
class="btn btn-error btn-sm flex-1"
onclick={() => review(photo, 'REJECTED')}
disabled={reviewing === photo.id}
>
Reject Reject
</button> </button>
</div> </div>
@@ -232,6 +250,48 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{:else}
<!-- All-unreviewed view -->
{#if activeUnreviewed.length === 0}
<div class="text-center py-10 text-base-content/50">
<p>All caught up — no unreviewed photos!</p>
</div>
{:else}
<div class="flex flex-col gap-4">
{#each activeUnreviewed as photo (photo.id)}
<div class="card bg-base-100 shadow-sm border border-base-200 overflow-hidden">
<button class="w-full cursor-zoom-in" onclick={() => expandedPhoto = photo}>
<AuthImage photoId={photo.id} version="SMALL" alt={photo.hunterName} class="w-full h-56 object-cover" />
</button>
<div class="p-3">
<p class="text-xs font-semibold uppercase tracking-wide text-primary mb-1">{photo.itemName}</p>
<div class="flex items-center justify-between mb-3">
<div>
<p class="font-semibold">{photo.hunterName} <span class="text-base-content/50 font-normal">· {photo.teamName}</span></p>
<p class="text-xs text-base-content/50">
{parseUTC(photo.photoUploadDateTime).toLocaleString()}
</p>
</div>
<StatusBadge status={photo.photoStatus} />
</div>
<div class="flex gap-2 mb-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>
<button class="btn btn-ghost btn-sm w-full text-base-content/50" onclick={() => review(photo, 'REMOVED')} disabled={reviewing === photo.id}>
Remove
</button>
</div>
</div>
{/each}
</div>
{/if}
{/if} {/if}
{/if} {/if}
</div> </div>