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

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
}