"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { ChevronDownIcon, SendIcon, UploadIcon } from "lucide-react"; import { useRouter } from "next/navigation"; import type { PublishingPolicy } from "@album/contracts"; import { cn } from "@/lib/utils"; import { MAX_PHOTO_BYTES } from "@album/contracts"; import { api } from "@/trpc/react"; import { imageContentType, isAllowedPhoto, putWithProgress } from "@/lib/upload"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; import { Progress } from "@/components/ui/progress"; import { Spinner } from "@/components/ui/spinner"; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; import { uploadProgress } from "@/lib/upload-progress"; type QueueItem = { file?: File; created?: { photoId: string; eventId: string; uploadUrl: string }; uploaded?: boolean; id: string; name: string; progress: number; status: "queued" | "uploading" | "done" | "error"; error?: string; }; function guestKey(slug: string, field: string) { return `album:guest:${slug}:${field}`; } export function GuestUpload({ slug, uploadEnabled, notesEnabled, notesPolicy, }: { slug: string; uploadEnabled: boolean; notesEnabled: boolean; notesPolicy: PublishingPolicy; }) { const router = useRouter(); const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [note, setNote] = useState(""); const [notify, setNotify] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false); const [dragging, setDragging] = useState(false); const [queue, setQueue] = useState([]); const ensureGuest = api.guest.ensure.useMutation(); const startSubmission = api.guest.startSubmission.useMutation(); const createPhoto = api.photos.create.useMutation(); const completePhoto = api.photos.complete.useMutation(); const retryUpload = api.photos.retryUpload.useMutation(); const uploading = useRef(false); const utils = api.useUtils(); const progress = uploadProgress(queue); useEffect(() => { const storedName = localStorage.getItem(guestKey(slug, "name")) ?? ""; const storedEmail = localStorage.getItem(guestKey(slug, "email")) ?? ""; const storedNote = localStorage.getItem(guestKey(slug, "note")) ?? ""; setName(storedName); setEmail(storedEmail); setNote(storedNote); setNotify(localStorage.getItem(guestKey(slug, "notify")) === "true"); if (storedName || storedEmail || storedNote) setDetailsOpen(true); }, [slug]); const busy = useMemo( () => queue.some((item) => item.status === "queued" || item.status === "uploading"), [queue], ); useEffect(() => { if (!busy) return; const warn = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ""; }; window.addEventListener("beforeunload", warn); return () => window.removeEventListener("beforeunload", warn); }, [busy]); async function sendNote(detailsOnly = false) { try { await ensureGuest.mutateAsync({ eventSlug: slug, displayName: name.trim() || undefined, email: email.trim() || undefined, notifyWhenReady: Boolean(email.trim()) && notify, note: detailsOnly ? undefined : note.trim() }); localStorage.setItem(guestKey(slug, "name"), name.trim()); localStorage.setItem(guestKey(slug, "email"), email.trim()); localStorage.setItem(guestKey(slug, "notify"), String(notify)); if (!detailsOnly) { localStorage.removeItem(guestKey(slug, "note")); setNote(""); } toast.success(detailsOnly ? "Guest details saved" : "Note sent"); router.refresh(); } catch (error) { toast.error(error instanceof Error ? error.message : "Could not send note"); } } async function uploadFiles(files: File[], retryItems?: QueueItem[]) { const accepted = files.filter(isAllowedPhoto); if (!uploadEnabled || uploading.current) return; if (accepted.length !== files.length) { toast.error("Some files were skipped. Use JPEG, PNG, WebP, or HEIC under 25 MB."); } if (!accepted.length) return; uploading.current = true; const items: QueueItem[] = retryItems ?? accepted.map((file) => ({ file, id: crypto.randomUUID(), name: file.name, progress: 0, status: "queued", })); for (const item of items) { item.status = "queued"; item.error = undefined; } setQueue((current) => retryItems ? current.map(entry => items.find(item => item.id === entry.id) ?? entry) : [...items, ...current]); const trimmedName = name.trim(); const trimmedEmail = email.trim(); const trimmedNote = note.trim(); try { localStorage.setItem(guestKey(slug, "name"), trimmedName); localStorage.setItem(guestKey(slug, "email"), trimmedEmail); localStorage.setItem(guestKey(slug, "note"), trimmedNote); localStorage.setItem(guestKey(slug, "notify"), String(notify)); await ensureGuest.mutateAsync({ eventSlug: slug, displayName: trimmedName || undefined, email: trimmedEmail || undefined, notifyWhenReady: Boolean(trimmedEmail) && notify, note: trimmedNote || undefined, }); const submission = await startSubmission.mutateAsync({ eventSlug: slug }); for (const [index, file] of accepted.entries()) { const item = items[index]; if (!item) continue; const contentType = imageContentType(file); if (!contentType) continue; setQueue((current) => current.map((entry) => entry.id === item.id ? { ...entry, status: "uploading" } : entry, ), ); try { const created = item.created ?? await createPhoto.mutateAsync({ eventSlug: slug, submissionId: submission.submissionId, contentType, fileName: file.name, byteSize: file.size, }); const previous = item.created; item.created = created; const uploadUrl = previous && !item.uploaded ? (await retryUpload.mutateAsync({ photoId: created.photoId, eventId: created.eventId })).uploadUrl : created.uploadUrl; if (!item.uploaded && uploadUrl) await putWithProgress(uploadUrl, file, contentType, (progress) => { setQueue((current) => current.map((entry) => entry.id === item.id ? { ...entry, progress } : entry, ), ); }); item.uploaded = true; await completePhoto.mutateAsync({ photoId: created.photoId }); setQueue((current) => current.map((entry) => entry.id === item.id ? { ...entry, status: "done", progress: 100, file: undefined } : entry, ), ); } catch (error) { const message = error instanceof Error ? error.message : "Upload failed"; setQueue((current) => current.map((entry) => entry.id === item.id ? { ...entry, created: item.created, uploaded: item.uploaded, status: "error", error: message } : entry, ), ); } } await utils.event.gallery.invalidate(slug); router.refresh(); } catch (error) { toast.error(error instanceof Error ? error.message : "Could not start upload"); setQueue((current) => current.map((entry) => items.some((item) => item.id === entry.id) && entry.status === "queued" ? { ...entry, status: "error", error: "Could not start upload" } : entry, ), ); } finally { uploading.current = false; } } if (!uploadEnabled && !notesEnabled) { return ( Submissions are closed for this event. ); } return (
{uploadEnabled ? : null} {queue.length > 0 ? (
{busy ? "Uploading your photos" : progress.failed ? "Some photos need attention" : "Photos uploaded"} {progress.percent}%
{progress.completed} of {progress.total} photos uploaded{progress.failed ? ` · ${progress.failed} need retry` : ""}

{busy ? "Keep this page open until uploading finishes." : progress.failed ? "Open file details below to retry unsuccessful uploads." : "Your originals are saved. Gallery previews may take a moment to appear."}

File details
    {queue.map((item) => (
  • {item.name} {item.status === "done" ? "Uploaded · processing for gallery" : item.status === "uploading" ? `${item.progress}% uploaded` : item.status === "error" ? "Needs retry" : "Waiting"}
    {item.error ? (

    {item.error}

    ) : null} {item.status === "error" && item.file ? : null}
  • ))}
) : null}
setDetailsOpen(event.currentTarget.open)} > Add a name or note
Your name (optional) setName(event.target.value)} placeholder="Your name" maxLength={80} className="tap-target" /> Email (optional) setEmail(event.target.value)} placeholder="you@example.com" className="tap-target" /> Stay anonymous if you skip this. Remembered on this device for this event. {email.trim() ? ( Email me when the gallery is ready ) : null} A note for the event people (optional)