Persist event sign designs and harden invitation permissions

This commit is contained in:
2026-09-10 09:52:02 -04:00
parent 3a115f1161
commit 76ef573b3a
15 changed files with 289 additions and 32 deletions
@@ -18,7 +18,7 @@ const presets = {
export function SignDesignControls({ design, onChange, onBusy }: { design: SignDesign; onChange: (patch: Partial<SignDesign>) => void; onBusy: (busy: boolean) => void }) {
const [busy, setBusy] = useState(false);
const [preset, setPreset] = useState("indigo");
const preset = Object.entries(presets).find(([, values]) => Object.entries(values).every(([key, value]) => key === "paper" || design[key as keyof SignDesign] === value))?.[0] ?? "";
const uploadId = useRef(0);
async function upload(file: File, field: "logoImage" | "backgroundImage") {
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) {
@@ -52,7 +52,7 @@ export function SignDesignControls({ design, onChange, onBusy }: { design: SignD
<div className="grid grid-cols-2 gap-3">{(["customWidth", "customHeight"] as const).map((key, i) => <Field key={key}><FieldLabel htmlFor={`sign-${key}`}>{i === 0 ? "Width" : "Height"}</FieldLabel><Input id={`sign-${key}`} type="number" step="0.01" min={design.unit === "mm" ? 76.2 : 3} max={design.unit === "mm" ? 1219.2 : 48} value={design[key] ?? ""} onChange={e => onChange({ [key]: e.target.value === "" ? 0 : Number(e.target.value) })} /></Field>)}</div>
<FieldDescription>Portrait, square, or landscape. 348 inches per edge. Small signs should be scanned up close.</FieldDescription>
</> : null}
<Field><FieldLabel htmlFor="sign-preset">Design preset</FieldLabel><Select value={preset} onValueChange={value => { setPreset(value); onChange(presets[value as keyof typeof presets]); }}><SelectTrigger id="sign-preset" className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="linenCard">Card · Linen (no photo)</SelectItem><SelectItem value="indigo">Typography · Indigo</SelectItem><SelectItem value="linen">Photo · Linen</SelectItem><SelectItem value="forest">Photo · Forest</SelectItem><SelectItem value="mono">Typography · Paper</SelectItem></SelectGroup></SelectContent></Select></Field>
<Field><FieldLabel htmlFor="sign-preset">Design preset</FieldLabel><Select value={preset} onValueChange={value => { onChange(presets[value as keyof typeof presets]); }}><SelectTrigger id="sign-preset" className="w-full"><SelectValue placeholder="Custom design" /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="linenCard">Card · Linen (no photo)</SelectItem><SelectItem value="indigo">Typography · Indigo</SelectItem><SelectItem value="linen">Photo · Linen</SelectItem><SelectItem value="forest">Photo · Forest</SelectItem><SelectItem value="mono">Typography · Paper</SelectItem></SelectGroup></SelectContent></Select></Field>
<Field><FieldLabel htmlFor="sign-font">Heading font</FieldLabel><Select value={design.font ?? "funnel"} onValueChange={font => onChange({ font: font as SignDesign["font"] })}><SelectTrigger id="sign-font" className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="funnel">Funnel Display</SelectItem><SelectItem value="geologica">Geologica</SelectItem><SelectItem value="inter">Inter</SelectItem></SelectGroup></SelectContent></Select></Field>
<FieldDescription>{design.layout === "compact" ? "The Linen text-and-QR design without a photo. Starts at 6 × 4 inches; choose any paper size above." : design.layout === "photo" ? "A large event photo with a compact QR section. Add a photo below to enable sign export." : "Bold typography, a full-color background, and a prominent QR. Your uploaded photo is kept for photo presets."}</FieldDescription>
<div className="grid grid-cols-2 gap-3">
@@ -10,6 +10,8 @@ export default async function GuestSignPage({ params }: { params: Promise<{ id:
let event;
try { event = await caller.manager.event({ eventId: id }); } catch { notFound(); }
const current = effectiveEvent(event);
const saved = await caller.signs.get({ eventId: id });
return <GuestSignStudio eventId={event.id} title={event.title} guestUrl={event.guestUrl} qr={signQr(event.guestUrl)}
savedUrl={saved?.url ?? null} savedRevision={saved?.revision ?? null} canSave={event.permissions.includes("settings.manage")}
warning={current.status === "draft" ? "This event is not public yet. Publish it before guests scan this sign." : !current.uploadEnabled ? "Submissions are currently closed. Enable uploads or check the event schedule before using this sign." : null} />;
}
@@ -2,7 +2,9 @@
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { ArrowLeftIcon, DownloadIcon, PrinterIcon, QrCodeIcon } from "lucide-react";
import { ArrowLeftIcon, DownloadIcon, PrinterIcon, QrCodeIcon, SaveIcon } from "lucide-react";
import { savedSignSchema } from "@album/contracts";
import { api } from "@/trpc/react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -12,12 +14,66 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectVa
import { guestQrSvg, guestSignSvg, signPrintHtml, signPaper, SIGN_PAPERS, type SignDesign, type SignQr, type SignPaper } from "@/lib/guest-sign";
import { SignDesignControls } from "./design-controls";
export function GuestSignStudio({ eventId, title, guestUrl, qr, warning }: {
function serializedDesign(design: SignDesign) {
const { fontData, interFontData, geologicaFontData, ...saved } = design;
return JSON.stringify(saved);
}
export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUrl, savedRevision, canSave }: {
eventId: string; title: string; guestUrl: string; qr: SignQr; warning: string | null;
savedUrl: string | null; savedRevision: string | null; canSave: boolean;
}) {
const [design, setDesign] = useState<SignDesign>({ title, headline: "Share your favorite moments", message: "Help us see the day through your eyes. Add your photos to our shared album.", paper: "letter", ink: "indigo", font: "funnel", layout: "typography", background: "#4055b5", accent: "#ffffff", decoration: "minimal", showBrand: true, customWidth: 8.5, customHeight: 11, unit: "in" });
const [fontError, setFontError] = useState(false);
const [assetBusy, setAssetBusy] = useState(false);
const [loadingDesign, setLoadingDesign] = useState(Boolean(savedUrl));
const [loadError, setLoadError] = useState(false);
const [revision, setRevision] = useState(savedRevision);
const [snapshot, setSnapshot] = useState(() => serializedDesign(design));
const [saving, setSaving] = useState(false);
const prepare = api.signs.prepare.useMutation();
const saveMutation = api.signs.save.useMutation();
const dirty = serializedDesign(design) !== snapshot;
useEffect(() => {
if (!savedUrl) return;
let active = true;
void fetch(savedUrl).then(async response => {
if (!response.ok) throw new Error("Could not load saved design");
const loaded = savedSignSchema.parse(await response.json()) as SignDesign;
if (active) {
setDesign(d => ({ ...loaded, fontData: d.fontData, interFontData: d.interFontData, geologicaFontData: d.geologicaFontData }));
setSnapshot(serializedDesign(loaded));
setLoadingDesign(false);
}
}).catch(() => { if (active) { setLoadError(true); setLoadingDesign(false); } });
return () => { active = false; };
}, [savedUrl]);
useEffect(() => {
if (!dirty) return;
const warn = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ""; };
const navigation = (event: MouseEvent) => {
const link = event.target instanceof Element ? event.target.closest("a[href]") as HTMLAnchorElement | null : null;
if (!link || link.download || link.target === "_blank" || event.metaKey || event.ctrlKey || event.shiftKey || !/^https?:/.test(link.href)) return;
if (link.href !== window.location.href && !window.confirm("Leave without saving your sign changes?")) { event.preventDefault(); event.stopPropagation(); }
};
window.addEventListener("beforeunload", warn);
document.addEventListener("click", navigation, true);
return () => { window.removeEventListener("beforeunload", warn); document.removeEventListener("click", navigation, true); };
}, [dirty]);
async function saveDesign() {
const content = serializedDesign(design);
const body = new Blob([content], { type: "application/json" });
if (body.size > 20 * 1024 * 1024) { toast.error("Design is too large. Use a smaller photo or logo."); return; }
setSaving(true);
try {
const upload = await prepare.mutateAsync({ eventId });
const response = await fetch(upload.url, { method: "PUT", headers: { "Content-Type": "application/json" }, body });
if (!response.ok) throw new Error("Design upload failed. Please try again.");
const result = await saveMutation.mutateAsync({ eventId, uploadId: upload.uploadId, revision });
setRevision(result.revision); setSnapshot(content); toast.success("Design saved to this event");
} catch (error) { toast.error(error instanceof Error ? error.message : "Could not save design"); }
finally { setSaving(false); }
}
useEffect(() => {
let active = true;
void Promise.all(["funnel-display", "inter", "geologica"].map(async name => {
@@ -40,7 +96,7 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning }: {
try { paper = signPaper(design); } catch (error) { sizeError = error instanceof Error ? error.message : "Invalid paper size"; }
const svg = guestSignSvg(sizeError ? { ...design, paper: "letter" } : design, guestUrl, qr);
const fontReady = Boolean(design.fontData && design.interFontData && design.geologicaFontData);
const valid = Boolean(design.title.trim() && design.headline.trim() && design.message.trim() && !sizeError && fontReady && !assetBusy && (design.layout !== "photo" || design.backgroundImage));
const valid = Boolean(!loadingDesign && !loadError && design.title.trim() && design.headline.trim() && design.message.trim() && !sizeError && fontReady && !assetBusy && (design.layout !== "photo" || design.backgroundImage));
const localUrl = ["localhost", "127.0.0.1", "[::1]"].includes(new URL(guestUrl).hostname);
function downloadSvg(content: string, filename: string) {
const url = URL.createObjectURL(new Blob([content], { type: "image/svg+xml;charset=utf-8" }));
@@ -81,6 +137,7 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning }: {
<p className="text-sm text-muted-foreground">A little sign. Everyones photos. Customize, preview, and print.</p>
</div>
<div className="flex flex-wrap gap-2">
{canSave ? <Button type="button" variant="outline" disabled={!valid || (!dirty && Boolean(revision)) || loadingDesign || loadError || saving} onClick={() => void saveDesign()}><SaveIcon data-icon="inline-start" />{saving ? "Saving…" : "Save design"}</Button> : null}
<Button type="button" variant="outline" onClick={() => downloadSvg(guestQrSvg(qr), `manyangles-${eventId}-qr.svg`)}><QrCodeIcon data-icon="inline-start" />Download QR SVG</Button>
<Button variant="outline" disabled={!valid} onClick={() => downloadSvg(svg, `manyangles-${eventId}-guest-sign-${design.paper}.svg`)}><DownloadIcon data-icon="inline-start" />Download sign SVG</Button>
<Button disabled={!valid || printing} onClick={print}><PrinterIcon data-icon="inline-start" />{printing ? "Preparing…" : "Print / Save PDF"}</Button>
@@ -88,8 +145,9 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning }: {
</div>
<div className="grid items-start gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
<Card>
<CardHeader><CardTitle>Make it yours</CardTitle><CardDescription>Edits update the preview only, not your event. Download your sign before leaving.</CardDescription></CardHeader>
<CardHeader><CardTitle>Make it yours</CardTitle><CardDescription role="status">{loadingDesign ? "Loading saved design…" : loadError ? "Could not load your saved design. Reload this page before editing." : saving ? "Saving design…" : dirty ? "Unsaved changes" : revision ? "Saved to this event" : "Choose a preset to get started."} {!canSave ? "You can preview and download; an event manager must save changes." : "Your photo, logo, and settings are saved together."}</CardDescription></CardHeader>
<CardContent>
<fieldset disabled={loadingDesign || loadError || saving} className="min-w-0">
<FieldGroup>
<Field><FieldLabel htmlFor="sign-paper">Paper size</FieldLabel>
<Select value={design.paper} onValueChange={paper => setDesign(d => ({ ...d, paper: paper as SignPaper }))}>
@@ -109,6 +167,7 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning }: {
<p role="status" className="text-sm text-muted-foreground">{localUrl ? "Local preview: this QR points to localhost and will not work on guests phones. Generate the final sign on your deployed site." : "Before printing a batch, scan one copy with your phone."}{warning ? ` ${warning}` : ""}</p>
<p className="text-xs text-muted-foreground">Print at 100% on the selected paper size, with browser headers and footers off. The sign includes a safe page margin and a clear border around the QR code.</p>
</FieldGroup>
</fieldset>
</CardContent>
</Card>
<div className="min-w-0 rounded-xl border bg-muted p-4 sm:p-6 xl:sticky xl:top-24">