diff --git a/README.md b/README.md index 18536ca..a10c45a 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,19 @@ Guests open a shareable event link, optionally add a name, email, and note, and upload photos. Event people approve what appears in the gallery. Originals stay full quality in S3-compatible object storage. +## Image processing + +Uploads go directly to object storage; the background worker creates WebP +derivatives with Sharp. Displays fit within 2048 × 2048 (quality 82), thumbnails +within 400 × 400 (quality 75), and cropped 8:3 banners within 2400 × 900 +(quality 82). Encoding uses effort 4. Small images are not enlarged. Display +copies are auto-oriented and omit source metadata; original uploads and exports +retain their original bytes. + +Existing JPEG derivatives remain supported through their saved database keys. +Only newly processed images use `.webp` keys and `image/webp`; this change does +not bulk-regenerate existing images or delete their JPEGs. + ## Repository ```text diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 7de91ce..686a304 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -3,6 +3,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", poweredByHeader: false, + images: { qualities: [75, 85] }, async redirects() { return [{ source: "/:path*", diff --git a/apps/web/package.json b/apps/web/package.json index e2b52e5..ef198e0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,9 @@ "@album/database": "workspace:*", "@album/email": "workspace:*", "@album/storage": "workspace:*", + "@fontsource-variable/funnel-display": "^5.3.0", + "@fontsource-variable/geologica": "^5.3.0", + "@fontsource-variable/inter": "^5.3.0", "@radix-ui/react-popover": "^1.1.23", "@tanstack/react-query": "^5.90.2", "@trpc/client": "^11.4.3", @@ -27,13 +30,16 @@ "date-fns": "^4.4.0", "dotenv": "^16.5.0", "drizzle-orm": "^0.45.2", + "heic-to": "^1.5.2", "lucide-react": "^0.468.0", "next": "16.2.12", "next-themes": "^0.4.6", + "qrcode": "^1.5.4", "radix-ui": "^1.6.7", "react": "19.2.8", "react-day-picker": "^9", "react-dom": "19.2.8", + "react-image-crop": "^11.1.2", "server-only": "^0.0.1", "shadcn": "^4.21.0", "sonner": "^2.0.7", @@ -46,9 +52,12 @@ "@tailwindcss/postcss": "^4.1.10", "@types/bun": "^1.3.14", "@types/node": "^22.15.32", + "@types/qrcode": "^1.5.6", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", + "jsqr": "^1.4.0", "postcss": "^8.5.22", + "sharp": "0.35.3", "tailwindcss": "^4.1.10", "typescript": "^5.8.3" } diff --git a/apps/web/public/branding/hadlock-tech.svg b/apps/web/public/branding/hadlock-tech.svg new file mode 100644 index 0000000..873acfa --- /dev/null +++ b/apps/web/public/branding/hadlock-tech.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/fonts/Funnel-Display-LICENSE.txt b/apps/web/public/fonts/Funnel-Display-LICENSE.txt new file mode 100644 index 0000000..6c994ae --- /dev/null +++ b/apps/web/public/fonts/Funnel-Display-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2023 The Funnel Project Authors (https://github.com/Dicotype/Funnel) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/web/public/fonts/Geologica-LICENSE.txt b/apps/web/public/fonts/Geologica-LICENSE.txt new file mode 100644 index 0000000..8357dcc --- /dev/null +++ b/apps/web/public/fonts/Geologica-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Geologica Project Authors (https://github.com/googlefonts/geologica) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/web/public/fonts/Inter-LICENSE.txt b/apps/web/public/fonts/Inter-LICENSE.txt new file mode 100644 index 0000000..40589da --- /dev/null +++ b/apps/web/public/fonts/Inter-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/web/public/fonts/funnel-display.woff2 b/apps/web/public/fonts/funnel-display.woff2 new file mode 100644 index 0000000..bb79810 Binary files /dev/null and b/apps/web/public/fonts/funnel-display.woff2 differ diff --git a/apps/web/public/fonts/geologica.woff2 b/apps/web/public/fonts/geologica.woff2 new file mode 100644 index 0000000..f5c1294 Binary files /dev/null and b/apps/web/public/fonts/geologica.woff2 differ diff --git a/apps/web/public/fonts/inter.woff2 b/apps/web/public/fonts/inter.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/apps/web/public/fonts/inter.woff2 differ diff --git a/apps/web/src/app/dashboard/events/[id]/event-settings-form.tsx b/apps/web/src/app/dashboard/events/[id]/event-settings-form.tsx index 418cc9f..af0209e 100644 --- a/apps/web/src/app/dashboard/events/[id]/event-settings-form.tsx +++ b/apps/web/src/app/dashboard/events/[id]/event-settings-form.tsx @@ -246,22 +246,24 @@ export function EventSettingsForm({
Choose from approved gallery photos ({bannerPhotos.length}) -
+
+
{bannerPhotos.map((photo, index) => ( ))}
+
{photos.isLoading ?

Loading photos…

: null} {photos.isError ?

Could not load banner photos. Try reopening Settings.

: null} {!photos.isLoading && !photos.isError && bannerPhotos.length === 0 ?

Upload and approve an event photo to use it as a banner.

: null}
- 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. + 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. Publish notes diff --git a/apps/web/src/app/dashboard/events/[id]/page.tsx b/apps/web/src/app/dashboard/events/[id]/page.tsx index 1aeafa1..9c2ff5d 100644 --- a/apps/web/src/app/dashboard/events/[id]/page.tsx +++ b/apps/web/src/app/dashboard/events/[id]/page.tsx @@ -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({ Gallery held )} -

{event.guestUrl}

+

{event.guestUrl.replace(/^https?:\/\//, "")}

{event.location ?

{event.location}

: null} + } tabs={tabs} diff --git a/apps/web/src/app/dashboard/events/[id]/sign/design-controls.tsx b/apps/web/src/app/dashboard/events/[id]/sign/design-controls.tsx new file mode 100644 index 0000000..23f3ee2 --- /dev/null +++ b/apps/web/src/app/dashboard/events/[id]/sign/design-controls.tsx @@ -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) => 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" ? <> + Units +
{(["customWidth", "customHeight"] as const).map((key, i) => {i === 0 ? "Width" : "Height"} onChange({ [key]: e.target.value === "" ? 0 : Number(e.target.value) })} />)}
+ Portrait, square, or landscape. 3–48 inches per edge. Small signs should be scanned up close. + : null} + Design preset + Heading font + {design.layout === "compact" ? "The Linen text-and-QR design without a photo. Starts at 6 × 4 inches; choose any paper size above." : design.layout === "photo" ? "A large event photo with a compact QR section. Add a photo below to enable sign export." : "Bold typography, a full-color background, and a prominent QR. Your uploaded photo is kept for photo presets."} +
+ Background onChange({ background: e.target.value })} /> + Accent onChange({ accent: e.target.value })} />Text adapts for contrast against the background. +
+ {([{ key: "backgroundImage", label: "Background photo" }, { key: "logoImage", label: "Your logo" }] as const).map(field => + {field.label} { const file = e.target.files?.[0]; e.target.value = ""; if (file) void upload(file, field.key); }} /> + {design[field.key] ? : null} + {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. + )} + Manyangles logo + ; +} diff --git a/apps/web/src/app/dashboard/events/[id]/sign/page.tsx b/apps/web/src/app/dashboard/events/[id]/sign/page.tsx new file mode 100644 index 0000000..7746b25 --- /dev/null +++ b/apps/web/src/app/dashboard/events/[id]/sign/page.tsx @@ -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 ; +} diff --git a/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx b/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx new file mode 100644 index 0000000..f901dae --- /dev/null +++ b/apps/web/src/app/dashboard/events/[id]/sign/studio.tsx @@ -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({ 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(null); + const [printing, setPrinting] = useState(false); + useEffect(() => () => { printFrame.current?.remove(); }, []); + let sizeError: string | null = null; + let paper = SIGN_PAPERS.letter as ReturnType; + 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
+
+
+ Back to event +

Guest sign studio

+

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

+
+
+ + + +
+
+
+ + Make it yoursEdits update the preview only, not your event. Download your sign before leaving. + + + Paper size + + + setDesign(d => ({ ...d, ...patch }))} onBusy={setAssetBusy} /> + {sizeError ?

{sizeError} Preview uses Letter until corrected.

: null} + {!fontReady ?

{fontError ? "Our fonts could not load. Reload before exporting." : "Loading embedded brand fonts…"}

: 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.label} + setDesign(d => ({ ...d, [field.key]: e.target.value }))} /> + )} + QR destination

{guestUrl.replace(/^https?:\/\//, "")}

Uses the saved guest link, not a temporary download URL. Changing the event slug later means reprinting the sign.
+

{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}` : ""}

+

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.

+
+
+
+
+
Live print preview{paper.width} × {paper.height}
+ {`Printable +
+
+
; +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index c5b180a..44c188c 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -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" /> @@ -117,6 +118,7 @@ export default async function HomePage() { const demoEvent = listed[0]; return ( + <>
@@ -152,31 +154,6 @@ export default async function HomePage() {
-
-
-

The whole handoff, handled

-

- Built for the photos that never make it out of the group chat. -

-
- {features.map((feature) => { - const Icon = feature.icon; - return ( - - -
- - {feature.number} -
- {feature.title} - {feature.body} -
-
- ); - })} -
-
-
{listed.length > 0 ? (
@@ -208,11 +185,35 @@ export default async function HomePage() {
) : null} -
-
- ManyanglesKeep the moment, not the compression. +
+
+
+ Wedding guests sharing a champagne toast with the couple in a garden +
+
+

Less chasing. More remembering.

+

The day flies by.
The photos stay together.

+

From the front row to the dance floor, everyone sees something different. Bring it all home.

+
    + {features.map((feature) => { + const Icon = feature.icon; + return
  1. + +
    +

    {feature.number}{feature.title}

    +

    {feature.body}

    +
    +
  2. ; + })} +
+ +
-
+
+ + ); } diff --git a/apps/web/src/assets/homepage/189193781.webp b/apps/web/src/assets/homepage/189193781.webp index 0aa4c4b..2c5cf67 100644 Binary files a/apps/web/src/assets/homepage/189193781.webp and b/apps/web/src/assets/homepage/189193781.webp differ diff --git a/apps/web/src/assets/homepage/208516668.webp b/apps/web/src/assets/homepage/208516668.webp index a43022d..9805716 100644 Binary files a/apps/web/src/assets/homepage/208516668.webp and b/apps/web/src/assets/homepage/208516668.webp differ diff --git a/apps/web/src/assets/homepage/2157148813.webp b/apps/web/src/assets/homepage/2157148813.webp new file mode 100644 index 0000000..f42bdb3 Binary files /dev/null and b/apps/web/src/assets/homepage/2157148813.webp differ diff --git a/apps/web/src/assets/homepage/2157148814.webp b/apps/web/src/assets/homepage/2157148814.webp new file mode 100644 index 0000000..69a8c46 Binary files /dev/null and b/apps/web/src/assets/homepage/2157148814.webp differ diff --git a/apps/web/src/assets/homepage/240365594.webp b/apps/web/src/assets/homepage/240365594.webp index 2ff734f..dc891c6 100644 Binary files a/apps/web/src/assets/homepage/240365594.webp and b/apps/web/src/assets/homepage/240365594.webp differ diff --git a/apps/web/src/assets/homepage/457677937.webp b/apps/web/src/assets/homepage/457677937.webp deleted file mode 100644 index 7f375ab..0000000 Binary files a/apps/web/src/assets/homepage/457677937.webp and /dev/null differ diff --git a/apps/web/src/assets/homepage/498647922.webp b/apps/web/src/assets/homepage/498647922.webp index 09e0d7d..1391af2 100644 Binary files a/apps/web/src/assets/homepage/498647922.webp and b/apps/web/src/assets/homepage/498647922.webp differ diff --git a/apps/web/src/assets/homepage/599129661.webp b/apps/web/src/assets/homepage/599129661.webp index f782f95..038ddd0 100644 Binary files a/apps/web/src/assets/homepage/599129661.webp and b/apps/web/src/assets/homepage/599129661.webp differ diff --git a/apps/web/src/assets/homepage/599131260.webp b/apps/web/src/assets/homepage/599131260.webp new file mode 100644 index 0000000..2132853 Binary files /dev/null and b/apps/web/src/assets/homepage/599131260.webp differ diff --git a/apps/web/src/assets/homepage/README.md b/apps/web/src/assets/homepage/README.md index b081081..d450cf4 100644 --- a/apps/web/src/assets/homepage/README.md +++ b/apps/web/src/assets/homepage/README.md @@ -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. diff --git a/apps/web/src/components/backend-shell.tsx b/apps/web/src/components/backend-shell.tsx index cc9a40e..0dd6fe2 100644 --- a/apps/web/src/components/backend-shell.tsx +++ b/apps/web/src/components/backend-shell.tsx @@ -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 (
{applicationLabel}
diff --git a/apps/web/src/components/banner-crop-dialog.tsx b/apps/web/src/components/banner-crop-dialog.tsx new file mode 100644 index 0000000..d01c589 --- /dev/null +++ b/apps/web/src/components/banner-crop-dialog.tsx @@ -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(); + const [crop, setCrop] = useState(); + const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); + const [error, setError] = useState(); + 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 ( + { if (!open) onCancel(); }}> + + + Crop your banner + Drag to reposition or resize the 8:3 frame. The shaded area will be cut off. Your original stays untouched. + +
+ {src && !error ? setCrop(percent)} keepSelection ruleOfThirds> + Banner crop preview { + const { naturalWidth: width, naturalHeight: height } = event.currentTarget; + setDimensions({ width, height }); + reset(width, height); + }} onError={() => setError("This image couldn't be opened. Choose another image.")} /> + :

{error ?? "Preparing image…"}

} +
+

The selected area becomes your banner everywhere. After uploading, use Save changes to publish it.

+ + + + + +
+
+ ); +} diff --git a/apps/web/src/components/banner-upload.tsx b/apps/web/src/components/banner-upload.tsx index f5292e6..d67db2f 100644 --- a/apps/web/src/components/banner-upload.tsx +++ b/apps/web/src/components/banner-upload.tsx @@ -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(null); const [uploading, setUploading] = useState(false); + const [cropFile, setCropFile] = useState(null); const [bannerId, setBannerId] = useState(null); const [startedAt, setStartedAt] = useState(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 (
+ {cropFile ? setCropFile(null)} onConfirm={(crop) => { + const file = cropFile; + setCropFile(null); + void upload(file, crop); + }} /> : null}
{ 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); }} />
-

A separate image just for this event's header. It won't appear in the gallery. Up to 25 MB.

+

Crop to 8:3 before uploading. Originals are preserved; the display copy is optimized. Separate from the gallery. Up to 25 MB.

{processing ?

Preparing your banner… You can keep editing while it processes.

: null} {status.isError || status.data?.status === "failed" || timedOut ?

{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."}

: null}
diff --git a/apps/web/src/components/event-map.tsx b/apps/web/src/components/event-map.tsx index ff74852..7a1b740 100644 --- a/apps/web/src/components/event-map.tsx +++ b/apps/web/src/components/event-map.tsx @@ -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 (
-