Add printable guest sign studio and refine event imagery

This commit is contained in:
2026-09-10 09:26:49 -04:00
parent 176b5fa95c
commit 3a115f1161
48 changed files with 1294 additions and 102 deletions
@@ -246,22 +246,24 @@ export function EventSettingsForm({
<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">
<div className="mt-3 max-h-80 overflow-y-auto p-1">
<fieldset disabled={bannerBusy} aria-labelledby="banner-label" className="grid min-w-0 auto-rows-max grid-cols-2 gap-3 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">
className="relative block aspect-[8/3] w-full min-w-0 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" />
<img src={photo.thumbUrl ?? photo.displayUrl ?? ""} alt="" width={320} height={120} loading="lazy" className="absolute inset-0 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>
</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}
</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>
<FieldDescription>Banners use an 8:3 frame everywhere. Uploads use your chosen crop and stay out of the gallery. Gallery photos are center-cropped and follow gallery visibility rules.</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="notes-policy">Publish notes</FieldLabel>
@@ -1,4 +1,7 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { QrCodeIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { createServerCaller } from "@/trpc/server";
import { Badge } from "@/components/ui/badge";
import { EventWorkspace, type EventWorkspaceTab } from "@/components/event-workspace";
@@ -132,10 +135,11 @@ export default async function EventDashboardPage({
<Badge variant="outline">Gallery held</Badge>
)}
</div>
<p className="truncate text-sm text-muted-foreground">{event.guestUrl}</p>
<p className="truncate text-sm text-muted-foreground">{event.guestUrl.replace(/^https?:\/\//, "")}</p>
{event.location ? <p className="text-sm text-muted-foreground">{event.location}</p> : null}
</div>
<CopyGuestLink url={event.guestUrl} />
<Button asChild variant="outline"><Link href={`/dashboard/events/${event.id}/sign`}><QrCodeIcon data-icon="inline-start" />Guest sign</Link></Button>
</div>
}
tabs={tabs}
@@ -0,0 +1,69 @@
"use client";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { SignDesign } from "@/lib/guest-sign";
const presets = {
linenCard: { layout: "compact", paper: "card6x4", background: "#f1eadf", accent: "#473a30", decoration: "minimal", font: "funnel" },
indigo: { layout: "typography", background: "#4055b5", accent: "#ffffff", decoration: "minimal", font: "funnel" },
linen: { layout: "photo", background: "#f1eadf", accent: "#473a30", decoration: "minimal", font: "funnel" },
forest: { layout: "photo", background: "#153e35", accent: "#ffffff", decoration: "minimal", font: "funnel" },
mono: { layout: "typography", background: "#f4f2ec", accent: "#171717", decoration: "minimal", font: "funnel" },
} as const;
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 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) {
toast.error("Choose a JPEG, PNG, or WebP up to 10 MB."); return;
}
const id = ++uploadId.current;
setBusy(true); onBusy(true);
const url = URL.createObjectURL(file);
try {
const image = new Image();
image.src = url;
await image.decode();
const max = field === "logoImage" ? 1000 : 2200;
const scale = Math.min(1, max / Math.max(image.naturalWidth, image.naturalHeight));
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(image.naturalWidth * scale)); canvas.height = Math.max(1, Math.round(image.naturalHeight * scale));
const context = canvas.getContext("2d");
if (!context) throw new Error("Image tools unavailable");
context.drawImage(image, 0, 0, canvas.width, canvas.height);
if (id === uploadId.current) onChange({ [field]: canvas.toDataURL("image/png") });
} catch { toast.error("Couldn't open this image. Try another file."); }
finally { URL.revokeObjectURL(url); setBusy(false); onBusy(false); }
}
return <>
{design.paper === "custom" ? <>
<Field><FieldLabel htmlFor="sign-unit">Units</FieldLabel><Select value={design.unit ?? "in"} onValueChange={value => {
const unit = value as "in" | "mm";
const factor = unit === design.unit ? 1 : unit === "mm" ? 25.4 : 1 / 25.4;
onChange({ unit, customWidth: Number(((design.customWidth ?? 8.5) * factor).toFixed(2)), customHeight: Number(((design.customHeight ?? 11) * factor).toFixed(2)) });
}}><SelectTrigger id="sign-unit" className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="in">Inches</SelectItem><SelectItem value="mm">Millimeters</SelectItem></SelectGroup></SelectContent></Select></Field>
<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-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">
<Field><FieldLabel htmlFor="sign-background">Background</FieldLabel><Input id="sign-background" type="color" value={design.background ?? "#eeece5"} onChange={e => onChange({ background: e.target.value })} /></Field>
<Field><FieldLabel htmlFor="sign-accent">Accent</FieldLabel><Input id="sign-accent" type="color" value={design.accent ?? "#4055b5"} onChange={e => onChange({ accent: e.target.value })} /><FieldDescription>Text adapts for contrast against the background.</FieldDescription></Field>
</div>
{([{ key: "backgroundImage", label: "Background photo" }, { key: "logoImage", label: "Your logo" }] as const).map(field => <Field key={field.key}>
<FieldLabel htmlFor={`sign-${field.key}`}>{field.label}</FieldLabel><Input id={`sign-${field.key}`} type="file" accept="image/jpeg,image/png,image/webp" disabled={busy} onChange={e => { const file = e.target.files?.[0]; e.target.value = ""; if (file) void upload(file, field.key); }} />
{design[field.key] ? <Button type="button" variant="outline" disabled={busy} onClick={() => onChange({ [field.key]: undefined })}>Remove {field.label.toLowerCase()}</Button> : null}
<FieldDescription>{field.key === "logoImage" ? "Transparent PNG works best. Replaces the Manyangles logo." : "Used by photo presets. Cropped to fill the photo area; switch presets without losing your upload."} Embedded in your export; never added to the event gallery.</FieldDescription>
</Field>)}
<Field><FieldLabel htmlFor="sign-brand">Manyangles logo</FieldLabel><Select value={design.showBrand === false ? "hide" : "show"} onValueChange={value => onChange({ showBrand: value === "show" })}><SelectTrigger id="sign-brand" className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="show">Show when no custom logo</SelectItem><SelectItem value="hide">Hide</SelectItem></SelectGroup></SelectContent></Select></Field>
</>;
}
@@ -0,0 +1,15 @@
import { notFound } from "next/navigation";
import { createServerCaller } from "@/trpc/server";
import { effectiveEvent } from "@/lib/event-lifecycle";
import { signQr } from "@/server/sign-qr";
import { GuestSignStudio } from "./studio";
export default async function GuestSignPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const caller = await createServerCaller();
let event;
try { event = await caller.manager.event({ eventId: id }); } catch { notFound(); }
const current = effectiveEvent(event);
return <GuestSignStudio eventId={event.id} title={event.title} guestUrl={event.guestUrl} qr={signQr(event.guestUrl)}
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} />;
}
@@ -0,0 +1,121 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { ArrowLeftIcon, DownloadIcon, PrinterIcon, QrCodeIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
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 }: {
eventId: string; title: string; guestUrl: string; qr: SignQr; warning: string | null;
}) {
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);
useEffect(() => {
let active = true;
void Promise.all(["funnel-display", "inter", "geologica"].map(async name => {
const response = await fetch(`/fonts/${name}.woff2`);
if (!response.ok) throw new Error("Font unavailable");
const bytes = new Uint8Array(await response.arrayBuffer());
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return `data:font/woff2;base64,${btoa(binary)}`;
})).then(([fontData, interFontData, geologicaFontData]) => {
if (active) setDesign(d => ({ ...d, fontData, interFontData, geologicaFontData }));
}).catch(() => { if (active) setFontError(true); });
return () => { active = false; };
}, []);
const printFrame = useRef<HTMLIFrameElement | null>(null);
const [printing, setPrinting] = useState(false);
useEffect(() => () => { printFrame.current?.remove(); }, []);
let sizeError: string | null = null;
let paper = SIGN_PAPERS.letter as ReturnType<typeof signPaper>;
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 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" }));
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function print() {
printFrame.current?.remove();
const frame = document.createElement("iframe");
frame.title = "Printable guest sign";
frame.setAttribute("aria-hidden", "true");
frame.tabIndex = -1;
frame.style.cssText = "position:fixed;left:-10000px;top:0;width:850px;height:1202px;border:0";
printFrame.current = frame;
setPrinting(true);
frame.onload = async () => {
try {
if (!frame.contentWindow) throw new Error("Print window unavailable");
await frame.contentDocument?.fonts.ready;
await Promise.all(Array.from(frame.contentDocument?.images ?? []).map(image => image.decode()));
frame.contentWindow.addEventListener("afterprint", () => frame.remove(), { once: true });
frame.contentWindow.focus();
frame.contentWindow.print();
} catch { toast.error("Printing is unavailable here. Download the SVG and open it in your browser to print."); }
finally { setPrinting(false); }
};
frame.srcdoc = signPrintHtml(svg, design);
document.body.append(frame);
}
return <div className="reveal flex min-w-0 flex-col gap-6 sm:gap-8">
<div className="flex flex-col gap-4 2xl:flex-row 2xl:items-end 2xl:justify-between">
<div className="flex min-w-0 flex-col gap-1">
<Link href={`/dashboard/events/${eventId}`} className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"><ArrowLeftIcon className="size-4" />Back to event</Link>
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">Guest sign studio</h1>
<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">
<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>
</div>
</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>
<CardContent>
<FieldGroup>
<Field><FieldLabel htmlFor="sign-paper">Paper size</FieldLabel>
<Select value={design.paper} onValueChange={paper => setDesign(d => ({ ...d, paper: paper as SignPaper }))}>
<SelectTrigger id="sign-paper" className="w-full"><SelectValue /></SelectTrigger>
<SelectContent><SelectGroup>{Object.entries(SIGN_PAPERS).map(([key, paper]) => <SelectItem key={key} value={key}>{paper.label}</SelectItem>)}<SelectItem value="custom">Custom dimensions</SelectItem></SelectGroup></SelectContent>
</Select>
</Field>
<SignDesignControls design={design} onChange={patch => setDesign(d => ({ ...d, ...patch }))} onBusy={setAssetBusy} />
{sizeError ? <p role="alert" className="text-sm text-destructive">{sizeError} Preview uses Letter until corrected.</p> : null}
{!fontReady ? <p role="status" className="text-sm text-muted-foreground">{fontError ? "Our fonts could not load. Reload before exporting." : "Loading embedded brand fonts…"}</p> : null}
{([{ key: "title", label: "Event name", max: 120 }, { key: "headline", label: "Headline", max: 44 }, { key: "message", label: "Welcome message", max: 120 }] as const).map(field =>
<Field key={field.key} data-invalid={!design[field.key].trim()}><FieldLabel htmlFor={`sign-${field.key}`}>{field.label}</FieldLabel>
<Input id={`sign-${field.key}`} value={design[field.key]} maxLength={field.max} aria-invalid={!design[field.key].trim()}
onChange={e => setDesign(d => ({ ...d, [field.key]: e.target.value }))} />
</Field>)}
<Field><FieldLabel>QR destination</FieldLabel><p className="break-all text-sm">{guestUrl.replace(/^https?:\/\//, "")}</p><FieldDescription>Uses the saved guest link, not a temporary download URL. Changing the event slug later means reprinting the sign.</FieldDescription></Field>
<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>
</CardContent>
</Card>
<div className="min-w-0 rounded-xl border bg-muted p-4 sm:p-6 xl:sticky xl:top-24">
<div className="mb-4 flex justify-between text-xs text-muted-foreground"><span>Live print preview</span><span>{paper.width} × {paper.height}</span></div>
<img src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`} alt={`Printable guest sign for ${design.title}, with a QR code and upload instructions`}
width={850} height={paper.viewHeight} className="mx-auto block h-auto max-h-[75dvh] w-full object-contain drop-shadow-lg" />
</div>
</div>
</div>;
}
+39 -38
View File
@@ -1,11 +1,11 @@
import Link from "next/link";
import Image from "next/image";
import weddingExit from "@/assets/homepage/457677937.webp";
import gardenToast from "@/assets/homepage/2157148814.webp";
import guestSelfie from "@/assets/homepage/2157148813.webp";
import weddingGreetings from "@/assets/homepage/599131260.webp";
import guestTunnel from "@/assets/homepage/599129661.webp";
import firstDance from "@/assets/homepage/498647922.webp";
import toast from "@/assets/homepage/240365594.webp";
import guest from "@/assets/homepage/189193781.webp";
import reception from "@/assets/homepage/208516668.webp";
import {
ArrowRightIcon,
CheckCircle2Icon,
@@ -18,6 +18,7 @@ import { createServerCaller } from "@/trpc/server";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { BrandMark } from "@/components/brand-mark";
import { SiteFooter } from "@/components/site-footer";
import { cn, formatEventDate } from "@/lib/utils";
import {
Card,
@@ -38,7 +39,7 @@ const features = [
number: "02",
icon: ShieldCheckIcon,
title: "You decide whats public",
body: "Every photo lands in a private review queue before it reaches the shared gallery.",
body: "Approve photos before sharing, publish automatically, or keep the gallery private. You choose.",
},
{
number: "03",
@@ -51,10 +52,10 @@ const features = [
const previewPhotos = [
{ src: guest, layout: "row-span-2", position: "50% 40%" },
{ src: firstDance, layout: "col-span-2", position: "50% 45%" },
{ src: toast, layout: "", position: "50% 50%" },
{ src: guestSelfie, layout: "", position: "50% 40%" },
{ src: guestTunnel, layout: "row-span-2", position: "50% 50%" },
{ src: reception, layout: "", position: "65% 50%" },
{ src: weddingExit, layout: "", position: "50% 50%" },
{ src: gardenToast, layout: "", position: "50% 35%" },
{ src: weddingGreetings, layout: "", position: "50% 50%" },
] as const;
function AlbumPreview() {
@@ -84,10 +85,10 @@ function AlbumPreview() {
src={photo.src}
alt=""
fill
sizes={index === 1 ? "(max-width: 640px) 60vw, 360px" : "(max-width: 640px) 30vw, 180px"}
sizes={index === 0 || index === 3 ? "(max-width: 640px) 80vw, 480px" : index === 1 ? "(max-width: 640px) 60vw, 360px" : "(max-width: 640px) 40vw, 240px"}
quality={85}
className="object-cover"
style={{ objectPosition: photo.position }}
placeholder="blur"
loading="eager"
/>
</div>
@@ -117,6 +118,7 @@ export default async function HomePage() {
const demoEvent = listed[0];
return (
<>
<main>
<section className="page-pad mx-auto grid min-h-[calc(100svh-4rem)] w-full max-w-6xl items-center gap-12 py-14 sm:py-20 lg:grid-cols-[0.88fr_1.12fr] lg:gap-16 lg:py-24">
<div className="reveal flex max-w-xl flex-col gap-6">
@@ -152,31 +154,6 @@ export default async function HomePage() {
<AlbumPreview />
</section>
<section className="border-y border-border/80 bg-card/80 backdrop-blur-sm" aria-labelledby="how-it-works">
<div className="page-pad mx-auto w-full max-w-6xl py-14 sm:py-20">
<p className="text-xs font-bold tracking-[0.14em] text-primary uppercase">The whole handoff, handled</p>
<h2 id="how-it-works" className="mt-3 max-w-2xl text-4xl font-semibold tracking-[-0.035em] sm:text-5xl">
Built for the photos that never make it out of the group chat.
</h2>
<div className="mt-8 grid gap-3 md:grid-cols-3">
{features.map((feature) => {
const Icon = feature.icon;
return (
<Card key={feature.title} className="feature-panel overflow-hidden bg-background/60 shadow-none">
<CardHeader>
<div className="mb-3 flex items-center justify-between">
<span className="flex size-10 items-center justify-center rounded-lg bg-primary text-primary-foreground"><Icon aria-hidden="true" /></span>
<span className="font-mono text-xs font-bold tracking-widest text-muted-foreground">{feature.number}</span>
</div>
<CardTitle className="text-xl font-semibold">{feature.title}</CardTitle>
<CardDescription className="leading-6">{feature.body}</CardDescription>
</CardHeader>
</Card>
);
})}
</div>
</div>
</section>
{listed.length > 0 ? (
<section className="page-pad mx-auto flex w-full max-w-6xl flex-col gap-6 py-14 sm:py-20">
@@ -208,11 +185,35 @@ export default async function HomePage() {
</section>
) : null}
<footer className="border-t border-border/80 bg-foreground text-background">
<div className="page-pad mx-auto flex w-full max-w-6xl items-center justify-between gap-4 py-7 text-xs text-background/65">
<span className="font-heading text-lg font-semibold text-background">Manyangles</span><span>Keep the moment, not the compression.</span>
<section className="border-t bg-card/60" aria-labelledby="how-it-works">
<div className="page-pad mx-auto grid w-full max-w-6xl gap-10 py-14 sm:py-20 lg:grid-cols-2 lg:items-center lg:gap-16">
<div className="relative overflow-hidden rounded-2xl">
<Image src={gardenToast} alt="Wedding guests sharing a champagne toast with the couple in a garden"
width={1000} height={1200} quality={85} sizes="(min-width: 1024px) 520px, 100vw"
className="aspect-[5/4] w-full object-cover lg:aspect-[5/6]" />
</div>
<div>
<p className="text-xs font-bold tracking-[0.14em] text-primary uppercase">Less chasing. More remembering.</p>
<h2 id="how-it-works" className="mt-3 scroll-mt-24 text-4xl font-semibold tracking-[-0.035em] sm:text-5xl">The day flies by.<br />The photos stay together.</h2>
<p className="mt-5 text-lg leading-7 text-muted-foreground">From the front row to the dance floor, everyone sees something different. Bring it all home.</p>
<ol className="mt-8 flex flex-col divide-y">
{features.map((feature) => {
const Icon = feature.icon;
return <li key={feature.number} className="flex gap-4 py-5 first:pt-0">
<span className="flex size-11 shrink-0 items-center justify-center rounded-full border bg-background text-primary"><Icon className="size-5" aria-hidden="true" /></span>
<div>
<h3 className="text-lg font-semibold"><span className="mr-2 font-mono text-xs text-muted-foreground">{feature.number}</span>{feature.title}</h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground">{feature.body}</p>
</div>
</li>;
})}
</ol>
<Button asChild size="lg" className="mt-5"><Link href="/sign-up">Start your shared album<ArrowRightIcon data-icon="inline-end" aria-hidden="true" /></Link></Button>
</div>
</div>
</footer>
</section>
</main>
<SiteFooter />
</>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

+6 -2
View File
@@ -9,9 +9,13 @@ and must not be redistributed as standalone stock assets.
- 189193781: candid wedding guest
- 208516668: reception setting
- 240365594: champagne toast
- 457677937: wedding ceremony exit
- 498647922: first dance
- 599129661: guests' wedding exit tunnel
- 599131260: newlyweds greeting friends and family
- 2157148813: friends taking a garden selfie
- 2157148814: wedding guests sharing an outdoor toast
Source pages: `https://stock.adobe.com/images/{asset-id}`.
Renditions: auto-oriented, metadata stripped, bounded to 1000px, WebP quality 80.
Renditions: auto-oriented, metadata stripped, bounded to 2000px wide, WebP quality 90.
Responsive homepage images use Next.js quality 85; tall collage tiles request
enough source pixels for their height-driven cover crop on high-density screens.
+2 -1
View File
@@ -31,6 +31,7 @@ export function BackendShell({ children, area, groups, activeGroupId }: {
const activeItem = activeNavigationItem(workspaces, pathname);
const activeWorkspace = workspaces.find((workspace) => workspace.items.includes(activeItem!)) ?? workspaces[0]!;
const activeGroup = groups.find((group) => group.id === activeGroupId) ?? groups[0];
const signEventId = pathname.match(/^\/dashboard\/events\/([^/]+)\/sign$/)?.[1];
return (
<div className="min-h-[calc(100dvh-4rem)] bg-background">
<DoubleSidebarNavigation key={area} workspaces={workspaces} activeItem={activeItem}
@@ -42,7 +43,7 @@ export function BackendShell({ children, area, groups, activeGroupId }: {
<Link href={area === "platform" ? "/admin" : "/dashboard"} className="hover:text-foreground">{applicationLabel}</Link>
<ChevronRight aria-hidden="true" className="size-3" />
<span>{activeWorkspace.label}</span>
{activeItem && activeItem.label !== activeWorkspace.label ? <><ChevronRight aria-hidden="true" className="size-3" /><span>{activeItem.label}</span></> : null}
{signEventId ? <><ChevronRight aria-hidden="true" className="size-3" /><Link href={`/dashboard/events/${signEventId}`} className="hover:text-foreground">Event</Link><ChevronRight aria-hidden="true" className="size-3" /><span aria-current="page">Guest sign</span></> : activeItem && activeItem.label !== activeWorkspace.label ? <><ChevronRight aria-hidden="true" className="size-3" /><span>{activeItem.label}</span></> : null}
</nav>
{children}
</div>
@@ -0,0 +1,69 @@
"use client";
import { useEffect, useState } from "react";
import ReactCrop, { centerCrop, makeAspectCrop, type PercentCrop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import { BANNER_ASPECT_RATIO, type BannerCrop } from "@album/contracts";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
export function BannerCropDialog({ file, onCancel, onConfirm }: {
file: File;
onCancel: () => void;
onConfirm: (crop: BannerCrop) => void;
}) {
const [src, setSrc] = useState<string>();
const [crop, setCrop] = useState<PercentCrop>();
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [error, setError] = useState<string>();
useEffect(() => {
let cancelled = false;
let url: string | undefined;
async function prepare() {
try {
let preview: Blob = file;
if (/image\/hei[cf]/.test(file.type) || /\.hei[cf]$/i.test(file.name)) {
const { heicTo } = await import("heic-to/csp");
preview = await heicTo({ blob: file, type: "image/jpeg", quality: 0.9 });
}
if (cancelled) return;
url = URL.createObjectURL(preview);
setSrc(url);
} catch {
if (!cancelled) setError("This image couldn't be opened. Try a JPEG, PNG, or WebP copy.");
}
}
void prepare();
return () => { cancelled = true; if (url) URL.revokeObjectURL(url); };
}, [file]);
function reset(width = dimensions.width, height = dimensions.height) {
setCrop(centerCrop(makeAspectCrop({ unit: "%", width: 100 }, BANNER_ASPECT_RATIO, width, height), width, height));
}
const valid = crop && crop.width * dimensions.width / 100 >= 8 && crop.height * dimensions.height / 100 >= 3 && !error;
return (
<Dialog open onOpenChange={(open) => { if (!open) onCancel(); }}>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Crop your banner</DialogTitle>
<DialogDescription>Drag to reposition or resize the 8:3 frame. The shaded area will be cut off. Your original stays untouched.</DialogDescription>
</DialogHeader>
<div className="flex min-h-32 justify-center overflow-hidden rounded-lg bg-muted">
{src && !error ? <ReactCrop crop={crop} aspect={BANNER_ASPECT_RATIO} onChange={(_, percent) => setCrop(percent)} keepSelection ruleOfThirds>
<img src={src} alt="Banner crop preview" className="block max-h-[50dvh] max-w-full object-contain"
onLoad={(event) => {
const { naturalWidth: width, naturalHeight: height } = event.currentTarget;
setDimensions({ width, height });
reset(width, height);
}} onError={() => setError("This image couldn't be opened. Choose another image.")} />
</ReactCrop> : <p role={error ? "alert" : "status"} className="self-center p-4 text-sm text-muted-foreground">{error ?? "Preparing image…"}</p>}
</div>
<p className="text-xs text-muted-foreground">The selected area becomes your banner everywhere. After uploading, use Save changes to publish it.</p>
<DialogFooter>
<Button type="button" variant="ghost" disabled={!crop || Boolean(error)} onClick={() => reset()}>Reset crop</Button>
<Button type="button" variant="outline" onClick={onCancel}>Cancel</Button>
<Button type="button" disabled={!valid} onClick={() => { if (crop && valid) onConfirm({ x: crop.x, y: crop.y, width: crop.width, height: crop.height }); }}>Use crop & upload</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+21 -6
View File
@@ -1,12 +1,15 @@
"use client";
import { useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import { toast } from "sonner";
import { UploadIcon } from "lucide-react";
import { allowedImageTypeSchema, MAX_PHOTO_BYTES } from "@album/contracts";
import { allowedImageTypeSchema, MAX_PHOTO_BYTES, type BannerCrop } from "@album/contracts";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
const BannerCropDialog = dynamic(() => import("./banner-crop-dialog").then((module) => module.BannerCropDialog), { ssr: false });
export function BannerUpload({ eventId, selectedId, onSelect, onBusyChange }: {
eventId: string;
selectedId: string | null;
@@ -15,6 +18,7 @@ export function BannerUpload({ eventId, selectedId, onSelect, onBusyChange }: {
}) {
const input = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [cropFile, setCropFile] = useState<File | null>(null);
const [bannerId, setBannerId] = useState<string | null>(null);
const [startedAt, setStartedAt] = useState<number | null>(null);
const [timedOut, setTimedOut] = useState(false);
@@ -40,7 +44,7 @@ export function BannerUpload({ eventId, selectedId, onSelect, onBusyChange }: {
const timer = setTimeout(() => setTimedOut(true), 120_000);
return () => clearTimeout(timer);
}, [startedAt]);
async function upload(file: File) {
async function upload(file: File, crop: BannerCrop) {
const mime = file.type || (/\.heic$/i.test(file.name) ? "image/heic" : /\.heif$/i.test(file.name) ? "image/heif" : "");
const parsed = allowedImageTypeSchema.safeParse(mime);
if (!parsed.success || file.size <= 0 || file.size > MAX_PHOTO_BYTES) {
@@ -52,7 +56,7 @@ export function BannerUpload({ eventId, selectedId, onSelect, onBusyChange }: {
setBannerId(null);
setStartedAt(null);
try {
const pending = await create.mutateAsync({ eventId, contentType: parsed.data, byteSize: file.size });
const pending = await create.mutateAsync({ eventId, contentType: parsed.data, byteSize: file.size, crop });
const response = await fetch(pending.uploadUrl, {
method: "PUT", body: file, headers: { "Content-Type": parsed.data }, signal: AbortSignal.timeout(120_000),
});
@@ -65,23 +69,34 @@ export function BannerUpload({ eventId, selectedId, onSelect, onBusyChange }: {
} finally { setUploading(false); }
}
const processing = Boolean(bannerId) && status.data?.status !== "failed" && !status.isError && !timedOut;
useEffect(() => { onBusyChange(uploading || processing); }, [uploading, processing, onBusyChange]);
useEffect(() => { onBusyChange(uploading || processing || Boolean(cropFile)); }, [uploading, processing, cropFile, onBusyChange]);
useEffect(() => () => onBusyChange(false), [onBusyChange]);
return (
<div className="flex flex-col gap-3 rounded-lg border p-3">
{cropFile ? <BannerCropDialog file={cropFile} onCancel={() => setCropFile(null)} onConfirm={(crop) => {
const file = cropFile;
setCropFile(null);
void upload(file, crop);
}} /> : null}
<div className="flex flex-wrap items-center gap-2">
<input ref={input} type="file" accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden" aria-label="Upload a dedicated event banner"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void upload(file);
if (!file) return;
const mime = file.type || (/\.heic$/i.test(file.name) ? "image/heic" : /\.heif$/i.test(file.name) ? "image/heif" : "");
if (!allowedImageTypeSchema.safeParse(mime).success || file.size <= 0 || file.size > MAX_PHOTO_BYTES) {
toast.error("Choose a JPEG, PNG, WebP, or HEIC image up to 25 MB.");
return;
}
setCropFile(file);
}} />
<Button type="button" variant="outline" disabled={uploading || processing} onClick={() => input.current?.click()}>
<UploadIcon data-icon="inline-start" />{uploading ? "Uploading…" : processing ? "Preparing banner…" : selectedId ? "Replace uploaded banner" : "Upload a banner"}
</Button>
</div>
<p className="text-xs text-muted-foreground">A separate image just for this event's header. It won't appear in the gallery. Up to 25 MB.</p>
<p className="text-xs text-muted-foreground">Crop to 8:3 before uploading. Originals are preserved; the display copy is optimized. Separate from the gallery. Up to 25 MB.</p>
{processing ? <p role="status" className="text-sm text-muted-foreground">Preparing your banner You can keep editing while it processes.</p> : null}
{status.isError || status.data?.status === "failed" || timedOut ? <p role="alert" className="text-sm text-destructive">{timedOut ? "Processing is taking longer than expected. Your previous banner is unchanged; try another upload." : "Couldn't prepare this banner. Your previous banner is unchanged. Try uploading another image."}</p> : null}
</div>
+4 -2
View File
@@ -8,8 +8,10 @@ export function EventMap({ latitude, longitude, location }: {
const url = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}`;
return (
<div className="flex flex-col gap-2">
<iframe title={`Map of ${location}`} src={openStreetMapEmbedUrl(latitude, longitude)}
loading="lazy" referrerPolicy="no-referrer" className="h-64 w-full rounded-xl border bg-muted" />
<div className="isolate overflow-hidden rounded-xl border bg-muted [clip-path:inset(0_round_var(--radius-xl))]">
<iframe title={`Map of ${location}`} src={openStreetMapEmbedUrl(latitude, longitude)}
loading="lazy" referrerPolicy="no-referrer" className="block h-64 w-full border-0" />
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer" className="underline underline-offset-4">© OpenStreetMap contributors</a>
<a href={url} target="_blank" rel="noreferrer" className="font-semibold underline underline-offset-4">Open Larger Map</a>
+48
View File
@@ -0,0 +1,48 @@
import Link from "next/link";
import { ArrowUpRightIcon } from "lucide-react";
import { BrandLockup } from "@/components/brand-mark";
const footerLink = "inline-flex min-h-11 items-center gap-2 rounded-sm text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring";
export function SiteFooter() {
return (
<footer className="border-t bg-muted/40">
<div className="page-pad mx-auto w-full max-w-6xl">
<div className="grid gap-10 py-12 sm:grid-cols-[1fr_auto] sm:gap-16 sm:py-16">
<div className="flex max-w-sm flex-col items-start gap-5">
<Link href="/" aria-label="Manyangles home" className="rounded-sm focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring">
<BrandLockup />
</Link>
<p className="text-sm leading-relaxed text-muted-foreground">One event. Every perspective.<br />A shared home for the moments everyone captured.</p>
</div>
<nav aria-label="Footer" className="grid grid-cols-2 gap-10 sm:gap-16">
<div>
<h2 className="mb-2 text-sm font-semibold">Gather your photos</h2>
<ul>
<li><Link href="/sign-up" className={footerLink}>Host an event</Link></li>
<li><Link href="/#how-it-works" className={footerLink}>How it works</Link></li>
</ul>
</div>
<div>
<h2 className="mb-2 text-sm font-semibold">Your albums</h2>
<ul>
<li><Link href="/sign-in" className={footerLink}>Sign in</Link></li>
<li><Link href="/dashboard" className={footerLink}>Open workspace</Link></li>
</ul>
</div>
</nav>
</div>
<div className="flex flex-col gap-6 border-t py-6 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">© {new Date().getFullYear()} Hadlock Technologies LLC. All rights reserved.</p>
<a href="https://hadlock.tech" aria-label="Made by Hadlock Tech — visit hadlock.tech"
className="group inline-flex min-h-11 w-fit items-center gap-4 rounded-sm text-foreground focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring">
<span className="text-xs text-muted-foreground">Made by</span>
{/* Original Hadlock Tech vector; the mask follows the active theme. */}
<span aria-hidden="true" className="block h-7 w-[121px] bg-current [mask-image:url('/branding/hadlock-tech.svg')] [mask-repeat:no-repeat] [mask-position:center] [mask-size:contain]" />
<ArrowUpRightIcon aria-hidden="true" className="size-4 text-muted-foreground transition-colors group-hover:text-foreground" />
</a>
</div>
</div>
</footer>
);
}
+1 -1
View File
@@ -75,8 +75,8 @@ export function SiteHeaderBar({ links, primary, signedIn, user, groups = [], act
<div className="ml-auto flex items-center gap-1">
{signedIn ? (
<>
<ApplicationSwitcher links={links} pathname={pathname} />
<ThemeToggle />
<ApplicationSwitcher links={links} pathname={pathname} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
+100
View File
@@ -0,0 +1,100 @@
import { expect, test } from "bun:test";
import sharp from "sharp";
import jsQR from "jsqr";
import { signQr } from "../server/sign-qr";
import { SIGN_PAPERS, guestQrSvg, guestSignSvg, signLines, signPrintHtml, signPaper, type SignDesign } from "./guest-sign";
test("standalone QR export scans to the guest link and keeps its quiet zone", async () => {
const url = "https://ma.hadlock.tech/e/test-wedding";
const svg = guestQrSvg(signQr(url));
expect(svg).toContain('transform="translate(4 4)"');
expect(svg).toContain('fill="white"');
const { data, info } = await sharp(Buffer.from(svg)).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
expect(jsQR(new Uint8ClampedArray(data), info.width, info.height)?.data).toBe(url);
});
const design: SignDesign = { title: "Sam & Alexs wedding", headline: "Share your favorite moments", message: "Add your photos to our shared album.", paper: "letter", ink: "indigo" };
test("Linen cards scan at every preset size with exact physical export dimensions", async () => {
const url = "https://ma.hadlock.tech/e/test-wedding";
for (const paper of Object.keys(SIGN_PAPERS) as (keyof typeof SIGN_PAPERS)[]) {
const svg = guestSignSvg({ ...design, paper, layout: "compact", background: "#f1eadf", accent: "#473a30" }, url, signQr(url));
const size = SIGN_PAPERS[paper];
expect(signPrintHtml(svg, paper)).toContain(`size:${size.width} ${size.height}`);
expect(svg).not.toContain("Add your event photo");
const { data, info } = await sharp(Buffer.from(svg), { density: 120 }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
expect(jsQR(new Uint8ClampedArray(data), info.width, info.height)?.data).toBe(url);
}
});
test("photo and typography layouts scan in portrait, square, and landscape", async () => {
const url = "https://ma.hadlock.tech/e/test-wedding";
const photo = `data:image/png;base64,${(await sharp({ create: { width: 80, height: 80, channels: 3, background: '#ae8268' } }).png().toBuffer()).toString('base64')}`;
for (const layout of ["photo", "typography"] as const) {
for (const [customWidth, customHeight] of [[8.5, 11], [5, 5], [10, 7]]) {
const svg = guestSignSvg({ ...design, layout, paper: "custom", customWidth, customHeight, backgroundImage: photo }, url, signQr(url));
const { data, info } = await sharp(Buffer.from(svg), { density: 120 }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
expect(jsQR(new Uint8ClampedArray(data), info.width, info.height)?.data).toBe(url);
expect(svg.includes('<image')).toBe(layout === "photo");
}
}
});
test("background is the page surface with contrasting text and an isolated white QR", () => {
const url = "https://ma.hadlock.tech/e/demo";
const svg = guestSignSvg({ ...design, background: "#204c40", accent: "#204c40" }, url, signQr(url));
expect(svg).toContain('font-family="SignInter, sans-serif" fill="#ffffff"');
expect(svg).not.toContain('rx="24" fill="white"');
expect(svg).toContain('<rect width="100%" height="100%" fill="white"/>');
const photo = guestSignSvg({ ...design, backgroundImage: "data:image/png;base64,AA==" }, url, signQr(url));
expect(photo).toContain('fill="black" opacity=".68"');
expect(photo).toContain('font-family="SignInter, sans-serif" fill="#ffffff"');
});
test("signs embed our font families and keep the wordmark in Geologica", () => {
const url = "https://ma.hadlock.tech/e/demo";
for (const font of ["funnel", "inter", "geologica"] as const) {
const svg = guestSignSvg({ ...design, font, fontData: "data:font/woff2;base64,AA==", interFontData: "data:font/woff2;base64,AA==", geologicaFontData: "data:font/woff2;base64,AA==" }, url, signQr(url));
for (const family of ["SignFunnel", "SignInter", "SignGeologica"]) expect(svg).toContain(`@font-face{font-family:${family};`);
expect(svg).toContain('font-family="SignInter, sans-serif"');
expect(svg).toContain('font-family="SignGeologica, sans-serif" font-size="26.5"');
const lockup = svg.slice(svg.indexOf('data-brand-lockup="manyangles"'));
expect(lockup).toContain('fill="currentColor"');
expect(lockup).toContain('stroke="currentColor"');
expect(lockup).not.toMatch(/(?:fill|stroke)="#/);
expect(svg).not.toMatch(/Arial|Georgia|Helvetica/);
}
const unsafe = guestSignSvg({ ...design, interFontData: "https://example.com/font.woff2" }, url, signQr(url));
expect(unsafe).not.toContain("https://example.com/font.woff2");
});
test("exported Letter and A4 signs scan to the complete guest URL", async () => {
const url = "https://ma.hadlock.tech/e/test-wedding";
for (const paper of ["letter", "a4"] as const) {
const svg = guestSignSvg({ ...design, paper }, url, signQr(url));
const { data, info } = await sharp(Buffer.from(svg), { density: 120 }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
expect(jsQR(new Uint8ClampedArray(data), info.width, info.height)?.data).toBe(url);
expect(svg).toContain('transform="translate(4 4)"');
expect(signPrintHtml(svg, paper)).toContain(paper === "letter" ? "size:8.5in 11in" : "size:210mm 297mm");
}
});
test("custom square and landscape designs retain exact print size and scan", async () => {
const url = "https://ma.hadlock.tech/e/test-wedding";
for (const [customWidth, customHeight] of [[5, 5], [10, 7], [4, 6]]) {
const custom: SignDesign = { ...design, paper: "custom", customWidth, customHeight, unit: "in", decoration: "frame", background: "#204c40" };
const svg = guestSignSvg(custom, url, signQr(url));
const { data, info } = await sharp(Buffer.from(svg), { density: 120 }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
expect(jsQR(new Uint8ClampedArray(data), info.width, info.height)?.data).toBe(url);
expect(signPrintHtml(svg, custom)).toContain(`size:${customWidth}in ${customHeight}in`);
}
expect(signPaper({ paper: "custom", customWidth: 127, customHeight: 178, unit: "mm" }).width).toBe("127mm");
expect(() => signPaper({ paper: "custom", customWidth: 0, customHeight: 10 })).toThrow();
expect(() => signPaper({ paper: "custom", customWidth: 48, customHeight: 3 })).toThrow();
});
test("external and SVG image uploads cannot inject export content", () => {
const svg = guestSignSvg({ ...design, logoImage: 'data:image/svg+xml;<script>', backgroundImage: 'https://example.com/track', accent: '\"><script>' }, "https://ma.hadlock.tech/e/demo", signQr("https://ma.hadlock.tech/e/demo"));
expect(svg).not.toContain("<script>");
expect(svg).not.toContain("https://example.com/track");
});
test("custom text is escaped and long words wrap", () => {
const svg = guestSignSvg({ ...design, title: '<script>alert("x")</script>' }, "https://ma.hadlock.tech/e/demo", signQr("https://ma.hadlock.tech/e/demo"));
expect(svg).not.toContain("<script>");
expect(svg).toContain("&lt;script&gt;");
expect(signLines("a".repeat(120), 48).every(line => line.length <= 48)).toBe(true);
expect(() => signQr("javascript:alert(1)")).toThrow();
});
+154
View File
@@ -0,0 +1,154 @@
export const SIGN_PAPERS = {
letter: { label: "US Letter · 8.5 × 11 in", width: "8.5in", height: "11in", viewHeight: 1100 },
a4: { label: "A4 · 210 × 297 mm", width: "210mm", height: "297mm", viewHeight: 1202 },
letterLandscape: { label: "US Letter landscape · 11 × 8.5 in", width: "11in", height: "8.5in", viewHeight: 850 * 8.5 / 11 },
a4Landscape: { label: "A4 landscape · 297 × 210 mm", width: "297mm", height: "210mm", viewHeight: 850 * 210 / 297 },
a5: { label: "A5 · 148 × 210 mm", width: "148mm", height: "210mm", viewHeight: 850 * 210 / 148 },
a5Landscape: { label: "A5 landscape · 210 × 148 mm", width: "210mm", height: "148mm", viewHeight: 850 * 148 / 210 },
card3x5: { label: "Small card · 3 × 5 in", width: "3in", height: "5in", viewHeight: 850 * 5 / 3 },
card5x3: { label: "Small landscape card · 5 × 3 in", width: "5in", height: "3in", viewHeight: 510 },
card4x6: { label: "Postcard · 4 × 6 in", width: "4in", height: "6in", viewHeight: 1275 },
card6x4: { label: "Landscape postcard · 6 × 4 in", width: "6in", height: "4in", viewHeight: 850 * 4 / 6 },
card5x7: { label: "Table card · 5 × 7 in", width: "5in", height: "7in", viewHeight: 1190 },
card7x5: { label: "Landscape table card · 7 × 5 in", width: "7in", height: "5in", viewHeight: 850 * 5 / 7 },
square: { label: "Square card · 5 × 5 in", width: "5in", height: "5in", viewHeight: 850 },
} as const;
export type SignPaper = keyof typeof SIGN_PAPERS | "custom";
export type SignDesign = { title: string; headline: string; message: string; paper: SignPaper; ink: "indigo" | "black";
customWidth?: number; customHeight?: number; unit?: "in" | "mm";
layout?: "photo" | "typography" | "compact";
background?: string; accent?: string; font?: "funnel" | "geologica" | "inter";
decoration?: "arch" | "frame" | "minimal"; backgroundImage?: string; logoImage?: string;
showBrand?: boolean; fontData?: string; interFontData?: string; geologicaFontData?: string;
};
export type SignQr = { path: string; size: number };
export function guestQrSvg(qr: SignQr) {
const size = qr.size + 8;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size * 16}" height="${size * 16}" viewBox="0 0 ${size} ${size}" shape-rendering="crispEdges"><title>Manyangles guest album QR code</title><rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/></svg>`;
}
export function signPaper(design: Pick<SignDesign, "paper" | "customWidth" | "customHeight" | "unit">) {
if (design.paper !== "custom") return SIGN_PAPERS[design.paper];
const unit = design.unit === "mm" ? "mm" : "in";
const width = design.customWidth ?? 8.5, height = design.customHeight ?? 11;
const inches = unit === "mm" ? 25.4 : 1;
if (![width, height].every(n => Number.isFinite(n) && n / inches >= 3 && n / inches <= 48)) throw new Error("Choose dimensions between 3 and 48 inches (76.21219.2 mm).");
if (Math.max(width / height, height / width) > 2.5) throw new Error("Keep the long edge within 2.5 times the short edge so the sign stays readable.");
return { label: "Custom", width: `${width}${unit}`, height: `${height}${unit}`, viewHeight: 850 * height / width };
}
export function escapeXml(value: string) {
return value.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[c]!);
}
// Hard-wrap even unbroken names/URLs; no user input becomes SVG markup.
export function signLines(value: string, limit: number) {
const words = value.trim().split(/\s+/).flatMap(word => word.match(new RegExp(`.{1,${limit}}`, "gu")) ?? []);
const lines: string[] = [];
for (const word of words) {
const last = lines.length - 1;
if (last >= 0 && `${lines[last]} ${word}`.length <= limit) lines[last] += ` ${word}`;
else lines.push(word);
}
return lines;
}
export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) {
const paper = signPaper(design);
const color = (value: string | undefined, fallback: string) => /^#[\da-f]{6}$/i.test(value ?? "") ? value! : fallback;
const ink = color(design.accent, design.ink === "black" ? "#171717" : "#4055b5");
const luminance = (hex: string) => {
const channels = [1, 3, 5].map(index => parseInt(hex.slice(index, index + 2), 16) / 255).map(c => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
return channels[0]! * .2126 + channels[1]! * .7152 + channels[2]! * .0722;
};
const background = color(design.background, "#eeece5");
const raster = (value?: string) => /^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(value ?? "") ? value : undefined;
const photo = raster(design.backgroundImage), logo = raster(design.logoImage);
const surfaceLuminance = luminance(background);
const bodyInk = photo || surfaceLuminance < .18 ? "#ffffff" : "#171717";
const accentContrast = (Math.max(luminance(ink), surfaceLuminance) + .05) / (Math.min(luminance(ink), surfaceLuminance) + .05);
const headingInk = !photo && accentContrast >= 4.5 ? ink : bodyInk;
const landscape = paper.viewHeight < 850;
const w = landscape ? 1200 : 850, h = landscape ? 800 : 1100;
const scale = Math.min(850 / w, paper.viewHeight / h);
const cx = landscape ? 328 : 425;
const text = (value: string, y: number, size: number, limit: number, weight = 400, x = cx) => signLines(value, limit).map((line, i) =>
`<text x="${x}" y="${y + i * size * 1.25}" text-anchor="middle" font-size="${size}" font-weight="${weight}">${escapeXml(line)}</text>`).join("");
const headingFont = design.font === "geologica" ? "SignGeologica, sans-serif" : design.font === "inter" ? "SignInter, sans-serif" : "SignFunnel, sans-serif";
const fontFaces = [["SignFunnel", design.fontData], ["SignInter", design.interFontData], ["SignGeologica", design.geologicaFontData]]
.filter(([, data]) => /^data:font\/woff2;base64,[A-Za-z0-9+/=]+$/.test(data ?? ""))
.map(([family, data]) => `@font-face{font-family:${family};src:url('${data}') format('woff2');font-weight:100 900;font-style:normal}`).join("");
const qx = landscape ? 710 : 240, qy = landscape ? 235 : 420;
if (design.layout) {
const compact = design.layout === "compact";
const photoLed = design.layout === "photo";
const foreground = surfaceLuminance < .18 ? "#ffffff" : "#171717";
const headingColor = accentContrast >= 4.5 ? ink : foreground;
const left = landscape && photoLed ? 650 : 70;
const top = photoLed ? (landscape ? 80 : 600) : 85;
const column = photoLed ? (landscape ? 480 : 410) : (landscape ? 600 : 710);
const fontSize = compact ? 58 : photoLed ? 46 : (landscape ? 80 : 88);
const headlineLines = signLines(design.headline, compact ? 20 : photoLed ? 18 : 16);
const headlineSize = Math.min(fontSize, (photoLed ? 170 : 350) / (headlineLines.length * 1.1));
const lines = (value: string, x: number, y: number, size: number, limit: number, weight = 400) =>
signLines(value, limit).map((line, index) => `<text x="${x}" y="${y + index * size * 1.25}" font-size="${size}" font-weight="${weight}">${escapeXml(line)}</text>`).join("");
const qrSize = compact ? 320 : photoLed ? 240 : 340;
const qrX = landscape ? (photoLed ? 870 : 790) : (photoLed ? 540 : 440);
const qrY = compact ? (landscape ? 260 : 530) : landscape ? (photoLed ? 415 : 235) : (photoLed ? 755 : 605);
const messageY = top + (compact ? 310 : photoLed ? 250 : 445);
const brandMarkup = logo ? `<image href="${logo}" x="${cx - 100}" y="${landscape ? 700 : 1005}" width="200" height="48" preserveAspectRatio="xMidYMid meet"/>` : design.showBrand !== false ? `<g data-brand-lockup="manyangles" color="${headingColor}" fill="currentColor" transform="translate(${cx - 96} ${landscape ? 711 : 1016})"><svg width="32" height="32" viewBox="0 0 32 32" fill="none"><path d="M14 4H8a4 4 0 0 0-4 4v6M18 4h6a4 4 0 0 1 4 4v6M28 18v6a4 4 0 0 1-4 4h-6M14 28H8a4 4 0 0 1-4-4v-6" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><circle cx="21" cy="11" r="2.25" fill="currentColor"/><path d="m8.5 22 5-6.2a1.7 1.7 0 0 1 2.6-.1l2.6 2.9 1.7-1.8a1.7 1.7 0 0 1 2.5 0l1.6 1.8" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg><text x="40" y="24" font-family="SignGeologica, sans-serif" font-size="26.5" font-weight="700" letter-spacing="-1.06" style="font-variation-settings:&#39;SHRP&#39; 70">Manyangles</text></g>` : "";
return `<svg xmlns="http://www.w3.org/2000/svg" width="${paper.width}" height="${paper.height}" viewBox="0 0 850 ${paper.viewHeight}">
<title>${escapeXml(design.title)} — guest photo sign</title>
<style>${fontFaces}</style>
<rect width="850" height="${paper.viewHeight}" fill="${background}"/>
<g transform="translate(${(850 - w * scale) / 2} ${(paper.viewHeight - h * scale) / 2}) scale(${scale})" font-family="SignInter, sans-serif" fill="${foreground}">
${photoLed ? `<svg width="${landscape ? 580 : w}" height="${landscape ? h : 530}" viewBox="0 0 ${landscape ? 580 : w} ${landscape ? h : 530}">
<rect width="100%" height="100%" fill="${ink}"/>
${photo ? `<image href="${photo}" width="100%" height="100%" preserveAspectRatio="xMidYMid slice"/>` : `<text x="50%" y="50%" text-anchor="middle" fill="white" font-size="22">Add your event photo</text>`}
</svg>` : ""}
${lines(design.title, left, top, compact ? 25 : 19, Math.floor(column / (compact ? 15 : 11)), 500)}
<g font-family="${headingFont}" fill="${headingColor}" letter-spacing="-1.8">
${headlineLines.map((line, index) => `<text x="${left}" y="${top + 80 + index * headlineSize * 1.1}" font-size="${headlineSize}" font-weight="650">${escapeXml(line)}</text>`).join("")}
</g>
${lines(design.message, left, messageY, compact ? 26 : 18, compact ? (landscape ? 38 : 24) : photoLed ? (landscape ? 42 : 35) : (landscape ? 48 : 32))}
<svg x="${qrX}" y="${qrY}" width="${qrSize}" height="${qrSize}" viewBox="0 0 ${qr.size + 8} ${qr.size + 8}" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/></svg>
${lines("Scan to share", qrX, qrY - 22, compact ? 24 : 18, 30, 600)}
${lines(guestUrl.replace(/^https?:\/\//, ""), qrX, qrY + qrSize + 24, compact ? 15 : 11, Math.floor(qrSize / (compact ? 9 : 7)))}
${!photoLed && !compact ? lines("Your photos. Our story.", left, landscape ? 665 : 850, 24, 30, 600) : ""}
${lines("No app or account needed.", left, photoLed ? (landscape ? 665 : 950) : (landscape ? 705 : 890), compact ? 22 : 16, photoLed && landscape ? 22 : 32)}
${lines("Photos and notes welcome.", left, photoLed ? (landscape ? 710 : 976) : (landscape ? 735 : 920), compact ? 22 : 16, photoLed && landscape ? 22 : 32)}
<g transform="translate(${(landscape ? (photoLed ? 650 : 790) : 70) - (cx - 96)} ${landscape ? (photoLed ? 39 : compact ? -21 : -626) : 20})">${brandMarkup}</g>
</g>
</svg>`;
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${paper.width}" height="${paper.height}" viewBox="0 0 850 ${paper.viewHeight}">
<title>${escapeXml(design.title)} — guest photo sign</title>
${fontFaces ? `<style>${fontFaces}</style>` : ""}
<rect width="850" height="${paper.viewHeight}" fill="${background}"/>
${photo ? `<image href="${photo}" width="850" height="${paper.viewHeight}" preserveAspectRatio="xMidYMid slice"/><rect width="850" height="${paper.viewHeight}" fill="black" opacity=".68"/>` : ""}
<g font-family="SignInter, sans-serif" fill="${bodyInk}" transform="translate(${(850 - w * scale) / 2} ${(paper.viewHeight - h * scale) / 2}) scale(${scale})">
${design.decoration !== "minimal" ? `<rect x="35" y="35" width="${w - 70}" height="${h - 70}" rx="${design.decoration === "arch" ? 24 : 0}" fill="none" stroke="${headingInk}" stroke-opacity=".3" stroke-width="1"/>` : ""}
<path d="M${landscape ? 90 : 85} 158h${landscape ? 470 : 680}" stroke="${headingInk}" stroke-opacity=".25" stroke-width="1"/>
<g font-family="${headingFont}">
${text(design.title, 108, 22, landscape ? 34 : 48, 500)}
<g fill="${headingInk}" letter-spacing="-1.5">${text(design.headline, landscape ? 265 : 230, 52, landscape ? 18 : 24, 600)}</g>
</g>
${text(design.message, landscape ? 485 : 359, 19, landscape ? 40 : 60)}
<svg x="${qx}" y="${qy}" width="370" height="370" viewBox="0 0 ${qr.size + 8} ${qr.size + 8}" shape-rendering="crispEdges">
<rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/>
</svg>
${text("Scan with your phone camera", landscape ? 644 : 820, 20, 40, 600, qx + 185)}
${text(guestUrl.replace(/^https?:\/\//, ""), landscape ? 675 : 854, guestUrl.length > 160 ? 10 : 14, guestUrl.length > 160 ? (landscape ? 68 : 110) : (landscape ? 46 : 78), 400, qx + 185)}
<path d="M${landscape ? 90 : 85} ${landscape ? 575 : 901}h${landscape ? 470 : 680}" stroke="${headingInk}" stroke-opacity=".25" stroke-width="1"/>
${text("Scan. Choose your photos. Upload.", landscape ? 611 : 935, 21, 50, 500)}
${text("No app or account needed. Notes welcome, too.", landscape ? 644 : 965, 16, landscape ? 36 : 55)}
${logo ? `<image href="${logo}" x="${cx - 100}" y="${landscape ? 700 : 1005}" width="200" height="48" preserveAspectRatio="xMidYMid meet"/>` : design.showBrand !== false ? `<g data-brand-lockup="manyangles" color="${headingInk}" fill="currentColor" transform="translate(${cx - 96} ${landscape ? 711 : 1016})"><svg width="32" height="32" viewBox="0 0 32 32" fill="none"><path d="M14 4H8a4 4 0 0 0-4 4v6M18 4h6a4 4 0 0 1 4 4v6M28 18v6a4 4 0 0 1-4 4h-6M14 28H8a4 4 0 0 1-4-4v-6" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><circle cx="21" cy="11" r="2.25" fill="currentColor"/><path d="m8.5 22 5-6.2a1.7 1.7 0 0 1 2.6-.1l2.6 2.9 1.7-1.8a1.7 1.7 0 0 1 2.5 0l1.6 1.8" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg><text x="40" y="24" font-family="SignGeologica, sans-serif" font-size="26.5" font-weight="700" letter-spacing="-1.06" style="font-variation-settings:&#39;SHRP&#39; 70">Manyangles</text></g>` : ""}
</g>
</svg>`;
}
export function signPrintHtml(svg: string, paper: SignPaper | SignDesign) {
const size = signPaper(typeof paper === "string" ? { paper } : paper);
return `<!doctype html><html><head><title>Manyangles guest sign</title><style>@page{size:${size.width} ${size.height};margin:0}html,body{margin:0;padding:0;background:white}svg{display:block;width:${size.width};height:${size.height}}*{print-color-adjust:exact;-webkit-print-color-adjust:exact}</style></head><body>${svg}</body></html>`;
}
+1 -1
View File
@@ -20,7 +20,7 @@ export const bannersRouter = createTRPCRouter({
if (!limit.allowed) throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many banner uploads. Try again shortly." });
const id = randomUUID();
const originalKey = `events/${input.eventId}/banners/${id}/original`;
await getDb().insert(eventBanners).values({ id, eventId: input.eventId, originalKey, contentType: input.contentType, byteSize: input.byteSize });
await getDb().insert(eventBanners).values({ id, eventId: input.eventId, originalKey, contentType: input.contentType, byteSize: input.byteSize, crop: input.crop });
return { bannerId: id, uploadUrl: await createPresignedPutUrl({ key: originalKey, contentType: input.contentType }) };
}),
complete: protectedProcedure.input(bannerInputSchema).mutation(async ({ ctx, input }) => {
+14
View File
@@ -0,0 +1,14 @@
import QRCode from "qrcode";
export function signQr(url: string) {
const parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("Guest link must be HTTP or HTTPS");
const { modules } = QRCode.create(url, { errorCorrectionLevel: "M" });
let path = "";
for (let y = 0; y < modules.size; y++) {
for (let x = 0; x < modules.size; x++) {
if (modules.get(y, x)) path += `M${x} ${y}h1v1h-1z`;
}
}
return { path, size: modules.size };
}