|
|
|
@@ -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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|