Files
scavengerhunt-fe/src/routes/hunter/HuntList.svelte
2026-05-18 23:58:37 -05:00

328 lines
12 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import {push} from 'svelte-spa-router'
import {auth} from '../../lib/stores/auth.svelte'
import {huntStatus, formatDateTime} from '../../lib/utils'
import {
apiCreateTeam,
apiGetOngoingHunts,
apiGetHunterTeam,
apiGetUnstartedHunts,
apiGetTeamHunters,
apiJoinTeam,
apiListTeams,
} from '../../lib/api/index'
import type {HuntResponse, HunterSummaryResponse, TeamResponse} from '../../lib/api/types'
import StatusBadge from '../../lib/components/StatusBadge.svelte'
import LoadingSpinner from '../../lib/components/LoadingSpinner.svelte'
$effect(() => {
if (!auth.isLoggedIn) push('/login')
else if (auth.isAdmin) push('/admin')
})
let ongoing = $state<HuntResponse[]>([])
let upcoming = $state<HuntResponse[]>([])
let loading = $state(true)
let error = $state('')
let tab = $state<'active' | 'upcoming'>('active')
// huntId → team the hunter has joined (null = not joined)
let ongoingTeams = $state<Record<string, TeamResponse | null>>({})
let upcomingTeams = $state<Record<string, TeamResponse | null>>({})
$effect(() => {
Promise.all([
apiGetOngoingHunts(),
apiGetUnstartedHunts(),
]).then(async ([o, u]) => {
ongoing = o
upcoming = u
const [ongoingEntries, upcomingEntries] = await Promise.all([
Promise.all(o.map(hunt =>
apiGetHunterTeam(hunt.id)
.then(t => [hunt.id, t] as const)
.catch(() => [hunt.id, null] as const)
)),
Promise.all(u.map(hunt =>
apiGetHunterTeam(hunt.id)
.then(t => [hunt.id, t] as const)
.catch(() => [hunt.id, null] as const)
)),
])
ongoingTeams = Object.fromEntries(ongoingEntries)
upcomingTeams = Object.fromEntries(upcomingEntries)
}).catch(e => {
error = e instanceof Error ? e.message : 'Failed to load hunts'
}).finally(() => {
loading = false
})
})
// ── Hunt sheet (shared for active + upcoming) ─────────────────────────────────
let sheetHunt = $state<HuntResponse | null>(null)
let sheetIsActive = $state(false)
let sheetTeams = $state<TeamResponse[]>([])
let sheetMembers = $state<HunterSummaryResponse[]>([])
let sheetLoading = $state(false)
let newTeamName = $state('')
let creatingTeam = $state(false)
let joiningTeamId = $state<string | null>(null)
let sheetError = $state('')
function currentTeam(hunt: HuntResponse) {
return sheetIsActive ? ongoingTeams[hunt.id] : upcomingTeams[hunt.id]
}
async function openSheet(hunt: HuntResponse, isActive: boolean) {
sheetHunt = hunt
sheetIsActive = isActive
sheetError = ''
newTeamName = ''
sheetMembers = []
sheetLoading = true
try {
const myTeam = currentTeam(hunt)
if (myTeam) {
const [teams, members] = await Promise.all([
apiListTeams(hunt.id),
apiGetTeamHunters(hunt.id, myTeam.id),
])
sheetTeams = teams
sheetMembers = members
} else {
sheetTeams = await apiListTeams(hunt.id)
}
} catch (e: unknown) {
sheetError = e instanceof Error ? e.message : 'Failed to load teams'
} finally {
sheetLoading = false
}
}
function closeSheet() {
sheetHunt = null
sheetTeams = []
}
async function createTeam() {
if (!sheetHunt || !newTeamName.trim()) return
creatingTeam = true
sheetError = ''
try {
await apiCreateTeam(sheetHunt.id, newTeamName.trim())
sheetTeams = await apiListTeams(sheetHunt.id)
const created = sheetTeams.find(t => t.name === newTeamName.trim())
if (created) await joinTeam(created.id)
else newTeamName = ''
} catch (e: unknown) {
sheetError = e instanceof Error ? e.message : 'Failed to create team'
} finally {
creatingTeam = false
}
}
async function joinTeam(teamId: string) {
if (!sheetHunt) return
joiningTeamId = teamId
sheetError = ''
try {
await apiJoinTeam(sheetHunt.id, teamId)
const team = sheetTeams.find(t => t.id === teamId) ?? null
if (sheetIsActive) {
ongoingTeams = { ...ongoingTeams, [sheetHunt.id]: team }
push(`/hunt/${sheetHunt.id}`)
} else {
upcomingTeams = { ...upcomingTeams, [sheetHunt.id]: team }
newTeamName = ''
closeSheet()
}
} catch (e: unknown) {
sheetError = e instanceof Error ? e.message : 'Failed to join team'
} finally {
joiningTeamId = null
}
}
</script>
<div class="p-4 pb-20">
<div class="tabs tabs-boxed bg-base-200 mb-4">
<button
class="tab flex-1 font-bold transition-all {tab === 'active' ? 'bg-primary text-primary-content rounded-lg' : 'text-base-content/40'}"
onclick={() => tab = 'active'}
>
Active
{#if ongoing.length > 0}<span class="badge badge-md ml-1 {tab === 'active' ? 'badge-outline' : 'badge-primary'}">{ongoing.length}</span>{/if}
</button>
<button
class="tab flex-1 font-bold transition-all {tab === 'upcoming' ? 'bg-primary text-primary-content rounded-lg' : 'text-base-content/40'}"
onclick={() => tab = 'upcoming'}
>
Upcoming
{#if upcoming.length > 0}<span class="badge badge-md ml-1 {tab === 'upcoming' ? 'badge-outline' : 'badge-info'}">{upcoming.length}</span>{/if}
</button>
</div>
{#if loading}
<LoadingSpinner />
{:else if error}
<div class="alert alert-error">{error}</div>
{:else}
{#if tab === 'active'}
{#if ongoing.length === 0}
<div class="text-center py-16 text-base-content/50">
<p class="text-4xl mb-3">🔍</p>
<p class="font-medium">No active hunts right now</p>
</div>
{:else}
<div class="flex flex-col gap-3">
{#each ongoing as hunt}
{@const myTeam = ongoingTeams[hunt.id]}
<button
class="card bg-base-100 shadow-sm border border-base-200 text-left w-full hover:border-primary hover:shadow-md transition-all"
onclick={() => myTeam ? push(`/hunt/${hunt.id}`) : openSheet(hunt, true)}
>
<div class="card-body p-4 gap-2">
<div class="flex items-start justify-between gap-2">
<h2 class="card-title text-base">{hunt.title}</h2>
<StatusBadge status={huntStatus(hunt)} />
</div>
<p class="text-xs text-base-content/50">
{formatDateTime(hunt.startDateTime)} {formatDateTime(hunt.endDateTime)}
</p>
{#if myTeam}
<p class="text-sm font-medium text-primary">Team: {myTeam.name}</p>
{:else}
<p class="text-sm font-medium text-secondary">Join to play →</p>
{/if}
</div>
</button>
{/each}
</div>
{/if}
{:else}
{#if upcoming.length === 0}
<div class="text-center py-16 text-base-content/50">
<p class="text-4xl mb-3">🔍</p>
<p class="font-medium">No upcoming hunts</p>
</div>
{:else}
<div class="flex flex-col gap-3">
{#each upcoming as hunt}
{@const myTeam = upcomingTeams[hunt.id]}
<button
class="card bg-base-100 shadow-sm border border-base-200 text-left w-full hover:border-primary hover:shadow-md transition-all"
onclick={() => openSheet(hunt, false)}
>
<div class="card-body p-4 gap-2">
<div class="flex items-start justify-between gap-2">
<h2 class="card-title text-base">{hunt.title}</h2>
<StatusBadge status={huntStatus(hunt)} />
</div>
<p class="text-xs text-base-content/50">
{formatDateTime(hunt.startDateTime)} {formatDateTime(hunt.endDateTime)}
</p>
{#if myTeam}
<p class="text-sm font-medium text-primary">Team: {myTeam.name}</p>
{:else}
<p class="text-sm font-medium text-secondary">Join a team →</p>
{/if}
</div>
</button>
{/each}
</div>
{/if}
{/if}
{/if}
</div>
<!-- Hunt sheet -->
{#if sheetHunt}
{@const hunt = sheetHunt}
{@const myTeam = sheetIsActive ? ongoingTeams[hunt.id] : upcomingTeams[hunt.id]}
<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-200 rounded-t-2xl shadow-2xl max-h-[80vh] overflow-y-auto">
<div class="p-4 border-b border-base-300 flex items-center justify-between sticky top-0 bg-base-200">
<div>
<h3 class="font-bold text-lg">{hunt.title}</h3>
<p class="text-xs text-base-content/50">{formatDateTime(hunt.startDateTime)} {formatDateTime(hunt.endDateTime)}</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 sheetError}
<div class="alert alert-error text-sm">{sheetError}</div>
{/if}
{#if sheetLoading}
<LoadingSpinner />
{:else if myTeam}
<!-- Already on a team -->
<div class="rounded-xl bg-base-100 border border-base-300 p-4 flex flex-col gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/40 mb-1">Your Team</p>
<p class="text-lg font-bold">{myTeam.name}</p>
</div>
{#if sheetMembers.length > 0}
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/40 mb-2">Members</p>
<div class="flex flex-col gap-1">
{#each sheetMembers as member}
<p class="text-sm font-medium">{member.name}</p>
{/each}
</div>
</div>
{/if}
</div>
{#if sheetIsActive}
<button class="btn btn-primary w-full" onclick={() => push(`/hunt/${hunt.id}`)}>
Play Now →
</button>
{/if}
{:else}
<!-- Join or create -->
{#if sheetIsActive}
<div class="alert alert-info text-sm">This hunt is already in progress — join a team to start playing!</div>
{/if}
<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 sheetTeams.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 sheetTeams 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}
{/if}
</div>
</div>
{/if}