525 lines
20 KiB
Svelte
525 lines
20 KiB
Svelte
<script lang="ts">
|
|
import {push} from 'svelte-spa-router'
|
|
import {fade} from 'svelte/transition'
|
|
import {auth} from '../../lib/stores/auth.svelte'
|
|
import {
|
|
apiCreateTeam,
|
|
apiGetHunt,
|
|
apiGetHunterTeam,
|
|
apiGetItemPhotos,
|
|
apiGetItems,
|
|
apiGetTeamHunters,
|
|
apiGetTeamItem,
|
|
apiJoinTeam,
|
|
apiListTeams,
|
|
apiRemovePhoto,
|
|
apiSubmitPhoto
|
|
} from '../../lib/api/index'
|
|
import type {HuntResponse, HunterSummaryResponse, ItemResponse, PhotoResponse, TeamItemResponse, 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')
|
|
})
|
|
|
|
let hunt = $state<HuntResponse | null>(null)
|
|
let myTeam = $state<TeamResponse | null>(null)
|
|
let teams = $state<TeamResponse[]>([])
|
|
let items = $state<ItemResponse[]>([])
|
|
let itemStatuses = $state<Record<string, TeamItemResponse>>({})
|
|
let loading = $state(true)
|
|
let error = $state('')
|
|
|
|
let newTeamName = $state('')
|
|
let creatingTeam = $state(false)
|
|
let joiningTeamId = $state<string | null>(null)
|
|
|
|
let selectedItem = $state<ItemResponse | null>(null)
|
|
let itemPhotos = $state<PhotoResponse[]>([])
|
|
let photosLoading = $state(false)
|
|
let submitting = $state(false)
|
|
let expandedPhoto = $state<PhotoResponse | null>(null)
|
|
let fileInput = $state<HTMLInputElement | undefined>()
|
|
let cameraInput = $state<HTMLInputElement | undefined>()
|
|
let _uploading = false
|
|
let _errorTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
function setTransientError(msg: string) {
|
|
error = msg
|
|
if (_errorTimer) clearTimeout(_errorTimer)
|
|
_errorTimer = setTimeout(() => { error = ''; _errorTimer = null }, 5000)
|
|
}
|
|
|
|
async function loadItems(huntId: string, team: TeamResponse) {
|
|
const itemList = await apiGetItems(huntId)
|
|
items = itemList
|
|
const statuses = await Promise.all(itemList.map(item => apiGetTeamItem(huntId, team.id, item.id)))
|
|
const map: Record<string, TeamItemResponse> = {}
|
|
statuses.forEach(s => { map[s.id] = s })
|
|
itemStatuses = map
|
|
}
|
|
|
|
$effect(() => {
|
|
const { huntId } = params
|
|
loading = true
|
|
error = ''
|
|
|
|
Promise.all([
|
|
apiGetHunt(huntId),
|
|
apiGetHunterTeam(huntId).catch(() => null),
|
|
apiListTeams(huntId),
|
|
]).then(async ([h, t, tList]) => {
|
|
hunt = h
|
|
myTeam = t
|
|
teams = tList
|
|
if (t) await loadItems(huntId, t)
|
|
}).catch(e => {
|
|
error = e instanceof Error ? e.message : 'Failed to load'
|
|
}).finally(() => {
|
|
loading = false
|
|
})
|
|
})
|
|
|
|
async function createTeam() {
|
|
if (!newTeamName.trim()) return
|
|
creatingTeam = true
|
|
error = ''
|
|
try {
|
|
await apiCreateTeam(params.huntId, newTeamName.trim())
|
|
teams = await apiListTeams(params.huntId)
|
|
const created = teams.find(t => t.name === newTeamName.trim())
|
|
if (created) await joinTeam(created.id)
|
|
newTeamName = ''
|
|
} catch (e: unknown) {
|
|
error = e instanceof Error ? e.message : 'Failed to create team'
|
|
} finally {
|
|
creatingTeam = false
|
|
}
|
|
}
|
|
|
|
async function joinTeam(teamId: string) {
|
|
joiningTeamId = teamId
|
|
error = ''
|
|
try {
|
|
await apiJoinTeam(params.huntId, teamId)
|
|
myTeam = teams.find(t => t.id === teamId) ?? null
|
|
if (myTeam) await loadItems(params.huntId, myTeam)
|
|
} catch (e: unknown) {
|
|
error = e instanceof Error ? e.message : 'Failed to join team'
|
|
} finally {
|
|
joiningTeamId = null
|
|
}
|
|
}
|
|
|
|
async function openItem(item: ItemResponse) {
|
|
selectedItem = item
|
|
photosLoading = true
|
|
itemPhotos = []
|
|
try {
|
|
itemPhotos = await apiGetItemPhotos(params.huntId, myTeam!.id, item.id)
|
|
} finally {
|
|
photosLoading = false
|
|
}
|
|
}
|
|
|
|
function closeSheet() {
|
|
selectedItem = null
|
|
itemPhotos = []
|
|
}
|
|
|
|
async function handleFileChange(e: Event) {
|
|
if (_uploading) return
|
|
const file = (e.target as HTMLInputElement).files?.[0]
|
|
if (!file || !selectedItem || !myTeam) return
|
|
_uploading = true
|
|
submitting = true
|
|
try {
|
|
await apiSubmitPhoto(params.huntId, myTeam.id, selectedItem.id, file)
|
|
itemPhotos = await apiGetItemPhotos(params.huntId, myTeam.id, selectedItem.id)
|
|
const status = await apiGetTeamItem(params.huntId, myTeam.id, selectedItem.id)
|
|
itemStatuses = { ...itemStatuses, [status.id]: status }
|
|
} catch (e: unknown) {
|
|
setTransientError(e instanceof Error ? e.message : 'Failed to submit photo')
|
|
} finally {
|
|
_uploading = false
|
|
submitting = false
|
|
;(e.target as HTMLInputElement).value = ''
|
|
}
|
|
}
|
|
|
|
async function removePhoto(photo: PhotoResponse) {
|
|
if (!selectedItem || !myTeam) return
|
|
try {
|
|
await apiRemovePhoto(params.huntId, myTeam.id, selectedItem.id, photo.id)
|
|
itemPhotos = itemPhotos.filter(p => p.id !== photo.id)
|
|
const status = await apiGetTeamItem(params.huntId, myTeam.id, selectedItem.id)
|
|
itemStatuses = { ...itemStatuses, [status.id]: status }
|
|
} catch (e: unknown) {
|
|
setTransientError(e instanceof Error ? e.message : 'Failed to remove photo')
|
|
}
|
|
}
|
|
|
|
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'
|
|
}
|
|
|
|
const approved = $derived(Object.values(itemStatuses).filter(s => s.itemFoundStatus === 'APPROVED').length)
|
|
const submitted = $derived(Object.values(itemStatuses).filter(s => s.itemFoundStatus === 'SUBMITTED').length)
|
|
const rejected = $derived(Object.values(itemStatuses).filter(s => s.itemFoundStatus === 'REJECTED').length)
|
|
|
|
let teamMembers = $state<HunterSummaryResponse[]>([])
|
|
let teamMembersLoading = $state(false)
|
|
let teamMembersOpen = $state(false)
|
|
|
|
async function openTeamMembers() {
|
|
if (!myTeam) return
|
|
teamMembersOpen = true
|
|
teamMembersLoading = true
|
|
try {
|
|
teamMembers = await apiGetTeamHunters(params.huntId, myTeam.id)
|
|
} finally {
|
|
teamMembersLoading = false
|
|
}
|
|
}
|
|
|
|
let searchQuery = $state('')
|
|
let filterStatus = $state<string | null>(null)
|
|
let filterPoints = $state<number | null>(null)
|
|
let sortBy = $state<'name' | 'points'>('points')
|
|
let sortDir = $state<'asc' | 'desc'>('asc')
|
|
|
|
const uniquePointValues = $derived([...new Set(items.map(i => i.points))].sort((a, b) => a - b))
|
|
|
|
const filteredItems = $derived(
|
|
[...items]
|
|
.filter(item => {
|
|
if (searchQuery && !item.name.toLowerCase().includes(searchQuery.toLowerCase())) return false
|
|
if (filterStatus && (itemStatuses[item.id]?.itemFoundStatus ?? 'NOT_FOUND') !== filterStatus) return false
|
|
if (filterPoints !== null && item.points !== filterPoints) return false
|
|
return true
|
|
})
|
|
.sort((a, b) => {
|
|
if (sortBy === 'name') {
|
|
const cmp = a.name.localeCompare(b.name)
|
|
return sortDir === 'asc' ? cmp : -cmp
|
|
}
|
|
const ptsCmp = a.points - b.points
|
|
if (ptsCmp !== 0) return sortDir === 'asc' ? ptsCmp : -ptsCmp
|
|
return a.name.localeCompare(b.name)
|
|
})
|
|
)
|
|
|
|
function toggleSort(field: 'name' | 'points') {
|
|
if (sortBy === field) sortDir = sortDir === 'asc' ? 'desc' : 'asc'
|
|
else { sortBy = field; sortDir = 'asc' }
|
|
}
|
|
|
|
const statusFilters: { label: string; value: string | null }[] = [
|
|
{ label: 'All', value: null },
|
|
{ label: 'Not Found', value: 'NOT_FOUND' },
|
|
{ label: 'Submitted', value: 'SUBMITTED' },
|
|
{ label: 'Approved', value: 'APPROVED' },
|
|
{ label: 'Rejected', value: 'REJECTED' },
|
|
]
|
|
</script>
|
|
|
|
<div class="pb-20">
|
|
{#if loading}
|
|
<div class="p-4"><LoadingSpinner /></div>
|
|
{:else if error && !hunt}
|
|
<div class="p-4"><div class="alert alert-error">{error}</div></div>
|
|
{:else if hunt}
|
|
<!-- Informational header -->
|
|
<div class="px-4 pt-4 pb-3 border-b border-base-200 bg-base-100">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<h1 class="text-xl font-bold leading-tight">{hunt.title}</h1>
|
|
<StatusBadge status={huntStatus(hunt)} />
|
|
</div>
|
|
{#if myTeam}
|
|
<button class="text-sm text-base-content/50 mt-0.5 text-left" onclick={openTeamMembers}>
|
|
Team: <span class="font-medium text-base-content/70 underline decoration-dotted underline-offset-2">{myTeam.name}</span>
|
|
</button>
|
|
{#if items.length > 0}
|
|
<div class="flex items-center gap-2 mt-2">
|
|
<div class="flex-1 h-2 rounded-full overflow-hidden bg-base-300 flex">
|
|
<div class="bg-success h-full transition-all" style="width: {approved / items.length * 100}%"></div>
|
|
<div class="bg-warning h-full transition-all" style="width: {submitted / items.length * 100}%"></div>
|
|
<div class="bg-error h-full transition-all" style="width: {rejected / items.length * 100}%"></div>
|
|
</div>
|
|
<span class="text-xs font-medium text-base-content/60">{approved}/{items.length}</span>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
{#if error}
|
|
<div transition:fade={{ duration: 400 }} class="px-4 pt-3"><div class="alert alert-error text-sm">{error}</div></div>
|
|
{/if}
|
|
|
|
<!-- No team yet: join or create -->
|
|
{#if !myTeam}
|
|
<div class="p-4 flex flex-col gap-4">
|
|
<form onsubmit={(e) => { e.preventDefault(); createTeam() }} class="flex gap-2">
|
|
<input
|
|
type="text"
|
|
placeholder="New team name"
|
|
class="input input-bordered input-sm flex-1"
|
|
bind:value={newTeamName}
|
|
disabled={creatingTeam}
|
|
/>
|
|
<button type="submit" class="btn btn-secondary btn-sm" disabled={creatingTeam || !newTeamName.trim()}>
|
|
{#if creatingTeam}<span class="loading loading-spinner loading-xs"></span>{/if}
|
|
Create
|
|
</button>
|
|
</form>
|
|
|
|
{#if teams.length > 0}
|
|
<div>
|
|
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/40 mb-2">Or join an existing team</p>
|
|
<div class="flex flex-col gap-2">
|
|
{#each teams as team}
|
|
<button
|
|
class="card bg-base-100 shadow-sm border border-base-200 text-left w-full hover:border-primary transition-colors"
|
|
onclick={() => joinTeam(team.id)}
|
|
disabled={joiningTeamId !== null}
|
|
>
|
|
<div class="card-body p-4 flex-row items-center justify-between">
|
|
<p class="font-semibold">{team.name}</p>
|
|
{#if joiningTeamId === team.id}
|
|
<span class="loading loading-spinner loading-xs"></span>
|
|
{/if}
|
|
</div>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- On a team: item list -->
|
|
{:else}
|
|
<!-- Filter & sort controls -->
|
|
<div class="px-4 pt-3 pb-2 flex flex-col gap-2 border-b border-base-200">
|
|
<input
|
|
type="search"
|
|
placeholder="Search items…"
|
|
class="input input-bordered input-sm w-full"
|
|
bind:value={searchQuery}
|
|
/>
|
|
<div class="flex gap-2 overflow-x-auto pb-1 no-scrollbar">
|
|
{#each statusFilters as f}
|
|
<button
|
|
class="btn btn-xs shrink-0"
|
|
class:btn-primary={filterStatus === f.value}
|
|
class:btn-outline={filterStatus !== f.value}
|
|
onclick={() => filterStatus = f.value}
|
|
>{f.label}</button>
|
|
{/each}
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<select
|
|
class="select select-bordered select-sm flex-1"
|
|
onchange={(e) => filterPoints = (e.target as HTMLSelectElement).value === '' ? null : Number((e.target as HTMLSelectElement).value)}
|
|
>
|
|
<option value="">All pts</option>
|
|
{#each uniquePointValues as pts}
|
|
<option value={pts}>{pts} pts</option>
|
|
{/each}
|
|
</select>
|
|
<button
|
|
class="btn btn-sm gap-1"
|
|
class:btn-primary={sortBy === 'points'}
|
|
class:btn-outline={sortBy !== 'points'}
|
|
onclick={() => toggleSort('points')}
|
|
>
|
|
Pts {sortBy === 'points' ? (sortDir === 'asc' ? '↑' : '↓') : '↕'}
|
|
</button>
|
|
<button
|
|
class="btn btn-sm gap-1"
|
|
class:btn-primary={sortBy === 'name'}
|
|
class:btn-outline={sortBy !== 'name'}
|
|
onclick={() => toggleSort('name')}
|
|
>
|
|
Name {sortBy === 'name' ? (sortDir === 'asc' ? '↑' : '↓') : '↕'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2 p-4">
|
|
{#each filteredItems as item}
|
|
{@const status = itemStatuses[item.id]}
|
|
<button
|
|
class="card bg-base-100 shadow-sm border border-base-200 text-left w-full hover:border-primary transition-colors"
|
|
onclick={() => openItem(item)}
|
|
>
|
|
<div class="card-body p-4 flex-row items-center gap-3">
|
|
<div class="flex-1 min-w-0">
|
|
<p class="font-semibold truncate">{item.name}</p>
|
|
<p class="text-xs text-base-content/50">{item.points} pts</p>
|
|
</div>
|
|
{#if status}
|
|
<StatusBadge status={status.itemFoundStatus} />
|
|
{/if}
|
|
</div>
|
|
</button>
|
|
{:else}
|
|
<p class="text-center text-base-content/50 py-8 text-sm">No items match your filters</p>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Team members popup -->
|
|
{#if teamMembersOpen && myTeam}
|
|
{@const team = myTeam}
|
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-6" role="presentation" onclick={() => teamMembersOpen = false}>
|
|
<div class="bg-base-100 rounded-2xl shadow-2xl w-full max-w-sm p-5" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/40">Team</p>
|
|
<h3 class="font-bold text-lg leading-tight">{team.name}</h3>
|
|
</div>
|
|
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => teamMembersOpen = false}>✕</button>
|
|
</div>
|
|
{#if teamMembersLoading}
|
|
<LoadingSpinner />
|
|
{:else}
|
|
<div class="flex flex-col gap-2">
|
|
{#each teamMembers as member}
|
|
<p class="text-sm font-medium py-1 border-b border-base-200 last:border-0">{member.name}</p>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Photo lightbox -->
|
|
{#if expandedPhoto}
|
|
{@const photo = expandedPhoto}
|
|
<div
|
|
class="fixed inset-0 z-60 bg-black/90 flex flex-col"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
>
|
|
<div class="flex items-center justify-between px-4 py-3 shrink-0">
|
|
<div>
|
|
<p class="font-semibold text-white">{photo.hunterName}</p>
|
|
<p class="text-xs text-white/40">{new Date(photo.photoUploadDateTime).toLocaleString()}</p>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<StatusBadge status={photo.photoStatus} />
|
|
<button class="btn btn-ghost btn-sm btn-circle text-white" onclick={() => expandedPhoto = null}>✕</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex-1 min-h-0 flex items-center justify-center px-4">
|
|
<AuthImage photoId={photo.id} version="LARGE" alt={photo.hunterName} class="max-h-full max-w-full object-contain rounded-lg" />
|
|
</div>
|
|
|
|
{#if photo.photoStatus === 'SUBMITTED' || photo.photoStatus === 'REJECTED'}
|
|
<div class="px-4 py-4 shrink-0">
|
|
<button class="btn btn-ghost btn-sm w-full text-white/40" onclick={() => { removePhoto(photo); expandedPhoto = null }}>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<div class="pb-4"></div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Item detail bottom sheet -->
|
|
{#if selectedItem}
|
|
<div class="fixed inset-0 z-40" onclick={closeSheet} role="presentation"></div>
|
|
<div class="fixed bottom-0 left-0 right-0 z-50 bg-base-100 rounded-t-2xl shadow-2xl max-h-[80vh] overflow-y-auto">
|
|
<div class="p-4 border-b border-base-200 flex items-center justify-between sticky top-0 bg-base-100">
|
|
<div>
|
|
<h3 class="font-bold text-lg">{selectedItem.name}</h3>
|
|
<p class="text-xs text-base-content/50">{selectedItem.points} points</p>
|
|
</div>
|
|
<button class="btn btn-ghost btn-sm btn-circle" onclick={closeSheet}>✕</button>
|
|
</div>
|
|
<div class="p-4 flex flex-col gap-4">
|
|
{#if submitting}
|
|
<button type="button" class="btn btn-primary w-full gap-2 opacity-50" disabled>
|
|
<span class="loading loading-spinner loading-sm"></span>
|
|
Uploading…
|
|
</button>
|
|
{:else}
|
|
<div class="flex gap-2">
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary flex-1 gap-2"
|
|
onclick={() => cameraInput?.click()}
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
|
<path fill-rule="evenodd" d="M4 5a2 2 0 00-2 2v8a2 2 0 002 2h12a2 2 0 002-2V7a2 2 0 00-2-2h-1.586a1 1 0 01-.707-.293l-1.121-1.121A2 2 0 0011.172 3H8.828a2 2 0 00-1.414.586L6.293 4.707A1 1 0 015.586 5H4zm6 9a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
|
|
</svg>
|
|
Camera
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-outline flex-1 gap-2"
|
|
onclick={() => fileInput?.click()}
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
|
<path fill-rule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clip-rule="evenodd" />
|
|
</svg>
|
|
Gallery
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
<input
|
|
bind:this={cameraInput}
|
|
type="file"
|
|
accept="image/*"
|
|
capture="environment"
|
|
class="hidden"
|
|
onchange={handleFileChange}
|
|
/>
|
|
<input
|
|
bind:this={fileInput}
|
|
type="file"
|
|
accept="image/*"
|
|
class="hidden"
|
|
onchange={handleFileChange}
|
|
/>
|
|
|
|
{#if photosLoading}
|
|
<LoadingSpinner />
|
|
{:else if itemPhotos.filter(p => p.photoStatus !== 'REMOVED').length > 0}
|
|
<div class="flex flex-col gap-3">
|
|
{#each itemPhotos.filter(p => p.photoStatus !== 'REMOVED') as photo (photo.id)}
|
|
<div class="card bg-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-48 object-cover" />
|
|
</button>
|
|
<div class="p-3 flex items-center justify-between">
|
|
<div>
|
|
<p class="text-sm font-medium">{photo.hunterName}</p>
|
|
<StatusBadge status={photo.photoStatus} />
|
|
</div>
|
|
{#if photo.photoStatus === 'SUBMITTED' || photo.photoStatus === 'REJECTED'}
|
|
<button class="btn btn-ghost btn-xs text-error" onclick={() => removePhoto(photo)}>Remove</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<p class="text-center text-base-content/50 py-4 text-sm">No photos submitted yet</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|