Adds ability to expand photos, and upload both from camera and from gallery
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<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, apiListTeams, apiReviewPhoto} from '../../lib/api/index'
|
||||||
import type {HuntResponse, ItemResponse, PhotoResponse, TeamResponse} from '../../lib/api/types'
|
import type {HuntResponse, ItemResponse, PhotoResponse, TeamResponse} from '../../lib/api/types'
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
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<PhotoWithTeam | null>(null)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const { huntId } = params
|
const { huntId } = params
|
||||||
@@ -54,11 +55,13 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function review(photo: PhotoWithTeam, status: 'APPROVED' | 'REJECTED') {
|
async function review(photo: PhotoWithTeam, 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 PhotoWithTeam
|
||||||
|
photos = photos.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 {
|
||||||
@@ -69,6 +72,66 @@
|
|||||||
const activePhotos = $derived(photos.filter(p => p.photoStatus !== 'REMOVED'))
|
const activePhotos = $derived(photos.filter(p => p.photoStatus !== 'REMOVED'))
|
||||||
</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="font-semibold text-white">{photo.hunterName} <span class="text-white/50 font-normal">· {photo.teamName}</span></p>
|
||||||
|
<p class="text-xs text-white/40">{new Date(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>
|
||||||
@@ -110,7 +173,9 @@
|
|||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
{#each activePhotos as photo}
|
{#each activePhotos as photo}
|
||||||
<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>
|
||||||
|
|||||||
@@ -41,7 +41,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
|
||||||
|
|
||||||
@@ -263,6 +265,40 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Photo lightbox -->
|
||||||
|
{#if 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">{expandedPhoto.hunterName}</p>
|
||||||
|
<p class="text-xs text-white/40">{new Date(expandedPhoto.photoUploadDateTime).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<StatusBadge status={expandedPhoto.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={expandedPhoto.id} version="LARGE" alt={expandedPhoto.hunterName} class="max-h-full max-w-full object-contain rounded-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if expandedPhoto.photoStatus === 'SUBMITTED' || expandedPhoto.photoStatus === 'REJECTED'}
|
||||||
|
<div class="px-4 py-4 shrink-0">
|
||||||
|
<button class="btn btn-ghost btn-sm w-full text-white/40" onclick={() => { removePhoto(expandedPhoto!); 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>
|
||||||
@@ -275,28 +311,47 @@
|
|||||||
<button class="btn btn-ghost btn-sm btn-circle" onclick={closeSheet}>✕</button>
|
<button class="btn btn-ghost btn-sm btn-circle" onclick={closeSheet}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4 flex flex-col gap-4">
|
<div class="p-4 flex flex-col gap-4">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary w-full gap-2"
|
|
||||||
class:opacity-50={submitting}
|
|
||||||
disabled={submitting}
|
|
||||||
onclick={() => fileInput?.click()}
|
|
||||||
>
|
|
||||||
{#if submitting}
|
{#if submitting}
|
||||||
|
<button type="button" class="btn btn-primary w-full gap-2 opacity-50" disabled>
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
Uploading…
|
Uploading…
|
||||||
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary flex-1 gap-2"
|
||||||
|
onclick={() => cameraInput?.click()}
|
||||||
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
<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" />
|
<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>
|
</svg>
|
||||||
Take / Upload Photo
|
Camera
|
||||||
{/if}
|
|
||||||
</button>
|
</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}
|
||||||
/>
|
/>
|
||||||
@@ -307,13 +362,15 @@
|
|||||||
<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-200 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 h-48 object-cover" />
|
||||||
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user