328 lines
12 KiB
Svelte
328 lines
12 KiB
Svelte
<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}
|