Add printable guest sign studio and refine event imagery

This commit is contained in:
2026-09-10 09:26:49 -04:00
parent 176b5fa95c
commit 3a115f1161
48 changed files with 1294 additions and 102 deletions
+13
View File
@@ -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
+1
View File
@@ -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*",
+9
View File
@@ -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"
}
+78
View File
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1487 344" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-120.524,-1098.56)">
<g id="slant-round" serif:id="slant/round" transform="matrix(0.661785,0,0,0.661785,24.3307,784.241)">
<g transform="matrix(1.20551,0,0,1.20551,-840.208,509.804)">
<g transform="matrix(233.753,0,0,233.753,1308.09,362.817)">
<path d="M0.213,-0C0.171,-0 0.142,-0.007 0.125,-0.022C0.108,-0.036 0.099,-0.057 0.099,-0.085C0.099,-0.101 0.101,-0.119 0.105,-0.14C0.109,-0.161 0.113,-0.181 0.117,-0.201L0.215,-0.657L0.321,-0.657L0.222,-0.192C0.219,-0.178 0.216,-0.165 0.214,-0.153C0.212,-0.14 0.21,-0.129 0.21,-0.119C0.21,-0.108 0.214,-0.1 0.221,-0.095C0.228,-0.089 0.239,-0.087 0.253,-0.087L0.34,-0.087L0.322,-0L0.213,-0ZM0.089,-0.445L0.107,-0.532L0.435,-0.532L0.416,-0.445L0.089,-0.445Z" style="fill-rule:nonzero;stroke:black;stroke-width:0.01px;"/>
</g>
<g transform="matrix(233.753,0,0,233.753,1399.35,362.817)">
<path d="M0.232,0.012C0.169,0.012 0.12,-0.005 0.086,-0.04C0.052,-0.075 0.035,-0.123 0.035,-0.184C0.035,-0.234 0.042,-0.281 0.056,-0.325C0.07,-0.369 0.091,-0.407 0.117,-0.44C0.144,-0.473 0.177,-0.499 0.215,-0.517C0.254,-0.535 0.298,-0.544 0.346,-0.544C0.386,-0.544 0.42,-0.537 0.448,-0.523C0.476,-0.509 0.497,-0.487 0.512,-0.459C0.526,-0.431 0.533,-0.396 0.533,-0.354C0.533,-0.334 0.532,-0.313 0.529,-0.293C0.526,-0.272 0.522,-0.253 0.517,-0.235L0.129,-0.235L0.146,-0.315L0.422,-0.315C0.423,-0.321 0.424,-0.328 0.425,-0.335C0.425,-0.342 0.426,-0.351 0.426,-0.362C0.426,-0.392 0.417,-0.415 0.401,-0.431C0.384,-0.446 0.36,-0.454 0.33,-0.454C0.303,-0.454 0.277,-0.447 0.254,-0.434C0.231,-0.421 0.212,-0.403 0.196,-0.38C0.18,-0.356 0.167,-0.329 0.158,-0.297C0.149,-0.266 0.145,-0.231 0.145,-0.194C0.145,-0.154 0.154,-0.124 0.172,-0.106C0.19,-0.088 0.216,-0.079 0.251,-0.079C0.283,-0.079 0.31,-0.086 0.334,-0.1C0.357,-0.114 0.373,-0.134 0.383,-0.16L0.492,-0.153C0.47,-0.103 0.439,-0.063 0.397,-0.033C0.355,-0.003 0.3,0.012 0.232,0.012Z" style="fill-rule:nonzero;stroke:black;stroke-width:0.01px;"/>
</g>
<g transform="matrix(233.753,0,0,233.753,1529.27,362.817)">
<path d="M0.222,0.012C0.159,0.012 0.112,-0.005 0.081,-0.038C0.05,-0.071 0.035,-0.117 0.035,-0.177C0.035,-0.252 0.047,-0.316 0.073,-0.371C0.098,-0.426 0.134,-0.468 0.18,-0.499C0.225,-0.529 0.279,-0.544 0.34,-0.544C0.381,-0.544 0.416,-0.536 0.443,-0.521C0.471,-0.505 0.492,-0.483 0.507,-0.454C0.521,-0.425 0.528,-0.391 0.528,-0.351L0.418,-0.345C0.418,-0.383 0.41,-0.411 0.394,-0.427C0.379,-0.444 0.354,-0.452 0.321,-0.452C0.287,-0.452 0.256,-0.441 0.23,-0.418C0.203,-0.395 0.183,-0.364 0.168,-0.326C0.153,-0.288 0.145,-0.247 0.145,-0.201C0.145,-0.156 0.153,-0.125 0.168,-0.107C0.183,-0.089 0.207,-0.08 0.242,-0.08C0.278,-0.08 0.309,-0.089 0.334,-0.109C0.359,-0.128 0.379,-0.157 0.393,-0.195L0.501,-0.189C0.487,-0.148 0.467,-0.113 0.441,-0.083C0.415,-0.053 0.383,-0.029 0.347,-0.013C0.31,0.004 0.269,0.012 0.222,0.012Z" style="fill-rule:nonzero;stroke:black;stroke-width:0.01px;"/>
</g>
<g transform="matrix(233.753,0,0,233.753,1656.24,362.817)">
<path d="M0.02,-0L0.17,-0.71L0.276,-0.71L0.213,-0.412L0.198,-0.415C0.218,-0.458 0.245,-0.491 0.278,-0.512C0.311,-0.533 0.347,-0.544 0.386,-0.544C0.434,-0.544 0.469,-0.531 0.493,-0.506C0.517,-0.481 0.529,-0.448 0.529,-0.407C0.529,-0.39 0.527,-0.371 0.524,-0.351C0.52,-0.331 0.517,-0.313 0.513,-0.299L0.45,-0L0.344,-0L0.41,-0.309C0.413,-0.321 0.415,-0.333 0.417,-0.345C0.419,-0.358 0.42,-0.371 0.42,-0.384C0.42,-0.406 0.413,-0.424 0.4,-0.436C0.387,-0.449 0.368,-0.455 0.341,-0.455C0.318,-0.455 0.296,-0.45 0.276,-0.439C0.256,-0.428 0.239,-0.412 0.224,-0.391C0.21,-0.37 0.199,-0.344 0.192,-0.312L0.126,-0L0.02,-0Z" style="fill-rule:nonzero;stroke:black;stroke-width:0.01px;"/>
</g>
<g transform="matrix(233.753,0,0,233.753,1787.71,362.817)">
<path d="M0.019,-0L0.132,-0.532L0.229,-0.532L0.202,-0.39L0.191,-0.397C0.205,-0.431 0.222,-0.459 0.242,-0.481C0.262,-0.502 0.285,-0.518 0.309,-0.529C0.334,-0.539 0.359,-0.544 0.386,-0.544C0.434,-0.544 0.469,-0.531 0.493,-0.506C0.517,-0.481 0.528,-0.449 0.528,-0.409C0.528,-0.391 0.527,-0.372 0.523,-0.351C0.52,-0.331 0.517,-0.313 0.513,-0.299L0.45,-0L0.344,-0L0.41,-0.309C0.413,-0.321 0.415,-0.333 0.417,-0.346C0.419,-0.359 0.42,-0.373 0.42,-0.387C0.42,-0.409 0.413,-0.425 0.4,-0.437C0.387,-0.449 0.367,-0.455 0.341,-0.455C0.318,-0.455 0.296,-0.449 0.276,-0.438C0.256,-0.427 0.238,-0.41 0.224,-0.389C0.209,-0.367 0.198,-0.341 0.191,-0.31L0.125,-0L0.019,-0Z" style="fill-rule:nonzero;stroke:black;stroke-width:0.01px;"/>
</g>
<g transform="matrix(233.753,0,0,233.753,1920.76,362.817)">
<path d="M0.23,0.012C0.166,0.012 0.118,-0.005 0.084,-0.04C0.051,-0.075 0.034,-0.121 0.034,-0.177C0.034,-0.225 0.04,-0.271 0.053,-0.315C0.065,-0.359 0.084,-0.398 0.109,-0.432C0.134,-0.467 0.165,-0.494 0.203,-0.514C0.24,-0.534 0.283,-0.544 0.333,-0.544C0.396,-0.544 0.445,-0.527 0.478,-0.492C0.511,-0.458 0.527,-0.412 0.527,-0.355C0.527,-0.307 0.521,-0.261 0.509,-0.217C0.496,-0.173 0.478,-0.134 0.453,-0.1C0.428,-0.065 0.397,-0.038 0.36,-0.018C0.323,0.002 0.279,0.012 0.23,0.012ZM0.244,-0.08C0.279,-0.08 0.309,-0.091 0.335,-0.114C0.361,-0.137 0.381,-0.169 0.395,-0.208C0.41,-0.247 0.417,-0.291 0.417,-0.339C0.417,-0.378 0.409,-0.407 0.393,-0.425C0.377,-0.443 0.352,-0.452 0.318,-0.452C0.284,-0.452 0.253,-0.441 0.227,-0.418C0.201,-0.394 0.181,-0.363 0.166,-0.324C0.152,-0.285 0.144,-0.241 0.144,-0.193C0.144,-0.153 0.153,-0.124 0.17,-0.107C0.186,-0.089 0.211,-0.08 0.244,-0.08Z" style="fill-rule:nonzero;stroke:black;stroke-width:0.01px;"/>
</g>
<g transform="matrix(233.753,0,0,233.753,2051.05,362.817)">
<path d="M0.132,-0C0.102,-0 0.079,-0.006 0.063,-0.017C0.047,-0.029 0.039,-0.047 0.039,-0.072C0.039,-0.088 0.041,-0.105 0.046,-0.123C0.05,-0.142 0.053,-0.159 0.057,-0.175L0.17,-0.71L0.276,-0.71L0.161,-0.167C0.158,-0.154 0.156,-0.143 0.154,-0.132C0.151,-0.121 0.15,-0.113 0.15,-0.106C0.15,-0.1 0.153,-0.095 0.157,-0.092C0.162,-0.088 0.169,-0.087 0.178,-0.087L0.223,-0.087L0.205,-0L0.132,-0Z" style="fill-rule:nonzero;stroke:black;stroke-width:0.01px;"/>
</g>
<g transform="matrix(233.753,0,0,233.753,2113.05,362.817)">
<path d="M0.23,0.012C0.166,0.012 0.118,-0.005 0.084,-0.04C0.051,-0.075 0.034,-0.121 0.034,-0.177C0.034,-0.225 0.04,-0.271 0.053,-0.315C0.065,-0.359 0.084,-0.398 0.109,-0.432C0.134,-0.467 0.165,-0.494 0.203,-0.514C0.24,-0.534 0.283,-0.544 0.333,-0.544C0.396,-0.544 0.445,-0.527 0.478,-0.492C0.511,-0.458 0.527,-0.412 0.527,-0.355C0.527,-0.307 0.521,-0.261 0.509,-0.217C0.496,-0.173 0.478,-0.134 0.453,-0.1C0.428,-0.065 0.397,-0.038 0.36,-0.018C0.323,0.002 0.279,0.012 0.23,0.012ZM0.244,-0.08C0.279,-0.08 0.309,-0.091 0.335,-0.114C0.361,-0.137 0.381,-0.169 0.395,-0.208C0.41,-0.247 0.417,-0.291 0.417,-0.339C0.417,-0.378 0.409,-0.407 0.393,-0.425C0.377,-0.443 0.352,-0.452 0.318,-0.452C0.284,-0.452 0.253,-0.441 0.227,-0.418C0.201,-0.394 0.181,-0.363 0.166,-0.324C0.152,-0.285 0.144,-0.241 0.144,-0.193C0.144,-0.153 0.153,-0.124 0.17,-0.107C0.186,-0.089 0.211,-0.08 0.244,-0.08Z" style="fill-rule:nonzero;stroke:black;stroke-width:0.01px;"/>
</g>
<g transform="matrix(233.753,0,0,233.753,2242.17,362.817)">
<path d="M0.192,0.162C0.158,0.162 0.128,0.159 0.104,0.152C0.08,0.145 0.06,0.134 0.045,0.121C0.029,0.108 0.018,0.092 0.011,0.073C0.004,0.054 0.001,0.033 0.001,0.009L0.111,0.002C0.111,0.025 0.118,0.043 0.132,0.056C0.145,0.068 0.171,0.074 0.209,0.074C0.255,0.074 0.289,0.065 0.313,0.048C0.337,0.032 0.353,0.003 0.361,-0.038L0.378,-0.116C0.359,-0.089 0.336,-0.068 0.308,-0.052C0.28,-0.035 0.247,-0.027 0.21,-0.027C0.156,-0.027 0.114,-0.044 0.085,-0.077C0.055,-0.11 0.041,-0.156 0.041,-0.216C0.041,-0.26 0.047,-0.302 0.06,-0.341C0.073,-0.381 0.091,-0.415 0.115,-0.446C0.139,-0.476 0.167,-0.5 0.201,-0.518C0.234,-0.535 0.272,-0.544 0.314,-0.544C0.355,-0.544 0.386,-0.535 0.408,-0.518C0.431,-0.5 0.445,-0.477 0.451,-0.448L0.469,-0.532L0.571,-0.532L0.467,-0.04C0.457,0.011 0.44,0.051 0.416,0.08C0.393,0.11 0.363,0.131 0.326,0.143C0.289,0.156 0.244,0.162 0.192,0.162ZM0.243,-0.117C0.272,-0.117 0.297,-0.124 0.319,-0.138C0.342,-0.151 0.36,-0.169 0.375,-0.192C0.39,-0.214 0.402,-0.238 0.409,-0.265C0.417,-0.291 0.42,-0.318 0.42,-0.344C0.42,-0.378 0.411,-0.404 0.393,-0.424C0.375,-0.444 0.35,-0.454 0.317,-0.454C0.292,-0.454 0.27,-0.448 0.25,-0.437C0.23,-0.425 0.212,-0.409 0.198,-0.389C0.183,-0.368 0.171,-0.345 0.164,-0.317C0.156,-0.29 0.152,-0.261 0.152,-0.229C0.152,-0.191 0.158,-0.164 0.172,-0.145C0.185,-0.126 0.209,-0.117 0.243,-0.117Z" style="fill-rule:nonzero;stroke:black;stroke-width:0.01px;"/>
</g>
<g transform="matrix(233.753,0,0,233.753,2377.23,362.817)">
<path d="M0.019,-0L0.131,-0.532L0.237,-0.532L0.125,-0L0.019,-0ZM0.146,-0.609L0.167,-0.715L0.278,-0.715L0.256,-0.609L0.146,-0.609Z" style="fill-rule:nonzero;stroke:black;stroke-width:0.01px;"/>
</g>
<g transform="matrix(233.753,0,0,233.753,2435.77,362.817)">
<path d="M0.232,0.012C0.169,0.012 0.12,-0.005 0.086,-0.04C0.052,-0.075 0.035,-0.123 0.035,-0.184C0.035,-0.234 0.042,-0.281 0.056,-0.325C0.07,-0.369 0.091,-0.407 0.117,-0.44C0.144,-0.473 0.177,-0.499 0.215,-0.517C0.254,-0.535 0.298,-0.544 0.346,-0.544C0.386,-0.544 0.42,-0.537 0.448,-0.523C0.476,-0.509 0.497,-0.487 0.512,-0.459C0.526,-0.431 0.533,-0.396 0.533,-0.354C0.533,-0.334 0.532,-0.313 0.529,-0.293C0.526,-0.272 0.522,-0.253 0.517,-0.235L0.129,-0.235L0.146,-0.315L0.422,-0.315C0.423,-0.321 0.424,-0.328 0.425,-0.335C0.425,-0.342 0.426,-0.351 0.426,-0.362C0.426,-0.392 0.417,-0.415 0.401,-0.431C0.384,-0.446 0.36,-0.454 0.33,-0.454C0.303,-0.454 0.277,-0.447 0.254,-0.434C0.231,-0.421 0.212,-0.403 0.196,-0.38C0.18,-0.356 0.167,-0.329 0.158,-0.297C0.149,-0.266 0.145,-0.231 0.145,-0.194C0.145,-0.154 0.154,-0.124 0.172,-0.106C0.19,-0.088 0.216,-0.079 0.251,-0.079C0.283,-0.079 0.31,-0.086 0.334,-0.1C0.357,-0.114 0.373,-0.134 0.383,-0.16L0.492,-0.153C0.47,-0.103 0.439,-0.063 0.397,-0.033C0.355,-0.003 0.3,0.012 0.232,0.012Z" style="fill-rule:nonzero;stroke:black;stroke-width:0.01px;"/>
</g>
<g transform="matrix(233.753,0,0,233.753,2565.69,362.817)">
<path d="M0.236,0.012C0.172,0.012 0.122,-0.004 0.085,-0.036C0.048,-0.068 0.027,-0.112 0.022,-0.168L0.131,-0.173C0.135,-0.14 0.147,-0.115 0.166,-0.1C0.185,-0.084 0.212,-0.077 0.247,-0.077C0.277,-0.077 0.302,-0.082 0.324,-0.094C0.345,-0.105 0.356,-0.123 0.356,-0.148C0.356,-0.161 0.353,-0.171 0.348,-0.18C0.342,-0.188 0.332,-0.196 0.316,-0.202C0.3,-0.207 0.277,-0.213 0.245,-0.218C0.188,-0.228 0.144,-0.245 0.115,-0.268C0.085,-0.292 0.07,-0.326 0.07,-0.371C0.07,-0.4 0.078,-0.427 0.092,-0.454C0.106,-0.48 0.129,-0.501 0.16,-0.519C0.192,-0.536 0.234,-0.544 0.286,-0.544C0.349,-0.544 0.398,-0.529 0.433,-0.498C0.468,-0.468 0.488,-0.425 0.493,-0.369L0.383,-0.363C0.381,-0.395 0.371,-0.419 0.354,-0.433C0.337,-0.448 0.312,-0.455 0.281,-0.455C0.246,-0.455 0.22,-0.447 0.204,-0.432C0.188,-0.416 0.181,-0.397 0.181,-0.375C0.181,-0.361 0.184,-0.35 0.191,-0.341C0.198,-0.331 0.209,-0.324 0.224,-0.318C0.24,-0.312 0.26,-0.307 0.286,-0.302C0.332,-0.294 0.369,-0.283 0.395,-0.271C0.421,-0.258 0.44,-0.242 0.45,-0.224C0.461,-0.205 0.466,-0.182 0.466,-0.156C0.466,-0.12 0.456,-0.09 0.436,-0.065C0.416,-0.039 0.388,-0.02 0.354,-0.007C0.319,0.006 0.28,0.012 0.236,0.012Z" style="fill-rule:nonzero;stroke:black;stroke-width:0.01px;"/>
</g>
<g transform="matrix(233.753,0,0,233.753,2690.79,362.817)">
</g>
</g>
<g transform="matrix(1.20551,0,0,1.20551,-797.429,238.41)">
<g transform="matrix(233.753,0,0,233.753,1308.09,362.817)">
<path d="M0.01,-0L0.16,-0.71L0.31,-0.71L0.246,-0.411L0.227,-0.412C0.247,-0.459 0.274,-0.493 0.307,-0.515C0.34,-0.537 0.377,-0.548 0.418,-0.548C0.465,-0.548 0.501,-0.536 0.525,-0.511C0.55,-0.486 0.562,-0.453 0.562,-0.41C0.562,-0.39 0.56,-0.368 0.556,-0.345C0.552,-0.322 0.548,-0.301 0.544,-0.284L0.483,-0L0.333,-0L0.397,-0.294C0.4,-0.306 0.402,-0.319 0.404,-0.332C0.406,-0.345 0.407,-0.358 0.407,-0.371C0.407,-0.39 0.402,-0.404 0.392,-0.414C0.382,-0.424 0.366,-0.429 0.344,-0.429C0.325,-0.429 0.307,-0.425 0.291,-0.416C0.275,-0.407 0.262,-0.394 0.251,-0.375C0.239,-0.357 0.231,-0.334 0.225,-0.305L0.16,-0L0.01,-0Z" style="fill-rule:nonzero;stroke:black;stroke-width:0.01px;"/>
</g>
<g transform="matrix(233.753,0,0,233.753,1446.75,362.817)">
<path d="M0.381,-0.244L0.242,-0.214C0.217,-0.209 0.198,-0.201 0.186,-0.189C0.173,-0.178 0.167,-0.161 0.167,-0.139C0.167,-0.123 0.172,-0.111 0.18,-0.103C0.189,-0.095 0.202,-0.091 0.22,-0.091C0.242,-0.091 0.261,-0.095 0.279,-0.102C0.297,-0.11 0.312,-0.121 0.325,-0.138C0.337,-0.154 0.346,-0.174 0.352,-0.199C0.354,-0.21 0.356,-0.224 0.36,-0.24C0.363,-0.257 0.367,-0.274 0.37,-0.292C0.374,-0.311 0.377,-0.327 0.379,-0.343C0.381,-0.358 0.382,-0.371 0.382,-0.379C0.382,-0.399 0.378,-0.414 0.369,-0.424C0.359,-0.434 0.343,-0.439 0.318,-0.439C0.291,-0.439 0.27,-0.433 0.254,-0.421C0.238,-0.408 0.225,-0.389 0.216,-0.362L0.067,-0.369C0.087,-0.43 0.118,-0.475 0.16,-0.504C0.203,-0.533 0.263,-0.548 0.341,-0.548C0.406,-0.548 0.454,-0.535 0.486,-0.508C0.517,-0.482 0.533,-0.442 0.533,-0.391C0.533,-0.377 0.531,-0.361 0.528,-0.341C0.525,-0.32 0.521,-0.299 0.516,-0.277C0.511,-0.254 0.506,-0.233 0.501,-0.212C0.496,-0.191 0.492,-0.172 0.489,-0.156C0.486,-0.141 0.484,-0.13 0.484,-0.124C0.484,-0.116 0.486,-0.11 0.49,-0.107C0.495,-0.104 0.501,-0.103 0.509,-0.103L0.524,-0.103L0.503,-0C0.497,0.001 0.489,0.003 0.479,0.004C0.468,0.005 0.457,0.006 0.447,0.006C0.414,0.006 0.389,-0.001 0.373,-0.014C0.356,-0.028 0.348,-0.046 0.348,-0.069C0.348,-0.077 0.349,-0.084 0.35,-0.092C0.351,-0.1 0.353,-0.108 0.356,-0.116L0.365,-0.108C0.354,-0.084 0.339,-0.063 0.32,-0.045C0.301,-0.027 0.279,-0.013 0.253,-0.003C0.227,0.007 0.197,0.012 0.164,0.012C0.116,0.012 0.079,0.002 0.052,-0.019C0.025,-0.04 0.012,-0.07 0.012,-0.11C0.012,-0.163 0.029,-0.204 0.063,-0.235C0.097,-0.265 0.146,-0.286 0.211,-0.299L0.401,-0.337L0.381,-0.244Z" style="fill-rule:nonzero;stroke:black;stroke-width:0.01px;"/>
</g>
<g transform="matrix(233.753,0,0,233.753,1577.98,362.817)">
<path d="M0.19,0.012C0.136,0.012 0.096,-0.005 0.07,-0.04C0.043,-0.075 0.03,-0.122 0.03,-0.182C0.03,-0.233 0.037,-0.281 0.051,-0.325C0.065,-0.369 0.084,-0.408 0.11,-0.442C0.135,-0.475 0.165,-0.501 0.199,-0.52C0.233,-0.539 0.27,-0.548 0.311,-0.548C0.345,-0.548 0.373,-0.54 0.396,-0.524C0.418,-0.508 0.434,-0.487 0.442,-0.46L0.495,-0.71L0.645,-0.71L0.494,-0L0.351,-0L0.373,-0.122L0.383,-0.11C0.362,-0.072 0.336,-0.043 0.304,-0.021C0.273,0.001 0.235,0.012 0.19,0.012ZM0.259,-0.103C0.28,-0.103 0.3,-0.109 0.318,-0.121C0.336,-0.133 0.352,-0.149 0.365,-0.17C0.378,-0.191 0.389,-0.216 0.397,-0.244C0.405,-0.272 0.408,-0.303 0.408,-0.336C0.408,-0.367 0.401,-0.391 0.388,-0.408C0.374,-0.424 0.355,-0.433 0.331,-0.433C0.31,-0.433 0.291,-0.427 0.274,-0.415C0.256,-0.403 0.241,-0.386 0.227,-0.366C0.214,-0.345 0.203,-0.321 0.196,-0.294C0.188,-0.267 0.185,-0.238 0.185,-0.207C0.185,-0.173 0.191,-0.148 0.203,-0.13C0.214,-0.112 0.233,-0.103 0.259,-0.103Z" style="fill-rule:nonzero;stroke:black;stroke-width:0.01px;"/>
</g>
<g transform="matrix(233.753,0,0,233.753,1720.76,362.817)">
<path d="M0.143,-0C0.107,-0 0.079,-0.007 0.061,-0.021C0.043,-0.035 0.034,-0.057 0.034,-0.088C0.034,-0.105 0.036,-0.124 0.041,-0.146C0.045,-0.168 0.16,-0.71 0.16,-0.71L0.31,-0.71L0.202,-0.202C0.2,-0.189 0.197,-0.176 0.195,-0.164C0.192,-0.152 0.191,-0.142 0.191,-0.135C0.191,-0.127 0.194,-0.121 0.198,-0.118C0.203,-0.114 0.211,-0.112 0.222,-0.112L0.262,-0.112L0.239,-0L0.143,-0Z" style="fill-rule:nonzero;stroke:black;stroke-width:0.01px;"/>
</g>
<g transform="matrix(233.753,0,0,233.753,1789.86,362.817)">
<path d="M0.245,0.012C0.175,0.012 0.122,-0.005 0.085,-0.04C0.049,-0.075 0.031,-0.123 0.031,-0.184C0.031,-0.233 0.037,-0.279 0.05,-0.323C0.062,-0.367 0.082,-0.405 0.108,-0.439C0.135,-0.473 0.168,-0.5 0.208,-0.519C0.248,-0.538 0.296,-0.548 0.35,-0.548C0.421,-0.548 0.474,-0.531 0.51,-0.496C0.546,-0.461 0.564,-0.413 0.564,-0.352C0.564,-0.303 0.558,-0.257 0.545,-0.213C0.532,-0.169 0.513,-0.13 0.487,-0.097C0.46,-0.063 0.427,-0.036 0.387,-0.017C0.347,0.002 0.3,0.012 0.245,0.012ZM0.265,-0.103C0.294,-0.103 0.319,-0.114 0.34,-0.134C0.362,-0.155 0.379,-0.182 0.391,-0.217C0.403,-0.252 0.41,-0.29 0.41,-0.333C0.41,-0.367 0.403,-0.392 0.39,-0.408C0.376,-0.424 0.357,-0.433 0.33,-0.433C0.301,-0.433 0.276,-0.422 0.254,-0.402C0.232,-0.381 0.215,-0.353 0.203,-0.318C0.191,-0.284 0.185,-0.245 0.185,-0.202C0.185,-0.169 0.192,-0.144 0.206,-0.128C0.22,-0.111 0.239,-0.103 0.265,-0.103Z" style="fill-rule:nonzero;stroke:black;stroke-width:0.01px;"/>
</g>
<g transform="matrix(233.753,0,0,233.753,1926.65,362.817)">
<path d="M0.238,0.012C0.169,0.012 0.117,-0.005 0.082,-0.038C0.048,-0.071 0.03,-0.12 0.03,-0.184C0.03,-0.259 0.044,-0.324 0.071,-0.378C0.099,-0.432 0.137,-0.474 0.186,-0.504C0.234,-0.533 0.291,-0.548 0.355,-0.548C0.401,-0.548 0.44,-0.54 0.471,-0.524C0.502,-0.507 0.525,-0.483 0.541,-0.452C0.556,-0.421 0.564,-0.383 0.564,-0.338L0.409,-0.331C0.409,-0.367 0.403,-0.393 0.391,-0.409C0.379,-0.424 0.359,-0.432 0.33,-0.432C0.302,-0.432 0.277,-0.422 0.255,-0.402C0.234,-0.382 0.216,-0.355 0.204,-0.321C0.191,-0.287 0.185,-0.249 0.185,-0.206C0.185,-0.17 0.191,-0.143 0.204,-0.127C0.216,-0.111 0.236,-0.103 0.263,-0.103C0.293,-0.103 0.319,-0.112 0.34,-0.13C0.361,-0.147 0.378,-0.174 0.39,-0.21L0.543,-0.204C0.529,-0.16 0.508,-0.122 0.479,-0.089C0.45,-0.057 0.415,-0.032 0.374,-0.014C0.334,0.003 0.288,0.012 0.238,0.012Z" style="fill-rule:nonzero;stroke:black;stroke-width:0.01px;"/>
</g>
<g transform="matrix(233.753,0,0,233.753,2061.9,362.817)">
<path d="M0.16,-0.131L0.192,-0.306L0.45,-0.536L0.637,-0.536L0.16,-0.131ZM0.01,-0L0.16,-0.71L0.31,-0.71L0.16,-0L0.01,-0ZM0.368,-0L0.264,-0.248L0.379,-0.333L0.533,-0L0.368,-0Z" style="fill-rule:nonzero;stroke:black;stroke-width:0.01px;"/>
</g>
<g transform="matrix(233.753,0,0,233.753,2204.53,362.817)">
</g>
<g transform="matrix(233.753,0,0,233.753,2257.92,362.817)">
</g>
</g>
<g transform="matrix(0.96583,0,0,0.984036,-9.32219,21.63)">
<path d="M311.416,461.453L281.645,600.375C289.122,590.297 297.573,582.144 306.999,575.916C323.911,564.741 342.767,559.153 363.566,559.153C387.706,559.153 405.998,565.418 418.443,577.948C430.888,590.478 437.111,607.649 437.111,629.46C437.111,639.413 436.088,650.35 434.042,662.271C431.997,674.193 430.02,684.453 428.113,693.051L427.462,696.076C423.753,695.216 420.018,694.752 416.268,694.75C406.179,694.744 395.48,698.167 384.836,704.403C372.642,711.548 360.183,722.586 348.622,733.745C346.354,735.934 343.856,738.282 341.291,740.656L352.618,688.384C354.19,682.014 355.504,675.476 356.562,668.772C357.619,662.068 358.148,655.533 358.148,649.167C358.148,639.479 355.506,632.129 350.221,627.118C344.936,622.107 336.803,619.602 325.823,619.602C315.935,619.602 306.916,621.837 298.768,626.309C290.619,630.78 283.68,637.62 277.95,646.83C272.22,656.039 267.786,667.925 264.649,682.487L251.522,744.09C238.026,749.008 223.339,759.127 209.691,775.266C195.858,791.625 174.257,805.089 160.149,812.821L222.685,520.219C248.05,495.448 277.874,475.362 311.416,461.453ZM167.579,829.767C182.86,821.112 206.229,805.095 222.641,785.686C237.826,767.728 254.036,758.385 267.374,757.899C293.757,756.936 302.691,778.88 318.086,779.007C336.919,779.162 379.548,712.1 414.23,712.121C451.598,712.144 483.219,775.838 507.785,775.779C524.374,775.74 533.917,757.587 553.577,757.745C563.589,757.825 577.104,768.353 591.118,779.64C602.675,788.947 640.35,820.705 666.264,842.607C619.483,926.992 528.796,985.05 423.739,986.884C310.019,988.869 213.48,924.048 167.579,829.767ZM278.49,839.21C278.49,839.21 304.093,836.136 326.031,822.275C348.428,808.124 369.348,792.845 375.016,792.822C384.512,792.783 389.858,806.521 399.148,806.075C412.642,805.428 428.651,781.005 442.132,780.304C453.181,779.73 462.606,790.088 473.411,793.844C485.308,797.98 493.412,795.767 493.412,795.767C493.412,795.767 485.653,791.267 474.788,781.04C468.553,775.172 455.548,760.741 448.613,753.468C439.024,743.412 431.096,732.407 414.23,732.443C396.454,732.481 378.823,753.996 363.705,769.996C361.805,772.006 346.912,787.343 326.504,804.966C305.719,822.914 278.49,839.21 278.49,839.21ZM191.013,835.528C191.013,835.528 198.753,834.654 212.863,828.48C234.834,818.867 248.25,808.244 253.605,808.284C263.1,808.356 269.553,814.837 277.343,814.911C289.97,815.032 304.884,797.976 304.884,797.976C304.884,797.976 300.044,793.491 292.116,788.043C284.572,782.858 276.793,777.654 270.195,778.095C262.994,778.577 254.156,783.666 233.748,801.289C212.963,819.238 191.013,835.528 191.013,835.528ZM444.972,847.714C444.972,847.714 460.408,849.651 480.379,840.466C501.271,830.857 510.121,817.267 521.837,817.219C531.333,817.18 531.587,823.077 543.15,823.2C549.857,823.271 564.23,810.962 570.599,808.858C576.701,806.843 587.107,812.929 593.443,813.343C601.094,813.844 609.904,813.566 609.904,813.566C609.904,813.566 594.304,800.885 580.945,790.381C565.983,778.617 562.447,772.447 552.368,772.483C534.593,772.547 524.153,792.804 507.527,805.859C502.985,809.424 491.579,822.338 467.899,834.348C444.737,846.095 444.972,847.714 444.972,847.714ZM215.601,885.767C215.791,895.51 391.383,897.206 391.75,900.77C392.145,904.604 279.347,904.07 279.388,911.079C279.421,916.747 388.247,915.497 388.209,918.468C388.157,922.422 307.862,929.231 307.906,933.11C307.947,936.729 417.063,938.623 417.252,942.432C417.439,946.187 374.935,948.552 374.932,952.509C374.93,955.439 393.626,958.15 436.923,958.622C462.095,958.896 485.516,954.565 485.422,951.428C485.303,947.447 449.314,945.119 449.219,942.655C449.124,940.205 531.133,936.516 531.261,931.696C531.368,927.625 442.356,921.757 442.276,919.178C442.212,917.13 558.319,915.343 558.409,911.079C558.473,908.009 443,903.452 443.03,900.77C443.055,898.507 621.947,890.989 621.84,886.225C621.789,883.919 521.675,884.701 416.688,884.351C318.799,884.024 215.535,882.359 215.601,885.767ZM681.616,619.738L595.583,619.766L574.301,719.027C572.647,726.603 571.143,733.885 569.788,740.872C564.39,739.115 559.011,738.144 553.734,738.101C541.83,738.006 532.257,741.958 523.591,747.203C520.143,749.29 516.882,751.593 513.572,753.607C511.888,754.632 510.324,755.863 508.379,756.096C507.433,755.535 503.282,753.035 500.771,750.897C498.037,748.569 495.24,745.913 492.378,743.066C494.161,734.289 496.032,725.328 497.991,716.185L518.753,619.766L474.568,619.766L486.739,563.071L531.413,563.071L550.495,471.901C575.034,484.681 597.215,501.04 616.762,520.085L608.019,563.071L652.508,563.071C664.182,580.704 674.281,599.613 681.616,619.738Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 23 KiB

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

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

+6 -2
View File
@@ -9,9 +9,13 @@ and must not be redistributed as standalone stock assets.
- 189193781: candid wedding guest
- 208516668: reception setting
- 240365594: champagne toast
- 457677937: wedding ceremony exit
- 498647922: first dance
- 599129661: guests' wedding exit tunnel
- 599131260: newlyweds greeting friends and family
- 2157148813: friends taking a garden selfie
- 2157148814: wedding guests sharing an outdoor toast
Source pages: `https://stock.adobe.com/images/{asset-id}`.
Renditions: auto-oriented, metadata stripped, bounded to 1000px, WebP quality 80.
Renditions: auto-oriented, metadata stripped, bounded to 2000px wide, WebP quality 90.
Responsive homepage images use Next.js quality 85; tall collage tiles request
enough source pixels for their height-driven cover crop on high-density screens.
+2 -1
View File
@@ -31,6 +31,7 @@ export function BackendShell({ children, area, groups, activeGroupId }: {
const activeItem = activeNavigationItem(workspaces, pathname);
const activeWorkspace = workspaces.find((workspace) => workspace.items.includes(activeItem!)) ?? workspaces[0]!;
const activeGroup = groups.find((group) => group.id === activeGroupId) ?? groups[0];
const signEventId = pathname.match(/^\/dashboard\/events\/([^/]+)\/sign$/)?.[1];
return (
<div className="min-h-[calc(100dvh-4rem)] bg-background">
<DoubleSidebarNavigation key={area} workspaces={workspaces} activeItem={activeItem}
@@ -42,7 +43,7 @@ export function BackendShell({ children, area, groups, activeGroupId }: {
<Link href={area === "platform" ? "/admin" : "/dashboard"} className="hover:text-foreground">{applicationLabel}</Link>
<ChevronRight aria-hidden="true" className="size-3" />
<span>{activeWorkspace.label}</span>
{activeItem && activeItem.label !== activeWorkspace.label ? <><ChevronRight aria-hidden="true" className="size-3" /><span>{activeItem.label}</span></> : null}
{signEventId ? <><ChevronRight aria-hidden="true" className="size-3" /><Link href={`/dashboard/events/${signEventId}`} className="hover:text-foreground">Event</Link><ChevronRight aria-hidden="true" className="size-3" /><span aria-current="page">Guest sign</span></> : activeItem && activeItem.label !== activeWorkspace.label ? <><ChevronRight aria-hidden="true" className="size-3" /><span>{activeItem.label}</span></> : null}
</nav>
{children}
</div>
@@ -0,0 +1,69 @@
"use client";
import { useEffect, useState } from "react";
import ReactCrop, { centerCrop, makeAspectCrop, type PercentCrop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import { BANNER_ASPECT_RATIO, type BannerCrop } from "@album/contracts";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
export function BannerCropDialog({ file, onCancel, onConfirm }: {
file: File;
onCancel: () => void;
onConfirm: (crop: BannerCrop) => void;
}) {
const [src, setSrc] = useState<string>();
const [crop, setCrop] = useState<PercentCrop>();
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [error, setError] = useState<string>();
useEffect(() => {
let cancelled = false;
let url: string | undefined;
async function prepare() {
try {
let preview: Blob = file;
if (/image\/hei[cf]/.test(file.type) || /\.hei[cf]$/i.test(file.name)) {
const { heicTo } = await import("heic-to/csp");
preview = await heicTo({ blob: file, type: "image/jpeg", quality: 0.9 });
}
if (cancelled) return;
url = URL.createObjectURL(preview);
setSrc(url);
} catch {
if (!cancelled) setError("This image couldn't be opened. Try a JPEG, PNG, or WebP copy.");
}
}
void prepare();
return () => { cancelled = true; if (url) URL.revokeObjectURL(url); };
}, [file]);
function reset(width = dimensions.width, height = dimensions.height) {
setCrop(centerCrop(makeAspectCrop({ unit: "%", width: 100 }, BANNER_ASPECT_RATIO, width, height), width, height));
}
const valid = crop && crop.width * dimensions.width / 100 >= 8 && crop.height * dimensions.height / 100 >= 3 && !error;
return (
<Dialog open onOpenChange={(open) => { if (!open) onCancel(); }}>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Crop your banner</DialogTitle>
<DialogDescription>Drag to reposition or resize the 8:3 frame. The shaded area will be cut off. Your original stays untouched.</DialogDescription>
</DialogHeader>
<div className="flex min-h-32 justify-center overflow-hidden rounded-lg bg-muted">
{src && !error ? <ReactCrop crop={crop} aspect={BANNER_ASPECT_RATIO} onChange={(_, percent) => setCrop(percent)} keepSelection ruleOfThirds>
<img src={src} alt="Banner crop preview" className="block max-h-[50dvh] max-w-full object-contain"
onLoad={(event) => {
const { naturalWidth: width, naturalHeight: height } = event.currentTarget;
setDimensions({ width, height });
reset(width, height);
}} onError={() => setError("This image couldn't be opened. Choose another image.")} />
</ReactCrop> : <p role={error ? "alert" : "status"} className="self-center p-4 text-sm text-muted-foreground">{error ?? "Preparing image…"}</p>}
</div>
<p className="text-xs text-muted-foreground">The selected area becomes your banner everywhere. After uploading, use Save changes to publish it.</p>
<DialogFooter>
<Button type="button" variant="ghost" disabled={!crop || Boolean(error)} onClick={() => reset()}>Reset crop</Button>
<Button type="button" variant="outline" onClick={onCancel}>Cancel</Button>
<Button type="button" disabled={!valid} onClick={() => { if (crop && valid) onConfirm({ x: crop.x, y: crop.y, width: crop.width, height: crop.height }); }}>Use crop & upload</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+21 -6
View File
@@ -1,12 +1,15 @@
"use client";
import { useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import { toast } from "sonner";
import { UploadIcon } from "lucide-react";
import { allowedImageTypeSchema, MAX_PHOTO_BYTES } from "@album/contracts";
import { allowedImageTypeSchema, MAX_PHOTO_BYTES, type BannerCrop } from "@album/contracts";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
const BannerCropDialog = dynamic(() => import("./banner-crop-dialog").then((module) => module.BannerCropDialog), { ssr: false });
export function BannerUpload({ eventId, selectedId, onSelect, onBusyChange }: {
eventId: string;
selectedId: string | null;
@@ -15,6 +18,7 @@ export function BannerUpload({ eventId, selectedId, onSelect, onBusyChange }: {
}) {
const input = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [cropFile, setCropFile] = useState<File | null>(null);
const [bannerId, setBannerId] = useState<string | null>(null);
const [startedAt, setStartedAt] = useState<number | null>(null);
const [timedOut, setTimedOut] = useState(false);
@@ -40,7 +44,7 @@ export function BannerUpload({ eventId, selectedId, onSelect, onBusyChange }: {
const timer = setTimeout(() => setTimedOut(true), 120_000);
return () => clearTimeout(timer);
}, [startedAt]);
async function upload(file: File) {
async function upload(file: File, crop: BannerCrop) {
const mime = file.type || (/\.heic$/i.test(file.name) ? "image/heic" : /\.heif$/i.test(file.name) ? "image/heif" : "");
const parsed = allowedImageTypeSchema.safeParse(mime);
if (!parsed.success || file.size <= 0 || file.size > MAX_PHOTO_BYTES) {
@@ -52,7 +56,7 @@ export function BannerUpload({ eventId, selectedId, onSelect, onBusyChange }: {
setBannerId(null);
setStartedAt(null);
try {
const pending = await create.mutateAsync({ eventId, contentType: parsed.data, byteSize: file.size });
const pending = await create.mutateAsync({ eventId, contentType: parsed.data, byteSize: file.size, crop });
const response = await fetch(pending.uploadUrl, {
method: "PUT", body: file, headers: { "Content-Type": parsed.data }, signal: AbortSignal.timeout(120_000),
});
@@ -65,23 +69,34 @@ export function BannerUpload({ eventId, selectedId, onSelect, onBusyChange }: {
} finally { setUploading(false); }
}
const processing = Boolean(bannerId) && status.data?.status !== "failed" && !status.isError && !timedOut;
useEffect(() => { onBusyChange(uploading || processing); }, [uploading, processing, onBusyChange]);
useEffect(() => { onBusyChange(uploading || processing || Boolean(cropFile)); }, [uploading, processing, cropFile, onBusyChange]);
useEffect(() => () => onBusyChange(false), [onBusyChange]);
return (
<div className="flex flex-col gap-3 rounded-lg border p-3">
{cropFile ? <BannerCropDialog file={cropFile} onCancel={() => setCropFile(null)} onConfirm={(crop) => {
const file = cropFile;
setCropFile(null);
void upload(file, crop);
}} /> : null}
<div className="flex flex-wrap items-center gap-2">
<input ref={input} type="file" accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden" aria-label="Upload a dedicated event banner"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void upload(file);
if (!file) return;
const mime = file.type || (/\.heic$/i.test(file.name) ? "image/heic" : /\.heif$/i.test(file.name) ? "image/heif" : "");
if (!allowedImageTypeSchema.safeParse(mime).success || file.size <= 0 || file.size > MAX_PHOTO_BYTES) {
toast.error("Choose a JPEG, PNG, WebP, or HEIC image up to 25 MB.");
return;
}
setCropFile(file);
}} />
<Button type="button" variant="outline" disabled={uploading || processing} onClick={() => input.current?.click()}>
<UploadIcon data-icon="inline-start" />{uploading ? "Uploading…" : processing ? "Preparing banner…" : selectedId ? "Replace uploaded banner" : "Upload a banner"}
</Button>
</div>
<p className="text-xs text-muted-foreground">A separate image just for this event's header. It won't appear in the gallery. Up to 25 MB.</p>
<p className="text-xs text-muted-foreground">Crop to 8:3 before uploading. Originals are preserved; the display copy is optimized. Separate from the gallery. Up to 25 MB.</p>
{processing ? <p role="status" className="text-sm text-muted-foreground">Preparing your banner You can keep editing while it processes.</p> : null}
{status.isError || status.data?.status === "failed" || timedOut ? <p role="alert" className="text-sm text-destructive">{timedOut ? "Processing is taking longer than expected. Your previous banner is unchanged; try another upload." : "Couldn't prepare this banner. Your previous banner is unchanged. Try uploading another image."}</p> : null}
</div>
+4 -2
View File
@@ -8,8 +8,10 @@ export function EventMap({ latitude, longitude, location }: {
const url = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}`;
return (
<div className="flex flex-col gap-2">
<iframe title={`Map of ${location}`} src={openStreetMapEmbedUrl(latitude, longitude)}
loading="lazy" referrerPolicy="no-referrer" className="h-64 w-full rounded-xl border bg-muted" />
<div className="isolate overflow-hidden rounded-xl border bg-muted [clip-path:inset(0_round_var(--radius-xl))]">
<iframe title={`Map of ${location}`} src={openStreetMapEmbedUrl(latitude, longitude)}
loading="lazy" referrerPolicy="no-referrer" className="block h-64 w-full border-0" />
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer" className="underline underline-offset-4">© OpenStreetMap contributors</a>
<a href={url} target="_blank" rel="noreferrer" className="font-semibold underline underline-offset-4">Open Larger Map</a>
+48
View File
@@ -0,0 +1,48 @@
import Link from "next/link";
import { ArrowUpRightIcon } from "lucide-react";
import { BrandLockup } from "@/components/brand-mark";
const footerLink = "inline-flex min-h-11 items-center gap-2 rounded-sm text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring";
export function SiteFooter() {
return (
<footer className="border-t bg-muted/40">
<div className="page-pad mx-auto w-full max-w-6xl">
<div className="grid gap-10 py-12 sm:grid-cols-[1fr_auto] sm:gap-16 sm:py-16">
<div className="flex max-w-sm flex-col items-start gap-5">
<Link href="/" aria-label="Manyangles home" className="rounded-sm focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring">
<BrandLockup />
</Link>
<p className="text-sm leading-relaxed text-muted-foreground">One event. Every perspective.<br />A shared home for the moments everyone captured.</p>
</div>
<nav aria-label="Footer" className="grid grid-cols-2 gap-10 sm:gap-16">
<div>
<h2 className="mb-2 text-sm font-semibold">Gather your photos</h2>
<ul>
<li><Link href="/sign-up" className={footerLink}>Host an event</Link></li>
<li><Link href="/#how-it-works" className={footerLink}>How it works</Link></li>
</ul>
</div>
<div>
<h2 className="mb-2 text-sm font-semibold">Your albums</h2>
<ul>
<li><Link href="/sign-in" className={footerLink}>Sign in</Link></li>
<li><Link href="/dashboard" className={footerLink}>Open workspace</Link></li>
</ul>
</div>
</nav>
</div>
<div className="flex flex-col gap-6 border-t py-6 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">© {new Date().getFullYear()} Hadlock Technologies LLC. All rights reserved.</p>
<a href="https://hadlock.tech" aria-label="Made by Hadlock Tech — visit hadlock.tech"
className="group inline-flex min-h-11 w-fit items-center gap-4 rounded-sm text-foreground focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring">
<span className="text-xs text-muted-foreground">Made by</span>
{/* Original Hadlock Tech vector; the mask follows the active theme. */}
<span aria-hidden="true" className="block h-7 w-[121px] bg-current [mask-image:url('/branding/hadlock-tech.svg')] [mask-repeat:no-repeat] [mask-position:center] [mask-size:contain]" />
<ArrowUpRightIcon aria-hidden="true" className="size-4 text-muted-foreground transition-colors group-hover:text-foreground" />
</a>
</div>
</div>
</footer>
);
}
+1 -1
View File
@@ -75,8 +75,8 @@ export function SiteHeaderBar({ links, primary, signedIn, user, groups = [], act
<div className="ml-auto flex items-center gap-1">
{signedIn ? (
<>
<ApplicationSwitcher links={links} pathname={pathname} />
<ThemeToggle />
<ApplicationSwitcher links={links} pathname={pathname} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
+100
View File
@@ -0,0 +1,100 @@
import { expect, test } from "bun:test";
import sharp from "sharp";
import jsQR from "jsqr";
import { signQr } from "../server/sign-qr";
import { SIGN_PAPERS, guestQrSvg, guestSignSvg, signLines, signPrintHtml, signPaper, type SignDesign } from "./guest-sign";
test("standalone QR export scans to the guest link and keeps its quiet zone", async () => {
const url = "https://ma.hadlock.tech/e/test-wedding";
const svg = guestQrSvg(signQr(url));
expect(svg).toContain('transform="translate(4 4)"');
expect(svg).toContain('fill="white"');
const { data, info } = await sharp(Buffer.from(svg)).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
expect(jsQR(new Uint8ClampedArray(data), info.width, info.height)?.data).toBe(url);
});
const design: SignDesign = { title: "Sam & Alexs wedding", headline: "Share your favorite moments", message: "Add your photos to our shared album.", paper: "letter", ink: "indigo" };
test("Linen cards scan at every preset size with exact physical export dimensions", async () => {
const url = "https://ma.hadlock.tech/e/test-wedding";
for (const paper of Object.keys(SIGN_PAPERS) as (keyof typeof SIGN_PAPERS)[]) {
const svg = guestSignSvg({ ...design, paper, layout: "compact", background: "#f1eadf", accent: "#473a30" }, url, signQr(url));
const size = SIGN_PAPERS[paper];
expect(signPrintHtml(svg, paper)).toContain(`size:${size.width} ${size.height}`);
expect(svg).not.toContain("Add your event photo");
const { data, info } = await sharp(Buffer.from(svg), { density: 120 }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
expect(jsQR(new Uint8ClampedArray(data), info.width, info.height)?.data).toBe(url);
}
});
test("photo and typography layouts scan in portrait, square, and landscape", async () => {
const url = "https://ma.hadlock.tech/e/test-wedding";
const photo = `data:image/png;base64,${(await sharp({ create: { width: 80, height: 80, channels: 3, background: '#ae8268' } }).png().toBuffer()).toString('base64')}`;
for (const layout of ["photo", "typography"] as const) {
for (const [customWidth, customHeight] of [[8.5, 11], [5, 5], [10, 7]]) {
const svg = guestSignSvg({ ...design, layout, paper: "custom", customWidth, customHeight, backgroundImage: photo }, url, signQr(url));
const { data, info } = await sharp(Buffer.from(svg), { density: 120 }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
expect(jsQR(new Uint8ClampedArray(data), info.width, info.height)?.data).toBe(url);
expect(svg.includes('<image')).toBe(layout === "photo");
}
}
});
test("background is the page surface with contrasting text and an isolated white QR", () => {
const url = "https://ma.hadlock.tech/e/demo";
const svg = guestSignSvg({ ...design, background: "#204c40", accent: "#204c40" }, url, signQr(url));
expect(svg).toContain('font-family="SignInter, sans-serif" fill="#ffffff"');
expect(svg).not.toContain('rx="24" fill="white"');
expect(svg).toContain('<rect width="100%" height="100%" fill="white"/>');
const photo = guestSignSvg({ ...design, backgroundImage: "data:image/png;base64,AA==" }, url, signQr(url));
expect(photo).toContain('fill="black" opacity=".68"');
expect(photo).toContain('font-family="SignInter, sans-serif" fill="#ffffff"');
});
test("signs embed our font families and keep the wordmark in Geologica", () => {
const url = "https://ma.hadlock.tech/e/demo";
for (const font of ["funnel", "inter", "geologica"] as const) {
const svg = guestSignSvg({ ...design, font, fontData: "data:font/woff2;base64,AA==", interFontData: "data:font/woff2;base64,AA==", geologicaFontData: "data:font/woff2;base64,AA==" }, url, signQr(url));
for (const family of ["SignFunnel", "SignInter", "SignGeologica"]) expect(svg).toContain(`@font-face{font-family:${family};`);
expect(svg).toContain('font-family="SignInter, sans-serif"');
expect(svg).toContain('font-family="SignGeologica, sans-serif" font-size="26.5"');
const lockup = svg.slice(svg.indexOf('data-brand-lockup="manyangles"'));
expect(lockup).toContain('fill="currentColor"');
expect(lockup).toContain('stroke="currentColor"');
expect(lockup).not.toMatch(/(?:fill|stroke)="#/);
expect(svg).not.toMatch(/Arial|Georgia|Helvetica/);
}
const unsafe = guestSignSvg({ ...design, interFontData: "https://example.com/font.woff2" }, url, signQr(url));
expect(unsafe).not.toContain("https://example.com/font.woff2");
});
test("exported Letter and A4 signs scan to the complete guest URL", async () => {
const url = "https://ma.hadlock.tech/e/test-wedding";
for (const paper of ["letter", "a4"] as const) {
const svg = guestSignSvg({ ...design, paper }, url, signQr(url));
const { data, info } = await sharp(Buffer.from(svg), { density: 120 }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
expect(jsQR(new Uint8ClampedArray(data), info.width, info.height)?.data).toBe(url);
expect(svg).toContain('transform="translate(4 4)"');
expect(signPrintHtml(svg, paper)).toContain(paper === "letter" ? "size:8.5in 11in" : "size:210mm 297mm");
}
});
test("custom square and landscape designs retain exact print size and scan", async () => {
const url = "https://ma.hadlock.tech/e/test-wedding";
for (const [customWidth, customHeight] of [[5, 5], [10, 7], [4, 6]]) {
const custom: SignDesign = { ...design, paper: "custom", customWidth, customHeight, unit: "in", decoration: "frame", background: "#204c40" };
const svg = guestSignSvg(custom, url, signQr(url));
const { data, info } = await sharp(Buffer.from(svg), { density: 120 }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
expect(jsQR(new Uint8ClampedArray(data), info.width, info.height)?.data).toBe(url);
expect(signPrintHtml(svg, custom)).toContain(`size:${customWidth}in ${customHeight}in`);
}
expect(signPaper({ paper: "custom", customWidth: 127, customHeight: 178, unit: "mm" }).width).toBe("127mm");
expect(() => signPaper({ paper: "custom", customWidth: 0, customHeight: 10 })).toThrow();
expect(() => signPaper({ paper: "custom", customWidth: 48, customHeight: 3 })).toThrow();
});
test("external and SVG image uploads cannot inject export content", () => {
const svg = guestSignSvg({ ...design, logoImage: 'data:image/svg+xml;<script>', backgroundImage: 'https://example.com/track', accent: '\"><script>' }, "https://ma.hadlock.tech/e/demo", signQr("https://ma.hadlock.tech/e/demo"));
expect(svg).not.toContain("<script>");
expect(svg).not.toContain("https://example.com/track");
});
test("custom text is escaped and long words wrap", () => {
const svg = guestSignSvg({ ...design, title: '<script>alert("x")</script>' }, "https://ma.hadlock.tech/e/demo", signQr("https://ma.hadlock.tech/e/demo"));
expect(svg).not.toContain("<script>");
expect(svg).toContain("&lt;script&gt;");
expect(signLines("a".repeat(120), 48).every(line => line.length <= 48)).toBe(true);
expect(() => signQr("javascript:alert(1)")).toThrow();
});
+154
View File
@@ -0,0 +1,154 @@
export const SIGN_PAPERS = {
letter: { label: "US Letter · 8.5 × 11 in", width: "8.5in", height: "11in", viewHeight: 1100 },
a4: { label: "A4 · 210 × 297 mm", width: "210mm", height: "297mm", viewHeight: 1202 },
letterLandscape: { label: "US Letter landscape · 11 × 8.5 in", width: "11in", height: "8.5in", viewHeight: 850 * 8.5 / 11 },
a4Landscape: { label: "A4 landscape · 297 × 210 mm", width: "297mm", height: "210mm", viewHeight: 850 * 210 / 297 },
a5: { label: "A5 · 148 × 210 mm", width: "148mm", height: "210mm", viewHeight: 850 * 210 / 148 },
a5Landscape: { label: "A5 landscape · 210 × 148 mm", width: "210mm", height: "148mm", viewHeight: 850 * 148 / 210 },
card3x5: { label: "Small card · 3 × 5 in", width: "3in", height: "5in", viewHeight: 850 * 5 / 3 },
card5x3: { label: "Small landscape card · 5 × 3 in", width: "5in", height: "3in", viewHeight: 510 },
card4x6: { label: "Postcard · 4 × 6 in", width: "4in", height: "6in", viewHeight: 1275 },
card6x4: { label: "Landscape postcard · 6 × 4 in", width: "6in", height: "4in", viewHeight: 850 * 4 / 6 },
card5x7: { label: "Table card · 5 × 7 in", width: "5in", height: "7in", viewHeight: 1190 },
card7x5: { label: "Landscape table card · 7 × 5 in", width: "7in", height: "5in", viewHeight: 850 * 5 / 7 },
square: { label: "Square card · 5 × 5 in", width: "5in", height: "5in", viewHeight: 850 },
} as const;
export type SignPaper = keyof typeof SIGN_PAPERS | "custom";
export type SignDesign = { title: string; headline: string; message: string; paper: SignPaper; ink: "indigo" | "black";
customWidth?: number; customHeight?: number; unit?: "in" | "mm";
layout?: "photo" | "typography" | "compact";
background?: string; accent?: string; font?: "funnel" | "geologica" | "inter";
decoration?: "arch" | "frame" | "minimal"; backgroundImage?: string; logoImage?: string;
showBrand?: boolean; fontData?: string; interFontData?: string; geologicaFontData?: string;
};
export type SignQr = { path: string; size: number };
export function guestQrSvg(qr: SignQr) {
const size = qr.size + 8;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size * 16}" height="${size * 16}" viewBox="0 0 ${size} ${size}" shape-rendering="crispEdges"><title>Manyangles guest album QR code</title><rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/></svg>`;
}
export function signPaper(design: Pick<SignDesign, "paper" | "customWidth" | "customHeight" | "unit">) {
if (design.paper !== "custom") return SIGN_PAPERS[design.paper];
const unit = design.unit === "mm" ? "mm" : "in";
const width = design.customWidth ?? 8.5, height = design.customHeight ?? 11;
const inches = unit === "mm" ? 25.4 : 1;
if (![width, height].every(n => Number.isFinite(n) && n / inches >= 3 && n / inches <= 48)) throw new Error("Choose dimensions between 3 and 48 inches (76.21219.2 mm).");
if (Math.max(width / height, height / width) > 2.5) throw new Error("Keep the long edge within 2.5 times the short edge so the sign stays readable.");
return { label: "Custom", width: `${width}${unit}`, height: `${height}${unit}`, viewHeight: 850 * height / width };
}
export function escapeXml(value: string) {
return value.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[c]!);
}
// Hard-wrap even unbroken names/URLs; no user input becomes SVG markup.
export function signLines(value: string, limit: number) {
const words = value.trim().split(/\s+/).flatMap(word => word.match(new RegExp(`.{1,${limit}}`, "gu")) ?? []);
const lines: string[] = [];
for (const word of words) {
const last = lines.length - 1;
if (last >= 0 && `${lines[last]} ${word}`.length <= limit) lines[last] += ` ${word}`;
else lines.push(word);
}
return lines;
}
export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) {
const paper = signPaper(design);
const color = (value: string | undefined, fallback: string) => /^#[\da-f]{6}$/i.test(value ?? "") ? value! : fallback;
const ink = color(design.accent, design.ink === "black" ? "#171717" : "#4055b5");
const luminance = (hex: string) => {
const channels = [1, 3, 5].map(index => parseInt(hex.slice(index, index + 2), 16) / 255).map(c => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
return channels[0]! * .2126 + channels[1]! * .7152 + channels[2]! * .0722;
};
const background = color(design.background, "#eeece5");
const raster = (value?: string) => /^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(value ?? "") ? value : undefined;
const photo = raster(design.backgroundImage), logo = raster(design.logoImage);
const surfaceLuminance = luminance(background);
const bodyInk = photo || surfaceLuminance < .18 ? "#ffffff" : "#171717";
const accentContrast = (Math.max(luminance(ink), surfaceLuminance) + .05) / (Math.min(luminance(ink), surfaceLuminance) + .05);
const headingInk = !photo && accentContrast >= 4.5 ? ink : bodyInk;
const landscape = paper.viewHeight < 850;
const w = landscape ? 1200 : 850, h = landscape ? 800 : 1100;
const scale = Math.min(850 / w, paper.viewHeight / h);
const cx = landscape ? 328 : 425;
const text = (value: string, y: number, size: number, limit: number, weight = 400, x = cx) => signLines(value, limit).map((line, i) =>
`<text x="${x}" y="${y + i * size * 1.25}" text-anchor="middle" font-size="${size}" font-weight="${weight}">${escapeXml(line)}</text>`).join("");
const headingFont = design.font === "geologica" ? "SignGeologica, sans-serif" : design.font === "inter" ? "SignInter, sans-serif" : "SignFunnel, sans-serif";
const fontFaces = [["SignFunnel", design.fontData], ["SignInter", design.interFontData], ["SignGeologica", design.geologicaFontData]]
.filter(([, data]) => /^data:font\/woff2;base64,[A-Za-z0-9+/=]+$/.test(data ?? ""))
.map(([family, data]) => `@font-face{font-family:${family};src:url('${data}') format('woff2');font-weight:100 900;font-style:normal}`).join("");
const qx = landscape ? 710 : 240, qy = landscape ? 235 : 420;
if (design.layout) {
const compact = design.layout === "compact";
const photoLed = design.layout === "photo";
const foreground = surfaceLuminance < .18 ? "#ffffff" : "#171717";
const headingColor = accentContrast >= 4.5 ? ink : foreground;
const left = landscape && photoLed ? 650 : 70;
const top = photoLed ? (landscape ? 80 : 600) : 85;
const column = photoLed ? (landscape ? 480 : 410) : (landscape ? 600 : 710);
const fontSize = compact ? 58 : photoLed ? 46 : (landscape ? 80 : 88);
const headlineLines = signLines(design.headline, compact ? 20 : photoLed ? 18 : 16);
const headlineSize = Math.min(fontSize, (photoLed ? 170 : 350) / (headlineLines.length * 1.1));
const lines = (value: string, x: number, y: number, size: number, limit: number, weight = 400) =>
signLines(value, limit).map((line, index) => `<text x="${x}" y="${y + index * size * 1.25}" font-size="${size}" font-weight="${weight}">${escapeXml(line)}</text>`).join("");
const qrSize = compact ? 320 : photoLed ? 240 : 340;
const qrX = landscape ? (photoLed ? 870 : 790) : (photoLed ? 540 : 440);
const qrY = compact ? (landscape ? 260 : 530) : landscape ? (photoLed ? 415 : 235) : (photoLed ? 755 : 605);
const messageY = top + (compact ? 310 : photoLed ? 250 : 445);
const brandMarkup = logo ? `<image href="${logo}" x="${cx - 100}" y="${landscape ? 700 : 1005}" width="200" height="48" preserveAspectRatio="xMidYMid meet"/>` : design.showBrand !== false ? `<g data-brand-lockup="manyangles" color="${headingColor}" fill="currentColor" transform="translate(${cx - 96} ${landscape ? 711 : 1016})"><svg width="32" height="32" viewBox="0 0 32 32" fill="none"><path d="M14 4H8a4 4 0 0 0-4 4v6M18 4h6a4 4 0 0 1 4 4v6M28 18v6a4 4 0 0 1-4 4h-6M14 28H8a4 4 0 0 1-4-4v-6" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><circle cx="21" cy="11" r="2.25" fill="currentColor"/><path d="m8.5 22 5-6.2a1.7 1.7 0 0 1 2.6-.1l2.6 2.9 1.7-1.8a1.7 1.7 0 0 1 2.5 0l1.6 1.8" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg><text x="40" y="24" font-family="SignGeologica, sans-serif" font-size="26.5" font-weight="700" letter-spacing="-1.06" style="font-variation-settings:&#39;SHRP&#39; 70">Manyangles</text></g>` : "";
return `<svg xmlns="http://www.w3.org/2000/svg" width="${paper.width}" height="${paper.height}" viewBox="0 0 850 ${paper.viewHeight}">
<title>${escapeXml(design.title)} — guest photo sign</title>
<style>${fontFaces}</style>
<rect width="850" height="${paper.viewHeight}" fill="${background}"/>
<g transform="translate(${(850 - w * scale) / 2} ${(paper.viewHeight - h * scale) / 2}) scale(${scale})" font-family="SignInter, sans-serif" fill="${foreground}">
${photoLed ? `<svg width="${landscape ? 580 : w}" height="${landscape ? h : 530}" viewBox="0 0 ${landscape ? 580 : w} ${landscape ? h : 530}">
<rect width="100%" height="100%" fill="${ink}"/>
${photo ? `<image href="${photo}" width="100%" height="100%" preserveAspectRatio="xMidYMid slice"/>` : `<text x="50%" y="50%" text-anchor="middle" fill="white" font-size="22">Add your event photo</text>`}
</svg>` : ""}
${lines(design.title, left, top, compact ? 25 : 19, Math.floor(column / (compact ? 15 : 11)), 500)}
<g font-family="${headingFont}" fill="${headingColor}" letter-spacing="-1.8">
${headlineLines.map((line, index) => `<text x="${left}" y="${top + 80 + index * headlineSize * 1.1}" font-size="${headlineSize}" font-weight="650">${escapeXml(line)}</text>`).join("")}
</g>
${lines(design.message, left, messageY, compact ? 26 : 18, compact ? (landscape ? 38 : 24) : photoLed ? (landscape ? 42 : 35) : (landscape ? 48 : 32))}
<svg x="${qrX}" y="${qrY}" width="${qrSize}" height="${qrSize}" viewBox="0 0 ${qr.size + 8} ${qr.size + 8}" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/></svg>
${lines("Scan to share", qrX, qrY - 22, compact ? 24 : 18, 30, 600)}
${lines(guestUrl.replace(/^https?:\/\//, ""), qrX, qrY + qrSize + 24, compact ? 15 : 11, Math.floor(qrSize / (compact ? 9 : 7)))}
${!photoLed && !compact ? lines("Your photos. Our story.", left, landscape ? 665 : 850, 24, 30, 600) : ""}
${lines("No app or account needed.", left, photoLed ? (landscape ? 665 : 950) : (landscape ? 705 : 890), compact ? 22 : 16, photoLed && landscape ? 22 : 32)}
${lines("Photos and notes welcome.", left, photoLed ? (landscape ? 710 : 976) : (landscape ? 735 : 920), compact ? 22 : 16, photoLed && landscape ? 22 : 32)}
<g transform="translate(${(landscape ? (photoLed ? 650 : 790) : 70) - (cx - 96)} ${landscape ? (photoLed ? 39 : compact ? -21 : -626) : 20})">${brandMarkup}</g>
</g>
</svg>`;
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${paper.width}" height="${paper.height}" viewBox="0 0 850 ${paper.viewHeight}">
<title>${escapeXml(design.title)} — guest photo sign</title>
${fontFaces ? `<style>${fontFaces}</style>` : ""}
<rect width="850" height="${paper.viewHeight}" fill="${background}"/>
${photo ? `<image href="${photo}" width="850" height="${paper.viewHeight}" preserveAspectRatio="xMidYMid slice"/><rect width="850" height="${paper.viewHeight}" fill="black" opacity=".68"/>` : ""}
<g font-family="SignInter, sans-serif" fill="${bodyInk}" transform="translate(${(850 - w * scale) / 2} ${(paper.viewHeight - h * scale) / 2}) scale(${scale})">
${design.decoration !== "minimal" ? `<rect x="35" y="35" width="${w - 70}" height="${h - 70}" rx="${design.decoration === "arch" ? 24 : 0}" fill="none" stroke="${headingInk}" stroke-opacity=".3" stroke-width="1"/>` : ""}
<path d="M${landscape ? 90 : 85} 158h${landscape ? 470 : 680}" stroke="${headingInk}" stroke-opacity=".25" stroke-width="1"/>
<g font-family="${headingFont}">
${text(design.title, 108, 22, landscape ? 34 : 48, 500)}
<g fill="${headingInk}" letter-spacing="-1.5">${text(design.headline, landscape ? 265 : 230, 52, landscape ? 18 : 24, 600)}</g>
</g>
${text(design.message, landscape ? 485 : 359, 19, landscape ? 40 : 60)}
<svg x="${qx}" y="${qy}" width="370" height="370" viewBox="0 0 ${qr.size + 8} ${qr.size + 8}" shape-rendering="crispEdges">
<rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/>
</svg>
${text("Scan with your phone camera", landscape ? 644 : 820, 20, 40, 600, qx + 185)}
${text(guestUrl.replace(/^https?:\/\//, ""), landscape ? 675 : 854, guestUrl.length > 160 ? 10 : 14, guestUrl.length > 160 ? (landscape ? 68 : 110) : (landscape ? 46 : 78), 400, qx + 185)}
<path d="M${landscape ? 90 : 85} ${landscape ? 575 : 901}h${landscape ? 470 : 680}" stroke="${headingInk}" stroke-opacity=".25" stroke-width="1"/>
${text("Scan. Choose your photos. Upload.", landscape ? 611 : 935, 21, 50, 500)}
${text("No app or account needed. Notes welcome, too.", landscape ? 644 : 965, 16, landscape ? 36 : 55)}
${logo ? `<image href="${logo}" x="${cx - 100}" y="${landscape ? 700 : 1005}" width="200" height="48" preserveAspectRatio="xMidYMid meet"/>` : design.showBrand !== false ? `<g data-brand-lockup="manyangles" color="${headingInk}" fill="currentColor" transform="translate(${cx - 96} ${landscape ? 711 : 1016})"><svg width="32" height="32" viewBox="0 0 32 32" fill="none"><path d="M14 4H8a4 4 0 0 0-4 4v6M18 4h6a4 4 0 0 1 4 4v6M28 18v6a4 4 0 0 1-4 4h-6M14 28H8a4 4 0 0 1-4-4v-6" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><circle cx="21" cy="11" r="2.25" fill="currentColor"/><path d="m8.5 22 5-6.2a1.7 1.7 0 0 1 2.6-.1l2.6 2.9 1.7-1.8a1.7 1.7 0 0 1 2.5 0l1.6 1.8" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg><text x="40" y="24" font-family="SignGeologica, sans-serif" font-size="26.5" font-weight="700" letter-spacing="-1.06" style="font-variation-settings:&#39;SHRP&#39; 70">Manyangles</text></g>` : ""}
</g>
</svg>`;
}
export function signPrintHtml(svg: string, paper: SignPaper | SignDesign) {
const size = signPaper(typeof paper === "string" ? { paper } : paper);
return `<!doctype html><html><head><title>Manyangles guest sign</title><style>@page{size:${size.width} ${size.height};margin:0}html,body{margin:0;padding:0;background:white}svg{display:block;width:${size.width};height:${size.height}}*{print-color-adjust:exact;-webkit-print-color-adjust:exact}</style></head><body>${svg}</body></html>`;
}
+1 -1
View File
@@ -20,7 +20,7 @@ export const bannersRouter = createTRPCRouter({
if (!limit.allowed) throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many banner uploads. Try again shortly." });
const id = randomUUID();
const originalKey = `events/${input.eventId}/banners/${id}/original`;
await getDb().insert(eventBanners).values({ id, eventId: input.eventId, originalKey, contentType: input.contentType, byteSize: input.byteSize });
await getDb().insert(eventBanners).values({ id, eventId: input.eventId, originalKey, contentType: input.contentType, byteSize: input.byteSize, crop: input.crop });
return { bannerId: id, uploadUrl: await createPresignedPutUrl({ key: originalKey, contentType: input.contentType }) };
}),
complete: protectedProcedure.input(bannerInputSchema).mutation(async ({ ctx, input }) => {
+14
View File
@@ -0,0 +1,14 @@
import QRCode from "qrcode";
export function signQr(url: string) {
const parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("Guest link must be HTTP or HTTPS");
const { modules } = QRCode.create(url, { errorCorrectionLevel: "M" });
let path = "";
for (let y = 0; y < modules.size; y++) {
for (let x = 0; x < modules.size; x++) {
if (modules.get(y, x)) path += `M${x} ${y}h1v1h-1z`;
}
}
return { path, size: modules.size };
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import { bannerExtract } from "./banner-crop";
import { bannerCropSchema } from "@album/contracts";
describe("banner crop", () => {
test("centers a legacy portrait in exact 8:3", () => {
expect(bannerExtract(1200, 2400)).toEqual({ left: 0, top: 975, width: 1200, height: 450 });
});
test("honors an off-center crop", () => {
expect(bannerExtract(4000, 3000, { x: 10, y: 20, width: 80, height: 40 })).toEqual({ left: 400, top: 600, width: 3200, height: 1200 });
});
test("keeps rounding inside original bounds", () => {
const crop = bannerExtract(1001, 751, { x: 0, y: 0, width: 100, height: 100 });
expect(crop.width / crop.height).toBe(8 / 3);
expect(crop.left + crop.width).toBeLessThanOrEqual(1001);
expect(crop.top + crop.height).toBeLessThanOrEqual(751);
});
test("rejects invalid and empty crops", () => {
expect(bannerCropSchema.safeParse({ x: 95, y: 0, width: 10, height: 20 }).success).toBe(false);
expect(() => bannerExtract(1000, 1000, { x: 0, y: 0, width: 0.01, height: 0.01 })).toThrow();
});
});
+19
View File
@@ -0,0 +1,19 @@
import { bannerCropSchema, type BannerCrop } from "@album/contracts";
// Percent coordinates refer to the auto-oriented original, never the thumbnail.
export function bannerExtract(width: number, height: number, input?: BannerCrop | null) {
const crop = input ? bannerCropSchema.parse(input) : { x: 0, y: 0, width: 100, height: 100 };
const availableWidth = Math.min(width, width * crop.width / 100);
const availableHeight = Math.min(height, height * crop.height / 100);
// Whole multiples guarantee an exact 8:3 output, including small images.
const unit = Math.floor(Math.min(availableWidth / 8, availableHeight / 3));
if (unit < 1) throw new Error("Banner crop is too small");
const croppedWidth = unit * 8;
const croppedHeight = unit * 3;
return {
left: Math.max(0, Math.min(width - croppedWidth, Math.round(width * crop.x / 100 + (availableWidth - croppedWidth) / 2))),
top: Math.max(0, Math.min(height - croppedHeight, Math.round(height * crop.y / 100 + (availableHeight - croppedHeight) / 2))),
width: croppedWidth,
height: croppedHeight,
};
}
+44
View File
@@ -0,0 +1,44 @@
import { expect, test } from "bun:test";
import sharp from "sharp";
import { bannerVariant, photoVariants, VARIANT_CONTENT_TYPE } from "./image-variants";
test("photo variants are bounded WebP files and leave original bytes untouched", async () => {
const original = await sharp({ create: { width: 3000, height: 1500, channels: 3, background: "#4878ab" } }).jpeg().toBuffer();
const before = Buffer.from(original);
const [display, thumb] = await photoVariants(sharp(original).rotate());
expect(VARIANT_CONTENT_TYPE).toBe("image/webp");
for (const [bytes, width, height] of [[display, 2048, 1024], [thumb, 400, 200]] as const) {
const metadata = await sharp(bytes).metadata();
expect(metadata.format).toBe("webp");
expect(metadata.width).toBe(width);
expect(metadata.height).toBe(height);
}
expect(original.equals(before)).toBe(true);
});
test("small images are not enlarged and transparency survives", async () => {
const image = sharp({ create: { width: 80, height: 120, channels: 4, background: { r: 20, g: 40, b: 60, alpha: 0.5 } } });
for (const bytes of await photoVariants(image)) {
const metadata = await sharp(bytes).metadata();
expect(metadata.width).toBe(80);
expect(metadata.height).toBe(120);
expect(metadata.hasAlpha).toBe(true);
}
});
test("banner generates an exact 8:3 WebP crop", async () => {
const bytes = await bannerVariant(sharp({ create: { width: 3200, height: 2400, channels: 3, background: "#4878ab" } }), { x: 0, y: 25, width: 100, height: 50 });
const metadata = await sharp(bytes).metadata();
expect(metadata.format).toBe("webp");
expect(metadata.width).toBe(2400);
expect(metadata.height).toBe(900);
});
test("auto-orientation is applied before sizing WebP variants", async () => {
const source = await sharp({ create: { width: 120, height: 80, channels: 3, background: "#4878ab" } }).jpeg().withMetadata({ orientation: 6 }).toBuffer();
const [display] = await photoVariants(sharp(source).rotate());
const metadata = await sharp(display).metadata();
expect(metadata.width).toBe(80);
expect(metadata.height).toBe(120);
expect(metadata.orientation).toBeUndefined();
});
+22
View File
@@ -0,0 +1,22 @@
import sharp, { type Sharp } from "sharp";
import type { BannerCrop } from "@album/contracts";
import { bannerExtract } from "./banner-crop";
export const VARIANT_CONTENT_TYPE = "image/webp";
// Callers supply an auto-oriented image. Never modify the stored original.
export function photoVariants(image: Sharp) {
return Promise.all([
image.clone().resize({ width: 2048, height: 2048, fit: "inside", withoutEnlargement: true })
.webp({ quality: 82, effort: 4 }).toBuffer(),
image.clone().resize({ width: 400, height: 400, fit: "inside", withoutEnlargement: true })
.webp({ quality: 75, effort: 4 }).toBuffer(),
]);
}
export async function bannerVariant(image: Sharp, crop?: BannerCrop | null) {
const { data, info } = await image.raw().toBuffer({ resolveWithObject: true });
return sharp(data, { raw: info }).extract(bannerExtract(info.width, info.height, crop))
.resize({ width: 2400, height: 900, fit: "inside", withoutEnlargement: true })
.webp({ quality: 82, effort: 4 }).toBuffer();
}
+10 -30
View File
@@ -1,6 +1,8 @@
import { sql } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import sharp from "sharp";
import { bannerVariant, photoVariants, VARIANT_CONTENT_TYPE } from "./image-variants";
import type { BannerCrop } from "@album/contracts";
import { processPhotoExport } from "./exports";
import { processEmailDelivery } from "@album/email/queue";
import convert from "heic-convert";
@@ -84,31 +86,10 @@ async function processJob(job: ClaimedJob) {
const metadata = await image.metadata();
const displayKey = displayObjectKey(photo.eventId, photo.id);
const thumbKey = thumbObjectKey(photo.eventId, photo.id);
const [display, thumb] = await Promise.all([
image
.clone()
.resize({
width: 2048,
height: 2048,
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 82, mozjpeg: true })
.toBuffer(),
image
.clone()
.resize({
width: 400,
height: 400,
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 75, mozjpeg: true })
.toBuffer(),
]);
const [display, thumb] = await photoVariants(image);
await Promise.all([
putObject({ key: displayKey, body: display, contentType: "image/jpeg" }),
putObject({ key: thumbKey, body: thumb, contentType: "image/jpeg" }),
putObject({ key: displayKey, body: display, contentType: VARIANT_CONTENT_TYPE }),
putObject({ key: thumbKey, body: thumb, contentType: VARIANT_CONTENT_TYPE }),
]);
await db.transaction(async (tx) => {
await tx
@@ -176,18 +157,17 @@ async function processBanner() {
SELECT id FROM event_banners
WHERE status = 'pending' OR (status = 'processing' AND updated_at < now() - interval '10 minutes')
ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1
) RETURNING id, event_id, original_key, content_type
) RETURNING id, event_id, original_key, content_type, crop
`);
const banner = (rows as unknown as { id: string; event_id: string; original_key: string; content_type: string }[])[0];
const banner = (rows as unknown as { id: string; event_id: string; original_key: string; content_type: string; crop: BannerCrop | null }[])[0];
if (!banner) return false;
const scope = and(eq(eventBanners.id, banner.id), eq(eventBanners.eventId, banner.event_id));
try {
const original = await getObjectBuffer(banner.original_key);
const image = await decodeImage(original, banner.content_type);
const display = await image.resize({ width: 2400, height: 2400, fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 82, mozjpeg: true }).toBuffer();
const displayKey = `events/${banner.event_id}/banners/${banner.id}/display.jpg`;
await putObject({ key: displayKey, body: display, contentType: "image/jpeg" });
const display = await bannerVariant(image, banner.crop);
const displayKey = `events/${banner.event_id}/banners/${banner.id}/display.webp`;
await putObject({ key: displayKey, body: display, contentType: VARIANT_CONTENT_TYPE });
await getDb().update(eventBanners).set({ status: "ready", displayKey, updatedAt: new Date() }).where(scope);
console.info(`Banner processed for event ${banner.event_id}`);
} catch {
+99 -12
View File
@@ -18,6 +18,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",
@@ -30,13 +33,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",
@@ -49,9 +55,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",
},
@@ -330,6 +339,12 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
"@fontsource-variable/funnel-display": ["@fontsource-variable/funnel-display@5.3.0", "", {}, "sha512-o/12lyrfflx5QAQFa+hAl0uBeWoBUAm5w71j+KssBseo++XJT3tnveDSg5IAnAdiclQHfFuEZiL5Wn2BjuFxtw=="],
"@fontsource-variable/geologica": ["@fontsource-variable/geologica@5.3.0", "", {}, "sha512-N4pGIVYOXkyXDMooL/HeNYWbx7aMxRP+xABgkjZwi6bVeM/GM/cqHdO8UNFQiR5QR52yLI1RrzgKxm0DKWO2wQ=="],
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.3.0", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="],
"@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
@@ -376,7 +391,7 @@
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="],
@@ -634,6 +649,8 @@
"@types/nodemailer": ["@types/nodemailer@8.0.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw=="],
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
@@ -654,6 +671,8 @@
"ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"archiver": ["archiver@8.0.0", "", { "dependencies": { "async": "^3.2.4", "buffer-crc32": "^1.0.0", "is-stream": "^4.0.0", "lazystream": "^1.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^3.0.0", "tar-stream": "^3.0.0", "zip-stream": "^7.0.2" } }, "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
@@ -716,6 +735,8 @@
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
@@ -728,12 +749,18 @@
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cn": ["cn@0.2.6", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ=="],
"code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"compress-commons": ["compress-commons@7.0.1", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^7.0.1", "is-stream": "^4.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ=="],
@@ -776,6 +803,8 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
"dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
@@ -796,6 +825,8 @@
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
"dot-prop": ["dot-prop@6.0.1", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
@@ -810,7 +841,7 @@
"electron-to-chromium": ["electron-to-chromium@1.5.422", "", {}, "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
@@ -876,7 +907,7 @@
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
@@ -892,6 +923,8 @@
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
@@ -920,6 +953,8 @@
"heic-decode": ["heic-decode@2.1.0", "", { "dependencies": { "libheif-js": "^1.19.8" } }, "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A=="],
"heic-to": ["heic-to@1.5.2", "", {}, "sha512-8Fns+lZHAWmz5U5IUxDeXKwIf3foBoKNPLxxFY4B0MkLjNuomEIHCoDbDE+x/llFK3NCEO1cu4+n3iUKY+Svmw=="],
"hono": ["hono@4.13.7", "", {}, "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
@@ -946,6 +981,8 @@
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="],
@@ -996,6 +1033,8 @@
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"jsqr": ["jsqr@1.4.0", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"kysely": ["kysely@0.29.5", "", {}, "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ=="],
@@ -1030,7 +1069,7 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="],
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
@@ -1102,7 +1141,7 @@
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
@@ -1116,7 +1155,7 @@
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@@ -1130,7 +1169,7 @@
"pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="],
"pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
"postal-mime": ["postal-mime@2.7.5", "", {}, "sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA=="],
@@ -1152,6 +1191,8 @@
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
@@ -1168,6 +1209,8 @@
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
"react-image-crop": ["react-image-crop@11.1.2", "", { "peerDependencies": { "react": ">=16.13.1" } }, "sha512-+0Pc2fxpwKL4u4oLmdKBw8XSwUceFbXbKEHvFOlsl/MGB1OVNic4uBlAPmEHGXYgoJIq+b63xHbc/aJMG0AVkA=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
@@ -1180,8 +1223,12 @@
"recast": ["recast@0.23.21", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
"resend": ["resend@6.26.0", "", { "dependencies": { "postal-mime": "2.7.5", "standardwebhooks": "1.0.0" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-vkrULozV5nGF8o7tyun/t0+qFgtPpn+cyRhVqeorhOsPUNDiSc/9p/pn8AprdHqJpYaTvftpnQgWurW++FkCdw=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
@@ -1214,6 +1261,8 @@
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
"set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
@@ -1260,7 +1309,7 @@
"streamx": ["streamx@2.28.1", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
@@ -1336,12 +1385,22 @@
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"wsl-utils": ["wsl-utils@1.0.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA=="],
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
"yocto-spinner": ["yocto-spinner@1.2.2", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng=="],
"yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="],
@@ -1370,10 +1429,6 @@
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
"@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
"@modelcontextprotocol/sdk/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
@@ -1392,6 +1447,8 @@
"@types/nodemailer/@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="],
"@types/qrcode/@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="],
"@types/readdir-glob/@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="],
"better-auth/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="],
@@ -1400,6 +1457,8 @@
"bun-types/@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"conf/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="],
"conf/json-schema-typed": ["json-schema-typed@7.0.3", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="],
@@ -1410,6 +1469,8 @@
"enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"heic-convert/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
@@ -1428,6 +1489,10 @@
"onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"ora/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
@@ -1436,12 +1501,16 @@
"stream-browserify/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
"type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
"wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"wsl-utils/powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
@@ -1508,10 +1577,14 @@
"@types/nodemailer/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"@types/qrcode/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"@types/readdir-glob/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"bun-types/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@@ -1558,12 +1631,20 @@
"next/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"next/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"next/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"next/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"next/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="],
"string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
@@ -1615,5 +1696,11 @@
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
"pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
}
}
+10
View File
@@ -173,10 +173,20 @@ export const completePhotoInputSchema = z.object({
photoId: z.string().uuid(),
});
export const BANNER_ASPECT_RATIO = 8 / 3;
export const bannerCropSchema = z.object({
x: z.number().finite().min(0).max(100),
y: z.number().finite().min(0).max(100),
width: z.number().finite().positive().max(100),
height: z.number().finite().positive().max(100),
}).refine((crop) => crop.x + crop.width <= 100.001 && crop.y + crop.height <= 100.001, "Crop must fit inside the image");
export type BannerCrop = z.infer<typeof bannerCropSchema>;
export const createBannerInputSchema = z.object({
eventId: z.string().uuid(),
contentType: allowedImageTypeSchema,
byteSize: z.number().int().positive().max(MAX_PHOTO_BYTES),
crop: bannerCropSchema.optional(),
});
export const bannerInputSchema = z.object({
eventId: z.string().uuid(),
@@ -0,0 +1 @@
ALTER TABLE event_banners ADD COLUMN crop jsonb;
@@ -71,6 +71,13 @@
"when": 1788906100000,
"tag": "0009_photo_exports",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1788996000000,
"tag": "0010_banner_crop",
"breakpoints": true
}
]
}
+1
View File
@@ -216,6 +216,7 @@ export const eventBanners = pgTable("event_banners", {
eventId: uuid("event_id").notNull().references(() => events.id, { onDelete: "cascade" }),
originalKey: text("original_key").notNull(),
displayKey: text("display_key"),
crop: jsonb("crop").$type<import("@album/contracts").BannerCrop>(),
contentType: text("content_type").notNull(),
byteSize: integer("byte_size").notNull(),
status: text("status").notNull().default("uploading"),
+2 -2
View File
@@ -13,7 +13,7 @@ describe("object keys", () => {
test("keeps originals and variants under the same photo prefix", () => {
const prefix = photoObjectPrefix(eventId, photoId);
expect(originalObjectKey(eventId, photoId).startsWith(prefix)).toBe(true);
expect(displayObjectKey(eventId, photoId)).toBe(`${prefix}display.jpg`);
expect(thumbObjectKey(eventId, photoId)).toBe(`${prefix}thumb.jpg`);
expect(displayObjectKey(eventId, photoId)).toBe(`${prefix}display.webp`);
expect(thumbObjectKey(eventId, photoId)).toBe(`${prefix}thumb.webp`);
});
});
+2 -2
View File
@@ -3,11 +3,11 @@ export function originalObjectKey(eventId: string, photoId: string) {
}
export function displayObjectKey(eventId: string, photoId: string) {
return `events/${eventId}/photos/${photoId}/display.jpg`;
return `events/${eventId}/photos/${photoId}/display.webp`;
}
export function thumbObjectKey(eventId: string, photoId: string) {
return `events/${eventId}/photos/${photoId}/thumb.jpg`;
return `events/${eventId}/photos/${photoId}/thumb.webp`;
}
export function photoObjectPrefix(eventId: string, photoId: string) {