Improve banner drafts and event settings assistant

This commit is contained in:
2026-09-09 19:12:06 -04:00
parent 2eecf853a1
commit 176b5fa95c
4 changed files with 157 additions and 60 deletions
@@ -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 (
<Card>
<div className="grid items-start gap-5 xl:grid-cols-[minmax(0,1fr)_19rem]">
<Card className="min-w-0">
<CardHeader>
<CardTitle className="text-lg font-semibold tracking-tight">Event settings</CardTitle>
<CardDescription>
@@ -166,13 +219,50 @@ export function EventSettingsForm({
</CardHeader>
<CardContent>
<form
id="event-settings-form"
className="flex flex-col gap-5"
onSubmit={(event) => {
event.preventDefault();
save();
}}
>
<fieldset disabled={updateEvent.isPending} className="min-w-0">
<FieldGroup>
<Field>
<span id="banner-label" className="text-sm font-medium">Event Banner</span>
<FieldDescription>
Preview your choice below, then Save changes to apply it. Uploading alone does not change the live banner.
</FieldDescription>
<div className="overflow-hidden rounded-xl border bg-muted">
{previewUrl ? <img src={previewUrl} alt="Selected event banner preview" width={1200} height={450} className="aspect-[8/3] w-full object-cover" /> :
<div className="flex aspect-[8/3] items-center justify-center gap-2 text-sm text-muted-foreground"><ImageIcon className="size-5" />{formCustomBanner || formBanner ? "Loading banner preview…" : "No banner selected"}</div>}
<p className="border-t bg-card px-4 py-3 text-sm" role="status">
{changes.includes("Banner") ? "Unsaved banner selection" : "Saved banner"} · {formCustomBanner ? "Dedicated upload" : formBanner ? "Gallery photo" : "No image"}
</p>
</div>
{customPreview.isError ? <Button type="button" variant="outline" onClick={() => void customPreview.refetch()}>Retry banner preview</Button> : null}
<BannerUpload key={bannerReset} eventId={eventId} selectedId={formCustomBanner} onBusyChange={setBannerBusy}
onSelect={(id) => { setFormCustomBanner(id); setFormBanner(null); }} />
<Button type="button" variant="outline" disabled={bannerBusy || (!formBanner && !formCustomBanner)} onClick={() => { setFormBanner(null); setFormCustomBanner(null); }}>Remove banner</Button>
<details className="rounded-lg border p-3">
<summary className="cursor-pointer py-2 text-sm font-medium">Choose from approved gallery photos ({bannerPhotos.length})</summary>
<fieldset disabled={bannerBusy} aria-labelledby="banner-label" className="mt-3 grid max-h-80 grid-cols-2 gap-3 overflow-y-auto sm:grid-cols-3">
{bannerPhotos.map((photo, index) => (
<button key={photo.id} type="button" aria-label={`Use photo ${index + 1} as banner`}
aria-pressed={formBanner === photo.id} onClick={() => { setFormBanner(photo.id); setFormCustomBanner(null); }}
className="relative aspect-[3/2] overflow-hidden rounded-lg border-2 border-transparent aria-pressed:border-primary focus-visible:outline-2 focus-visible:outline-ring">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photo.thumbUrl ?? photo.displayUrl ?? ""} alt="" width={300} height={200} loading="lazy" className="size-full object-cover" />
{formBanner === photo.id ? <span className="absolute right-2 top-2 rounded-full bg-primary p-1 text-primary-foreground"><CheckIcon aria-hidden="true" className="size-4" /></span> : null}
</button>
))}
</fieldset>
{photos.isLoading ? <p className="text-xs text-muted-foreground">Loading photos</p> : null}
{photos.isError ? <p role="alert" className="text-sm text-destructive">Could not load banner photos. Try reopening Settings.</p> : null}
{!photos.isLoading && !photos.isError && bannerPhotos.length === 0 ? <p className="text-sm text-muted-foreground">Upload and approve an event photo to use it as a banner.</p> : null}
</details>
<FieldDescription>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.</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="notes-policy">Publish notes</FieldLabel>
<NativeSelect id="notes-policy" value={publishing.notesPolicy} onChange={(e) => 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}
/>
</Field>
<Field data-invalid={!slugValid || (formSlug !== slug && !slugChecking && !slugAvailable && !availability.isError)}>
@@ -257,32 +348,6 @@ export function EventSettingsForm({
}} />
<FieldDescription>Shown on the event's guest page.</FieldDescription>
</Field>
<Field>
<span id="banner-label" className="text-sm font-medium">Event Banner</span>
<FieldDescription>
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.
</FieldDescription>
<BannerUpload eventId={eventId} selectedId={formCustomBanner}
onSelect={(id) => { setFormCustomBanner(id); setFormBanner(null); }} />
<div role="group" aria-labelledby="banner-label" className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<button type="button" aria-pressed={formBanner === null && formCustomBanner === null} onClick={() => { setFormBanner(null); setFormCustomBanner(null); }}
className="flex aspect-[3/2] flex-col items-center justify-center gap-2 rounded-lg border-2 border-border bg-muted text-sm text-muted-foreground aria-pressed:border-primary aria-pressed:text-primary focus-visible:outline-2 focus-visible:outline-ring">
<ImageIcon aria-hidden="true" className="size-5" />No Banner
</button>
{bannerPhotos.map((photo, index) => (
<button key={photo.id} type="button" aria-label={`Use photo ${index + 1} as banner`}
aria-pressed={formBanner === photo.id} onClick={() => { setFormBanner(photo.id); setFormCustomBanner(null); }}
className="relative aspect-[3/2] overflow-hidden rounded-lg border-2 border-transparent aria-pressed:border-primary focus-visible:outline-2 focus-visible:outline-ring">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photo.thumbUrl ?? photo.displayUrl ?? ""} alt="" width={300} height={200} loading="lazy" className="size-full object-cover" />
{formBanner === photo.id ? <span className="absolute right-2 top-2 rounded-full bg-primary p-1 text-primary-foreground"><CheckIcon aria-hidden="true" className="size-4" /></span> : null}
</button>
))}
</div>
{photos.isLoading ? <p className="text-xs text-muted-foreground">Loading photos</p> : null}
{photos.isError ? <p role="alert" className="text-sm text-destructive">Could not load banner photos. Try reopening Settings.</p> : null}
{!photos.isLoading && !photos.isError && bannerPhotos.length === 0 ? <p className="text-sm text-muted-foreground">Upload and approve an event photo to use it as a banner.</p> : null}
</Field>
<Field orientation="horizontal">
<FieldLabel htmlFor="uploads">Accept uploads</FieldLabel>
<Switch
@@ -290,7 +355,6 @@ export function EventSettingsForm({
checked={formUploadEnabled}
onCheckedChange={(checked) => {
setFormUploadEnabled(checked);
save({ uploadEnabled: checked });
}}
/>
</Field>
@@ -301,40 +365,40 @@ export function EventSettingsForm({
checked={formListed}
onCheckedChange={(checked) => {
setFormListed(checked);
save({ listed: checked });
}}
/>
</Field>
</FieldGroup>
</fieldset>
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={updateEvent.isPending || !canSaveSlug}>
<Button type="submit" disabled={updateEvent.isPending || issues.length > 0 || !dirty}>
{updateEvent.isPending ? <Spinner data-icon="inline-start" /> : null}
Save
Save changes
</Button>
{status !== "published" ? (
<Button
type="button"
variant="outline"
disabled={updateEvent.isPending}
disabled={updateEvent.isPending || issues.length > 0}
onClick={() => save({ status: "published" })}
>
Publish link
Save and publish link
</Button>
) : (
<Button
type="button"
variant="outline"
disabled={updateEvent.isPending}
disabled={updateEvent.isPending || issues.length > 0}
onClick={() => save({ status: "closed" })}
>
Close uploads
Save and close uploads
</Button>
)}
{status === "closed" ? (
<Button
type="button"
variant="ghost"
disabled={updateEvent.isPending}
disabled={updateEvent.isPending || issues.length > 0}
onClick={() => save({ status: "published" })}
>
Reopen
@@ -343,7 +407,7 @@ export function EventSettingsForm({
<Button
type="button"
variant="secondary"
disabled={release.isPending || galleryPolicy === "never"}
disabled={release.isPending || updateEvent.isPending || dirty || bannerBusy || galleryPolicy === "never"}
onClick={() =>
release.mutate({ eventId, notifyGuests: true })
}
@@ -354,5 +418,22 @@ export function EventSettingsForm({
</form>
</CardContent>
</Card>
<aside aria-label="Event settings assistant" className="order-first xl:order-last xl:sticky xl:top-24">
<Card>
<CardHeader><CardTitle>Assistant</CardTitle><CardDescription>Review your settings before they go live.</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-4">
<div role="status" aria-live="polite"><p className="text-sm font-medium">{updateEvent.isPending ? "Saving changes…" : dirty ? `${changes.length} unsaved ${changes.length === 1 ? "change" : "changes"}` : "All changes saved"}</p>
{dirty ? <ul className="mt-2 list-inside list-disc text-sm text-muted-foreground">{changes.map((change) => <li key={change}>{change}</li>)}</ul> : <p className="mt-1 text-sm text-muted-foreground">Selections stay in draft until you save.</p>}</div>
{issues.length ? <Alert variant="destructive"><AlertTitle>Needs attention</AlertTitle><AlertDescription><ul className="list-inside list-disc">{issues.map((issue) => <li key={issue}>{issue}</li>)}</ul></AlertDescription></Alert> : null}
{updateEvent.error ? <Alert variant="destructive"><AlertTitle>Changes not saved</AlertTitle><AlertDescription>{updateEvent.error.message} Your edits are still here. Correct the issue and try again.</AlertDescription></Alert> : null}
<Button type="submit" form="event-settings-form" disabled={!dirty || issues.length > 0 || updateEvent.isPending}><SaveIcon data-icon="inline-start" />{updateEvent.isPending ? "Saving…" : "Save changes"}</Button>
<Button type="button" variant="outline" disabled={!dirty || bannerBusy || updateEvent.isPending} onClick={discard}>Discard changes</Button>
<p className="text-xs text-muted-foreground">{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."}</p>
<p className="text-xs text-muted-foreground">Display images are optimized for fast loading. Original uploads are retained unchanged.</p>
<p className="text-xs text-muted-foreground">This pane tracks event settings. Scheduling below has its own save action. Save settings before releasing the gallery or notifying guests.</p>
</CardContent>
</Card>
</aside>
</div>
);
}
@@ -56,7 +56,6 @@ export default async function EventDashboardPage({
label: "Settings",
content: (
<div className="flex flex-col gap-4">
<EventSchedule eventId={event.id} />
<EventSettingsForm
eventId={event.id}
title={event.title}
@@ -77,6 +76,7 @@ export default async function EventDashboardPage({
showSubmitterStats={event.showSubmitterStats}
showNoteStats={event.showNoteStats}
/>
<EventSchedule eventId={event.id} />
</div>
),
},