diff --git a/package.json b/package.json index 9562279..292573f 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "@hello-pangea/dnd": "^18.0.1", "@prisma/client": "^6.5.0", "@trafficlunar/asmcrypto.js": "^1.0.2", - "@yudiel/react-qr-scanner": "2.2.2-beta.2", "bit-buffer": "^0.2.5", + "downshift": "^9.0.9", "embla-carousel-react": "^8.5.2", "jsqr": "^1.4.0", "next": "15.2.4", @@ -25,6 +25,7 @@ "react-dom": "^19.0.0", "react-dropzone": "^14.3.8", "react-select": "^5.10.1", + "react-webcam": "^7.2.0", "sharp": "^0.34.0", "zod": "^3.24.2" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bff814f..d27c82e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,12 +20,12 @@ importers: '@trafficlunar/asmcrypto.js': specifier: ^1.0.2 version: 1.0.2 - '@yudiel/react-qr-scanner': - specifier: 2.2.2-beta.2 - version: 2.2.2-beta.2(@types/emscripten@1.40.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) bit-buffer: specifier: ^0.2.5 version: 0.2.5 + downshift: + specifier: ^9.0.9 + version: 9.0.9(react@19.1.0) embla-carousel-react: specifier: ^8.5.2 version: 8.5.2(react@19.1.0) @@ -53,6 +53,9 @@ importers: react-select: specifier: ^5.10.1 version: 5.10.1(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react-webcam: + specifier: ^7.2.0 + version: 7.2.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) sharp: specifier: ^0.34.0 version: 0.34.0 @@ -881,9 +884,6 @@ packages: '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} - '@types/emscripten@1.40.1': - resolution: {integrity: sha512-sr53lnYkQNhjHNN0oJDdUm5564biioI5DuOpycufDVK7D3y+GR3oUswe2rlwY1nPNyusHbrJ9WoTyIHl4/Bpwg==} - '@types/estree@1.0.7': resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} @@ -1037,12 +1037,6 @@ packages: cpu: [x64] os: [win32] - '@yudiel/react-qr-scanner@2.2.2-beta.2': - resolution: {integrity: sha512-WrnO1ejWKItHjJSsWkR4wSP1hhCV3K1wjlaTUI6RdNyOzZnj5CV3mG5F8SQj/pGdkkxr2q7/qfIgqlB/arB4pg==} - peerDependencies: - react: ^17 || ^18 || ^19 - react-dom: ^17 || ^18 || ^19 - acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1129,9 +1123,6 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - barcode-detector@3.0.1: - resolution: {integrity: sha512-3fCzG/Py4SVgZJhubD1mt7rVprtHEVWrxQN4FUOG0oulPE4193evbgyptxcOYsfTNEtMlWc+Ec9tdxhjlR4/Ww==} - bit-buffer@0.2.5: resolution: {integrity: sha512-x1yGnmXvFg6e3DiyRztElbcn1bsCTFSoM/ncAzY62uE0JdTl5xlKJd0ooqLYoPbhdsnpehSIQrdIvclcZJYwiA==} @@ -1189,6 +1180,9 @@ packages: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} + compute-scroll-into-view@3.1.1: + resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1267,6 +1261,11 @@ packages: dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + downshift@9.0.9: + resolution: {integrity: sha512-ygOT8blgiz5liDuEFAIaPeU4dDEa+w9p6PHVUisPIjrkF5wfR59a52HpGWAVVMoWnoFO8po2mZSScKZueihS7g==} + peerDependencies: + react: '>=16.12.0' + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -2094,6 +2093,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@18.2.0: + resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} + react-redux@9.2.0: resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} peerDependencies: @@ -2118,6 +2120,12 @@ packages: react: '>=16.6.0' react-dom: '>=16.6.0' + react-webcam@7.2.0: + resolution: {integrity: sha512-xkrzYPqa1ag2DP+2Q/kLKBmCIfEx49bVdgCCCcZf88oF+0NPEbkwYk3/s/C7Zy0mhM8k+hpdNkBLzxg8H0aWcg==} + peerDependencies: + react: '>=16.2.0' + react-dom: '>=16.2.0' + react@19.1.0: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} @@ -2174,9 +2182,6 @@ packages: scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} - sdp@3.2.0: - resolution: {integrity: sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==} - semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2337,10 +2342,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@4.39.1: - resolution: {integrity: sha512-uW9qzd66uyHYxwyVBYiwS4Oi0qZyUqwjU+Oevr6ZogYiXt99EOYtwvzMSLw1c3lYo2HzJsep/NB23iEVEgjG/w==} - engines: {node: '>=16'} - typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -2389,10 +2390,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - webrtc-adapter@9.0.1: - resolution: {integrity: sha512-1AQO+d4ElfVSXyzNVTOewgGT/tAomwwztX/6e3totvyyzXPvXIIuUUjAmyZGbKBKbZOXauuJooZm3g6IuFuiNQ==} - engines: {node: '>=6.0.0', npm: '>=3.10.0'} - which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2429,11 +2426,6 @@ packages: zod@3.24.2: resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} - zxing-wasm@2.1.0: - resolution: {integrity: sha512-CvuwDZHRHwg6PeCARaCDIp3dauD4cin0mbHrQQZtMDrr5mblPzCimAjdw/XMhD/Au10q/f5+SAupvYqYvUOg1Q==} - peerDependencies: - '@types/emscripten': '>=1.39.6' - snapshots: '@alloc/quick-lru@5.2.0': {} @@ -3096,8 +3088,6 @@ snapshots: '@types/cookie@0.6.0': {} - '@types/emscripten@1.40.1': {} - '@types/estree@1.0.7': {} '@types/json-schema@7.0.15': {} @@ -3248,15 +3238,6 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.3.3': optional: true - '@yudiel/react-qr-scanner@2.2.2-beta.2(@types/emscripten@1.40.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - barcode-detector: 3.0.1(@types/emscripten@1.40.1) - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - webrtc-adapter: 9.0.1 - transitivePeerDependencies: - - '@types/emscripten' - acorn-jsx@5.3.2(acorn@8.14.1): dependencies: acorn: 8.14.1 @@ -3365,12 +3346,6 @@ snapshots: balanced-match@1.0.2: {} - barcode-detector@3.0.1(@types/emscripten@1.40.1): - dependencies: - zxing-wasm: 2.1.0(@types/emscripten@1.40.1) - transitivePeerDependencies: - - '@types/emscripten' - bit-buffer@0.2.5: {} brace-expansion@1.1.11: @@ -3434,6 +3409,8 @@ snapshots: color-convert: 2.0.1 color-string: 1.9.1 + compute-scroll-into-view@3.1.1: {} + concat-map@0.0.1: {} convert-source-map@1.9.0: {} @@ -3513,6 +3490,15 @@ snapshots: '@babel/runtime': 7.27.0 csstype: 3.1.3 + downshift@9.0.9(react@19.1.0): + dependencies: + '@babel/runtime': 7.27.0 + compute-scroll-into-view: 3.1.1 + prop-types: 15.8.1 + react: 19.1.0 + react-is: 18.2.0 + tslib: 2.8.1 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4486,6 +4472,8 @@ snapshots: react-is@16.13.1: {} + react-is@18.2.0: {} + react-redux@9.2.0(@types/react@19.1.0)(react@19.1.0)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 @@ -4521,6 +4509,11 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + react-webcam@7.2.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react@19.1.0: {} redux@5.0.1: {} @@ -4590,8 +4583,6 @@ snapshots: scheduler@0.26.0: {} - sdp@3.2.0: {} - semver@6.3.1: {} semver@7.7.1: {} @@ -4817,8 +4808,6 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@4.39.1: {} - typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -4895,10 +4884,6 @@ snapshots: dependencies: react: 19.1.0 - webrtc-adapter@9.0.1: - dependencies: - sdp: 3.2.0 - which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -4951,8 +4936,3 @@ snapshots: yocto-queue@0.1.0: {} zod@3.24.2: {} - - zxing-wasm@2.1.0(@types/emscripten@1.40.1): - dependencies: - '@types/emscripten': 1.40.1 - type-fest: 4.39.1 diff --git a/src/app/components/submit-form.tsx b/src/app/components/submit-form.tsx index 21364c7..d612bc9 100644 --- a/src/app/components/submit-form.tsx +++ b/src/app/components/submit-form.tsx @@ -187,7 +187,7 @@ export default function SubmitForm() { or - diff --git a/src/app/components/qr-finder.tsx b/src/app/components/submit/qr-finder.tsx similarity index 100% rename from src/app/components/qr-finder.tsx rename to src/app/components/submit/qr-finder.tsx diff --git a/src/app/components/submit/qr-scanner.tsx b/src/app/components/submit/qr-scanner.tsx index b1f2956..216fead 100644 --- a/src/app/components/submit/qr-scanner.tsx +++ b/src/app/components/submit/qr-scanner.tsx @@ -1,10 +1,12 @@ "use client"; -import { useEffect, useState } from "react"; -import { IDetectedBarcode, Scanner } from "@yudiel/react-qr-scanner"; +import { useCallback, useEffect, useRef, useState } from "react"; +import Webcam from "react-webcam"; +import jsQR from "jsqr"; import { Icon } from "@iconify/react"; -import QrFinder from "../qr-finder"; +import QrFinder from "./qr-finder"; +import { useSelect } from "downshift"; interface Props { isOpen: boolean; @@ -15,62 +17,164 @@ interface Props { export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) { const [permissionGranted, setPermissionGranted] = useState(null); - useEffect(() => { + const [devices, setDevices] = useState([]); + const [selectedDeviceId, setSelectedDeviceId] = useState(null); + + const webcamRef = useRef(null); + const requestRef = useRef(null); + const canvasRef = useRef(null); + + const cameraItems = devices.map((device) => ({ + value: device.deviceId, + label: device.label || `Camera ${device.deviceId.slice(-5)}`, + })); + + const { + isOpen: isDropdownOpen, + getToggleButtonProps, + getMenuProps, + getItemProps, + highlightedIndex, + selectedItem, + } = useSelect({ + items: cameraItems, + selectedItem: cameraItems.find((item) => item.value === selectedDeviceId) ?? null, + onSelectedItemChange: ({ selectedItem }) => { + setSelectedDeviceId(selectedItem?.value ?? null); + }, + }); + + const scanQRCode = useCallback(() => { if (!isOpen) return; + // Continue scanning in a loop + requestRef.current = requestAnimationFrame(scanQRCode); + + const webcam = webcamRef.current; + const canvas = canvasRef.current; + if (!webcam || !canvas) return; + + const video = webcam.video; + if (!video || video.videoWidth === 0 || video.videoHeight === 0) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight); + + const imageData = ctx.getImageData(0, 0, video.videoWidth, video.videoHeight); + const code = jsQR(imageData.data, imageData.width, imageData.height); + if (!code) return; + + console.log(code); + + // Cancel animation frame to stop scanning + if (requestRef.current) { + cancelAnimationFrame(requestRef.current); + } + + setQrBytesRaw(code.binaryData!); + setIsOpen(false); + }, [isOpen, setIsOpen, setQrBytesRaw]); + + const requestPermission = async () => { navigator.mediaDevices .getUserMedia({ video: true }) .then(() => setPermissionGranted(true)) .catch(() => setPermissionGranted(false)); + }; + + useEffect(() => { + if (!isOpen) return; + requestPermission(); + + navigator.mediaDevices.enumerateDevices().then((devices) => { + const videoDevices = devices.filter((d) => d.kind === "videoinput"); + setDevices(videoDevices); + if (!selectedDeviceId && videoDevices.length > 0) { + setSelectedDeviceId(videoDevices[0].deviceId); + } + }); }, [isOpen]); - const handleScan = (result: IDetectedBarcode[]) => { - // todo: fix scan, use jsQR instead, data is wrong - setIsOpen(false); - - // Convert to bytes - // const encoder = new TextEncoder(); - // const byteArray = encoder.encode(result[0].rawValue); - - // setQrBytes(byteArray); - }; + useEffect(() => { + if (!isOpen && !permissionGranted) return; + requestRef.current = requestAnimationFrame(scanQRCode); + }, [permissionGranted]); if (isOpen) return (
-
+

Scan QR Code

+ {devices.length > 0 && ( +
+ +
+ {/* Toggle button to open the dropdown */} + + + {/* Dropdown menu */} +
    + {isDropdownOpen && + cameraItems.map((item, index) => ( +
  • + {item.label} +
  • + ))} +
+
+
+ )} +
- {permissionGranted === null ? ( -
-
-

Camera access denied

-

Please allow camera access in your browser settings to scan QR codes

-
+ {!permissionGranted ? ( +
+

Camera access denied

+

Please allow camera access in your browser settings to scan QR codes

+
) : ( <> - - + )}
-
diff --git a/src/app/components/submit/qr-upload.tsx b/src/app/components/submit/qr-upload.tsx index cbcac41..cbb25e3 100644 --- a/src/app/components/submit/qr-upload.tsx +++ b/src/app/components/submit/qr-upload.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; import { FileWithPath, useDropzone } from "react-dropzone"; import { Icon } from "@iconify/react"; import jsQR from "jsqr"; @@ -10,6 +10,8 @@ interface Props { } export default function QrUpload({ setQrBytesRaw }: Props) { + const canvasRef = useRef(null); + const onDrop = useCallback((acceptedFiles: FileWithPath[]) => { acceptedFiles.forEach((file) => { // Scan QR code @@ -17,7 +19,9 @@ export default function QrUpload({ setQrBytesRaw }: Props) { reader.onload = async (event) => { const image = new Image(); image.onload = () => { - const canvas = document.createElement("canvas"); + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); if (!ctx) return; @@ -26,9 +30,10 @@ export default function QrUpload({ setQrBytesRaw }: Props) { ctx.drawImage(image, 0, 0, image.width, image.height); const imageData = ctx.getImageData(0, 0, image.width, image.height); - const decoded = jsQR(imageData.data, image.width, image.height); + const code = jsQR(imageData.data, image.width, image.height); + if (!code) return; - setQrBytesRaw(decoded?.binaryData!); + setQrBytesRaw(code.binaryData!); }; image.src = event.target!.result as string; }; @@ -59,6 +64,8 @@ export default function QrUpload({ setQrBytesRaw }: Props) { or click to open

+ +
); }