"use client"; import { useEffect, useMemo, 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"; type QueueItem = { 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 utils = api.useUtils(); 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], ); 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[]) { const accepted = files.filter(isAllowedPhoto); if (!uploadEnabled || busy) 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; const items: QueueItem[] = accepted.map((file) => ({ id: crypto.randomUUID(), name: file.name, progress: 0, status: "queued", })); setQueue((current) => [...items, ...current]); const trimmedName = name.trim(); const trimmedEmail = email.trim(); const trimmedNote = note.trim(); 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)); try { 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 = await createPhoto.mutateAsync({ eventSlug: slug, submissionId: submission.submissionId, contentType, fileName: file.name, byteSize: file.size, }); await putWithProgress(created.uploadUrl, file, contentType, (progress) => { setQueue((current) => current.map((entry) => entry.id === item.id ? { ...entry, progress } : entry, ), ); }); await completePhoto.mutateAsync({ photoId: created.photoId }); setQueue((current) => current.map((entry) => entry.id === item.id ? { ...entry, status: "done", progress: 100 } : entry, ), ); } catch (error) { const message = error instanceof Error ? error.message : "Upload failed"; setQueue((current) => current.map((entry) => entry.id === item.id ? { ...entry, 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, ), ); } } if (!uploadEnabled && !notesEnabled) { return ( Submissions are closed for this event. ); } return (
{uploadEnabled ? : null} {queue.length > 0 ? ( ) : 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)