Compare commits
19 Commits
5aeb6818f8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c6f1475b84 | |||
| 2c7cb00745 | |||
| ef8ab903ee | |||
| 44a5805b2b | |||
| efab8b3083 | |||
| 46060fc150 | |||
| 1772081495 | |||
| aae0c3ff8d | |||
| 86866f4db7 | |||
| 6e4edd96ce | |||
| fd4d86db5b | |||
| 3a0a0ae791 | |||
| 63d027ebb7 | |||
| 53706b4035 | |||
| 67fe81dc41 | |||
| 2ba8b60063 | |||
| 6191c6c804 | |||
| 2c614a2d46 | |||
| 51a9448fdb |
@@ -4,7 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Scavenger Hunt</title>
|
<!-- App name is defined in src/lib/config.ts — keep in sync -->
|
||||||
|
<title>Scrounge Quest</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,8 +1,30 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
<!-- Magnifying glass circle -->
|
<!-- Shield (navy blue, gold border) -->
|
||||||
<circle cx="26" cy="26" r="18" stroke="#14b8a6" stroke-width="5" fill="#ffffff"/>
|
<path d="M8,5 H56 V36 Q56,57 32,63 Q8,57 8,36 Z" fill="#1e3a8a"/>
|
||||||
<!-- Magnifying glass handle -->
|
<path d="M8,5 H56 V36 Q56,57 32,63 Q8,57 8,36 Z" fill="none" stroke="#d4a017" stroke-width="2.5"/>
|
||||||
<line x1="39" y1="39" x2="56" y2="56" stroke="#f97316" stroke-width="6" stroke-linecap="round"/>
|
|
||||||
<!-- SH text inside the lens -->
|
<!-- Bushy tail — drawn first so body sits on top of it -->
|
||||||
<text x="26" y="31" text-anchor="middle" font-family="Georgia, serif" font-size="15" font-weight="700" fill="#14b8a6">SH</text>
|
<path d="M30,44 C46,46 58,34 54,18 C50,6 36,6 32,16"
|
||||||
|
fill="none" stroke="#d4a017" stroke-width="11" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Body (upright) -->
|
||||||
|
<ellipse cx="24" cy="37" rx="7.5" ry="10.5" fill="#d4a017"/>
|
||||||
|
|
||||||
|
<!-- Head -->
|
||||||
|
<circle cx="24" cy="22" r="6.5" fill="#d4a017"/>
|
||||||
|
|
||||||
|
<!-- Ears -->
|
||||||
|
<polygon points="19,17 16,9 23,14" fill="#d4a017"/>
|
||||||
|
<polygon points="26,16 28,8 32,12" fill="#d4a017"/>
|
||||||
|
|
||||||
|
<!-- Eye -->
|
||||||
|
<circle cx="22" cy="21" r="1.5" fill="#1e3a8a"/>
|
||||||
|
|
||||||
|
<!-- Front paws raised (rearing up) -->
|
||||||
|
<line x1="18" y1="30" x2="12" y2="22" stroke="#d4a017" stroke-width="4.5" stroke-linecap="round"/>
|
||||||
|
<line x1="30" y1="28" x2="37" y2="20" stroke="#d4a017" stroke-width="4.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Hind legs -->
|
||||||
|
<line x1="18" y1="46" x2="16" y2="54" stroke="#d4a017" stroke-width="4.5" stroke-linecap="round"/>
|
||||||
|
<line x1="30" y1="47" x2="33" y2="54" stroke="#d4a017" stroke-width="4.5" stroke-linecap="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 498 B After Width: | Height: | Size: 1.3 KiB |
@@ -1,6 +1,7 @@
|
|||||||
import {BASE_URL, client} from './client'
|
import {BASE_URL, client} from './client'
|
||||||
import type {
|
import type {
|
||||||
HunterLeaderboardResponse,
|
HunterLeaderboardResponse,
|
||||||
|
HunterSummaryResponse,
|
||||||
HuntResponse,
|
HuntResponse,
|
||||||
ItemResponse,
|
ItemResponse,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
@@ -29,16 +30,19 @@ export const apiGetHunt = (huntId: string) =>
|
|||||||
export const apiGetUnstartedHunts = () =>
|
export const apiGetUnstartedHunts = () =>
|
||||||
client.get<HuntResponse[]>('/hunt/unstarted')
|
client.get<HuntResponse[]>('/hunt/unstarted')
|
||||||
|
|
||||||
|
export const apiGetOngoingHunts = () =>
|
||||||
|
client.get<HuntResponse[]>('/hunt/ongoing')
|
||||||
|
|
||||||
export const apiGetAllHunts = (status?: 'UNSTARTED' | 'ONGOING' | 'CLOSED') =>
|
export const apiGetAllHunts = (status?: 'UNSTARTED' | 'ONGOING' | 'CLOSED') =>
|
||||||
client.get<HuntResponse[]>(`/hunt${status ? `?status=${status}` : ''}`)
|
client.get<HuntResponse[]>(`/hunt${status ? `?status=${status}` : ''}`)
|
||||||
|
|
||||||
export const apiCreateHunt = (title: string, startDateTime: string, endDateTime: string) =>
|
export const apiCreateHunt = (title: string, startDateTime: string, endDateTime: string) =>
|
||||||
client.post<HuntResponse>('/hunt', { title, startDateTime, endDateTime })
|
client.post<HuntResponse>('/hunt', { title, startDateTime, endDateTime })
|
||||||
|
|
||||||
// ── Hunter ────────────────────────────────────────────────────────────────────
|
export const apiUpdateHunt = (huntId: string, title: string, startDateTime: string, endDateTime: string, isTerminated: boolean) =>
|
||||||
|
client.patch<HuntResponse>(`/hunt/${huntId}`, { title, startDateTime, endDateTime, isTerminated })
|
||||||
|
|
||||||
export const apiGetOngoingHunts = () =>
|
// ── Hunter ────────────────────────────────────────────────────────────────────
|
||||||
client.get<HuntResponse[]>('/hunter/hunt/ongoing')
|
|
||||||
|
|
||||||
export const apiGetHunterTeam = (huntId: string) =>
|
export const apiGetHunterTeam = (huntId: string) =>
|
||||||
client.get<TeamResponse>(`/hunter/hunt/${huntId}/team`)
|
client.get<TeamResponse>(`/hunter/hunt/${huntId}/team`)
|
||||||
@@ -57,6 +61,9 @@ export const apiCreateTeam = (huntId: string, name: string) =>
|
|||||||
export const apiGetTeam = (huntId: string, teamId: string) =>
|
export const apiGetTeam = (huntId: string, teamId: string) =>
|
||||||
client.get<TeamResponse>(`/hunt/${huntId}/team/${teamId}`)
|
client.get<TeamResponse>(`/hunt/${huntId}/team/${teamId}`)
|
||||||
|
|
||||||
|
export const apiGetTeamHunters = (huntId: string, teamId: string) =>
|
||||||
|
client.get<HunterSummaryResponse[]>(`/hunt/${huntId}/team/${teamId}/hunter`)
|
||||||
|
|
||||||
// ── Items ─────────────────────────────────────────────────────────────────────
|
// ── Items ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const apiGetItems = (huntId: string) =>
|
export const apiGetItems = (huntId: string) =>
|
||||||
@@ -97,6 +104,9 @@ export function photoUrl(photoId: string, version: 'ORIGINAL' | 'LARGE' | 'MEDIU
|
|||||||
export const apiReviewPhoto = (photoId: string, status: 'SUBMITTED' | 'APPROVED' | 'REJECTED' | 'REMOVED') =>
|
export const apiReviewPhoto = (photoId: string, status: 'SUBMITTED' | 'APPROVED' | 'REJECTED' | 'REMOVED') =>
|
||||||
client.patch<void>(`/admin/photo/${photoId}`, { status })
|
client.patch<void>(`/admin/photo/${photoId}`, { status })
|
||||||
|
|
||||||
|
export const apiGetUnreviewedPhotos = (huntId: string) =>
|
||||||
|
client.get<PhotoResponse[]>(`/admin/hunt/${huntId}/photo/unreviewed`)
|
||||||
|
|
||||||
// ── Stats ─────────────────────────────────────────────────────────────────────
|
// ── Stats ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const apiGetTeamLeaderboard = (huntId: string) =>
|
export const apiGetTeamLeaderboard = (huntId: string) =>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface HuntResponse {
|
|||||||
startDateTime: string
|
startDateTime: string
|
||||||
endDateTime: string
|
endDateTime: string
|
||||||
isTerminated: boolean
|
isTerminated: boolean
|
||||||
|
terminated?: boolean // Spring may serialize boolean getter isTerminated() as "terminated"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamResponse {
|
export interface TeamResponse {
|
||||||
@@ -51,3 +52,8 @@ export interface HunterLeaderboardResponse {
|
|||||||
hunterName: string
|
hunterName: string
|
||||||
score: number
|
score: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HunterSummaryResponse {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="btm-nav btm-nav-sm bg-base-100 border-t border-base-300 fixed bottom-0 left-0 right-0 z-30">
|
<nav class="btm-nav bg-base-100 border-t border-base-300 fixed bottom-0 left-0 right-0 z-30 pt-3">
|
||||||
<button class:active={isActive('/')} onclick={() => push('/')}>
|
<button class="px-8" 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">
|
<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="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" />
|
<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>
|
</svg>
|
||||||
<span class="btm-nav-label text-xs">Hunts</span>
|
<span class="btm-nav-label text-xs">Hunts</span>
|
||||||
@@ -20,18 +20,19 @@
|
|||||||
|
|
||||||
{#if huntId}
|
{#if huntId}
|
||||||
<button
|
<button
|
||||||
|
class="px-8"
|
||||||
class:active={router.location.includes('/leaderboard')}
|
class:active={router.location.includes('/leaderboard')}
|
||||||
onclick={() => push(`/hunt/${huntId}/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">
|
<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="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" />
|
<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>
|
</svg>
|
||||||
<span class="btm-nav-label text-xs">Scores</span>
|
<span class="btm-nav-label text-xs">Scores</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button class:active={isActive('/rules')} onclick={() => push('/rules')}>
|
<button class="px-8" class:active={isActive('/rules')} onclick={() => push('/rules')}>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="btm-nav-label text-xs">Rules</span>
|
<span class="btm-nav-label text-xs">Rules</span>
|
||||||
|
|||||||
@@ -4,9 +4,11 @@
|
|||||||
let {
|
let {
|
||||||
value = $bindable(''),
|
value = $bindable(''),
|
||||||
defaultTimeIndex = 36,
|
defaultTimeIndex = 36,
|
||||||
|
initialValue = '',
|
||||||
}: {
|
}: {
|
||||||
value?: string
|
value?: string
|
||||||
defaultTimeIndex?: number
|
defaultTimeIndex?: number
|
||||||
|
initialValue?: string
|
||||||
} = $props()
|
} = $props()
|
||||||
|
|
||||||
const months = [
|
const months = [
|
||||||
@@ -25,11 +27,21 @@
|
|||||||
return { value: i, label: `${displayHour}:${minute.toString().padStart(2, '0')} ${ampm}` }
|
return { value: i, label: `${displayHour}:${minute.toString().padStart(2, '0')} ${ampm}` }
|
||||||
})
|
})
|
||||||
|
|
||||||
const today = new Date()
|
const seed = untrack(() => {
|
||||||
let month = $state(today.getMonth())
|
if (initialValue) {
|
||||||
let day = $state(today.getDate())
|
// Append Z if no timezone indicator is present so it's always parsed as UTC
|
||||||
let year = $state(today.getFullYear())
|
const normalized = /Z$|[+-]\d{2}:?\d{2}$/.test(initialValue) ? initialValue : initialValue + 'Z'
|
||||||
let timeIndex = $state(untrack(() => defaultTimeIndex))
|
const d = new Date(normalized)
|
||||||
|
return { month: d.getMonth(), day: d.getDate(), year: d.getFullYear(), timeIndex: d.getHours() * 4 + Math.round(d.getMinutes() / 15) }
|
||||||
|
}
|
||||||
|
const today = new Date()
|
||||||
|
return { month: today.getMonth(), day: today.getDate(), year: today.getFullYear(), timeIndex: defaultTimeIndex }
|
||||||
|
})
|
||||||
|
|
||||||
|
let month = $state(seed.month)
|
||||||
|
let day = $state(seed.day)
|
||||||
|
let year = $state(seed.year)
|
||||||
|
let timeIndex = $state(seed.timeIndex)
|
||||||
|
|
||||||
const daysInMonth = $derived(new Date(year, month + 1, 0).getDate())
|
const daysInMonth = $derived(new Date(year, month + 1, 0).getDate())
|
||||||
|
|
||||||
@@ -38,10 +50,9 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const pad = (n: number) => n.toString().padStart(2, '0')
|
|
||||||
const hour = Math.floor(timeIndex / 4)
|
const hour = Math.floor(timeIndex / 4)
|
||||||
const minute = (timeIndex % 4) * 15
|
const minute = (timeIndex % 4) * 15
|
||||||
value = new Date(`${year}-${pad(month + 1)}-${pad(day)}T${pad(hour)}:${pad(minute)}`).toISOString()
|
value = new Date(year, month, day, hour, minute).toISOString()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
import {push} from 'svelte-spa-router'
|
import {push} from 'svelte-spa-router'
|
||||||
import {auth, clearTokens} from '../stores/auth.svelte'
|
import {auth, clearTokens} from '../stores/auth.svelte'
|
||||||
import {apiLogout} from '../api/index'
|
import {apiLogout} from '../api/index'
|
||||||
|
import {APP_NAME} from '../config'
|
||||||
|
|
||||||
let { title = 'Scavenger Hunt' }: { title?: string } = $props()
|
let { title = APP_NAME }: { title?: string } = $props()
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
2
src/lib/config.ts
Normal file
2
src/lib/config.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// If you change this, also change it in `index.html`
|
||||||
|
export const APP_NAME = 'Scrounge Quest'
|
||||||
23
src/lib/utils.ts
Normal file
23
src/lib/utils.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { HuntResponse } from './api/types'
|
||||||
|
|
||||||
|
// Ensures API datetime strings are always parsed as UTC, even if the Z suffix is missing.
|
||||||
|
export function parseUTC(iso: string): Date {
|
||||||
|
const normalized = /Z$|[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z'
|
||||||
|
return new Date(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function huntStatus(hunt: HuntResponse): 'ONGOING' | 'UNSTARTED' | 'CLOSED' {
|
||||||
|
if (hunt.isTerminated || hunt.terminated) return 'CLOSED'
|
||||||
|
const now = Date.now()
|
||||||
|
if (now < parseUTC(hunt.startDateTime).getTime()) return 'UNSTARTED'
|
||||||
|
if (now > parseUTC(hunt.endDateTime).getTime()) return 'CLOSED'
|
||||||
|
return 'ONGOING'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(iso: string): string {
|
||||||
|
return parseUTC(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(iso: string): string {
|
||||||
|
return parseUTC(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import {push} from 'svelte-spa-router'
|
import {push} from 'svelte-spa-router'
|
||||||
import {apiLogin} from '../lib/api/index'
|
import {apiLogin} from '../lib/api/index'
|
||||||
import {auth, setTokens} from '../lib/stores/auth.svelte'
|
import {auth, setTokens} from '../lib/stores/auth.svelte'
|
||||||
|
import {APP_NAME} from '../lib/config'
|
||||||
|
|
||||||
let email = $state('')
|
let email = $state('')
|
||||||
let password = $state('')
|
let password = $state('')
|
||||||
@@ -32,7 +33,7 @@
|
|||||||
<div class="card bg-base-100 shadow-xl w-full max-w-sm">
|
<div class="card bg-base-100 shadow-xl w-full max-w-sm">
|
||||||
<div class="card-body gap-4">
|
<div class="card-body gap-4">
|
||||||
<div class="text-center mb-2">
|
<div class="text-center mb-2">
|
||||||
<h1 class="text-3xl font-extrabold text-primary">Scavenger Hunt</h1>
|
<h1 class="text-3xl font-extrabold text-primary">{APP_NAME}</h1>
|
||||||
<p class="text-base-content/60 mt-1">Sign in to play</p>
|
<p class="text-base-content/60 mt-1">Sign in to play</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {push} from 'svelte-spa-router'
|
import {push} from 'svelte-spa-router'
|
||||||
import {auth} from '../../lib/stores/auth.svelte'
|
import {auth} from '../../lib/stores/auth.svelte'
|
||||||
import {apiGetAllHunts} from '../../lib/api/index'
|
import {huntStatus, formatDate} from '../../lib/utils'
|
||||||
|
import {apiGetAllHunts, apiGetUnreviewedPhotos} from '../../lib/api/index'
|
||||||
import type {HuntResponse} from '../../lib/api/types'
|
import type {HuntResponse} from '../../lib/api/types'
|
||||||
import StatusBadge from '../../lib/components/StatusBadge.svelte'
|
import StatusBadge from '../../lib/components/StatusBadge.svelte'
|
||||||
import LoadingSpinner from '../../lib/components/LoadingSpinner.svelte'
|
import LoadingSpinner from '../../lib/components/LoadingSpinner.svelte'
|
||||||
@@ -14,25 +15,25 @@
|
|||||||
let hunts = $state<HuntResponse[]>([])
|
let hunts = $state<HuntResponse[]>([])
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
|
let unreviewedCounts = $state<Record<string, number>>({})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
apiGetAllHunts()
|
apiGetAllHunts()
|
||||||
.then(h => { hunts = h })
|
.then(async h => {
|
||||||
|
hunts = h
|
||||||
|
const counts = await Promise.all(
|
||||||
|
h.map(hunt =>
|
||||||
|
apiGetUnreviewedPhotos(hunt.id)
|
||||||
|
.then(photos => [hunt.id, photos.length] as const)
|
||||||
|
.catch(() => [hunt.id, 0] as const)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
unreviewedCounts = Object.fromEntries(counts)
|
||||||
|
})
|
||||||
.catch(e => { error = e instanceof Error ? e.message : 'Failed to load' })
|
.catch(e => { error = e instanceof Error ? e.message : 'Failed to load' })
|
||||||
.finally(() => { loading = false })
|
.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>
|
</script>
|
||||||
|
|
||||||
<div class="p-4 pb-6">
|
<div class="p-4 pb-6">
|
||||||
@@ -68,6 +69,9 @@
|
|||||||
<div class="card-actions justify-end gap-2 mt-1">
|
<div class="card-actions justify-end gap-2 mt-1">
|
||||||
<button class="btn btn-outline btn-xs" onclick={() => push(`/admin/hunt/${hunt.id}/review`)}>
|
<button class="btn btn-outline btn-xs" onclick={() => push(`/admin/hunt/${hunt.id}/review`)}>
|
||||||
Review Photos
|
Review Photos
|
||||||
|
{#if (unreviewedCounts[hunt.id] ?? 0) > 0}
|
||||||
|
<span class="badge badge-error badge-sm">{unreviewedCounts[hunt.id]}</span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary btn-xs" onclick={() => push(`/admin/hunt/${hunt.id}`)}>
|
<button class="btn btn-primary btn-xs" onclick={() => push(`/admin/hunt/${hunt.id}`)}>
|
||||||
Manage
|
Manage
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {push} from 'svelte-spa-router'
|
import {push} from 'svelte-spa-router'
|
||||||
import {auth} from '../../lib/stores/auth.svelte'
|
import {auth} from '../../lib/stores/auth.svelte'
|
||||||
|
import {huntStatus} from '../../lib/utils'
|
||||||
import {
|
import {
|
||||||
apiAddItem,
|
apiAddItem,
|
||||||
apiCreateTeam,
|
apiCreateTeam,
|
||||||
@@ -8,11 +9,13 @@
|
|||||||
apiGetHunt,
|
apiGetHunt,
|
||||||
apiGetItems,
|
apiGetItems,
|
||||||
apiListTeams,
|
apiListTeams,
|
||||||
|
apiUpdateHunt,
|
||||||
apiUpdateItem
|
apiUpdateItem
|
||||||
} from '../../lib/api/index'
|
} from '../../lib/api/index'
|
||||||
import type {HuntResponse, ItemResponse, TeamResponse} from '../../lib/api/types'
|
import type {HuntResponse, ItemResponse, TeamResponse} from '../../lib/api/types'
|
||||||
import StatusBadge from '../../lib/components/StatusBadge.svelte'
|
import StatusBadge from '../../lib/components/StatusBadge.svelte'
|
||||||
import LoadingSpinner from '../../lib/components/LoadingSpinner.svelte'
|
import LoadingSpinner from '../../lib/components/LoadingSpinner.svelte'
|
||||||
|
import DateTimePicker from '../../lib/components/DateTimePicker.svelte'
|
||||||
|
|
||||||
let { params }: { params: { huntId: string } } = $props()
|
let { params }: { params: { huntId: string } } = $props()
|
||||||
|
|
||||||
@@ -34,6 +37,40 @@
|
|||||||
let newTeamName = $state('')
|
let newTeamName = $state('')
|
||||||
let addingTeam = $state(false)
|
let addingTeam = $state(false)
|
||||||
|
|
||||||
|
let editingHunt = $state(false)
|
||||||
|
let editTitle = $state('')
|
||||||
|
let editStart = $state('')
|
||||||
|
let editEnd = $state('')
|
||||||
|
let editTerminated = $state(false)
|
||||||
|
let savingHunt = $state(false)
|
||||||
|
|
||||||
|
function startEditHunt() {
|
||||||
|
if (!hunt) return
|
||||||
|
editTitle = hunt.title
|
||||||
|
editStart = hunt.startDateTime
|
||||||
|
editEnd = hunt.endDateTime
|
||||||
|
editTerminated = hunt.isTerminated || hunt.terminated || false
|
||||||
|
editingHunt = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditHunt() {
|
||||||
|
editingHunt = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveHunt() {
|
||||||
|
if (!hunt || !editTitle.trim() || !editStart || !editEnd) return
|
||||||
|
savingHunt = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
hunt = await apiUpdateHunt(params.huntId, editTitle.trim(), editStart, editEnd, editTerminated)
|
||||||
|
editingHunt = false
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to update hunt'
|
||||||
|
} finally {
|
||||||
|
savingHunt = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let editingItemId = $state<string | null>(null)
|
let editingItemId = $state<string | null>(null)
|
||||||
let editName = $state('')
|
let editName = $state('')
|
||||||
let editPoints = $state(0)
|
let editPoints = $state(0)
|
||||||
@@ -115,30 +152,72 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<div class="p-4 pb-6">
|
<div class="p-4 pb-6">
|
||||||
<div class="flex items-center gap-3 mb-4">
|
<div class="flex items-center gap-3 mb-4">
|
||||||
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => push('/admin')}>←</button>
|
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => push('/admin')}>←</button>
|
||||||
<div>
|
<div class="flex-1 min-w-0">
|
||||||
{#if hunt}
|
{#if hunt}
|
||||||
<h1 class="text-lg font-bold leading-tight">{hunt.title}</h1>
|
<h1 class="text-lg font-bold leading-tight truncate">{hunt.title}</h1>
|
||||||
<StatusBadge status={huntStatus(hunt)} />
|
<StatusBadge status={huntStatus(hunt)} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if hunt && !editingHunt}
|
||||||
|
<button class="btn btn-outline btn-sm shrink-0" onclick={startEditHunt}>Edit Hunt</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if editingHunt && hunt}
|
||||||
|
<div class="bg-base-200 rounded-2xl p-4 mb-4 flex flex-col gap-4">
|
||||||
|
<h2 class="font-bold text-sm uppercase tracking-wide text-base-content/50">Edit Hunt</h2>
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error text-sm">{error}</div>
|
||||||
|
{/if}
|
||||||
|
<label class="form-control">
|
||||||
|
<span class="label-text text-xs font-medium mb-1">Title</span>
|
||||||
|
<input type="text" class="input input-bordered input-sm" bind:value={editTitle} disabled={savingHunt} required />
|
||||||
|
</label>
|
||||||
|
<fieldset class="form-control">
|
||||||
|
<legend class="label-text text-xs font-medium mb-1">Start</legend>
|
||||||
|
<DateTimePicker bind:value={editStart} initialValue={editStart} />
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="form-control">
|
||||||
|
<legend class="label-text text-xs font-medium mb-1">End</legend>
|
||||||
|
<DateTimePicker bind:value={editEnd} initialValue={editEnd} />
|
||||||
|
</fieldset>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center justify-between p-4 rounded-xl border-2 transition-all {editTerminated ? 'border-error bg-error/10' : 'border-base-300 bg-base-100'}"
|
||||||
|
onclick={() => editTerminated = !editTerminated}
|
||||||
|
disabled={savingHunt}
|
||||||
|
>
|
||||||
|
<div class="text-left">
|
||||||
|
<p class="font-semibold {editTerminated ? 'text-error' : 'text-base-content'}">Terminated</p>
|
||||||
|
<p class="text-xs text-base-content/50">Permanently closes the hunt for all hunters</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-6 h-6 rounded-full border-2 flex items-center justify-center shrink-0 {editTerminated ? 'border-error bg-error' : 'border-base-300'}">
|
||||||
|
{#if editTerminated}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-error-content" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 00-1.414 0L8 12.586 4.707 9.293a1 1 0 00-1.414 1.414l4 4a1 1 0 001.414 0l8-8a1 1 0 000-1.414z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="btn btn-primary btn-sm flex-1" onclick={saveHunt} disabled={savingHunt}>
|
||||||
|
{#if savingHunt}<span class="loading loading-spinner loading-xs"></span>{/if}
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick={cancelEditHunt} disabled={savingHunt}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
{:else}
|
{:else}
|
||||||
{#if error}
|
{#if error && !editingHunt}
|
||||||
<div class="alert alert-error mb-4 text-sm">{error}</div>
|
<div class="alert alert-error mb-4 text-sm">{error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -1,64 +1,85 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {push, router} from 'svelte-spa-router'
|
import {push} from 'svelte-spa-router'
|
||||||
import {auth} from '../../lib/stores/auth.svelte'
|
import {auth} from '../../lib/stores/auth.svelte'
|
||||||
import {apiGetHunt, apiGetItemPhotos, apiGetItems, apiListTeams, apiReviewPhoto} from '../../lib/api/index'
|
import {apiGetHunt, apiGetItemPhotos, apiGetItems, apiGetUnreviewedPhotos, apiListTeams, apiReviewPhoto} from '../../lib/api/index'
|
||||||
|
import {parseUTC} from '../../lib/utils'
|
||||||
import type {HuntResponse, ItemResponse, PhotoResponse, TeamResponse} from '../../lib/api/types'
|
import type {HuntResponse, ItemResponse, PhotoResponse, TeamResponse} from '../../lib/api/types'
|
||||||
import StatusBadge from '../../lib/components/StatusBadge.svelte'
|
import StatusBadge from '../../lib/components/StatusBadge.svelte'
|
||||||
import AuthImage from '../../lib/components/AuthImage.svelte'
|
import AuthImage from '../../lib/components/AuthImage.svelte'
|
||||||
import LoadingSpinner from '../../lib/components/LoadingSpinner.svelte'
|
import LoadingSpinner from '../../lib/components/LoadingSpinner.svelte'
|
||||||
|
|
||||||
let { params }: { params: { huntId: string } } = $props()
|
let { params }: { params: { huntId: string } } = $props()
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!auth.isLoggedIn) push('/login')
|
if (!auth.isLoggedIn) push('/login')
|
||||||
if (!auth.isAdmin) push('/')
|
if (!auth.isAdmin) push('/')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type PhotoWithContext = PhotoResponse & { teamName: string; itemName: string }
|
||||||
|
|
||||||
let hunt = $state<HuntResponse | null>(null)
|
let hunt = $state<HuntResponse | null>(null)
|
||||||
let teams = $state<TeamResponse[]>([])
|
let teams = $state<TeamResponse[]>([])
|
||||||
let items = $state<ItemResponse[]>([])
|
let items = $state<ItemResponse[]>([])
|
||||||
let selectedTeam = $state<TeamResponse | null>(null)
|
|
||||||
let selectedItem = $state<ItemResponse | null>(null)
|
let selectedItem = $state<ItemResponse | null>(null)
|
||||||
let photos = $state<PhotoResponse[]>([])
|
let photos = $state<PhotoWithContext[]>([])
|
||||||
|
let unreviewedPhotos = $state<PhotoWithContext[]>([])
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
let photosLoading = $state(false)
|
let photosLoading = $state(false)
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
let reviewing = $state<string | null>(null)
|
let reviewing = $state<string | null>(null)
|
||||||
|
let expandedPhoto = $state<PhotoWithContext | null>(null)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const { huntId } = params
|
const { huntId } = params
|
||||||
Promise.all([apiGetHunt(huntId), apiListTeams(huntId), apiGetItems(huntId)])
|
Promise.all([apiGetHunt(huntId), apiListTeams(huntId), apiGetItems(huntId), apiGetUnreviewedPhotos(huntId)])
|
||||||
.then(([h, t, i]) => {
|
.then(async ([h, t, i, unreviewed]) => {
|
||||||
hunt = h
|
hunt = h
|
||||||
teams = t
|
teams = t
|
||||||
items = i
|
items = i
|
||||||
// Pre-select team from query string if provided
|
// Build photoId → context map from all item+team combos in parallel
|
||||||
const qs = new URLSearchParams(router.querystring ?? '')
|
const rows = await Promise.all(
|
||||||
const preTeam = qs.get('teamId')
|
i.flatMap(item =>
|
||||||
if (preTeam) {
|
t.map(team =>
|
||||||
selectedTeam = t.find(x => x.id === preTeam) ?? null
|
apiGetItemPhotos(huntId, team.id, item.id)
|
||||||
}
|
.then(ps => ps.map(p => [p.id, { itemName: item.name, teamName: team.name }] as [string, { itemName: string; teamName: string }]))
|
||||||
|
.catch(() => [] as [string, { itemName: string; teamName: string }][])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const contextMap = new Map(rows.flat())
|
||||||
|
unreviewedPhotos = unreviewed.map(p => {
|
||||||
|
const ctx = contextMap.get(p.id) ?? { itemName: 'Unknown', teamName: 'Unknown' }
|
||||||
|
return { ...p, ...ctx }
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.catch(e => { error = e instanceof Error ? e.message : 'Failed to load' })
|
.catch(e => { error = e instanceof Error ? e.message : 'Failed to load' })
|
||||||
.finally(() => { loading = false })
|
.finally(() => { loading = false })
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (selectedTeam && selectedItem) {
|
if (selectedItem) {
|
||||||
photosLoading = true
|
photosLoading = true
|
||||||
photos = []
|
photos = []
|
||||||
apiGetItemPhotos(params.huntId, selectedTeam.id, selectedItem.id)
|
Promise.all(
|
||||||
.then(p => { photos = p })
|
teams.map(team =>
|
||||||
|
apiGetItemPhotos(params.huntId, team.id, selectedItem!.id)
|
||||||
|
.then(ps => ps.map(p => ({ ...p, teamName: team.name, itemName: selectedItem!.name })))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(results => { photos = results.flat() })
|
||||||
.catch(e => { error = e instanceof Error ? e.message : 'Failed to load photos' })
|
.catch(e => { error = e instanceof Error ? e.message : 'Failed to load photos' })
|
||||||
.finally(() => { photosLoading = false })
|
.finally(() => { photosLoading = false })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function review(photo: PhotoResponse, status: 'APPROVED' | 'REJECTED') {
|
async function review(photo: PhotoWithContext, status: 'APPROVED' | 'REJECTED' | 'REMOVED') {
|
||||||
reviewing = photo.id
|
reviewing = photo.id
|
||||||
try {
|
try {
|
||||||
await apiReviewPhoto(photo.id, status)
|
await apiReviewPhoto(photo.id, status)
|
||||||
photos = photos.map(p => p.id === photo.id ? { ...p, photoStatus: status } : p)
|
const updated = { ...photo, photoStatus: status } as PhotoWithContext
|
||||||
|
photos = photos.map(p => p.id === photo.id ? updated : p)
|
||||||
|
unreviewedPhotos = unreviewedPhotos.map(p => p.id === photo.id ? updated : p)
|
||||||
|
if (expandedPhoto?.id === photo.id) expandedPhoto = updated
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
error = e instanceof Error ? e.message : 'Failed to review photo'
|
error = e instanceof Error ? e.message : 'Failed to review photo'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -66,9 +87,71 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const activePhotos = $derived(photos.filter(p => p.photoStatus !== 'REMOVED'))
|
const activePhotos = $derived(photos.filter(p => p.photoStatus !== 'REMOVED'))
|
||||||
|
const activeUnreviewed = $derived(unreviewedPhotos.filter(p => p.photoStatus === 'SUBMITTED'))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Lightbox -->
|
||||||
|
{#if expandedPhoto}
|
||||||
|
{@const photo = expandedPhoto}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 bg-black/90 flex flex-col"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 shrink-0">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-white/40">{photo.itemName}</p>
|
||||||
|
<p class="font-semibold text-white">{photo.hunterName} <span class="text-white/50 font-normal">· {photo.teamName}</span></p>
|
||||||
|
<p class="text-xs text-white/40">{parseUTC(photo.photoUploadDateTime).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<StatusBadge status={photo.photoStatus} />
|
||||||
|
<button class="btn btn-ghost btn-sm btn-circle text-white" onclick={() => expandedPhoto = null}>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-h-0 flex items-center justify-center px-4">
|
||||||
|
<AuthImage photoId={photo.id} version="LARGE" alt={photo.hunterName} class="max-h-full max-w-full object-contain rounded-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 py-4 shrink-0">
|
||||||
|
{#if photo.photoStatus === 'SUBMITTED'}
|
||||||
|
<div class="flex gap-2 mb-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>
|
||||||
|
<button class="btn btn-ghost btn-sm w-full text-white/40" onclick={() => review(photo, 'REMOVED')} disabled={reviewing === photo.id}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
{:else if photo.photoStatus === 'APPROVED'}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="btn btn-outline btn-error btn-sm flex-1" onclick={() => review(photo, 'REJECTED')} disabled={reviewing === photo.id}>
|
||||||
|
Revoke Approval
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm text-white/40" onclick={() => review(photo, 'REMOVED')} disabled={reviewing === photo.id}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if photo.photoStatus === 'REJECTED'}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="btn btn-outline btn-success btn-sm flex-1" onclick={() => review(photo, 'APPROVED')} disabled={reviewing === photo.id}>
|
||||||
|
Approve Instead
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm text-white/40" onclick={() => review(photo, 'REMOVED')} disabled={reviewing === photo.id}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="p-4 pb-6">
|
<div class="p-4 pb-6">
|
||||||
<div class="flex items-center gap-3 mb-4">
|
<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>
|
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => push(`/admin/hunt/${params.huntId}`)}>←</button>
|
||||||
@@ -82,43 +165,31 @@
|
|||||||
{#if loading}
|
{#if loading}
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Team selector -->
|
<!-- Item selector -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<p class="text-sm font-medium text-base-content/60 mb-2">Select Team</p>
|
<p class="text-sm font-medium text-base-content/60 mb-2">
|
||||||
|
{#if selectedItem}
|
||||||
|
Filtering by item — tap again to see all unreviewed
|
||||||
|
{:else}
|
||||||
|
Unreviewed ({activeUnreviewed.length})
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
{#each teams as team}
|
{#each items as item}
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm"
|
class="btn btn-sm"
|
||||||
class:btn-primary={selectedTeam?.id === team.id}
|
class:btn-secondary={selectedItem?.id === item.id}
|
||||||
class:btn-outline={selectedTeam?.id !== team.id}
|
class:btn-outline={selectedItem?.id !== item.id}
|
||||||
onclick={() => { selectedTeam = team; selectedItem = null; photos = [] }}
|
onclick={() => { selectedItem = selectedItem?.id === item.id ? null : item }}
|
||||||
>
|
>
|
||||||
{team.name}
|
{item.name}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if selectedTeam}
|
{#if selectedItem}
|
||||||
<!-- Item selector -->
|
<!-- Per-item photo view -->
|
||||||
<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}
|
{#if photosLoading}
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
{:else if activePhotos.length === 0}
|
{:else if activePhotos.length === 0}
|
||||||
@@ -127,51 +198,100 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
{#each activePhotos as photo}
|
{#each activePhotos as photo (photo.id)}
|
||||||
<div class="card bg-base-100 shadow-sm border border-base-200 overflow-hidden">
|
<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" />
|
<button class="w-full cursor-zoom-in" onclick={() => expandedPhoto = photo}>
|
||||||
|
<AuthImage photoId={photo.id} version="SMALL" alt={photo.hunterName} class="w-full h-56 object-cover" />
|
||||||
|
</button>
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold">{photo.hunterName}</p>
|
<p class="font-semibold">{photo.hunterName} <span class="text-base-content/50 font-normal">· {photo.teamName}</span></p>
|
||||||
<p class="text-xs text-base-content/50">
|
<p class="text-xs text-base-content/50">
|
||||||
{new Date(photo.photoUploadDateTime).toLocaleString()}
|
{parseUTC(photo.photoUploadDateTime).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={photo.photoStatus} />
|
<StatusBadge status={photo.photoStatus} />
|
||||||
</div>
|
</div>
|
||||||
{#if photo.photoStatus === 'SUBMITTED'}
|
{#if photo.photoStatus === 'SUBMITTED'}
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2 mb-2">
|
||||||
<button
|
<button class="btn btn-success btn-sm flex-1" onclick={() => review(photo, 'APPROVED')} disabled={reviewing === photo.id}>
|
||||||
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}
|
{#if reviewing === photo.id}<span class="loading loading-spinner loading-xs"></span>{/if}
|
||||||
Approve
|
Approve
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button class="btn btn-error btn-sm flex-1" onclick={() => review(photo, 'REJECTED')} disabled={reviewing === photo.id}>
|
||||||
class="btn btn-error btn-sm flex-1"
|
|
||||||
onclick={() => review(photo, 'REJECTED')}
|
|
||||||
disabled={reviewing === photo.id}
|
|
||||||
>
|
|
||||||
Reject
|
Reject
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn btn-ghost btn-sm w-full text-base-content/50" onclick={() => review(photo, 'REMOVED')} disabled={reviewing === photo.id}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
{:else if photo.photoStatus === 'APPROVED'}
|
{:else if photo.photoStatus === 'APPROVED'}
|
||||||
<button class="btn btn-outline btn-error btn-sm w-full" onclick={() => review(photo, 'REJECTED')} disabled={reviewing === photo.id}>
|
<div class="flex gap-2">
|
||||||
Revoke Approval
|
<button class="btn btn-outline btn-error btn-sm flex-1" onclick={() => review(photo, 'REJECTED')} disabled={reviewing === photo.id}>
|
||||||
</button>
|
Revoke Approval
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm text-base-content/50" onclick={() => review(photo, 'REMOVED')} disabled={reviewing === photo.id}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{:else if photo.photoStatus === 'REJECTED'}
|
{:else if photo.photoStatus === 'REJECTED'}
|
||||||
<button class="btn btn-outline btn-success btn-sm w-full" onclick={() => review(photo, 'APPROVED')} disabled={reviewing === photo.id}>
|
<div class="flex gap-2">
|
||||||
Approve Instead
|
<button class="btn btn-outline btn-success btn-sm flex-1" onclick={() => review(photo, 'APPROVED')} disabled={reviewing === photo.id}>
|
||||||
</button>
|
Approve Instead
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm text-base-content/50" onclick={() => review(photo, 'REMOVED')} disabled={reviewing === photo.id}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<!-- All-unreviewed view -->
|
||||||
|
{#if activeUnreviewed.length === 0}
|
||||||
|
<div class="text-center py-10 text-base-content/50">
|
||||||
|
<p>All caught up — no unreviewed photos!</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
{#each activeUnreviewed as photo (photo.id)}
|
||||||
|
<div class="card bg-base-100 shadow-sm border border-base-200 overflow-hidden">
|
||||||
|
<button class="w-full cursor-zoom-in" onclick={() => expandedPhoto = photo}>
|
||||||
|
<AuthImage photoId={photo.id} version="SMALL" alt={photo.hunterName} class="w-full h-56 object-cover" />
|
||||||
|
</button>
|
||||||
|
<div class="p-3">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-primary mb-1">{photo.itemName}</p>
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold">{photo.hunterName} <span class="text-base-content/50 font-normal">· {photo.teamName}</span></p>
|
||||||
|
<p class="text-xs text-base-content/50">
|
||||||
|
{parseUTC(photo.photoUploadDateTime).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={photo.photoStatus} />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 mb-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>
|
||||||
|
<button class="btn btn-ghost btn-sm w-full text-base-content/50" onclick={() => review(photo, 'REMOVED')} disabled={reviewing === photo.id}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,29 +1,56 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {push} from 'svelte-spa-router'
|
import {push} from 'svelte-spa-router'
|
||||||
import {auth} from '../../lib/stores/auth.svelte'
|
import {auth} from '../../lib/stores/auth.svelte'
|
||||||
import {apiGetOngoingHunts, apiGetUnstartedHunts} from '../../lib/api/index'
|
import {huntStatus, formatDateTime} from '../../lib/utils'
|
||||||
import type {HuntResponse} from '../../lib/api/types'
|
import {
|
||||||
import StatusBadge from '../../lib/components/StatusBadge.svelte'
|
apiCreateTeam,
|
||||||
import LoadingSpinner from '../../lib/components/LoadingSpinner.svelte'
|
apiGetOngoingHunts,
|
||||||
|
apiGetHunterTeam,
|
||||||
|
apiGetUnstartedHunts,
|
||||||
|
apiGetTeamHunters,
|
||||||
|
apiJoinTeam,
|
||||||
|
apiListTeams,
|
||||||
|
} from '../../lib/api/index'
|
||||||
|
import type {HuntResponse, HunterSummaryResponse, TeamResponse} from '../../lib/api/types'
|
||||||
|
import StatusBadge from '../../lib/components/StatusBadge.svelte'
|
||||||
|
import LoadingSpinner from '../../lib/components/LoadingSpinner.svelte'
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!auth.isLoggedIn) push('/login')
|
if (!auth.isLoggedIn) push('/login')
|
||||||
else if (auth.isAdmin) push('/admin')
|
else if (auth.isAdmin) push('/admin')
|
||||||
})
|
})
|
||||||
|
|
||||||
let ongoing = $state<HuntResponse[]>([])
|
let ongoing = $state<HuntResponse[]>([])
|
||||||
let upcoming = $state<HuntResponse[]>([])
|
let upcoming = $state<HuntResponse[]>([])
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
let tab = $state<'active' | 'upcoming'>('active')
|
let tab = $state<'active' | 'upcoming'>('active')
|
||||||
|
|
||||||
|
// huntId → team the hunter has joined (null = not joined)
|
||||||
|
let ongoingTeams = $state<Record<string, TeamResponse | null>>({})
|
||||||
|
let upcomingTeams = $state<Record<string, TeamResponse | null>>({})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
apiGetOngoingHunts(),
|
apiGetOngoingHunts(),
|
||||||
apiGetUnstartedHunts(),
|
apiGetUnstartedHunts(),
|
||||||
]).then(([o, u]) => {
|
]).then(async ([o, u]) => {
|
||||||
ongoing = o
|
ongoing = o
|
||||||
upcoming = u
|
upcoming = u
|
||||||
|
const [ongoingEntries, upcomingEntries] = await Promise.all([
|
||||||
|
Promise.all(o.map(hunt =>
|
||||||
|
apiGetHunterTeam(hunt.id)
|
||||||
|
.then(t => [hunt.id, t] as const)
|
||||||
|
.catch(() => [hunt.id, null] as const)
|
||||||
|
)),
|
||||||
|
Promise.all(u.map(hunt =>
|
||||||
|
apiGetHunterTeam(hunt.id)
|
||||||
|
.then(t => [hunt.id, t] as const)
|
||||||
|
.catch(() => [hunt.id, null] as const)
|
||||||
|
)),
|
||||||
|
])
|
||||||
|
ongoingTeams = Object.fromEntries(ongoingEntries)
|
||||||
|
upcomingTeams = Object.fromEntries(upcomingEntries)
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
error = e instanceof Error ? e.message : 'Failed to load hunts'
|
error = e instanceof Error ? e.message : 'Failed to load hunts'
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
@@ -31,30 +58,108 @@
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function huntStatus(hunt: HuntResponse): 'ONGOING' | 'UNSTARTED' | 'CLOSED' {
|
// ── Hunt sheet (shared for active + upcoming) ─────────────────────────────────
|
||||||
if (hunt.isTerminated) return 'CLOSED'
|
|
||||||
const now = Date.now()
|
let sheetHunt = $state<HuntResponse | null>(null)
|
||||||
const start = new Date(hunt.startDateTime).getTime()
|
let sheetIsActive = $state(false)
|
||||||
const end = new Date(hunt.endDateTime).getTime()
|
let sheetTeams = $state<TeamResponse[]>([])
|
||||||
if (now < start) return 'UNSTARTED'
|
let sheetMembers = $state<HunterSummaryResponse[]>([])
|
||||||
if (now > end) return 'CLOSED'
|
let sheetLoading = $state(false)
|
||||||
return 'ONGOING'
|
let newTeamName = $state('')
|
||||||
|
let creatingTeam = $state(false)
|
||||||
|
let joiningTeamId = $state<string | null>(null)
|
||||||
|
let sheetError = $state('')
|
||||||
|
|
||||||
|
function currentTeam(hunt: HuntResponse) {
|
||||||
|
return sheetIsActive ? ongoingTeams[hunt.id] : upcomingTeams[hunt.id]
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dt: string) {
|
async function openSheet(hunt: HuntResponse, isActive: boolean) {
|
||||||
return new Date(dt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
sheetHunt = hunt
|
||||||
|
sheetIsActive = isActive
|
||||||
|
sheetError = ''
|
||||||
|
newTeamName = ''
|
||||||
|
sheetMembers = []
|
||||||
|
sheetLoading = true
|
||||||
|
try {
|
||||||
|
const myTeam = currentTeam(hunt)
|
||||||
|
if (myTeam) {
|
||||||
|
const [teams, members] = await Promise.all([
|
||||||
|
apiListTeams(hunt.id),
|
||||||
|
apiGetTeamHunters(hunt.id, myTeam.id),
|
||||||
|
])
|
||||||
|
sheetTeams = teams
|
||||||
|
sheetMembers = members
|
||||||
|
} else {
|
||||||
|
sheetTeams = await apiListTeams(hunt.id)
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
sheetError = e instanceof Error ? e.message : 'Failed to load teams'
|
||||||
|
} finally {
|
||||||
|
sheetLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSheet() {
|
||||||
|
sheetHunt = null
|
||||||
|
sheetTeams = []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTeam() {
|
||||||
|
if (!sheetHunt || !newTeamName.trim()) return
|
||||||
|
creatingTeam = true
|
||||||
|
sheetError = ''
|
||||||
|
try {
|
||||||
|
await apiCreateTeam(sheetHunt.id, newTeamName.trim())
|
||||||
|
sheetTeams = await apiListTeams(sheetHunt.id)
|
||||||
|
const created = sheetTeams.find(t => t.name === newTeamName.trim())
|
||||||
|
if (created) await joinTeam(created.id)
|
||||||
|
else newTeamName = ''
|
||||||
|
} catch (e: unknown) {
|
||||||
|
sheetError = e instanceof Error ? e.message : 'Failed to create team'
|
||||||
|
} finally {
|
||||||
|
creatingTeam = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinTeam(teamId: string) {
|
||||||
|
if (!sheetHunt) return
|
||||||
|
joiningTeamId = teamId
|
||||||
|
sheetError = ''
|
||||||
|
try {
|
||||||
|
await apiJoinTeam(sheetHunt.id, teamId)
|
||||||
|
const team = sheetTeams.find(t => t.id === teamId) ?? null
|
||||||
|
if (sheetIsActive) {
|
||||||
|
ongoingTeams = { ...ongoingTeams, [sheetHunt.id]: team }
|
||||||
|
push(`/hunt/${sheetHunt.id}`)
|
||||||
|
} else {
|
||||||
|
upcomingTeams = { ...upcomingTeams, [sheetHunt.id]: team }
|
||||||
|
newTeamName = ''
|
||||||
|
closeSheet()
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
sheetError = e instanceof Error ? e.message : 'Failed to join team'
|
||||||
|
} finally {
|
||||||
|
joiningTeamId = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-4 pb-20">
|
<div class="p-4 pb-20">
|
||||||
<div class="tabs tabs-boxed bg-base-200 mb-4">
|
<div class="tabs tabs-boxed bg-base-200 mb-4">
|
||||||
<button class="tab flex-1" class:tab-active={tab === 'active'} onclick={() => tab = 'active'}>
|
<button
|
||||||
|
class="tab flex-1 font-bold transition-all {tab === 'active' ? 'bg-primary text-primary-content rounded-lg' : 'text-base-content/40'}"
|
||||||
|
onclick={() => tab = 'active'}
|
||||||
|
>
|
||||||
Active
|
Active
|
||||||
{#if ongoing.length > 0}<span class="badge badge-primary badge-sm ml-1">{ongoing.length}</span>{/if}
|
{#if ongoing.length > 0}<span class="badge badge-md ml-1 {tab === 'active' ? 'badge-outline' : 'badge-primary'}">{ongoing.length}</span>{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="tab flex-1" class:tab-active={tab === 'upcoming'} onclick={() => tab = 'upcoming'}>
|
<button
|
||||||
|
class="tab flex-1 font-bold transition-all {tab === 'upcoming' ? 'bg-primary text-primary-content rounded-lg' : 'text-base-content/40'}"
|
||||||
|
onclick={() => tab = 'upcoming'}
|
||||||
|
>
|
||||||
Upcoming
|
Upcoming
|
||||||
{#if upcoming.length > 0}<span class="badge badge-info badge-sm ml-1">{upcoming.length}</span>{/if}
|
{#if upcoming.length > 0}<span class="badge badge-md ml-1 {tab === 'upcoming' ? 'badge-outline' : 'badge-info'}">{upcoming.length}</span>{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -63,31 +168,160 @@
|
|||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="alert alert-error">{error}</div>
|
<div class="alert alert-error">{error}</div>
|
||||||
{:else}
|
{:else}
|
||||||
{@const hunts = tab === 'active' ? ongoing : upcoming}
|
{#if tab === 'active'}
|
||||||
{#if hunts.length === 0}
|
{#if ongoing.length === 0}
|
||||||
<div class="text-center py-16 text-base-content/50">
|
<div class="text-center py-16 text-base-content/50">
|
||||||
<p class="text-4xl mb-3">🔍</p>
|
<p class="text-4xl mb-3">🔍</p>
|
||||||
<p class="font-medium">{tab === 'active' ? 'No active hunts right now' : 'No upcoming hunts'}</p>
|
<p class="font-medium">No active hunts right now</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
{#each hunts as hunt}
|
{#each ongoing as hunt}
|
||||||
<button
|
{@const myTeam = ongoingTeams[hunt.id]}
|
||||||
class="card bg-base-100 shadow-sm border border-base-200 text-left w-full hover:border-primary hover:shadow-md transition-all"
|
<button
|
||||||
onclick={() => push(`/hunt/${hunt.id}`)}
|
class="card bg-base-100 shadow-sm border border-base-200 text-left w-full hover:border-primary hover:shadow-md transition-all"
|
||||||
>
|
onclick={() => myTeam ? push(`/hunt/${hunt.id}`) : openSheet(hunt, true)}
|
||||||
<div class="card-body p-4 gap-2">
|
>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="card-body p-4 gap-2">
|
||||||
<h2 class="card-title text-base">{hunt.title}</h2>
|
<div class="flex items-start justify-between gap-2">
|
||||||
<StatusBadge status={huntStatus(hunt)} />
|
<h2 class="card-title text-base">{hunt.title}</h2>
|
||||||
|
<StatusBadge status={huntStatus(hunt)} />
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-base-content/50">
|
||||||
|
{formatDateTime(hunt.startDateTime)} – {formatDateTime(hunt.endDateTime)}
|
||||||
|
</p>
|
||||||
|
{#if myTeam}
|
||||||
|
<p class="text-sm font-medium text-primary">Team: {myTeam.name}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm font-medium text-secondary">Join to play →</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-base-content/50">
|
</button>
|
||||||
{formatDate(hunt.startDateTime)} – {formatDate(hunt.endDateTime)}
|
{/each}
|
||||||
</p>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</button>
|
|
||||||
{/each}
|
{:else}
|
||||||
</div>
|
{#if upcoming.length === 0}
|
||||||
|
<div class="text-center py-16 text-base-content/50">
|
||||||
|
<p class="text-4xl mb-3">🔍</p>
|
||||||
|
<p class="font-medium">No upcoming hunts</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#each upcoming as hunt}
|
||||||
|
{@const myTeam = upcomingTeams[hunt.id]}
|
||||||
|
<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={() => openSheet(hunt, false)}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{formatDateTime(hunt.startDateTime)} – {formatDateTime(hunt.endDateTime)}
|
||||||
|
</p>
|
||||||
|
{#if myTeam}
|
||||||
|
<p class="text-sm font-medium text-primary">Team: {myTeam.name}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm font-medium text-secondary">Join a team →</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Hunt sheet -->
|
||||||
|
{#if sheetHunt}
|
||||||
|
{@const hunt = sheetHunt}
|
||||||
|
{@const myTeam = sheetIsActive ? ongoingTeams[hunt.id] : upcomingTeams[hunt.id]}
|
||||||
|
<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-200 rounded-t-2xl shadow-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<div class="p-4 border-b border-base-300 flex items-center justify-between sticky top-0 bg-base-200">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold text-lg">{hunt.title}</h3>
|
||||||
|
<p class="text-xs text-base-content/50">{formatDateTime(hunt.startDateTime)} – {formatDateTime(hunt.endDateTime)}</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost btn-sm btn-circle" onclick={closeSheet}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 flex flex-col gap-4">
|
||||||
|
{#if sheetError}
|
||||||
|
<div class="alert alert-error text-sm">{sheetError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if sheetLoading}
|
||||||
|
<LoadingSpinner />
|
||||||
|
{:else if myTeam}
|
||||||
|
<!-- Already on a team -->
|
||||||
|
<div class="rounded-xl bg-base-100 border border-base-300 p-4 flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/40 mb-1">Your Team</p>
|
||||||
|
<p class="text-lg font-bold">{myTeam.name}</p>
|
||||||
|
</div>
|
||||||
|
{#if sheetMembers.length > 0}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/40 mb-2">Members</p>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
{#each sheetMembers as member}
|
||||||
|
<p class="text-sm font-medium">{member.name}</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if sheetIsActive}
|
||||||
|
<button class="btn btn-primary w-full" onclick={() => push(`/hunt/${hunt.id}`)}>
|
||||||
|
Play Now →
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<!-- Join or create -->
|
||||||
|
{#if sheetIsActive}
|
||||||
|
<div class="alert alert-info text-sm">This hunt is already in progress — join a team to start playing!</div>
|
||||||
|
{/if}
|
||||||
|
<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 sheetTeams.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 sheetTeams 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}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -2,19 +2,21 @@
|
|||||||
import {push} from 'svelte-spa-router'
|
import {push} from 'svelte-spa-router'
|
||||||
import {fade} from 'svelte/transition'
|
import {fade} from 'svelte/transition'
|
||||||
import {auth} from '../../lib/stores/auth.svelte'
|
import {auth} from '../../lib/stores/auth.svelte'
|
||||||
|
import {huntStatus, parseUTC} from '../../lib/utils'
|
||||||
import {
|
import {
|
||||||
apiCreateTeam,
|
apiCreateTeam,
|
||||||
apiGetHunt,
|
apiGetHunt,
|
||||||
apiGetHunterTeam,
|
apiGetHunterTeam,
|
||||||
apiGetItemPhotos,
|
apiGetItemPhotos,
|
||||||
apiGetItems,
|
apiGetItems,
|
||||||
|
apiGetTeamHunters,
|
||||||
apiGetTeamItem,
|
apiGetTeamItem,
|
||||||
apiJoinTeam,
|
apiJoinTeam,
|
||||||
apiListTeams,
|
apiListTeams,
|
||||||
apiRemovePhoto,
|
apiRemovePhoto,
|
||||||
apiSubmitPhoto
|
apiSubmitPhoto
|
||||||
} from '../../lib/api/index'
|
} from '../../lib/api/index'
|
||||||
import type {HuntResponse, ItemResponse, PhotoResponse, TeamItemResponse, TeamResponse} from '../../lib/api/types'
|
import type {HuntResponse, HunterSummaryResponse, ItemResponse, PhotoResponse, TeamItemResponse, TeamResponse} from '../../lib/api/types'
|
||||||
import StatusBadge from '../../lib/components/StatusBadge.svelte'
|
import StatusBadge from '../../lib/components/StatusBadge.svelte'
|
||||||
import AuthImage from '../../lib/components/AuthImage.svelte'
|
import AuthImage from '../../lib/components/AuthImage.svelte'
|
||||||
import LoadingSpinner from '../../lib/components/LoadingSpinner.svelte'
|
import LoadingSpinner from '../../lib/components/LoadingSpinner.svelte'
|
||||||
@@ -41,7 +43,9 @@
|
|||||||
let itemPhotos = $state<PhotoResponse[]>([])
|
let itemPhotos = $state<PhotoResponse[]>([])
|
||||||
let photosLoading = $state(false)
|
let photosLoading = $state(false)
|
||||||
let submitting = $state(false)
|
let submitting = $state(false)
|
||||||
|
let expandedPhoto = $state<PhotoResponse | null>(null)
|
||||||
let fileInput = $state<HTMLInputElement | undefined>()
|
let fileInput = $state<HTMLInputElement | undefined>()
|
||||||
|
let cameraInput = $state<HTMLInputElement | undefined>()
|
||||||
let _uploading = false
|
let _uploading = false
|
||||||
let _errorTimer: ReturnType<typeof setTimeout> | null = null
|
let _errorTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
@@ -160,15 +164,65 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function huntStatus(h: HuntResponse): 'ONGOING' | 'UNSTARTED' | 'CLOSED' {
|
|
||||||
if (h.isTerminated) return 'CLOSED'
|
const approved = $derived(Object.values(itemStatuses).filter(s => s.itemFoundStatus === 'APPROVED').length)
|
||||||
const now = Date.now()
|
const submitted = $derived(Object.values(itemStatuses).filter(s => s.itemFoundStatus === 'SUBMITTED').length)
|
||||||
if (now < new Date(h.startDateTime).getTime()) return 'UNSTARTED'
|
const rejected = $derived(Object.values(itemStatuses).filter(s => s.itemFoundStatus === 'REJECTED').length)
|
||||||
if (now > new Date(h.endDateTime).getTime()) return 'CLOSED'
|
|
||||||
return 'ONGOING'
|
let teamMembers = $state<HunterSummaryResponse[]>([])
|
||||||
|
let teamMembersLoading = $state(false)
|
||||||
|
let teamMembersOpen = $state(false)
|
||||||
|
|
||||||
|
async function openTeamMembers() {
|
||||||
|
if (!myTeam) return
|
||||||
|
teamMembersOpen = true
|
||||||
|
teamMembersLoading = true
|
||||||
|
try {
|
||||||
|
teamMembers = await apiGetTeamHunters(params.huntId, myTeam.id)
|
||||||
|
} finally {
|
||||||
|
teamMembersLoading = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const approved = $derived(Object.values(itemStatuses).filter(s => s.itemFoundStatus === 'APPROVED').length)
|
let searchQuery = $state('')
|
||||||
|
let filterStatus = $state<string | null>(null)
|
||||||
|
let filterPoints = $state<number | null>(null)
|
||||||
|
let sortBy = $state<'name' | 'points'>('points')
|
||||||
|
let sortDir = $state<'asc' | 'desc'>('asc')
|
||||||
|
|
||||||
|
const uniquePointValues = $derived([...new Set(items.map(i => i.points))].sort((a, b) => a - b))
|
||||||
|
|
||||||
|
const filteredItems = $derived(
|
||||||
|
[...items]
|
||||||
|
.filter(item => {
|
||||||
|
if (searchQuery && !item.name.toLowerCase().includes(searchQuery.toLowerCase())) return false
|
||||||
|
if (filterStatus && (itemStatuses[item.id]?.itemFoundStatus ?? 'NOT_FOUND') !== filterStatus) return false
|
||||||
|
if (filterPoints !== null && item.points !== filterPoints) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (sortBy === 'name') {
|
||||||
|
const cmp = a.name.localeCompare(b.name)
|
||||||
|
return sortDir === 'asc' ? cmp : -cmp
|
||||||
|
}
|
||||||
|
const ptsCmp = a.points - b.points
|
||||||
|
if (ptsCmp !== 0) return sortDir === 'asc' ? ptsCmp : -ptsCmp
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleSort(field: 'name' | 'points') {
|
||||||
|
if (sortBy === field) sortDir = sortDir === 'asc' ? 'desc' : 'asc'
|
||||||
|
else { sortBy = field; sortDir = 'asc' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusFilters: { label: string; value: string | null }[] = [
|
||||||
|
{ label: 'All', value: null },
|
||||||
|
{ label: 'Not Found', value: 'NOT_FOUND' },
|
||||||
|
{ label: 'Submitted', value: 'SUBMITTED' },
|
||||||
|
{ label: 'Approved', value: 'APPROVED' },
|
||||||
|
{ label: 'Rejected', value: 'REJECTED' },
|
||||||
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="pb-20">
|
<div class="pb-20">
|
||||||
@@ -184,11 +238,17 @@
|
|||||||
<StatusBadge status={huntStatus(hunt)} />
|
<StatusBadge status={huntStatus(hunt)} />
|
||||||
</div>
|
</div>
|
||||||
{#if myTeam}
|
{#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>
|
<button class="text-sm text-base-content/50 mt-0.5 text-left" onclick={openTeamMembers}>
|
||||||
|
Team: <span class="font-medium text-base-content/70 underline decoration-dotted underline-offset-2">{myTeam.name}</span>
|
||||||
|
</button>
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<div class="flex items-center gap-2 mt-2">
|
<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>
|
<div class="flex-1 h-2 rounded-full overflow-hidden bg-base-300 flex">
|
||||||
<span class="text-xs font-medium text-primary">{approved}/{items.length}</span>
|
<div class="bg-success h-full transition-all" style="width: {approved / items.length * 100}%"></div>
|
||||||
|
<div class="bg-warning h-full transition-all" style="width: {submitted / items.length * 100}%"></div>
|
||||||
|
<div class="bg-error h-full transition-all" style="width: {rejected / items.length * 100}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-medium text-base-content/60">{approved}/{items.length}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -240,8 +300,55 @@
|
|||||||
|
|
||||||
<!-- On a team: item list -->
|
<!-- On a team: item list -->
|
||||||
{:else}
|
{:else}
|
||||||
|
<!-- Filter & sort controls -->
|
||||||
|
<div class="px-4 pt-3 pb-2 flex flex-col gap-2 border-b border-base-200">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search items…"
|
||||||
|
class="input input-bordered input-sm w-full"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2 overflow-x-auto pb-1 no-scrollbar">
|
||||||
|
{#each statusFilters as f}
|
||||||
|
<button
|
||||||
|
class="btn btn-xs shrink-0"
|
||||||
|
class:btn-primary={filterStatus === f.value}
|
||||||
|
class:btn-outline={filterStatus !== f.value}
|
||||||
|
onclick={() => filterStatus = f.value}
|
||||||
|
>{f.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
class="select select-bordered select-sm flex-1"
|
||||||
|
onchange={(e) => filterPoints = (e.target as HTMLSelectElement).value === '' ? null : Number((e.target as HTMLSelectElement).value)}
|
||||||
|
>
|
||||||
|
<option value="">All pts</option>
|
||||||
|
{#each uniquePointValues as pts}
|
||||||
|
<option value={pts}>{pts} pts</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm gap-1"
|
||||||
|
class:btn-primary={sortBy === 'points'}
|
||||||
|
class:btn-outline={sortBy !== 'points'}
|
||||||
|
onclick={() => toggleSort('points')}
|
||||||
|
>
|
||||||
|
Pts {sortBy === 'points' ? (sortDir === 'asc' ? '↑' : '↓') : '↕'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm gap-1"
|
||||||
|
class:btn-primary={sortBy === 'name'}
|
||||||
|
class:btn-outline={sortBy !== 'name'}
|
||||||
|
onclick={() => toggleSort('name')}
|
||||||
|
>
|
||||||
|
Name {sortBy === 'name' ? (sortDir === 'asc' ? '↑' : '↓') : '↕'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 p-4">
|
<div class="flex flex-col gap-2 p-4">
|
||||||
{#each items as item}
|
{#each filteredItems as item}
|
||||||
{@const status = itemStatuses[item.id]}
|
{@const status = itemStatuses[item.id]}
|
||||||
<button
|
<button
|
||||||
class="card bg-base-100 shadow-sm border border-base-200 text-left w-full hover:border-primary transition-colors"
|
class="card bg-base-100 shadow-sm border border-base-200 text-left w-full hover:border-primary transition-colors"
|
||||||
@@ -257,46 +364,127 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
{:else}
|
||||||
|
<p class="text-center text-base-content/50 py-8 text-sm">No items match your filters</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Team members popup -->
|
||||||
|
{#if teamMembersOpen && myTeam}
|
||||||
|
{@const team = myTeam}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-6" role="presentation" onclick={() => teamMembersOpen = false}>
|
||||||
|
<div class="bg-base-200 rounded-2xl shadow-2xl w-full max-w-sm p-5" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/40">Team</p>
|
||||||
|
<h3 class="font-bold text-lg leading-tight">{team.name}</h3>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => teamMembersOpen = false}>✕</button>
|
||||||
|
</div>
|
||||||
|
{#if teamMembersLoading}
|
||||||
|
<LoadingSpinner />
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#each teamMembers as member}
|
||||||
|
<p class="text-sm font-medium py-1 border-b border-base-200 last:border-0">{member.name}</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Photo lightbox -->
|
||||||
|
{#if expandedPhoto}
|
||||||
|
{@const photo = expandedPhoto}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-60 bg-black/90 flex flex-col"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 shrink-0">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-white">{photo.hunterName}</p>
|
||||||
|
<p class="text-xs text-white/40">{parseUTC(photo.photoUploadDateTime).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<StatusBadge status={photo.photoStatus} />
|
||||||
|
<button class="btn btn-ghost btn-sm btn-circle text-white" onclick={() => expandedPhoto = null}>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-h-0 flex items-center justify-center px-4">
|
||||||
|
<AuthImage photoId={photo.id} version="LARGE" alt={photo.hunterName} class="max-h-full max-w-full object-contain rounded-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if photo.photoStatus === 'SUBMITTED' || photo.photoStatus === 'REJECTED'}
|
||||||
|
<div class="px-4 py-4 shrink-0">
|
||||||
|
<button class="btn btn-ghost btn-sm w-full text-white/40" onclick={() => { removePhoto(photo); expandedPhoto = null }}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="pb-4"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Item detail bottom sheet -->
|
<!-- Item detail bottom sheet -->
|
||||||
{#if selectedItem}
|
{#if selectedItem}
|
||||||
<div class="fixed inset-0 z-40" onclick={closeSheet} role="presentation"></div>
|
<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="fixed bottom-0 left-0 right-0 z-50 bg-base-200 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 class="p-4 border-b border-base-300 flex items-center justify-between sticky top-0 bg-base-200">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-bold text-lg">{selectedItem.name}</h3>
|
<h3 class="font-bold text-lg">{selectedItem.name}</h3>
|
||||||
<p class="text-xs text-base-content/50">{selectedItem.points} points</p>
|
<p class="text-xs text-base-content/50">{selectedItem.points} points</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-ghost btn-sm btn-circle" onclick={closeSheet}>✕</button>
|
<button class="btn btn-outline btn-sm" onclick={closeSheet}>Done</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4 flex flex-col gap-4">
|
<div class="p-4 flex flex-col gap-4">
|
||||||
<button
|
{#if submitting}
|
||||||
type="button"
|
<button type="button" class="btn btn-primary w-full gap-2 opacity-50" disabled>
|
||||||
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>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
Uploading…
|
Uploading…
|
||||||
{:else}
|
</button>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
{:else}
|
||||||
<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" />
|
<div class="flex gap-2">
|
||||||
</svg>
|
<button
|
||||||
Take / Upload Photo
|
type="button"
|
||||||
{/if}
|
class="btn btn-primary flex-1 gap-2"
|
||||||
</button>
|
onclick={() => cameraInput?.click()}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
Camera
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline flex-1 gap-2"
|
||||||
|
onclick={() => fileInput?.click()}
|
||||||
|
>
|
||||||
|
<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 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Gallery
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<input
|
||||||
|
bind:this={cameraInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
class="hidden"
|
||||||
|
onchange={handleFileChange}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
bind:this={fileInput}
|
bind:this={fileInput}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
capture="environment"
|
|
||||||
class="hidden"
|
class="hidden"
|
||||||
onchange={handleFileChange}
|
onchange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
@@ -306,14 +494,16 @@
|
|||||||
{:else if itemPhotos.filter(p => p.photoStatus !== 'REMOVED').length > 0}
|
{:else if itemPhotos.filter(p => p.photoStatus !== 'REMOVED').length > 0}
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
{#each itemPhotos.filter(p => p.photoStatus !== 'REMOVED') as photo (photo.id)}
|
{#each itemPhotos.filter(p => p.photoStatus !== 'REMOVED') as photo (photo.id)}
|
||||||
<div class="card bg-base-200 overflow-hidden">
|
<div class="card bg-base-100 overflow-hidden">
|
||||||
<AuthImage photoId={photo.id} version="LARGE" alt={photo.hunterName} class="w-full h-48 object-cover" />
|
<button class="w-full cursor-zoom-in" onclick={() => expandedPhoto = photo}>
|
||||||
|
<AuthImage photoId={photo.id} version="SMALL" alt={photo.hunterName} class="w-full max-h-64 object-contain bg-base-300" />
|
||||||
|
</button>
|
||||||
<div class="p-3 flex items-center justify-between">
|
<div class="p-3 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium">{photo.hunterName}</p>
|
<p class="text-sm font-medium">{photo.hunterName}</p>
|
||||||
<StatusBadge status={photo.photoStatus} />
|
<StatusBadge status={photo.photoStatus} />
|
||||||
</div>
|
</div>
|
||||||
{#if photo.photoStatus === 'SUBMITTED'}
|
{#if photo.photoStatus === 'SUBMITTED' || photo.photoStatus === 'REJECTED'}
|
||||||
<button class="btn btn-ghost btn-xs text-error" onclick={() => removePhoto(photo)}>Remove</button>
|
<button class="btn btn-ghost btn-xs text-error" onclick={() => removePhoto(photo)}>Remove</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -323,6 +513,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<p class="text-center text-base-content/50 py-4 text-sm">No photos submitted yet</p>
|
<p class="text-center text-base-content/50 py-4 text-sm">No photos submitted yet</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user