First crack at the app. Still lots of bugs to squash.
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user