Add printable guest sign studio and refine event imagery
@@ -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. 3–48 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. Everyone’s 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>;
|
||||
}
|
||||
@@ -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 what’s 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 414 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 370 KiB |
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 & Alex’s 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("<script>");
|
||||
expect(signLines("a".repeat(120), 48).every(line => line.length <= 48)).toBe(true);
|
||||
expect(() => signQr("javascript:alert(1)")).toThrow();
|
||||
});
|
||||
@@ -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.2–1219.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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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:'SHRP' 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:'SHRP' 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>`;
|
||||
}
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||