From 76ef573b3a7656831398e544c6350f837119594f Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Thu, 10 Sep 2026 09:52:02 -0400 Subject: [PATCH] Persist event sign designs and harden invitation permissions --- .../events/[id]/sign/design-controls.tsx | 4 +- .../app/dashboard/events/[id]/sign/page.tsx | 2 + .../app/dashboard/events/[id]/sign/studio.tsx | 67 +++++++++++++++++-- apps/web/src/lib/guest-sign.ts | 25 ++----- apps/web/src/server/api/root.ts | 2 + apps/web/src/server/api/routers/group.ts | 27 +++++++- apps/web/src/server/api/routers/signs.ts | 59 ++++++++++++++++ apps/web/src/server/invites.ts | 13 +++- .../web/src/server/polish.integration.test.ts | 63 +++++++++++++++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/sign.test.ts | 10 +++ packages/contracts/src/sign.ts | 32 +++++++++ .../database/drizzle/0011_event_signs.sql | 6 ++ packages/database/drizzle/meta/_journal.json | 3 +- packages/database/src/schema.ts | 7 ++ 15 files changed, 289 insertions(+), 32 deletions(-) create mode 100644 apps/web/src/server/api/routers/signs.ts create mode 100644 apps/web/src/server/polish.integration.test.ts create mode 100644 packages/contracts/src/sign.test.ts create mode 100644 packages/contracts/src/sign.ts create mode 100644 packages/database/drizzle/0011_event_signs.sql diff --git a/apps/web/src/app/dashboard/events/[id]/sign/design-controls.tsx b/apps/web/src/app/dashboard/events/[id]/sign/design-controls.tsx index 23f3ee2..99d59f6 100644 --- a/apps/web/src/app/dashboard/events/[id]/sign/design-controls.tsx +++ b/apps/web/src/app/dashboard/events/[id]/sign/design-controls.tsx @@ -18,7 +18,7 @@ const presets = { export function SignDesignControls({ design, onChange, onBusy }: { design: SignDesign; onChange: (patch: Partial) => 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
{(["customWidth", "customHeight"] as const).map((key, i) => {i === 0 ? "Width" : "Height"} onChange({ [key]: e.target.value === "" ? 0 : Number(e.target.value) })} />)}
Portrait, square, or landscape. 3–48 inches per edge. Small signs should be scanned up close. : null} - Design preset + Design preset Heading font {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."}
diff --git a/apps/web/src/app/dashboard/events/[id]/sign/page.tsx b/apps/web/src/app/dashboard/events/[id]/sign/page.tsx index 7746b25..1ac53c1 100644 --- a/apps/web/src/app/dashboard/events/[id]/sign/page.tsx +++ b/apps/web/src/app/dashboard/events/[id]/sign/page.tsx @@ -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 ; } diff --git a/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx b/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx index f901dae..948a45c 100644 --- a/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx +++ b/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx @@ -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({ 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 }: {

A little sign. Everyone’s photos. Customize, preview, and print.

+ {canSave ? : null} @@ -88,8 +145,9 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning }: {
- Make it yoursEdits update the preview only, not your event. Download your sign before leaving. + Make it yours{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."} +
Paper size