First crack at the app. Still lots of bugs to squash.
This commit is contained in:
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!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" />
|
||||||
|
<title>Scavenger Hunt</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1985
package-lock.json
generated
Normal file
1985
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal 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": "^6.2.1",
|
||||||
|
"@tsconfig/svelte": "^5.0.6",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"svelte": "^5.43.8",
|
||||||
|
"svelte-check": "^4.3.4",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"daisyui": "^5.5.19",
|
||||||
|
"svelte-spa-router": "^5.1.0",
|
||||||
|
"tailwindcss": "^4.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
public/favicon.svg
Normal file
8
public/favicon.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<!-- Magnifying glass circle -->
|
||||||
|
<circle cx="26" cy="26" r="18" stroke="#14b8a6" stroke-width="5" fill="#ffffff"/>
|
||||||
|
<!-- Magnifying glass handle -->
|
||||||
|
<line x1="39" y1="39" x2="56" y2="56" stroke="#f97316" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<!-- SH text inside the lens -->
|
||||||
|
<text x="26" y="31" text-anchor="middle" font-family="Georgia, serif" font-size="15" font-weight="700" fill="#14b8a6">SH</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 498 B |
47
src/App.svelte
Normal file
47
src/App.svelte
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {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'
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
34
src/app.css
Normal 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
10
src/lib/Counter.svelte
Normal 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
92
src/lib/api/client.ts
Normal 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()
|
||||||
|
},
|
||||||
|
}
|
||||||
106
src/lib/api/index.ts
Normal file
106
src/lib/api/index.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import {BASE_URL, client} from './client'
|
||||||
|
import type {
|
||||||
|
HunterLeaderboardResponse,
|
||||||
|
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 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 })
|
||||||
|
|
||||||
|
// ── Hunter ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const apiGetOngoingHunts = () =>
|
||||||
|
client.get<HuntResponse[]>('/hunter/hunt/ongoing')
|
||||||
|
|
||||||
|
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}`)
|
||||||
|
|
||||||
|
// ── 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 })
|
||||||
|
|
||||||
|
// ── 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`)
|
||||||
53
src/lib/api/types.ts
Normal file
53
src/lib/api/types.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export interface HuntResponse {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
startDateTime: string
|
||||||
|
endDateTime: string
|
||||||
|
isTerminated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
55
src/lib/components/AuthImage.svelte
Normal file
55
src/lib/components/AuthImage.svelte
Normal 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}
|
||||||
32
src/lib/components/BottomNav.svelte
Normal file
32
src/lib/components/BottomNav.svelte
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="btm-nav btm-nav-sm bg-base-100 border-t border-base-300 fixed bottom-0 left-0 right-0 z-30">
|
||||||
|
<button class:active={isActive('/')} onclick={() => push('/')}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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:active={router.location.includes('/leaderboard')}
|
||||||
|
onclick={() => push(`/hunt/${huntId}/leaderboard`)}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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}
|
||||||
|
</div>
|
||||||
88
src/lib/components/DateTimePicker.svelte
Normal file
88
src/lib/components/DateTimePicker.svelte
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
defaultTimeIndex = 36,
|
||||||
|
}: {
|
||||||
|
value?: string
|
||||||
|
defaultTimeIndex?: number
|
||||||
|
} = $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 today = new Date()
|
||||||
|
let month = $state(today.getMonth())
|
||||||
|
let day = $state(today.getDate())
|
||||||
|
let year = $state(today.getFullYear())
|
||||||
|
let timeIndex = $state(defaultTimeIndex)
|
||||||
|
|
||||||
|
const daysInMonth = $derived(new Date(year, month + 1, 0).getDate())
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (day > daysInMonth) day = daysInMonth
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||||
|
const hour = Math.floor(timeIndex / 4)
|
||||||
|
const minute = (timeIndex % 4) * 15
|
||||||
|
value = new Date(`${year}-${pad(month + 1)}-${pad(day)}T${pad(hour)}:${pad(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>
|
||||||
3
src/lib/components/LoadingSpinner.svelte
Normal file
3
src/lib/components/LoadingSpinner.svelte
Normal 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>
|
||||||
21
src/lib/components/StatusBadge.svelte
Normal file
21
src/lib/components/StatusBadge.svelte
Normal 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>
|
||||||
42
src/lib/components/TopBar.svelte
Normal file
42
src/lib/components/TopBar.svelte
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {push} from 'svelte-spa-router'
|
||||||
|
import {auth, clearTokens} from '../stores/auth.svelte'
|
||||||
|
import {apiLogout} from '../api/index'
|
||||||
|
|
||||||
|
let { title = 'Scavenger Hunt' }: { title?: string } = $props()
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
if (auth.refreshToken) await apiLogout(auth.refreshToken)
|
||||||
|
} finally {
|
||||||
|
clearTokens()
|
||||||
|
push('/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div 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>
|
||||||
|
</div>
|
||||||
43
src/lib/stores/auth.svelte.ts
Normal file
43
src/lib/stores/auth.svelte.ts
Normal 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')
|
||||||
|
}
|
||||||
20
src/lib/utils/jwt.ts
Normal file
20
src/lib/utils/jwt.ts
Normal 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
9
src/main.ts
Normal 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
|
||||||
78
src/routes/Login.svelte
Normal file
78
src/routes/Login.svelte
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {push} from 'svelte-spa-router'
|
||||||
|
import {apiLogin} from '../lib/api/index'
|
||||||
|
import {auth, setTokens} from '../lib/stores/auth.svelte'
|
||||||
|
|
||||||
|
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">Scavenger Hunt</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>
|
||||||
95
src/routes/Signup.svelte
Normal file
95
src/routes/Signup.svelte
Normal 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>
|
||||||
81
src/routes/admin/AdminHome.svelte
Normal file
81
src/routes/admin/AdminHome.svelte
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {push} from 'svelte-spa-router'
|
||||||
|
import {auth} from '../../lib/stores/auth.svelte'
|
||||||
|
import {apiGetAllHunts} 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('')
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
apiGetAllHunts()
|
||||||
|
.then(h => { hunts = h })
|
||||||
|
.catch(e => { error = e instanceof Error ? e.message : 'Failed to load' })
|
||||||
|
.finally(() => { loading = false })
|
||||||
|
})
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dt: string) {
|
||||||
|
return new Date(dt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
}
|
||||||
|
</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
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-xs" onclick={() => push(`/admin/hunt/${hunt.id}`)}>
|
||||||
|
Manage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
70
src/routes/admin/HuntCreate.svelte
Normal file
70
src/routes/admin/HuntCreate.svelte
Normal 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">
|
||||||
|
<div class="label"><span class="label-text font-medium">Hunt Title</span></div>
|
||||||
|
<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>
|
||||||
253
src/routes/admin/HuntManage.svelte
Normal file
253
src/routes/admin/HuntManage.svelte
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {push} from 'svelte-spa-router'
|
||||||
|
import {auth} from '../../lib/stores/auth.svelte'
|
||||||
|
import {
|
||||||
|
apiAddItem,
|
||||||
|
apiCreateTeam,
|
||||||
|
apiDeleteItem,
|
||||||
|
apiGetHunt,
|
||||||
|
apiGetItems,
|
||||||
|
apiListTeams,
|
||||||
|
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'
|
||||||
|
|
||||||
|
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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
{#if hunt}
|
||||||
|
<h1 class="text-lg font-bold leading-tight">{hunt.title}</h1>
|
||||||
|
<StatusBadge status={huntStatus(hunt)} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<LoadingSpinner />
|
||||||
|
{:else}
|
||||||
|
{#if error}
|
||||||
|
<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>
|
||||||
177
src/routes/admin/PhotoReview.svelte
Normal file
177
src/routes/admin/PhotoReview.svelte
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {push, router} from 'svelte-spa-router'
|
||||||
|
import {auth} from '../../lib/stores/auth.svelte'
|
||||||
|
import {apiGetHunt, apiGetItemPhotos, apiGetItems, apiListTeams, apiReviewPhoto} from '../../lib/api/index'
|
||||||
|
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('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
let hunt = $state<HuntResponse | null>(null)
|
||||||
|
let teams = $state<TeamResponse[]>([])
|
||||||
|
let items = $state<ItemResponse[]>([])
|
||||||
|
let selectedTeam = $state<TeamResponse | null>(null)
|
||||||
|
let selectedItem = $state<ItemResponse | null>(null)
|
||||||
|
let photos = $state<PhotoResponse[]>([])
|
||||||
|
let loading = $state(true)
|
||||||
|
let photosLoading = $state(false)
|
||||||
|
let error = $state('')
|
||||||
|
let reviewing = $state<string | null>(null)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const { huntId } = params
|
||||||
|
Promise.all([apiGetHunt(huntId), apiListTeams(huntId), apiGetItems(huntId)])
|
||||||
|
.then(([h, t, i]) => {
|
||||||
|
hunt = h
|
||||||
|
teams = t
|
||||||
|
items = i
|
||||||
|
// Pre-select team from query string if provided
|
||||||
|
const qs = new URLSearchParams(router.querystring ?? '')
|
||||||
|
const preTeam = qs.get('teamId')
|
||||||
|
if (preTeam) {
|
||||||
|
selectedTeam = t.find(x => x.id === preTeam) ?? null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => { error = e instanceof Error ? e.message : 'Failed to load' })
|
||||||
|
.finally(() => { loading = false })
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (selectedTeam && selectedItem) {
|
||||||
|
photosLoading = true
|
||||||
|
photos = []
|
||||||
|
apiGetItemPhotos(params.huntId, selectedTeam.id, selectedItem.id)
|
||||||
|
.then(p => { photos = p })
|
||||||
|
.catch(e => { error = e instanceof Error ? e.message : 'Failed to load photos' })
|
||||||
|
.finally(() => { photosLoading = false })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function review(photo: PhotoResponse, status: 'APPROVED' | 'REJECTED') {
|
||||||
|
reviewing = photo.id
|
||||||
|
try {
|
||||||
|
await apiReviewPhoto(photo.id, status)
|
||||||
|
photos = photos.map(p => p.id === photo.id ? { ...p, photoStatus: status } : p)
|
||||||
|
} 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'))
|
||||||
|
</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/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}
|
||||||
|
<!-- Team selector -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="text-sm font-medium text-base-content/60 mb-2">Select Team</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each teams as team}
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
class:btn-primary={selectedTeam?.id === team.id}
|
||||||
|
class:btn-outline={selectedTeam?.id !== team.id}
|
||||||
|
onclick={() => { selectedTeam = team; selectedItem = null; photos = [] }}
|
||||||
|
>
|
||||||
|
{team.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selectedTeam}
|
||||||
|
<!-- Item selector -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="text-sm font-medium text-base-content/60 mb-2">Select Item</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 = item }}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedTeam && selectedItem}
|
||||||
|
{#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}
|
||||||
|
<div class="card bg-base-100 shadow-sm border border-base-200 overflow-hidden">
|
||||||
|
<AuthImage photoId={photo.id} version="LARGE" alt={photo.hunterName} class="w-full h-56 object-cover" />
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold">{photo.hunterName}</p>
|
||||||
|
<p class="text-xs text-base-content/50">
|
||||||
|
{new Date(photo.photoUploadDateTime).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={photo.photoStatus} />
|
||||||
|
</div>
|
||||||
|
{#if photo.photoStatus === 'SUBMITTED'}
|
||||||
|
<div class="flex gap-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>
|
||||||
|
{:else if photo.photoStatus === 'APPROVED'}
|
||||||
|
<button class="btn btn-outline btn-error btn-sm w-full" onclick={() => review(photo, 'REJECTED')} disabled={reviewing === photo.id}>
|
||||||
|
Revoke Approval
|
||||||
|
</button>
|
||||||
|
{:else if photo.photoStatus === 'REJECTED'}
|
||||||
|
<button class="btn btn-outline btn-success btn-sm w-full" onclick={() => review(photo, 'APPROVED')} disabled={reviewing === photo.id}>
|
||||||
|
Approve Instead
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
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>
|
||||||
8
svelte.config.js
Normal file
8
svelte.config.js
Normal 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
21
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal 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
7
vite.config.ts
Normal 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()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user