First crack at the app. Still lots of bugs to squash.

This commit is contained in:
2026-05-17 23:30:08 -05:00
parent cfd936e5fa
commit 435481549c
35 changed files with 4029 additions and 0 deletions

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()
},
}

106
src/lib/api/index.ts Normal file
View 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
View 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
}

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

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

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

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')
}

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
}