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