"use client"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { UploadIcon } from "lucide-react"; import { allowedImageTypeSchema, MAX_PHOTO_BYTES } from "@album/contracts"; import { api } from "@/trpc/react"; import { Button } from "@/components/ui/button"; export function BannerUpload({ eventId, selectedId, onSelect, onBusyChange }: { eventId: string; selectedId: string | null; onSelect: (id: string) => void; onBusyChange: (busy: boolean) => void; }) { const input = useRef(null); const [uploading, setUploading] = useState(false); const [bannerId, setBannerId] = useState(null); const [startedAt, setStartedAt] = useState(null); const [timedOut, setTimedOut] = useState(false); const selectRef = useRef(onSelect); useEffect(() => { selectRef.current = onSelect; }, [onSelect]); const create = api.banners.create.useMutation(); const complete = api.banners.complete.useMutation(); const status = api.banners.status.useQuery({ eventId, bannerId: bannerId ?? "" }, { enabled: Boolean(bannerId), refetchInterval: (query) => timedOut || ["ready", "failed"].includes(query.state.data?.status ?? "") ? false : 1500, retry: false, }); useEffect(() => { if (!timedOut && bannerId && status.data?.status === "ready") { selectRef.current(bannerId); setBannerId(null); setStartedAt(null); toast.success("Banner ready to preview. Save changes to apply it."); } }, [bannerId, status.data?.status, timedOut]); useEffect(() => { if (!startedAt) return; const timer = setTimeout(() => setTimedOut(true), 120_000); return () => clearTimeout(timer); }, [startedAt]); async function upload(file: File) { const mime = file.type || (/\.heic$/i.test(file.name) ? "image/heic" : /\.heif$/i.test(file.name) ? "image/heif" : ""); const parsed = allowedImageTypeSchema.safeParse(mime); if (!parsed.success || file.size <= 0 || file.size > MAX_PHOTO_BYTES) { toast.error("Choose a JPEG, PNG, WebP, or HEIC image up to 25 MB."); return; } setUploading(true); setTimedOut(false); setBannerId(null); setStartedAt(null); try { const pending = await create.mutateAsync({ eventId, contentType: parsed.data, byteSize: file.size }); const response = await fetch(pending.uploadUrl, { method: "PUT", body: file, headers: { "Content-Type": parsed.data }, signal: AbortSignal.timeout(120_000), }); if (!response.ok) throw new Error("Banner upload failed. Please try again."); await complete.mutateAsync({ eventId, bannerId: pending.bannerId }); setBannerId(pending.bannerId); setStartedAt(Date.now()); } catch (error) { toast.error(error instanceof Error ? error.message : "Banner upload failed"); } finally { setUploading(false); } } const processing = Boolean(bannerId) && status.data?.status !== "failed" && !status.isError && !timedOut; useEffect(() => { onBusyChange(uploading || processing); }, [uploading, processing, onBusyChange]); useEffect(() => () => onBusyChange(false), [onBusyChange]); return (
{ const file = event.target.files?.[0]; event.target.value = ""; if (file) void upload(file); }} />

A separate image just for this event's header. It won't appear in the gallery. Up to 25 MB.

{processing ?

Preparing your banner… You can keep editing while it processes.

: null} {status.isError || status.data?.status === "failed" || timedOut ?

{timedOut ? "Processing is taking longer than expected. Your previous banner is unchanged; try another upload." : "Couldn't prepare this banner. Your previous banner is unchanged. Try uploading another image."}

: null}
); }