First crack at the app. Still lots of bugs to squash.
This commit is contained in:
93
src/routes/hunter/HuntList.svelte
Normal file
93
src/routes/hunter/HuntList.svelte
Normal file
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import {push} from 'svelte-spa-router'
|
||||
import {auth} from '../../lib/stores/auth.svelte'
|
||||
import {apiGetOngoingHunts, apiGetUnstartedHunts} from '../../lib/api/index'
|
||||
import type {HuntResponse} 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')
|
||||
|
||||
$effect(() => {
|
||||
Promise.all([
|
||||
apiGetOngoingHunts(),
|
||||
apiGetUnstartedHunts(),
|
||||
]).then(([o, u]) => {
|
||||
ongoing = o
|
||||
upcoming = u
|
||||
}).catch(e => {
|
||||
error = e instanceof Error ? e.message : 'Failed to load hunts'
|
||||
}).finally(() => {
|
||||
loading = false
|
||||
})
|
||||
})
|
||||
|
||||
function huntStatus(hunt: HuntResponse): 'ONGOING' | 'UNSTARTED' | 'CLOSED' {
|
||||
if (hunt.isTerminated) return 'CLOSED'
|
||||
const now = Date.now()
|
||||
const start = new Date(hunt.startDateTime).getTime()
|
||||
const end = new Date(hunt.endDateTime).getTime()
|
||||
if (now < start) return 'UNSTARTED'
|
||||
if (now > end) return 'CLOSED'
|
||||
return 'ONGOING'
|
||||
}
|
||||
|
||||
function formatDate(dt: string) {
|
||||
return new Date(dt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-4 pb-20">
|
||||
<div class="tabs tabs-boxed bg-base-200 mb-4">
|
||||
<button class="tab flex-1" class:tab-active={tab === 'active'} onclick={() => tab = 'active'}>
|
||||
Active
|
||||
{#if ongoing.length > 0}<span class="badge badge-primary badge-sm ml-1">{ongoing.length}</span>{/if}
|
||||
</button>
|
||||
<button class="tab flex-1" class:tab-active={tab === 'upcoming'} onclick={() => tab = 'upcoming'}>
|
||||
Upcoming
|
||||
{#if upcoming.length > 0}<span class="badge badge-info badge-sm ml-1">{upcoming.length}</span>{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<LoadingSpinner />
|
||||
{:else if error}
|
||||
<div class="alert alert-error">{error}</div>
|
||||
{:else}
|
||||
{@const hunts = tab === 'active' ? ongoing : upcoming}
|
||||
{#if hunts.length === 0}
|
||||
<div class="text-center py-16 text-base-content/50">
|
||||
<p class="text-4xl mb-3">🔍</p>
|
||||
<p class="font-medium">{tab === 'active' ? 'No active hunts right now' : 'No upcoming hunts'}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each hunts as hunt}
|
||||
<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={() => push(`/hunt/${hunt.id}`)}
|
||||
>
|
||||
<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">
|
||||
{formatDate(hunt.startDateTime)} – {formatDate(hunt.endDateTime)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
328
src/routes/hunter/HuntLobby.svelte
Normal file
328
src/routes/hunter/HuntLobby.svelte
Normal file
@@ -0,0 +1,328 @@
|
||||
<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,
|
||||
apiGetTeamItem,
|
||||
apiJoinTeam,
|
||||
apiListTeams,
|
||||
apiRemovePhoto,
|
||||
apiSubmitPhoto
|
||||
} from '../../lib/api/index'
|
||||
import type {HuntResponse, 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 fileInput = $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)
|
||||
</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}
|
||||
<p class="text-sm text-base-content/50 mt-0.5">Team: <span class="font-medium text-base-content/70">{myTeam.name}</span></p>
|
||||
{#if items.length > 0}
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<progress class="progress progress-primary flex-1 h-1.5" value={approved} max={items.length}></progress>
|
||||
<span class="text-xs font-medium text-primary">{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}
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
{#each items 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>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary w-full gap-2"
|
||||
class:opacity-50={submitting}
|
||||
disabled={submitting}
|
||||
onclick={() => fileInput?.click()}
|
||||
>
|
||||
{#if submitting}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Uploading…
|
||||
{:else}
|
||||
<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>
|
||||
Take / Upload Photo
|
||||
{/if}
|
||||
</button>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
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">
|
||||
<AuthImage photoId={photo.id} version="LARGE" alt={photo.hunterName} class="w-full h-48 object-cover" />
|
||||
<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'}
|
||||
<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}
|
||||
9
src/routes/hunter/HuntPlay.svelte
Normal file
9
src/routes/hunter/HuntPlay.svelte
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import {replace} from 'svelte-spa-router'
|
||||
|
||||
let { params }: { params: { huntId: string } } = $props()
|
||||
|
||||
$effect(() => {
|
||||
replace(`/hunt/${params.huntId}`)
|
||||
})
|
||||
</script>
|
||||
85
src/routes/hunter/Leaderboard.svelte
Normal file
85
src/routes/hunter/Leaderboard.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import {push} from 'svelte-spa-router'
|
||||
import {auth} from '../../lib/stores/auth.svelte'
|
||||
import {apiGetHunt, apiGetHunterLeaderboard, apiGetTeamLeaderboard} from '../../lib/api/index'
|
||||
import type {HunterLeaderboardResponse, HuntResponse, TeamLeaderboardResponse} from '../../lib/api/types'
|
||||
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 teamBoard = $state<TeamLeaderboardResponse[]>([])
|
||||
let hunterBoard = $state<HunterLeaderboardResponse[]>([])
|
||||
let loading = $state(true)
|
||||
let error = $state('')
|
||||
let tab = $state<'teams' | 'hunters'>('teams')
|
||||
|
||||
$effect(() => {
|
||||
const { huntId } = params
|
||||
loading = true
|
||||
Promise.all([
|
||||
apiGetHunt(huntId),
|
||||
apiGetTeamLeaderboard(huntId),
|
||||
apiGetHunterLeaderboard(huntId),
|
||||
]).then(([h, t, hu]) => {
|
||||
hunt = h
|
||||
teamBoard = t
|
||||
hunterBoard = hu
|
||||
}).catch(e => {
|
||||
error = e instanceof Error ? e.message : 'Failed to load leaderboard'
|
||||
}).finally(() => { loading = false })
|
||||
})
|
||||
|
||||
const medals = ['🥇', '🥈', '🥉']
|
||||
</script>
|
||||
|
||||
<div class="p-4 pb-20">
|
||||
{#if hunt}
|
||||
<h1 class="text-xl font-bold mb-4">{hunt.title}</h1>
|
||||
{/if}
|
||||
|
||||
<div class="tabs tabs-boxed bg-base-200 mb-4">
|
||||
<button class="tab flex-1" class:tab-active={tab === 'teams'} onclick={() => tab = 'teams'}>Teams</button>
|
||||
<button class="tab flex-1" class:tab-active={tab === 'hunters'} onclick={() => tab = 'hunters'}>Hunters</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<LoadingSpinner />
|
||||
{:else if error}
|
||||
<div class="alert alert-error">{error}</div>
|
||||
{:else if tab === 'teams'}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each teamBoard as entry}
|
||||
<div class="card bg-base-100 shadow-sm border border-base-200">
|
||||
<div class="card-body p-4 flex-row items-center gap-3">
|
||||
<span class="text-2xl w-8 text-center">{medals[entry.rank - 1] ?? entry.rank}</span>
|
||||
<span class="flex-1 font-semibold">{entry.teamName}</span>
|
||||
<span class="text-primary font-bold">{entry.score} pts</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if teamBoard.length === 0}
|
||||
<p class="text-center py-10 text-base-content/50">No scores yet</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each hunterBoard as entry}
|
||||
<div class="card bg-base-100 shadow-sm border border-base-200">
|
||||
<div class="card-body p-4 flex-row items-center gap-3">
|
||||
<span class="text-2xl w-8 text-center">{medals[entry.rank - 1] ?? entry.rank}</span>
|
||||
<span class="flex-1 font-semibold">{entry.hunterName}</span>
|
||||
<span class="text-primary font-bold">{entry.score} pts</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if hunterBoard.length === 0}
|
||||
<p class="text-center py-10 text-base-content/50">No scores yet</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user