Improve banner drafts and event settings assistant
This commit is contained in:
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user