Compare commits

...

23 Commits

Author SHA1 Message Date
c6f1475b84 Adds unreviewed notification bubble in Admin hunts list 2026-05-19 10:26:49 -05:00
2c7cb00745 Lists all unreviewed photos for a hunt 2026-05-19 10:02:04 -05:00
ef8ab903ee Updates favicon for Scrounge Quest theming 2026-05-19 00:05:17 -05:00
44a5805b2b Makes photo dialog clearer 2026-05-18 23:58:37 -05:00
efab8b3083 Fixes terminated hunt issues 2026-05-18 23:35:12 -05:00
46060fc150 Allows Hunters to join in mid-Hunt 2026-05-18 22:14:24 -05:00
1772081495 Fixes time zone based stuff 2026-05-18 20:59:55 -05:00
aae0c3ff8d Consolidates and renames the app title 2026-05-18 17:28:39 -05:00
86866f4db7 Adds display of Team members 2026-05-18 17:12:43 -05:00
6e4edd96ce Allows for editing Hunt details 2026-05-18 16:31:00 -05:00
fd4d86db5b Unstarted Hunts no longer go to the Items view for Hunters 2026-05-18 16:12:48 -05:00
3a0a0ae791 Adds filters and sorts for Hunt view 2026-05-18 14:19:16 -05:00
63d027ebb7 Makes the progress bar more informative 2026-05-18 13:55:09 -05:00
53706b4035 Fixes up some non-null silliness 2026-05-18 13:46:40 -05:00
67fe81dc41 Adds ability to expand photos, and upload both from camera and from gallery 2026-05-18 13:44:23 -05:00
2ba8b60063 Allows admins to remove photos 2026-05-18 13:02:39 -05:00
6191c6c804 Makes the photo review process easier 2026-05-18 12:05:31 -05:00
2c614a2d46 Makes the active tab easier to determine 2026-05-18 11:35:45 -05:00
51a9448fdb Makes the bottom nav buttons easier to press 2026-05-18 11:35:26 -05:00
5aeb6818f8 Fixes startup warning 2026-05-18 10:29:20 -05:00
ebe0a9692f Adds rules page 2026-05-18 00:44:53 -05:00
642228f4a9 Does some code tidying 2026-05-18 00:30:18 -05:00
435481549c First crack at the app. Still lots of bugs to squash. 2026-05-17 23:30:08 -05:00
38 changed files with 4368 additions and 0 deletions

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en" data-theme="hunt">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- App name is defined in src/lib/config.ts — keep in sync -->
<title>Scrounge Quest</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1542
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "scavengerhuntfe",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tsconfig/svelte": "^5.0.6",
"@types/node": "^24.10.1",
"svelte": "^5.55.7",
"svelte-check": "^4.3.4",
"typescript": "~5.9.3",
"vite": "^8.0.0"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.0",
"daisyui": "^5.5.19",
"svelte-spa-router": "^5.1.0",
"tailwindcss": "^4.3.0"
}
}

30
public/favicon.svg Normal file
View File

@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<!-- Shield (navy blue, gold border) -->
<path d="M8,5 H56 V36 Q56,57 32,63 Q8,57 8,36 Z" fill="#1e3a8a"/>
<path d="M8,5 H56 V36 Q56,57 32,63 Q8,57 8,36 Z" fill="none" stroke="#d4a017" stroke-width="2.5"/>
<!-- Bushy tail — drawn first so body sits on top of it -->
<path d="M30,44 C46,46 58,34 54,18 C50,6 36,6 32,16"
fill="none" stroke="#d4a017" stroke-width="11" stroke-linecap="round"/>
<!-- Body (upright) -->
<ellipse cx="24" cy="37" rx="7.5" ry="10.5" fill="#d4a017"/>
<!-- Head -->
<circle cx="24" cy="22" r="6.5" fill="#d4a017"/>
<!-- Ears -->
<polygon points="19,17 16,9 23,14" fill="#d4a017"/>
<polygon points="26,16 28,8 32,12" fill="#d4a017"/>
<!-- Eye -->
<circle cx="22" cy="21" r="1.5" fill="#1e3a8a"/>
<!-- Front paws raised (rearing up) -->
<line x1="18" y1="30" x2="12" y2="22" stroke="#d4a017" stroke-width="4.5" stroke-linecap="round"/>
<line x1="30" y1="28" x2="37" y2="20" stroke="#d4a017" stroke-width="4.5" stroke-linecap="round"/>
<!-- Hind legs -->
<line x1="18" y1="46" x2="16" y2="54" stroke="#d4a017" stroke-width="4.5" stroke-linecap="round"/>
<line x1="30" y1="47" x2="33" y2="54" stroke="#d4a017" stroke-width="4.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

49
src/App.svelte Normal file
View File

@@ -0,0 +1,49 @@
<script lang="ts">
import Router, {router} from 'svelte-spa-router'
import {auth} from './lib/stores/auth.svelte'
import TopBar from './lib/components/TopBar.svelte'
import BottomNav from './lib/components/BottomNav.svelte'
import Login from './routes/Login.svelte'
import Signup from './routes/Signup.svelte'
import HuntList from './routes/hunter/HuntList.svelte'
import HuntLobby from './routes/hunter/HuntLobby.svelte'
import HuntPlay from './routes/hunter/HuntPlay.svelte'
import Leaderboard from './routes/hunter/Leaderboard.svelte'
import AdminHome from './routes/admin/AdminHome.svelte'
import HuntCreate from './routes/admin/HuntCreate.svelte'
import HuntManage from './routes/admin/HuntManage.svelte'
import PhotoReview from './routes/admin/PhotoReview.svelte'
import Rules from './routes/Rules.svelte'
const routes = {
'/': HuntList,
'/login': Login,
'/signup': Signup,
'/hunt/:huntId': HuntLobby,
'/hunt/:huntId/play': HuntPlay,
'/hunt/:huntId/leaderboard': Leaderboard,
'/admin': AdminHome,
'/admin/hunt/create': HuntCreate,
'/admin/hunt/:huntId': HuntManage,
'/admin/hunt/:huntId/review': PhotoReview,
'/rules': Rules,
}
const publicRoutes = ['/login', '/signup']
const isPublicRoute = $derived(publicRoutes.includes(router.location))
const showShell = $derived(auth.isLoggedIn && !isPublicRoute)
const showBottomNav = $derived(showShell && !auth.isAdmin)
</script>
<div class="min-h-screen bg-base-200" class:pb-16={showBottomNav}>
{#if showShell}
<TopBar />
{/if}
<Router {routes} />
{#if showBottomNav}
<BottomNav />
{/if}
</div>

34
src/app.css Normal file
View File

@@ -0,0 +1,34 @@
@import "tailwindcss";
@plugin "daisyui" {
themes: hunt --default;
}
[data-theme="hunt"] {
color-scheme: light;
--color-primary: #14b8a6;
--color-primary-content: #ffffff;
--color-secondary: #f97316;
--color-secondary-content: #ffffff;
--color-accent: #fbbf24;
--color-accent-content: #1c1917;
--color-neutral: #374151;
--color-neutral-content: #f9fafb;
--color-base-100: #ffffff;
--color-base-200: #f1f5f9;
--color-base-300: #e2e8f0;
--color-base-content: #1e293b;
--color-info: #0ea5e9;
--color-info-content: #ffffff;
--color-success: #22c55e;
--color-success-content: #ffffff;
--color-warning: #f59e0b;
--color-warning-content: #1c1917;
--color-error: #ef4444;
--color-error-content: #ffffff;
--radius-box: 1rem;
--radius-field: 0.5rem;
--radius-selector: 2rem;
}

10
src/lib/Counter.svelte Normal file
View File

@@ -0,0 +1,10 @@
<script lang="ts">
let count: number = $state(0)
const increment = () => {
count += 1
}
</script>
<button onclick={increment}>
count is {count}
</button>

92
src/lib/api/client.ts Normal file
View File

@@ -0,0 +1,92 @@
import {clearTokens, getAccessToken, getRefreshToken, setAccessToken} from '../stores/auth.svelte'
export const BASE_URL = 'http://localhost:8080'
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message)
}
}
async function attemptRefresh(): Promise<boolean> {
const rt = getRefreshToken()
if (!rt) return false
try {
const res = await fetch(`${BASE_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: rt }),
})
if (!res.ok) {
clearTokens()
return false
}
const data: { accessToken: string } = await res.json()
setAccessToken(data.accessToken)
return true
} catch {
clearTokens()
return false
}
}
async function rawRequest(path: string, init: RequestInit = {}, retry = true): Promise<Response> {
const token = getAccessToken()
const headers = new Headers(init.headers)
if (token) headers.set('Authorization', `Bearer ${token}`)
if (!(init.body instanceof FormData) && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
const res = await fetch(`${BASE_URL}${path}`, { ...init, headers })
if (res.status === 401 && retry) {
const ok = await attemptRefresh()
if (ok) return rawRequest(path, init, false)
clearTokens()
window.location.hash = '/login'
}
return res
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await rawRequest(path, init)
if (!res.ok) {
const text = await res.text().catch(() => res.statusText)
throw new ApiError(res.status, text)
}
const ct = res.headers.get('content-type') ?? ''
if (ct.includes('application/json')) return res.json() as Promise<T>
return undefined as T
}
export const client = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: 'POST',
body: body != null ? JSON.stringify(body) : undefined,
}),
patch: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: 'PATCH',
body: body != null ? JSON.stringify(body) : undefined,
}),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
postFile: <T>(path: string, file: File) => {
const form = new FormData()
form.append('file', file)
return request<T>(path, { method: 'POST', body: form })
},
getBlob: async (path: string): Promise<Blob> => {
const res = await rawRequest(path)
if (!res.ok) throw new ApiError(res.status, res.statusText)
return res.blob()
},
}

116
src/lib/api/index.ts Normal file
View File

@@ -0,0 +1,116 @@
import {BASE_URL, client} from './client'
import type {
HunterLeaderboardResponse,
HunterSummaryResponse,
HuntResponse,
ItemResponse,
LoginResponse,
PhotoResponse,
TeamItemResponse,
TeamLeaderboardResponse,
TeamResponse,
} from './types'
// ── Auth ─────────────────────────────────────────────────────────────────────
export const apiLogin = (email: string, password: string) =>
client.post<LoginResponse>('/auth/login', { email, password })
export const apiLogout = (refreshToken: string) =>
client.post<void>('/auth/logout', { refreshToken })
export const apiSignup = (name: string, email: string, password: string) =>
client.post<void>('/signup', { name, email, password })
// ── Hunts ─────────────────────────────────────────────────────────────────────
export const apiGetHunt = (huntId: string) =>
client.get<HuntResponse>(`/hunt/${huntId}`)
export const apiGetUnstartedHunts = () =>
client.get<HuntResponse[]>('/hunt/unstarted')
export const apiGetOngoingHunts = () =>
client.get<HuntResponse[]>('/hunt/ongoing')
export const apiGetAllHunts = (status?: 'UNSTARTED' | 'ONGOING' | 'CLOSED') =>
client.get<HuntResponse[]>(`/hunt${status ? `?status=${status}` : ''}`)
export const apiCreateHunt = (title: string, startDateTime: string, endDateTime: string) =>
client.post<HuntResponse>('/hunt', { title, startDateTime, endDateTime })
export const apiUpdateHunt = (huntId: string, title: string, startDateTime: string, endDateTime: string, isTerminated: boolean) =>
client.patch<HuntResponse>(`/hunt/${huntId}`, { title, startDateTime, endDateTime, isTerminated })
// ── Hunter ────────────────────────────────────────────────────────────────────
export const apiGetHunterTeam = (huntId: string) =>
client.get<TeamResponse>(`/hunter/hunt/${huntId}/team`)
export const apiJoinTeam = (huntId: string, teamId: string) =>
client.post<void>(`/hunter/hunt/${huntId}/team/${teamId}`)
// ── Teams ─────────────────────────────────────────────────────────────────────
export const apiListTeams = (huntId: string) =>
client.get<TeamResponse[]>(`/hunt/${huntId}/team`)
export const apiCreateTeam = (huntId: string, name: string) =>
client.post<void>(`/hunt/${huntId}/team`, { name })
export const apiGetTeam = (huntId: string, teamId: string) =>
client.get<TeamResponse>(`/hunt/${huntId}/team/${teamId}`)
export const apiGetTeamHunters = (huntId: string, teamId: string) =>
client.get<HunterSummaryResponse[]>(`/hunt/${huntId}/team/${teamId}/hunter`)
// ── Items ─────────────────────────────────────────────────────────────────────
export const apiGetItems = (huntId: string) =>
client.get<ItemResponse[]>(`/hunt/${huntId}/item`)
export const apiAddItem = (huntId: string, name: string, points: number) =>
client.post<ItemResponse>(`/hunt/${huntId}/item`, { name, points })
export const apiUpdateItem = (huntId: string, itemId: string, name: string, points: number) =>
client.patch<ItemResponse>(`/hunt/${huntId}/item/${itemId}`, { name, points })
export const apiDeleteItem = (huntId: string, itemId: string) =>
client.delete<void>(`/hunt/${huntId}/item/${itemId}`)
export const apiGetTeamItem = (huntId: string, teamId: string, itemId: string) =>
client.get<TeamItemResponse>(`/hunt/${huntId}/team/${teamId}/item/${itemId}`)
// ── Photos ────────────────────────────────────────────────────────────────────
export const apiGetItemPhotos = (huntId: string, teamId: string, itemId: string) =>
client.get<PhotoResponse[]>(`/hunt/${huntId}/team/${teamId}/item/${itemId}/photo`)
export const apiSubmitPhoto = (huntId: string, teamId: string, itemId: string, file: File) =>
client.postFile<void>(`/hunt/${huntId}/team/${teamId}/item/${itemId}/photo`, file)
export const apiRemovePhoto = (huntId: string, teamId: string, itemId: string, photoId: string) =>
client.patch<void>(`/hunt/${huntId}/team/${teamId}/item/${itemId}/photo/${photoId}`)
export const apiGetPhotoBlob = (photoId: string, version: 'ORIGINAL' | 'LARGE' | 'MEDIUM' | 'SMALL' = 'MEDIUM') =>
client.getBlob(`/photo/${photoId}/file?version=${version}`)
export function photoUrl(photoId: string, version: 'ORIGINAL' | 'LARGE' | 'MEDIUM' | 'SMALL' = 'MEDIUM') {
return `${BASE_URL}/photo/${photoId}/file?version=${version}`
}
// ── Admin ─────────────────────────────────────────────────────────────────────
export const apiReviewPhoto = (photoId: string, status: 'SUBMITTED' | 'APPROVED' | 'REJECTED' | 'REMOVED') =>
client.patch<void>(`/admin/photo/${photoId}`, { status })
export const apiGetUnreviewedPhotos = (huntId: string) =>
client.get<PhotoResponse[]>(`/admin/hunt/${huntId}/photo/unreviewed`)
// ── Stats ─────────────────────────────────────────────────────────────────────
export const apiGetTeamLeaderboard = (huntId: string) =>
client.get<TeamLeaderboardResponse[]>(`/stats/lead/hunt/${huntId}/team`)
export const apiGetHunterLeaderboard = (huntId: string) =>
client.get<HunterLeaderboardResponse[]>(`/stats/lead/hunt/${huntId}/hunter`)

59
src/lib/api/types.ts Normal file
View File

@@ -0,0 +1,59 @@
export interface HuntResponse {
id: string
title: string
startDateTime: string
endDateTime: string
isTerminated: boolean
terminated?: boolean // Spring may serialize boolean getter isTerminated() as "terminated"
}
export interface TeamResponse {
id: string
name: string
}
export interface ItemResponse {
id: string
name: string
points: number
}
export interface TeamItemResponse {
id: string
itemFoundStatus: 'NOT_FOUND' | 'SUBMITTED' | 'APPROVED' | 'REJECTED'
}
export interface PhotoResponse {
id: string
hunterName: string
photoUploadDateTime: string
photoStatus: 'SUBMITTED' | 'APPROVED' | 'REJECTED' | 'REMOVED'
photoStatusChangeDateTime: string
}
export interface LoginResponse {
accessToken: string
refreshToken: string
name?: string
}
export interface RefreshResponse {
accessToken: string
}
export interface TeamLeaderboardResponse {
rank: number
teamName: string
score: number
}
export interface HunterLeaderboardResponse {
rank: number
hunterName: string
score: number
}
export interface HunterSummaryResponse {
id: string
name: string
}

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import {apiGetPhotoBlob} from '../api/index'
let { photoId, version = 'MEDIUM', alt = 'Photo', class: cls = '' }: {
photoId: string
version?: 'ORIGINAL' | 'LARGE' | 'MEDIUM' | 'SMALL'
alt?: string
class?: string
} = $props()
let src = $state<string | null>(null)
let error = $state(false)
$effect(() => {
let objectUrl: string | null = null
let cancelled = false
error = false
src = null
async function load(attempt = 0) {
try {
const blob = await apiGetPhotoBlob(photoId, version)
if (cancelled) return
if (objectUrl) URL.revokeObjectURL(objectUrl)
objectUrl = URL.createObjectURL(blob)
src = objectUrl
} catch {
if (cancelled) return
if (attempt < 3) {
await new Promise(r => setTimeout(r, 1500 * (attempt + 1)))
if (!cancelled) load(attempt + 1)
} else {
error = true
}
}
}
load()
return () => {
cancelled = true
if (objectUrl) URL.revokeObjectURL(objectUrl)
}
})
</script>
{#if error}
<div class="bg-base-300 flex items-center justify-center {cls}">
<span class="text-base-content/40 text-sm">No image</span>
</div>
{:else if src}
<img {src} {alt} class={cls} />
{:else}
<div class="bg-base-300 animate-pulse {cls}"></div>
{/if}

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import {push, router} from 'svelte-spa-router'
function isActive(path: string) {
return router.location === path || router.location.startsWith(path + '/')
}
const huntId = $derived(
(router.params as Record<string, string> | null)?.huntId ?? null
)
</script>
<nav class="btm-nav bg-base-100 border-t border-base-300 fixed bottom-0 left-0 right-0 z-30 pt-3">
<button class="px-8" class:active={isActive('/')} onclick={() => push('/')}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<span class="btm-nav-label text-xs">Hunts</span>
</button>
{#if huntId}
<button
class="px-8"
class:active={router.location.includes('/leaderboard')}
onclick={() => push(`/hunt/${huntId}/leaderboard`)}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<span class="btm-nav-label text-xs">Scores</span>
</button>
{/if}
<button class="px-8" class:active={isActive('/rules')} onclick={() => push('/rules')}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="btm-nav-label text-xs">Rules</span>
</button>
</nav>

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import { untrack } from 'svelte'
let {
value = $bindable(''),
defaultTimeIndex = 36,
initialValue = '',
}: {
value?: string
defaultTimeIndex?: number
initialValue?: string
} = $props()
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
]
const currentYear = new Date().getFullYear()
const years = Array.from({ length: 5 }, (_, i) => currentYear + i)
const timeOptions = Array.from({ length: 96 }, (_, i) => {
const hour = Math.floor(i / 4)
const minute = (i % 4) * 15
const ampm = hour < 12 ? 'AM' : 'PM'
const displayHour = hour % 12 === 0 ? 12 : hour % 12
return { value: i, label: `${displayHour}:${minute.toString().padStart(2, '0')} ${ampm}` }
})
const seed = untrack(() => {
if (initialValue) {
// Append Z if no timezone indicator is present so it's always parsed as UTC
const normalized = /Z$|[+-]\d{2}:?\d{2}$/.test(initialValue) ? initialValue : initialValue + 'Z'
const d = new Date(normalized)
return { month: d.getMonth(), day: d.getDate(), year: d.getFullYear(), timeIndex: d.getHours() * 4 + Math.round(d.getMinutes() / 15) }
}
const today = new Date()
return { month: today.getMonth(), day: today.getDate(), year: today.getFullYear(), timeIndex: defaultTimeIndex }
})
let month = $state(seed.month)
let day = $state(seed.day)
let year = $state(seed.year)
let timeIndex = $state(seed.timeIndex)
const daysInMonth = $derived(new Date(year, month + 1, 0).getDate())
$effect(() => {
if (day > daysInMonth) day = daysInMonth
})
$effect(() => {
const hour = Math.floor(timeIndex / 4)
const minute = (timeIndex % 4) * 15
value = new Date(year, month, day, hour, minute).toISOString()
})
</script>
<div class="rounded-2xl border border-base-300 bg-base-100 overflow-hidden shadow-sm">
<!-- Date section -->
<div class="px-4 pt-4 pb-3 border-b border-base-200">
<p class="flex items-center gap-1.5 text-primary text-xs font-bold uppercase tracking-widest mb-2.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd" />
</svg>
Date
</p>
<div class="flex gap-2">
<select class="select select-sm bg-base-200 border-0 flex-1 font-medium" bind:value={month}>
{#each months as m, i}
<option value={i}>{m}</option>
{/each}
</select>
<select class="select select-sm bg-base-200 border-0 w-[4.5rem] font-medium" bind:value={day}>
{#each Array.from({ length: daysInMonth }, (_, i) => i + 1) as d}
<option value={d}>{d}</option>
{/each}
</select>
<select class="select select-sm bg-base-200 border-0 w-24 font-medium" bind:value={year}>
{#each years as y}
<option value={y}>{y}</option>
{/each}
</select>
</div>
</div>
<!-- Time section -->
<div class="px-4 pt-3 pb-4">
<p class="flex items-center gap-1.5 text-secondary text-xs font-bold uppercase tracking-widest mb-2.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd" />
</svg>
Time
</p>
<select class="select select-sm bg-base-200 border-0 w-full font-medium" bind:value={timeIndex}>
{#each timeOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
</div>

View File

@@ -0,0 +1,3 @@
<div class="flex justify-center items-center py-16">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
type Status = 'NOT_FOUND' | 'SUBMITTED' | 'APPROVED' | 'REJECTED' | 'REMOVED'
| 'UNSTARTED' | 'ONGOING' | 'CLOSED'
let { status }: { status: Status } = $props()
const config: Record<Status, { label: string; cls: string }> = {
NOT_FOUND: { label: 'Not Found', cls: 'badge-ghost' },
SUBMITTED: { label: 'Submitted', cls: 'badge-warning' },
APPROVED: { label: 'Approved', cls: 'badge-success' },
REJECTED: { label: 'Rejected', cls: 'badge-error' },
REMOVED: { label: 'Removed', cls: 'badge-ghost' },
UNSTARTED: { label: 'Upcoming', cls: 'badge-info' },
ONGOING: { label: 'Active', cls: 'badge-success' },
CLOSED: { label: 'Closed', cls: 'badge-ghost' },
}
const { label, cls } = $derived(config[status] ?? { label: status, cls: 'badge-ghost' })
</script>
<span class="badge {cls} font-medium px-3">{label}</span>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import {push} from 'svelte-spa-router'
import {auth, clearTokens} from '../stores/auth.svelte'
import {apiLogout} from '../api/index'
import {APP_NAME} from '../config'
let { title = APP_NAME }: { title?: string } = $props()
async function handleLogout() {
try {
if (auth.refreshToken) await apiLogout(auth.refreshToken)
} finally {
clearTokens()
push('/login')
}
}
</script>
<nav class="navbar bg-primary text-primary-content shadow-sm sticky top-0 z-30">
<div class="navbar-start">
<span class="text-lg font-bold tracking-tight">{title}</span>
</div>
<div class="navbar-end">
{#if auth.isLoggedIn}
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-circle" aria-label="Account menu">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</button>
<ul class="dropdown-content menu p-2 shadow bg-base-100 text-base-content rounded-box w-48 mt-2">
{#if auth.name}
<li class="menu-title px-3 py-1 text-xs font-semibold text-base-content/50 truncate">{auth.name}</li>
{/if}
{#if auth.isAdmin}
<li><button onclick={() => push('/admin')}>Admin Dashboard</button></li>
{/if}
<li><button onclick={handleLogout}>Log out</button></li>
</ul>
</div>
{/if}
</div>
</nav>

2
src/lib/config.ts Normal file
View File

@@ -0,0 +1,2 @@
// If you change this, also change it in `index.html`
export const APP_NAME = 'Scrounge Quest'

View File

@@ -0,0 +1,43 @@
import {getRoleFromToken, getSubjectFromToken, type UserRole} from '../utils/jwt'
let _accessToken = $state<string | null>(localStorage.getItem('accessToken'))
let _refreshToken = $state<string | null>(localStorage.getItem('refreshToken'))
let _name = $state<string | null>(localStorage.getItem('hunterName'))
export const auth = {
get accessToken() { return _accessToken },
get refreshToken() { return _refreshToken },
get isLoggedIn() { return _accessToken !== null },
get role(): UserRole | null { return _accessToken ? getRoleFromToken(_accessToken) : null },
get isAdmin() { return auth.role === 'ADMIN' },
get subject() { return _accessToken ? getSubjectFromToken(_accessToken) : null },
get name() { return _name },
}
export function setTokens(accessToken: string, refreshToken: string, name?: string) {
_accessToken = accessToken
_refreshToken = refreshToken
localStorage.setItem('accessToken', accessToken)
localStorage.setItem('refreshToken', refreshToken)
if (name !== undefined) {
_name = name
localStorage.setItem('hunterName', name)
}
}
export function setAccessToken(token: string) {
_accessToken = token
localStorage.setItem('accessToken', token)
}
export function getAccessToken() { return _accessToken }
export function getRefreshToken() { return _refreshToken }
export function clearTokens() {
_accessToken = null
_refreshToken = null
_name = null
localStorage.removeItem('accessToken')
localStorage.removeItem('refreshToken')
localStorage.removeItem('hunterName')
}

23
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { HuntResponse } from './api/types'
// Ensures API datetime strings are always parsed as UTC, even if the Z suffix is missing.
export function parseUTC(iso: string): Date {
const normalized = /Z$|[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z'
return new Date(normalized)
}
export function huntStatus(hunt: HuntResponse): 'ONGOING' | 'UNSTARTED' | 'CLOSED' {
if (hunt.isTerminated || hunt.terminated) return 'CLOSED'
const now = Date.now()
if (now < parseUTC(hunt.startDateTime).getTime()) return 'UNSTARTED'
if (now > parseUTC(hunt.endDateTime).getTime()) return 'CLOSED'
return 'ONGOING'
}
export function formatDateTime(iso: string): string {
return parseUTC(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
export function formatDate(iso: string): string {
return parseUTC(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}

20
src/lib/utils/jwt.ts Normal file
View File

@@ -0,0 +1,20 @@
export type UserRole = 'ADMIN' | 'HUNTER'
export function decodeJwt(token: string): Record<string, unknown> | null {
try {
return JSON.parse(atob(token.split('.')[1]))
} catch {
return null
}
}
export function getRoleFromToken(token: string): UserRole {
const payload = decodeJwt(token)
if (!payload) return 'HUNTER'
return payload.isAdmin === true ? 'ADMIN' : 'HUNTER'
}
export function getSubjectFromToken(token: string): string | null {
const payload = decodeJwt(token)
return typeof payload?.sub === 'string' ? payload.sub : null
}

9
src/main.ts Normal file
View File

@@ -0,0 +1,9 @@
import {mount} from 'svelte'
import './app.css'
import App from './App.svelte'
const app = mount(App, {
target: document.getElementById('app')!,
})
export default app

79
src/routes/Login.svelte Normal file
View File

@@ -0,0 +1,79 @@
<script lang="ts">
import {push} from 'svelte-spa-router'
import {apiLogin} from '../lib/api/index'
import {auth, setTokens} from '../lib/stores/auth.svelte'
import {APP_NAME} from '../lib/config'
let email = $state('')
let password = $state('')
let loading = $state(false)
let error = $state('')
$effect(() => {
if (auth.isLoggedIn) push('/')
})
async function handleLogin() {
if (!email || !password) return
loading = true
error = ''
try {
const res = await apiLogin(email, password)
setTokens(res.accessToken, res.refreshToken, res.name)
push('/')
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Login failed'
} finally {
loading = false
}
}
</script>
<div class="min-h-screen bg-base-200 flex items-center justify-center p-4">
<div class="card bg-base-100 shadow-xl w-full max-w-sm">
<div class="card-body gap-4">
<div class="text-center mb-2">
<h1 class="text-3xl font-extrabold text-primary">{APP_NAME}</h1>
<p class="text-base-content/60 mt-1">Sign in to play</p>
</div>
{#if error}
<div class="alert alert-error text-sm">{error}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleLogin() }} class="flex flex-col gap-3">
<label class="floating-label">
<input
type="email"
placeholder="Email"
class="input input-bordered w-full"
bind:value={email}
required
/>
<span>Email</span>
</label>
<label class="floating-label">
<input
type="password"
placeholder="Password"
class="input input-bordered w-full"
bind:value={password}
required
/>
<span>Password</span>
</label>
<button type="submit" class="btn btn-primary w-full mt-2" disabled={loading}>
{#if loading}<span class="loading loading-spinner loading-sm"></span>{/if}
Sign In
</button>
</form>
<p class="text-center text-sm text-base-content/60">
No account?
<a href="#/signup" class="link link-primary font-medium">Sign up</a>
</p>
</div>
</div>
</div>

65
src/routes/Rules.svelte Normal file
View File

@@ -0,0 +1,65 @@
<script lang="ts">
</script>
<div class="max-w-lg mx-auto px-4 py-6">
<h1 class="text-2xl font-bold mb-1">Rules</h1>
<p class="text-base-content/60 text-sm mb-6">How to play fairly and have a great time.</p>
<div class="space-y-3">
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4 px-5">
<h2 class="card-title text-base">1. Photos Must Be Taken Live</h2>
<p class="text-sm text-base-content/70">All photos must be taken during the hunt. No screenshots, stock images, or photos taken before the hunt started.</p>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4 px-5">
<h2 class="card-title text-base">2. Stay Safe and Legal</h2>
<p class="text-sm text-base-content/70">Never trespass on private property or put yourself in danger to get a shot. No clue is worth it.</p>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4 px-5">
<h2 class="card-title text-base">3. Leave No Trace</h2>
<p class="text-sm text-base-content/70">Leave every location exactly as you found it. Be respectful of spaces and the people in them.</p>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4 px-5">
<h2 class="card-title text-base">4. No Sharing Answers</h2>
<p class="text-sm text-base-content/70">Keep your finds to yourself until the hunt is over. Sharing clue answers or locations with other teams is not allowed.</p>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4 px-5">
<h2 class="card-title text-base">5. Photos Must Be Clear</h2>
<p class="text-sm text-base-content/70">Submitted photos must clearly show the required subject. Blurry, cropped, or ambiguous photos may be rejected by the organizer.</p>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4 px-5">
<h2 class="card-title text-base">6. Finish Before Time's Up</h2>
<p class="text-sm text-base-content/70">All submissions must be made before the hunt ends. Late entries will not count toward your score.</p>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4 px-5">
<h2 class="card-title text-base">7. Organizer Decisions Are Final</h2>
<p class="text-sm text-base-content/70">The hunt organizer has final say on whether a photo meets the requirements. Disputes should be raised politely and promptly.</p>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body py-4 px-5">
<h2 class="card-title text-base">8. Have Fun</h2>
<p class="text-sm text-base-content/70">Good sportsmanship is expected from everyone. Celebrate the wins, shake off the misses, and enjoy the adventure.</p>
</div>
</div>
</div>
</div>

95
src/routes/Signup.svelte Normal file
View File

@@ -0,0 +1,95 @@
<script lang="ts">
import {push} from 'svelte-spa-router'
import {apiSignup} from '../lib/api/index'
import {auth} from '../lib/stores/auth.svelte'
let name = $state('')
let email = $state('')
let password = $state('')
let loading = $state(false)
let error = $state('')
let success = $state(false)
$effect(() => {
if (auth.isLoggedIn) push('/')
})
async function handleSignup() {
if (!name || !email || !password) return
loading = true
error = ''
try {
await apiSignup(name, email, password)
success = true
setTimeout(() => push('/login'), 1500)
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Signup failed'
} finally {
loading = false
}
}
</script>
<div class="min-h-screen bg-base-200 flex items-center justify-center p-4">
<div class="card bg-base-100 shadow-xl w-full max-w-sm">
<div class="card-body gap-4">
<div class="text-center mb-2">
<h1 class="text-3xl font-extrabold text-primary">Join the Hunt</h1>
<p class="text-base-content/60 mt-1">Create your account</p>
</div>
{#if success}
<div class="alert alert-success text-sm">Account created! Redirecting to login…</div>
{/if}
{#if error}
<div class="alert alert-error text-sm">{error}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleSignup() }} class="flex flex-col gap-3">
<label class="floating-label">
<input
type="text"
placeholder="Name"
class="input input-bordered w-full"
bind:value={name}
required
/>
<span>Name</span>
</label>
<label class="floating-label">
<input
type="email"
placeholder="Email"
class="input input-bordered w-full"
bind:value={email}
required
/>
<span>Email</span>
</label>
<label class="floating-label">
<input
type="password"
placeholder="Password"
class="input input-bordered w-full"
bind:value={password}
required
/>
<span>Password</span>
</label>
<button type="submit" class="btn btn-primary w-full mt-2" disabled={loading || success}>
{#if loading}<span class="loading loading-spinner loading-sm"></span>{/if}
Create Account
</button>
</form>
<p class="text-center text-sm text-base-content/60">
Already have an account?
<a href="#/login" class="link link-primary font-medium">Sign in</a>
</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import {push} from 'svelte-spa-router'
import {auth} from '../../lib/stores/auth.svelte'
import {huntStatus, formatDate} from '../../lib/utils'
import {apiGetAllHunts, apiGetUnreviewedPhotos} 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')
if (!auth.isAdmin) push('/')
})
let hunts = $state<HuntResponse[]>([])
let loading = $state(true)
let error = $state('')
let unreviewedCounts = $state<Record<string, number>>({})
$effect(() => {
apiGetAllHunts()
.then(async h => {
hunts = h
const counts = await Promise.all(
h.map(hunt =>
apiGetUnreviewedPhotos(hunt.id)
.then(photos => [hunt.id, photos.length] as const)
.catch(() => [hunt.id, 0] as const)
)
)
unreviewedCounts = Object.fromEntries(counts)
})
.catch(e => { error = e instanceof Error ? e.message : 'Failed to load' })
.finally(() => { loading = false })
})
</script>
<div class="p-4 pb-6">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold">Hunts</h1>
<button class="btn btn-primary btn-sm" onclick={() => push('/admin/hunt/create')}>
+ New Hunt
</button>
</div>
{#if loading}
<LoadingSpinner />
{:else if error}
<div class="alert alert-error">{error}</div>
{:else 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">No hunts yet</p>
<button class="btn btn-primary mt-4" onclick={() => push('/admin/hunt/create')}>Create First Hunt</button>
</div>
{:else}
<div class="flex flex-col gap-3">
{#each hunts as hunt}
<div class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body p-4 gap-2">
<div class="flex items-start justify-between gap-2">
<h2 class="font-semibold">{hunt.title}</h2>
<StatusBadge status={huntStatus(hunt)} />
</div>
<p class="text-xs text-base-content/50">
{formatDate(hunt.startDateTime)} {formatDate(hunt.endDateTime)}
</p>
<div class="card-actions justify-end gap-2 mt-1">
<button class="btn btn-outline btn-xs" onclick={() => push(`/admin/hunt/${hunt.id}/review`)}>
Review Photos
{#if (unreviewedCounts[hunt.id] ?? 0) > 0}
<span class="badge badge-error badge-sm">{unreviewedCounts[hunt.id]}</span>
{/if}
</button>
<button class="btn btn-primary btn-xs" onclick={() => push(`/admin/hunt/${hunt.id}`)}>
Manage
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import {push} from 'svelte-spa-router'
import {auth} from '../../lib/stores/auth.svelte'
import {apiCreateHunt} from '../../lib/api/index'
import DateTimePicker from '../../lib/components/DateTimePicker.svelte'
$effect(() => {
if (!auth.isLoggedIn) push('/login')
if (!auth.isAdmin) push('/')
})
let title = $state('')
let startDateTime = $state('')
let endDateTime = $state('')
let loading = $state(false)
let error = $state('')
async function handleCreate() {
if (!title) return
loading = true
error = ''
try {
const hunt = await apiCreateHunt(title, startDateTime, endDateTime)
push(`/admin/hunt/${hunt.id}`)
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Failed to create hunt'
} finally {
loading = false
}
}
</script>
<div class="p-4">
<div class="flex items-center gap-3 mb-6">
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => push('/admin')}>←</button>
<h1 class="text-xl font-bold">New Hunt</h1>
</div>
{#if error}
<div class="alert alert-error mb-4 text-sm">{error}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleCreate() }} class="flex flex-col gap-4">
<label class="form-control">
<span class="label"><span class="label-text font-medium">Hunt Title</span></span>
<input
type="text"
placeholder="e.g. Summer Scavenger Hunt 2026"
class="input input-bordered"
bind:value={title}
required
/>
</label>
<fieldset class="form-control">
<legend class="label"><span class="label-text font-medium">Start</span></legend>
<DateTimePicker bind:value={startDateTime} defaultTimeIndex={36} />
</fieldset>
<fieldset class="form-control">
<legend class="label"><span class="label-text font-medium">End</span></legend>
<DateTimePicker bind:value={endDateTime} defaultTimeIndex={68} />
</fieldset>
<button type="submit" class="btn btn-primary mt-2" disabled={loading}>
{#if loading}<span class="loading loading-spinner loading-sm"></span>{/if}
Create Hunt
</button>
</form>
</div>

View File

@@ -0,0 +1,332 @@
<script lang="ts">
import {push} from 'svelte-spa-router'
import {auth} from '../../lib/stores/auth.svelte'
import {huntStatus} from '../../lib/utils'
import {
apiAddItem,
apiCreateTeam,
apiDeleteItem,
apiGetHunt,
apiGetItems,
apiListTeams,
apiUpdateHunt,
apiUpdateItem
} from '../../lib/api/index'
import type {HuntResponse, ItemResponse, TeamResponse} from '../../lib/api/types'
import StatusBadge from '../../lib/components/StatusBadge.svelte'
import LoadingSpinner from '../../lib/components/LoadingSpinner.svelte'
import DateTimePicker from '../../lib/components/DateTimePicker.svelte'
let { params }: { params: { huntId: string } } = $props()
$effect(() => {
if (!auth.isLoggedIn) push('/login')
if (!auth.isAdmin) push('/')
})
let hunt = $state<HuntResponse | null>(null)
let items = $state<ItemResponse[]>([])
let teams = $state<TeamResponse[]>([])
let loading = $state(true)
let error = $state('')
let newItemName = $state('')
let newItemPoints = $state(10)
let addingItem = $state(false)
let newTeamName = $state('')
let addingTeam = $state(false)
let editingHunt = $state(false)
let editTitle = $state('')
let editStart = $state('')
let editEnd = $state('')
let editTerminated = $state(false)
let savingHunt = $state(false)
function startEditHunt() {
if (!hunt) return
editTitle = hunt.title
editStart = hunt.startDateTime
editEnd = hunt.endDateTime
editTerminated = hunt.isTerminated || hunt.terminated || false
editingHunt = true
}
function cancelEditHunt() {
editingHunt = false
}
async function saveHunt() {
if (!hunt || !editTitle.trim() || !editStart || !editEnd) return
savingHunt = true
error = ''
try {
hunt = await apiUpdateHunt(params.huntId, editTitle.trim(), editStart, editEnd, editTerminated)
editingHunt = false
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Failed to update hunt'
} finally {
savingHunt = false
}
}
let editingItemId = $state<string | null>(null)
let editName = $state('')
let editPoints = $state(0)
let savingItem = $state(false)
function startEdit(item: ItemResponse) {
editingItemId = item.id
editName = item.name
editPoints = item.points
}
function cancelEdit() {
editingItemId = null
}
let deletingItemId = $state<string | null>(null)
async function deleteItem(item: ItemResponse) {
if (!confirm(`Delete "${item.name}"? This cannot be undone.`)) return
deletingItemId = item.id
try {
await apiDeleteItem(params.huntId, item.id)
items = items.filter(i => i.id !== item.id)
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Failed to delete item'
} finally {
deletingItemId = null
}
}
async function saveItem(item: ItemResponse) {
if (!editName.trim() || editPoints < 1) return
savingItem = true
try {
const updated = await apiUpdateItem(params.huntId, item.id, editName.trim(), editPoints)
items = items.map(i => i.id === updated.id ? updated : i)
editingItemId = null
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Failed to update item'
} finally {
savingItem = false
}
}
$effect(() => {
const { huntId } = params
Promise.all([apiGetHunt(huntId), apiGetItems(huntId), apiListTeams(huntId)])
.then(([h, i, t]) => { hunt = h; items = i; teams = t })
.catch(e => { error = e instanceof Error ? e.message : 'Failed to load' })
.finally(() => { loading = false })
})
async function addItem() {
if (!newItemName || newItemPoints < 1) return
addingItem = true
try {
const item = await apiAddItem(params.huntId, newItemName, newItemPoints)
items = [...items, item]
newItemName = ''
newItemPoints = 10
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Failed to add item'
} finally {
addingItem = false
}
}
async function addTeam() {
if (!newTeamName) return
addingTeam = true
try {
await apiCreateTeam(params.huntId, newTeamName)
teams = await apiListTeams(params.huntId)
newTeamName = ''
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Failed to create team'
} finally {
addingTeam = false
}
}
</script>
<div class="p-4 pb-6">
<div class="flex items-center gap-3 mb-4">
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => push('/admin')}>←</button>
<div class="flex-1 min-w-0">
{#if hunt}
<h1 class="text-lg font-bold leading-tight truncate">{hunt.title}</h1>
<StatusBadge status={huntStatus(hunt)} />
{/if}
</div>
{#if hunt && !editingHunt}
<button class="btn btn-outline btn-sm shrink-0" onclick={startEditHunt}>Edit Hunt</button>
{/if}
</div>
{#if editingHunt && hunt}
<div class="bg-base-200 rounded-2xl p-4 mb-4 flex flex-col gap-4">
<h2 class="font-bold text-sm uppercase tracking-wide text-base-content/50">Edit Hunt</h2>
{#if error}
<div class="alert alert-error text-sm">{error}</div>
{/if}
<label class="form-control">
<span class="label-text text-xs font-medium mb-1">Title</span>
<input type="text" class="input input-bordered input-sm" bind:value={editTitle} disabled={savingHunt} required />
</label>
<fieldset class="form-control">
<legend class="label-text text-xs font-medium mb-1">Start</legend>
<DateTimePicker bind:value={editStart} initialValue={editStart} />
</fieldset>
<fieldset class="form-control">
<legend class="label-text text-xs font-medium mb-1">End</legend>
<DateTimePicker bind:value={editEnd} initialValue={editEnd} />
</fieldset>
<button
type="button"
class="flex items-center justify-between p-4 rounded-xl border-2 transition-all {editTerminated ? 'border-error bg-error/10' : 'border-base-300 bg-base-100'}"
onclick={() => editTerminated = !editTerminated}
disabled={savingHunt}
>
<div class="text-left">
<p class="font-semibold {editTerminated ? 'text-error' : 'text-base-content'}">Terminated</p>
<p class="text-xs text-base-content/50">Permanently closes the hunt for all hunters</p>
</div>
<div class="w-6 h-6 rounded-full border-2 flex items-center justify-center shrink-0 {editTerminated ? 'border-error bg-error' : 'border-base-300'}">
{#if editTerminated}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-error-content" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 00-1.414 0L8 12.586 4.707 9.293a1 1 0 00-1.414 1.414l4 4a1 1 0 001.414 0l8-8a1 1 0 000-1.414z" clip-rule="evenodd" />
</svg>
{/if}
</div>
</button>
<div class="flex gap-2">
<button class="btn btn-primary btn-sm flex-1" onclick={saveHunt} disabled={savingHunt}>
{#if savingHunt}<span class="loading loading-spinner loading-xs"></span>{/if}
Save
</button>
<button class="btn btn-ghost btn-sm" onclick={cancelEditHunt} disabled={savingHunt}>Cancel</button>
</div>
</div>
{/if}
{#if loading}
<LoadingSpinner />
{:else}
{#if error && !editingHunt}
<div class="alert alert-error mb-4 text-sm">{error}</div>
{/if}
<!-- Items -->
<section class="mb-6">
<h2 class="font-bold text-base mb-3">Items ({items.length})</h2>
<form onsubmit={(e) => { e.preventDefault(); addItem() }} class="flex gap-2 mb-3">
<input
type="text"
placeholder="Item name"
class="input input-bordered input-sm flex-1"
bind:value={newItemName}
required
/>
<input
type="number"
placeholder="pts"
class="input input-bordered input-sm w-20"
bind:value={newItemPoints}
min="1"
required
/>
<button type="submit" class="btn btn-primary btn-sm" disabled={addingItem}>
{#if addingItem}<span class="loading loading-spinner loading-xs"></span>{:else}Add{/if}
</button>
</form>
<div class="flex flex-col gap-2">
{#each items as item}
<div class="bg-base-100 rounded-xl shadow-sm border border-base-200 px-4 py-3">
{#if editingItemId === item.id}
<form onsubmit={(e) => { e.preventDefault(); saveItem(item) }} class="flex gap-2 items-center">
<input
type="text"
class="input input-bordered input-sm flex-1"
bind:value={editName}
required
disabled={savingItem}
/>
<input
type="number"
class="input input-bordered input-sm w-20"
bind:value={editPoints}
min="1"
required
disabled={savingItem}
/>
<button type="submit" class="btn btn-primary btn-sm" disabled={savingItem}>
{#if savingItem}<span class="loading loading-spinner loading-xs"></span>{:else}Save{/if}
</button>
<button type="button" class="btn btn-ghost btn-sm" onclick={cancelEdit} disabled={savingItem}></button>
</form>
{:else}
<div class="flex items-center justify-between">
<span class="font-medium">{item.name}</span>
<div class="flex items-center gap-2">
<span class="badge badge-outline">{item.points} pts</span>
<button class="btn btn-ghost btn-xs" onclick={() => startEdit(item)} disabled={deletingItemId === item.id}>Edit</button>
<button class="btn btn-ghost btn-xs text-error" onclick={() => deleteItem(item)} disabled={deletingItemId === item.id}>
{#if deletingItemId === item.id}<span class="loading loading-spinner loading-xs"></span>{:else}Delete{/if}
</button>
</div>
</div>
{/if}
</div>
{/each}
{#if items.length === 0}
<p class="text-base-content/50 text-sm text-center py-4">No items yet — add one above</p>
{/if}
</div>
</section>
<!-- Teams -->
<section>
<h2 class="font-bold text-base mb-3">Teams ({teams.length})</h2>
<form onsubmit={(e) => { e.preventDefault(); addTeam() }} class="flex gap-2 mb-3">
<input
type="text"
placeholder="Team name"
class="input input-bordered input-sm flex-1"
bind:value={newTeamName}
required
/>
<button type="submit" class="btn btn-secondary btn-sm" disabled={addingTeam}>
{#if addingTeam}<span class="loading loading-spinner loading-xs"></span>{:else}Add{/if}
</button>
</form>
<div class="flex flex-col gap-2">
{#each teams as team}
<div class="flex items-center justify-between bg-base-100 rounded-xl px-4 py-3 shadow-sm border border-base-200">
<span class="font-medium">{team.name}</span>
<button class="btn btn-ghost btn-xs" onclick={() => push(`/admin/hunt/${params.huntId}/review?teamId=${team.id}`)}>
Review →
</button>
</div>
{/each}
{#if teams.length === 0}
<p class="text-base-content/50 text-sm text-center py-4">No teams yet — add one above</p>
{/if}
</div>
</section>
<div class="mt-6">
<button class="btn btn-outline w-full" onclick={() => push(`/admin/hunt/${params.huntId}/review`)}>
Review All Photos →
</button>
</div>
{/if}
</div>

View File

@@ -0,0 +1,297 @@
<script lang="ts">
import {push} from 'svelte-spa-router'
import {auth} from '../../lib/stores/auth.svelte'
import {apiGetHunt, apiGetItemPhotos, apiGetItems, apiGetUnreviewedPhotos, apiListTeams, apiReviewPhoto} from '../../lib/api/index'
import {parseUTC} from '../../lib/utils'
import type {HuntResponse, ItemResponse, PhotoResponse, 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')
if (!auth.isAdmin) push('/')
})
type PhotoWithContext = PhotoResponse & { teamName: string; itemName: string }
let hunt = $state<HuntResponse | null>(null)
let teams = $state<TeamResponse[]>([])
let items = $state<ItemResponse[]>([])
let selectedItem = $state<ItemResponse | null>(null)
let photos = $state<PhotoWithContext[]>([])
let unreviewedPhotos = $state<PhotoWithContext[]>([])
let loading = $state(true)
let photosLoading = $state(false)
let error = $state('')
let reviewing = $state<string | null>(null)
let expandedPhoto = $state<PhotoWithContext | null>(null)
$effect(() => {
const { huntId } = params
Promise.all([apiGetHunt(huntId), apiListTeams(huntId), apiGetItems(huntId), apiGetUnreviewedPhotos(huntId)])
.then(async ([h, t, i, unreviewed]) => {
hunt = h
teams = t
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' })
.finally(() => { loading = false })
})
$effect(() => {
if (selectedItem) {
photosLoading = true
photos = []
Promise.all(
teams.map(team =>
apiGetItemPhotos(params.huntId, team.id, selectedItem!.id)
.then(ps => ps.map(p => ({ ...p, teamName: team.name, itemName: selectedItem!.name })))
)
)
.then(results => { photos = results.flat() })
.catch(e => { error = e instanceof Error ? e.message : 'Failed to load photos' })
.finally(() => { photosLoading = false })
}
})
async function review(photo: PhotoWithContext, status: 'APPROVED' | 'REJECTED' | 'REMOVED') {
reviewing = photo.id
try {
await apiReviewPhoto(photo.id, status)
const updated = { ...photo, photoStatus: status } as PhotoWithContext
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
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Failed to review photo'
} finally {
reviewing = null
}
}
const activePhotos = $derived(photos.filter(p => p.photoStatus !== 'REMOVED'))
const activeUnreviewed = $derived(unreviewedPhotos.filter(p => p.photoStatus === 'SUBMITTED'))
</script>
<!-- Lightbox -->
{#if expandedPhoto}
{@const photo = expandedPhoto}
<div
class="fixed inset-0 z-50 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="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="text-xs text-white/40">{parseUTC(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>
<div class="px-4 py-4 shrink-0">
{#if photo.photoStatus === 'SUBMITTED'}
<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-white/40" onclick={() => review(photo, 'REMOVED')} disabled={reviewing === photo.id}>
Remove
</button>
{:else if photo.photoStatus === 'APPROVED'}
<div class="flex gap-2">
<button class="btn btn-outline btn-error btn-sm flex-1" onclick={() => review(photo, 'REJECTED')} disabled={reviewing === photo.id}>
Revoke Approval
</button>
<button class="btn btn-ghost btn-sm text-white/40" onclick={() => review(photo, 'REMOVED')} disabled={reviewing === photo.id}>
Remove
</button>
</div>
{:else if photo.photoStatus === 'REJECTED'}
<div class="flex gap-2">
<button class="btn btn-outline btn-success btn-sm flex-1" onclick={() => review(photo, 'APPROVED')} disabled={reviewing === photo.id}>
Approve Instead
</button>
<button class="btn btn-ghost btn-sm text-white/40" onclick={() => review(photo, 'REMOVED')} disabled={reviewing === photo.id}>
Remove
</button>
</div>
{/if}
</div>
</div>
{/if}
<div class="p-4 pb-6">
<div class="flex items-center gap-3 mb-4">
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => push(`/admin/hunt/${params.huntId}`)}>←</button>
<h1 class="text-lg font-bold">{hunt?.title ?? 'Photo Review'}</h1>
</div>
{#if error}
<div class="alert alert-error mb-4 text-sm">{error}</div>
{/if}
{#if loading}
<LoadingSpinner />
{:else}
<!-- Item selector -->
<div class="mb-4">
<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">
{#each items as item}
<button
class="btn btn-sm"
class:btn-secondary={selectedItem?.id === item.id}
class:btn-outline={selectedItem?.id !== item.id}
onclick={() => { selectedItem = selectedItem?.id === item.id ? null : item }}
>
{item.name}
</button>
{/each}
</div>
</div>
{#if selectedItem}
<!-- Per-item photo view -->
{#if photosLoading}
<LoadingSpinner />
{:else if activePhotos.length === 0}
<div class="text-center py-10 text-base-content/50">
<p>No photos submitted for this item</p>
</div>
{:else}
<div class="flex flex-col gap-4">
{#each activePhotos 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">
<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>
{#if photo.photoStatus === 'SUBMITTED'}
<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>
{:else if photo.photoStatus === 'APPROVED'}
<div class="flex gap-2">
<button class="btn btn-outline btn-error btn-sm flex-1" onclick={() => review(photo, 'REJECTED')} disabled={reviewing === photo.id}>
Revoke Approval
</button>
<button class="btn btn-ghost btn-sm text-base-content/50" onclick={() => review(photo, 'REMOVED')} disabled={reviewing === photo.id}>
Remove
</button>
</div>
{:else if photo.photoStatus === 'REJECTED'}
<div class="flex gap-2">
<button class="btn btn-outline btn-success btn-sm flex-1" onclick={() => review(photo, 'APPROVED')} disabled={reviewing === photo.id}>
Approve Instead
</button>
<button class="btn btn-ghost btn-sm text-base-content/50" onclick={() => review(photo, 'REMOVED')} disabled={reviewing === photo.id}>
Remove
</button>
</div>
{/if}
</div>
</div>
{/each}
</div>
{/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}
</div>

View File

@@ -0,0 +1,327 @@
<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}

View File

@@ -0,0 +1,519 @@
<script lang="ts">
import {push} from 'svelte-spa-router'
import {fade} from 'svelte/transition'
import {auth} from '../../lib/stores/auth.svelte'
import {huntStatus, parseUTC} from '../../lib/utils'
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')
}
}
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-200 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">{parseUTC(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-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">{selectedItem.name}</h3>
<p class="text-xs text-base-content/50">{selectedItem.points} points</p>
</div>
<button class="btn btn-outline btn-sm" onclick={closeSheet}>Done</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-100 overflow-hidden">
<button class="w-full cursor-zoom-in" onclick={() => expandedPhoto = photo}>
<AuthImage photoId={photo.id} version="SMALL" alt={photo.hunterName} class="w-full max-h-64 object-contain bg-base-300" />
</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}

View 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>

View 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>

8
svelte.config.js Normal file
View File

@@ -0,0 +1,8 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
}

21
tsconfig.app.json Normal file
View File

@@ -0,0 +1,21 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"types": ["svelte", "vite/client"],
"noEmit": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"moduleDetection": "force"
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss(), svelte()],
})