diff --git a/package-lock.json b/package-lock.json index 57f8066..4925c60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "@tanstack/react-start": "latest", "better-auth": "^1.6.29", "drizzle-orm": "^0.45.2", + "pdfjs-dist": "^6.2.108", "postgres": "^3.4.9", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -1310,6 +1311,271 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.8.tgz", + "integrity": "sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.0.8", + "@napi-rs/canvas-darwin-arm64": "1.0.8", + "@napi-rs/canvas-darwin-x64": "1.0.8", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.8", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.8", + "@napi-rs/canvas-linux-arm64-musl": "1.0.8", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.8", + "@napi-rs/canvas-linux-x64-gnu": "1.0.8", + "@napi-rs/canvas-linux-x64-musl": "1.0.8", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.8", + "@napi-rs/canvas-win32-x64-msvc": "1.0.8" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.8.tgz", + "integrity": "sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.8.tgz", + "integrity": "sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.8.tgz", + "integrity": "sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.8.tgz", + "integrity": "sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.8.tgz", + "integrity": "sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.8.tgz", + "integrity": "sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.8.tgz", + "integrity": "sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.8.tgz", + "integrity": "sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.8.tgz", + "integrity": "sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.8.tgz", + "integrity": "sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.8.tgz", + "integrity": "sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@noble/ciphers": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.3.0.tgz", @@ -3732,6 +3998,18 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pdfjs-dist": { + "version": "6.2.108", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz", + "integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^1.0.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index 6ab506b..e4cf41f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@tanstack/react-start": "latest", "better-auth": "^1.6.29", "drizzle-orm": "^0.45.2", + "pdfjs-dist": "^6.2.108", "postgres": "^3.4.9", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/public/menu.pdf b/public/menu.pdf new file mode 100644 index 0000000..a135105 Binary files /dev/null and b/public/menu.pdf differ diff --git a/src/components/pdf-menu.tsx b/src/components/pdf-menu.tsx new file mode 100644 index 0000000..de4a4e7 --- /dev/null +++ b/src/components/pdf-menu.tsx @@ -0,0 +1,191 @@ +import { useEffect, useRef, useState } from 'react' +import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist' + +type Props = { + src: string +} + +// pdfjs-dist touches the DOM/Canvas at import time, so it's loaded lazily inside +// the effect below instead of at module scope — this file also runs during SSR. +export function PdfMenu({ src }: Props) { + const containerRef = useRef(null) + const canvasRef = useRef(null) + const pdfRef = useRef(null) + const directionRef = useRef(1) + const hasRenderedOnceRef = useRef(false) + const [error, setError] = useState(false) + const [numPages, setNumPages] = useState(0) + const [pageNumber, setPageNumber] = useState(1) + const [isBusy, setIsBusy] = useState(false) + + function goToPage(next: number) { + directionRef.current = next > pageNumber ? 1 : -1 + setPageNumber(next) + } + + useEffect(() => { + let cancelled = false + setError(false) + setNumPages(0) + setPageNumber(1) + pdfRef.current = null + hasRenderedOnceRef.current = false + + async function load() { + const pdfjsLib = await import('pdfjs-dist') + pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url, + ).href + + try { + const pdf = await pdfjsLib.getDocument({ url: src }).promise + if (cancelled) return + pdfRef.current = pdf + setNumPages(pdf.numPages) + } catch (err) { + console.error('PdfMenu failed to load', err) + if (!cancelled) setError(true) + } + } + + load() + + return () => { + cancelled = true + } + }, [src]) + + useEffect(() => { + const pdf = pdfRef.current + const canvas = canvasRef.current + const container = containerRef.current + if (!pdf || !canvas || !container || numPages === 0) return + + let cancelled = false + let renderTask: RenderTask | undefined + let exitTimeout: ReturnType | undefined + + function wait(ms: number) { + return new Promise((resolve) => { + exitTimeout = setTimeout(resolve, ms) + }) + } + + async function render(pdf: PDFDocumentProxy, canvas: HTMLCanvasElement, container: HTMLDivElement) { + const direction = directionRef.current + setIsBusy(true) + + if (hasRenderedOnceRef.current) { + canvas.style.transition = 'opacity 0.18s ease, transform 0.18s ease' + canvas.style.opacity = '0' + canvas.style.transform = `translateX(${direction * -2}rem)` + await wait(180) + if (cancelled) return + } + + try { + const page = await pdf.getPage(pageNumber) + if (cancelled) return + const unscaledViewport = page.getViewport({ scale: 1 }) + const scale = container.clientWidth / unscaledViewport.width + const viewport = page.getViewport({ scale }) + + const pixelRatio = window.devicePixelRatio || 1 + canvas.width = viewport.width * pixelRatio + canvas.height = viewport.height * pixelRatio + canvas.style.width = `${viewport.width}px` + canvas.style.height = `${viewport.height}px` + + const context = canvas.getContext('2d') + if (!context) return + context.scale(pixelRatio, pixelRatio) + + renderTask = page.render({ canvasContext: context, viewport, canvas }) + await renderTask.promise + if (cancelled) return + + // Jump to the entry offset with transitions off, then force the browser to + // commit that position before re-enabling them — otherwise the two style + // writes land in the same frame and the entry animation never paints. + canvas.style.transition = 'none' + canvas.style.transform = `translateX(${direction * 2}rem)` + canvas.offsetHeight + canvas.style.transition = 'opacity 0.22s ease, transform 0.22s ease' + canvas.style.opacity = '1' + canvas.style.transform = 'translateX(0)' + hasRenderedOnceRef.current = true + } catch (err) { + if (!cancelled) { + console.error('PdfMenu failed to render page', err) + setError(true) + } + } finally { + if (!cancelled) setIsBusy(false) + } + } + + render(pdf, canvas, container) + + return () => { + cancelled = true + clearTimeout(exitTimeout) + renderTask?.cancel() + } + }, [numPages, pageNumber]) + + if (error) { + return ( +

+ Die Speisekarte konnte nicht geladen werden.{' '} + + Als PDF öffnen + + . +

+ ) + } + + return ( +
+
+ + {numPages === 0 && ( +
+ + )} +
+ {numPages > 1 && ( +
+ + + Seite {pageNumber} von {numPages} + + +
+ )} +

+ Speisekarte nicht geladen?{' '} + + Klick hier + +

+
+ ) +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index b846b8b..8d0d573 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { createFileRoute, useRouter } from '@tanstack/react-router' +import { PdfMenu } from '#/components/pdf-menu' import { ReservationConfirmedModal } from '#/components/reservation-confirmed-modal' import { ReservationDateTimeField } from '#/components/reservation-datetime-field' import { SiteFooter } from '#/components/site-footer' @@ -227,6 +228,16 @@ function Home() {
+
+
+
+ Was uns schmeckt +

Speisekarte

+
+ +
+
+
diff --git a/src/styles.css b/src/styles.css index a2229f4..7f28fb3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1508,6 +1508,83 @@ a { pointer-events: none; } +.pdf-menu { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.pdf-menu-viewport { + position: relative; + width: 100%; + overflow: hidden; + border-radius: 0.4rem; + border: 1px solid var(--line); + background: #fff; + box-shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 25%); +} + +.pdf-menu-loading { + position: absolute; + top: 50%; + left: 50%; + width: 90%; + max-width: 20rem; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + opacity: 0; + animation: pdf-menu-spinner-fade-in 0.15s ease 0.15s forwards; +} + +.pdf-menu-loading p { + margin: 0; + text-align: center; + font-family: 'Fraunces', Georgia, serif; + font-style: italic; + color: #6b5f4d; +} + +.pdf-menu-spinner { + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + border: 3px solid var(--line); + border-top-color: var(--gold); + animation: pdf-menu-spin 0.8s linear infinite; +} + +@keyframes pdf-menu-spin { + to { + transform: rotate(360deg); + } +} + +@keyframes pdf-menu-spinner-fade-in { + to { + opacity: 1; + } +} + +.pdf-menu-page { + display: block; + width: 100%; + max-width: 100%; + aspect-ratio: 210 / 297; + opacity: 0; +} + +.pdf-menu-pagination { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + color: var(--text-muted); +} + /* A classic map-pin: a square with three rounded corners (50% 50% 50% 0 leaves the bottom-left one sharp), rotated -45deg around that exact sharp corner so the corner itself — not the box center — lands on the `left`/`top` coordinate