diff --git a/apps/web/src/app/dashboard/events/[id]/event-settings-form.tsx b/apps/web/src/app/dashboard/events/[id]/event-settings-form.tsx index 0dc77aa..418cc9f 100644 --- a/apps/web/src/app/dashboard/events/[id]/event-settings-form.tsx +++ b/apps/web/src/app/dashboard/events/[id]/event-settings-form.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; -import { CheckIcon, ImageIcon, WandSparklesIcon } from "lucide-react"; +import { CheckIcon, ImageIcon, SaveIcon, WandSparklesIcon } from "lucide-react"; import { eventSlugSchema } from "@album/contracts"; import { LocationInput } from "@/components/location-input"; import { BannerUpload } from "@/components/banner-upload"; @@ -15,6 +15,7 @@ import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { Spinner } from "@/components/ui/spinner"; import { NativeSelect } from "@/components/ui/native-select"; +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; import type { PublishingPolicy } from "@album/contracts"; import { Card, @@ -103,6 +104,56 @@ export function EventSettingsForm({ ); const [formUploadEnabled, setFormUploadEnabled] = useState(uploadEnabled); const [formListed, setFormListed] = useState(listed); + const [bannerBusy, setBannerBusy] = useState(false); + const [bannerReset, setBannerReset] = useState(0); + const customPreview = api.banners.status.useQuery({ eventId, bannerId: formCustomBanner ?? "" }, { enabled: Boolean(formCustomBanner), retry: false }); + const previewUrl = formCustomBanner ? customPreview.data?.url : bannerPhotos.find((photo) => photo.id === formBanner)?.displayUrl; + const draft = { ...publishing, title: formTitle, slug: formSlug, description: formDescription.trim() || null, + location: formLocation.trim() || null, locationCoordinates: coordinates, bannerPhotoId: formBanner, + customBannerId: formCustomBanner, uploadEnabled: formUploadEnabled, listed: formListed }; + const [baseline, setBaseline] = useState(draft); + const changes = [ + ["Title", draft.title !== baseline.title], ["Guest link", draft.slug !== baseline.slug], + ["Description", draft.description !== baseline.description], + ["Location", draft.location !== baseline.location || JSON.stringify(draft.locationCoordinates) !== JSON.stringify(baseline.locationCoordinates)], + ["Banner", draft.bannerPhotoId !== baseline.bannerPhotoId || draft.customBannerId !== baseline.customBannerId], + ["Publishing rules", draft.notesPolicy !== baseline.notesPolicy || draft.galleryPolicy !== baseline.galleryPolicy], + ["Guest statistics", draft.showPhotoStats !== baseline.showPhotoStats || draft.showSubmitterStats !== baseline.showSubmitterStats || draft.showNoteStats !== baseline.showNoteStats], + ["Uploads", draft.uploadEnabled !== baseline.uploadEnabled], ["Homepage listing", draft.listed !== baseline.listed], + ].filter(([, changed]) => changed).map(([label]) => String(label)); + const dirty = changes.length > 0; + const issues = [ + !formTitle.trim() || formTitle.trim().length > 120 ? "Enter a title between 1 and 120 characters." : null, + !canSaveSlug ? slugMessage : null, + formDescription.trim().length > 2000 ? "Keep the description under 2,000 characters." : null, + formLocation.trim().length > 300 ? "Keep the location under 300 characters." : null, + bannerBusy ? "Wait for the banner upload and processing to finish." : null, + formCustomBanner && (customPreview.isPending || customPreview.isError || customPreview.data?.status !== "ready") ? "The selected banner isn't ready. Retry its preview or select another banner." : null, + formBanner && !photos.isLoading && !bannerPhotos.some((photo) => photo.id === formBanner) ? "The selected gallery photo is unavailable. Choose another banner." : null, + ].filter((issue): issue is string => Boolean(issue)); + useEffect(() => { + if (!dirty && !bannerBusy) return; + const beforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ""; }; + const navigate = (event: MouseEvent) => { + const target = event.target instanceof Element ? event.target.closest('a[href]') : null; + if (!target || target.getAttribute("target") === "_blank" || event.metaKey || event.ctrlKey) return; + if (!window.confirm("You have unsaved event settings. Leave without saving?")) { + event.preventDefault(); event.stopPropagation(); + } + }; + window.addEventListener("beforeunload", beforeUnload); + document.addEventListener("click", navigate, true); + return () => { window.removeEventListener("beforeunload", beforeUnload); document.removeEventListener("click", navigate, true); }; + }, [dirty, bannerBusy]); + function discard() { + setFormTitle(baseline.title); setFormSlug(baseline.slug); setFormDescription(baseline.description ?? ""); + setFormLocation(baseline.location ?? ""); setCoordinates(baseline.locationCoordinates); + setFormBanner(baseline.bannerPhotoId); setFormCustomBanner(baseline.customBannerId); + setFormUploadEnabled(baseline.uploadEnabled); setFormListed(baseline.listed); + setPublishing({ notesPolicy: baseline.notesPolicy, galleryPolicy: baseline.galleryPolicy, showPhotoStats: baseline.showPhotoStats, + showSubmitterStats: baseline.showSubmitterStats, showNoteStats: baseline.showNoteStats }); + setBannerReset((value) => value + 1); updateEvent.reset(); + } const utils = api.useUtils(); async function generateSlug() { setGeneratingSlug(true); @@ -114,7 +165,8 @@ export function EventSettingsForm({ } finally { setGeneratingSlug(false); } } const updateEvent = api.manager.updateEvent.useMutation({ - onSuccess: async () => { + onSuccess: async (_result, variables) => { + setBaseline({ ...draft, ...variables }); toast.success("Event saved"); await utils.manager.event.invalidate({ eventId }); router.refresh(); @@ -138,7 +190,7 @@ export function EventSettingsForm({ uploadEnabled?: boolean; listed?: boolean; }) { - if (!canSaveSlug) { toast.error("Choose a valid, available guest link before saving."); return; } + if (issues.length || updateEvent.isPending) { toast.error(issues[0] ?? "Saving is already in progress."); return; } updateEvent.mutate({ ...publishing, eventId, @@ -156,7 +208,8 @@ export function EventSettingsForm({ } return ( - +
+ Event settings @@ -166,13 +219,50 @@ export function EventSettingsForm({
{ event.preventDefault(); save(); }} > +
+ + + + Preview your choice below, then Save changes to apply it. Uploading alone does not change the live banner. + +
+ {previewUrl ? Selected event banner preview : +
{formCustomBanner || formBanner ? "Loading banner preview…" : "No banner selected"}
} +

+ {changes.includes("Banner") ? "Unsaved banner selection" : "Saved banner"} · {formCustomBanner ? "Dedicated upload" : formBanner ? "Gallery photo" : "No image"} +

+
+ {customPreview.isError ? : null} + { setFormCustomBanner(id); setFormBanner(null); }} /> + +
+ Choose from approved gallery photos ({bannerPhotos.length}) +
+ {bannerPhotos.map((photo, index) => ( + + ))} +
+ {photos.isLoading ?

Loading photos…

: null} + {photos.isError ?

Could not load banner photos. Try reopening Settings.

: null} + {!photos.isLoading && !photos.isError && bannerPhotos.length === 0 ?

Upload and approve an event photo to use it as a banner.

: null} +
+ Dedicated uploads stay out of the gallery. Gallery banners follow gallery visibility rules. Images are center-cropped for this preview; page layouts may crop differently. +
Publish notes setPublishing({ ...publishing, notesPolicy: e.target.value as PublishingPolicy })}> @@ -209,6 +299,7 @@ export function EventSettingsForm({ value={formTitle} onChange={(event) => setFormTitle(event.target.value)} required + maxLength={120} /> @@ -257,32 +348,6 @@ export function EventSettingsForm({ }} /> Shown on the event's guest page. - - - - Upload a dedicated banner, or choose an approved gallery photo. Dedicated banners appear as soon as the event is published; gallery photos follow gallery publishing settings. - - { setFormCustomBanner(id); setFormBanner(null); }} /> -
- - {bannerPhotos.map((photo, index) => ( - - ))} -
- {photos.isLoading ?

Loading photos…

: null} - {photos.isError ?

Could not load banner photos. Try reopening Settings.

: null} - {!photos.isLoading && !photos.isError && bannerPhotos.length === 0 ?

Upload and approve an event photo to use it as a banner.

: null} -
Accept uploads { setFormUploadEnabled(checked); - save({ uploadEnabled: checked }); }} /> @@ -301,40 +365,40 @@ export function EventSettingsForm({ checked={formListed} onCheckedChange={(checked) => { setFormListed(checked); - save({ listed: checked }); }} />
+
- {status !== "published" ? ( ) : ( )} {status === "closed" ? ( + +

{formBanner && publishing.galleryPolicy === "never" ? "Your gallery banner will be hidden while the gallery is private. Use a dedicated upload for an independent header." : "A dedicated banner is visible when the event is published. It does not add a photo to the gallery."}

+

Display images are optimized for fast loading. Original uploads are retained unchanged.

+

This pane tracks event settings. Scheduling below has its own save action. Save settings before releasing the gallery or notifying guests.

+ + + +
); } diff --git a/apps/web/src/app/dashboard/events/[id]/page.tsx b/apps/web/src/app/dashboard/events/[id]/page.tsx index c69f51e..1aeafa1 100644 --- a/apps/web/src/app/dashboard/events/[id]/page.tsx +++ b/apps/web/src/app/dashboard/events/[id]/page.tsx @@ -56,7 +56,6 @@ export default async function EventDashboardPage({ label: "Settings", content: (
- +
), }, diff --git a/apps/web/src/components/banner-upload.tsx b/apps/web/src/components/banner-upload.tsx index b07638d..f5292e6 100644 --- a/apps/web/src/components/banner-upload.tsx +++ b/apps/web/src/components/banner-upload.tsx @@ -1,27 +1,45 @@ "use client"; -import { useRef, useState } from "react"; +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 }: { +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(selectedId); + 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) => ["ready", "failed"].includes(query.state.data?.status ?? "") ? false : 1500, + 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); @@ -30,6 +48,9 @@ export function BannerUpload({ eventId, selectedId, onSelect }: { 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, { @@ -38,11 +59,14 @@ export function BannerUpload({ eventId, selectedId, onSelect }: { 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 !== "ready" && status.data?.status !== "failed" && !status.isError; + const processing = Boolean(bannerId) && status.data?.status !== "failed" && !status.isError && !timedOut; + useEffect(() => { onBusyChange(uploading || processing); }, [uploading, processing, onBusyChange]); + useEffect(() => () => onBusyChange(false), [onBusyChange]); return (
@@ -54,21 +78,12 @@ export function BannerUpload({ eventId, selectedId, onSelect }: { if (file) void upload(file); }} /> - {status.data?.status === "ready" && bannerId ? ( - - ) : null}

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" ?

Couldn't prepare this banner. Try uploading another image.

: null} - {status.data?.url ? ( - // eslint-disable-next-line @next/next/no-img-element - Uploaded banner preview - ) : 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}
); } diff --git a/apps/web/src/components/event-workspace.tsx b/apps/web/src/components/event-workspace.tsx index 3eb22bf..eb3b3db 100644 --- a/apps/web/src/components/event-workspace.tsx +++ b/apps/web/src/components/event-workspace.tsx @@ -1,6 +1,6 @@ "use client"; -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { ImageIcon, Settings2Icon, UsersIcon, StickyNoteIcon, ActivityIcon } from "lucide-react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -18,13 +18,14 @@ export function EventWorkspace({ tabs: EventWorkspaceTab[]; }) { const initial = tabs[0]?.id ?? "photos"; + const [settingsVisited, setSettingsVisited] = useState(initial === "settings"); const icons = { photos: ImageIcon, settings: Settings2Icon, people: UsersIcon, notes: StickyNoteIcon, activity: ActivityIcon, audit: ActivityIcon }; return (
{heading} {tabs.length === 0 ? null : ( - + { if (value === "settings") setSettingsVisited(true); }} className="gap-5"> {tabs.map((tab) => { const Icon = icons[tab.id as keyof typeof icons]; @@ -40,7 +41,7 @@ export function EventWorkspace({ ); })} {tabs.map((tab) => ( - + {tab.content} ))}