fix: qr scanner to use jsQR and add camera selector

This commit is contained in:
trafficlunar 2025-04-08 19:24:33 +01:00
parent 1e0132990a
commit 9f8de81610
6 changed files with 188 additions and 96 deletions

View file

@ -14,8 +14,8 @@
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@prisma/client": "^6.5.0", "@prisma/client": "^6.5.0",
"@trafficlunar/asmcrypto.js": "^1.0.2", "@trafficlunar/asmcrypto.js": "^1.0.2",
"@yudiel/react-qr-scanner": "2.2.2-beta.2",
"bit-buffer": "^0.2.5", "bit-buffer": "^0.2.5",
"downshift": "^9.0.9",
"embla-carousel-react": "^8.5.2", "embla-carousel-react": "^8.5.2",
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
"next": "15.2.4", "next": "15.2.4",
@ -25,6 +25,7 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-select": "^5.10.1", "react-select": "^5.10.1",
"react-webcam": "^7.2.0",
"sharp": "^0.34.0", "sharp": "^0.34.0",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },

View file

@ -20,12 +20,12 @@ importers:
'@trafficlunar/asmcrypto.js': '@trafficlunar/asmcrypto.js':
specifier: ^1.0.2 specifier: ^1.0.2
version: 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: bit-buffer:
specifier: ^0.2.5 specifier: ^0.2.5
version: 0.2.5 version: 0.2.5
downshift:
specifier: ^9.0.9
version: 9.0.9(react@19.1.0)
embla-carousel-react: embla-carousel-react:
specifier: ^8.5.2 specifier: ^8.5.2
version: 8.5.2(react@19.1.0) version: 8.5.2(react@19.1.0)
@ -53,6 +53,9 @@ importers:
react-select: react-select:
specifier: ^5.10.1 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) 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: sharp:
specifier: ^0.34.0 specifier: ^0.34.0
version: 0.34.0 version: 0.34.0
@ -881,9 +884,6 @@ packages:
'@types/cookie@0.6.0': '@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
'@types/emscripten@1.40.1':
resolution: {integrity: sha512-sr53lnYkQNhjHNN0oJDdUm5564biioI5DuOpycufDVK7D3y+GR3oUswe2rlwY1nPNyusHbrJ9WoTyIHl4/Bpwg==}
'@types/estree@1.0.7': '@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
@ -1037,12 +1037,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] 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: acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies: peerDependencies:
@ -1129,9 +1123,6 @@ packages:
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
barcode-detector@3.0.1:
resolution: {integrity: sha512-3fCzG/Py4SVgZJhubD1mt7rVprtHEVWrxQN4FUOG0oulPE4193evbgyptxcOYsfTNEtMlWc+Ec9tdxhjlR4/Ww==}
bit-buffer@0.2.5: bit-buffer@0.2.5:
resolution: {integrity: sha512-x1yGnmXvFg6e3DiyRztElbcn1bsCTFSoM/ncAzY62uE0JdTl5xlKJd0ooqLYoPbhdsnpehSIQrdIvclcZJYwiA==} resolution: {integrity: sha512-x1yGnmXvFg6e3DiyRztElbcn1bsCTFSoM/ncAzY62uE0JdTl5xlKJd0ooqLYoPbhdsnpehSIQrdIvclcZJYwiA==}
@ -1189,6 +1180,9 @@ packages:
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
engines: {node: '>=12.5.0'} engines: {node: '>=12.5.0'}
compute-scroll-into-view@3.1.1:
resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==}
concat-map@0.0.1: concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@ -1267,6 +1261,11 @@ packages:
dom-helpers@5.2.1: dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
downshift@9.0.9:
resolution: {integrity: sha512-ygOT8blgiz5liDuEFAIaPeU4dDEa+w9p6PHVUisPIjrkF5wfR59a52HpGWAVVMoWnoFO8po2mZSScKZueihS7g==}
peerDependencies:
react: '>=16.12.0'
dunder-proto@1.0.1: dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -2094,6 +2093,9 @@ packages:
react-is@16.13.1: react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-is@18.2.0:
resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
react-redux@9.2.0: react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies: peerDependencies:
@ -2118,6 +2120,12 @@ packages:
react: '>=16.6.0' react: '>=16.6.0'
react-dom: '>=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: react@19.1.0:
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2174,9 +2182,6 @@ packages:
scheduler@0.26.0: scheduler@0.26.0:
resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==}
sdp@3.2.0:
resolution: {integrity: sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==}
semver@6.3.1: semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true hasBin: true
@ -2337,10 +2342,6 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} 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: typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -2389,10 +2390,6 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 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: which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -2429,11 +2426,6 @@ packages:
zod@3.24.2: zod@3.24.2:
resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} 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: snapshots:
'@alloc/quick-lru@5.2.0': {} '@alloc/quick-lru@5.2.0': {}
@ -3096,8 +3088,6 @@ snapshots:
'@types/cookie@0.6.0': {} '@types/cookie@0.6.0': {}
'@types/emscripten@1.40.1': {}
'@types/estree@1.0.7': {} '@types/estree@1.0.7': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
@ -3248,15 +3238,6 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.3.3': '@unrs/resolver-binding-win32-x64-msvc@1.3.3':
optional: true 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): acorn-jsx@5.3.2(acorn@8.14.1):
dependencies: dependencies:
acorn: 8.14.1 acorn: 8.14.1
@ -3365,12 +3346,6 @@ snapshots:
balanced-match@1.0.2: {} 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: {} bit-buffer@0.2.5: {}
brace-expansion@1.1.11: brace-expansion@1.1.11:
@ -3434,6 +3409,8 @@ snapshots:
color-convert: 2.0.1 color-convert: 2.0.1
color-string: 1.9.1 color-string: 1.9.1
compute-scroll-into-view@3.1.1: {}
concat-map@0.0.1: {} concat-map@0.0.1: {}
convert-source-map@1.9.0: {} convert-source-map@1.9.0: {}
@ -3513,6 +3490,15 @@ snapshots:
'@babel/runtime': 7.27.0 '@babel/runtime': 7.27.0
csstype: 3.1.3 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: dunder-proto@1.0.1:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@ -4486,6 +4472,8 @@ snapshots:
react-is@16.13.1: {} 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): react-redux@9.2.0(@types/react@19.1.0)(react@19.1.0)(redux@5.0.1):
dependencies: dependencies:
'@types/use-sync-external-store': 0.0.6 '@types/use-sync-external-store': 0.0.6
@ -4521,6 +4509,11 @@ snapshots:
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0(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: {} react@19.1.0: {}
redux@5.0.1: {} redux@5.0.1: {}
@ -4590,8 +4583,6 @@ snapshots:
scheduler@0.26.0: {} scheduler@0.26.0: {}
sdp@3.2.0: {}
semver@6.3.1: {} semver@6.3.1: {}
semver@7.7.1: {} semver@7.7.1: {}
@ -4817,8 +4808,6 @@ snapshots:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
type-fest@4.39.1: {}
typed-array-buffer@1.0.3: typed-array-buffer@1.0.3:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
@ -4895,10 +4884,6 @@ snapshots:
dependencies: dependencies:
react: 19.1.0 react: 19.1.0
webrtc-adapter@9.0.1:
dependencies:
sdp: 3.2.0
which-boxed-primitive@1.1.1: which-boxed-primitive@1.1.1:
dependencies: dependencies:
is-bigint: 1.1.0 is-bigint: 1.1.0
@ -4951,8 +4936,3 @@ snapshots:
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
zod@3.24.2: {} 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

View file

@ -187,7 +187,7 @@ export default function SubmitForm() {
<span>or</span> <span>or</span>
<button onClick={() => setIsQrScannerOpen(true)} className="pill button gap-2"> <button type="button" onClick={() => setIsQrScannerOpen(true)} className="pill button gap-2">
<Icon icon="mdi:camera" fontSize={20} /> <Icon icon="mdi:camera" fontSize={20} />
Use your camera Use your camera
</button> </button>

View file

@ -1,10 +1,12 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { IDetectedBarcode, Scanner } from "@yudiel/react-qr-scanner"; import Webcam from "react-webcam";
import jsQR from "jsqr";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import QrFinder from "../qr-finder"; import QrFinder from "./qr-finder";
import { useSelect } from "downshift";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
@ -15,62 +17,164 @@ interface Props {
export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) { export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(null); const [permissionGranted, setPermissionGranted] = useState<boolean | null>(null);
useEffect(() => { const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
const webcamRef = useRef<Webcam>(null);
const requestRef = useRef<number>(null);
const canvasRef = useRef<HTMLCanvasElement>(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; 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 navigator.mediaDevices
.getUserMedia({ video: true }) .getUserMedia({ video: true })
.then(() => setPermissionGranted(true)) .then(() => setPermissionGranted(true))
.catch(() => setPermissionGranted(false)); .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]); }, [isOpen]);
const handleScan = (result: IDetectedBarcode[]) => { useEffect(() => {
// todo: fix scan, use jsQR instead, data is wrong if (!isOpen && !permissionGranted) return;
setIsOpen(false); requestRef.current = requestAnimationFrame(scanQRCode);
}, [permissionGranted]);
// Convert to bytes
// const encoder = new TextEncoder();
// const byteArray = encoder.encode(result[0].rawValue);
// setQrBytes(byteArray);
};
if (isOpen) if (isOpen)
return ( return (
<div className="fixed inset-0 flex items-center justify-center z-40 backdrop-brightness-75 backdrop-blur-xs"> <div className="fixed inset-0 flex items-center justify-center z-40 backdrop-brightness-75 backdrop-blur-xs">
<div className="bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md"> <div className="bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">Scan QR Code</h2> <h2 className="text-xl font-bold">Scan QR Code</h2>
<button onClick={() => setIsOpen(false)} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer"> <button onClick={() => setIsOpen(false)} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" /> <Icon icon="material-symbols:close-rounded" />
</button> </button>
</div> </div>
{devices.length > 0 && (
<div className="mb-4 flex flex-col gap-1">
<label className="text-sm font-semibold">Camera:</label>
<div className="relative w-full">
{/* Toggle button to open the dropdown */}
<button type="button" {...getToggleButtonProps()} className="pill input w-full !px-2 !py-0.5 !justify-between text-sm">
{selectedItem?.label || "Select a camera"}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
</button>
{/* Dropdown menu */}
<ul
{...getMenuProps()}
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
isDropdownOpen ? "block" : "hidden"
}`}
>
{isDropdownOpen &&
cameraItems.map((item, index) => (
<li
key={item.value}
{...getItemProps({ item, index })}
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
>
{item.label}
</li>
))}
</ul>
</div>
</div>
)}
<div className="relative w-full aspect-square"> <div className="relative w-full aspect-square">
{permissionGranted === null ? ( {!permissionGranted ? (
<div className="absolute inset-0 flex items-center justify-center rounded-lg border-2 border-amber-500"> <div className="absolute inset-0 flex flex-col items-center justify-center rounded-2xl border-2 border-amber-500 text-center p-8">
<div className="text-center p-4">
<p className="text-red-400 font-bold text-lg mb-2">Camera access denied</p> <p className="text-red-400 font-bold text-lg mb-2">Camera access denied</p>
<p className="text-gray-600">Please allow camera access in your browser settings to scan QR codes</p> <p className="text-gray-600">Please allow camera access in your browser settings to scan QR codes</p>
</div> <button type="button" onClick={requestPermission} className="pill button text-xs mt-2 !py-0.5 !px-2">
Request Permission
</button>
</div> </div>
) : ( ) : (
<> <>
<Scanner <Webcam
formats={["qr_code"]} ref={webcamRef}
onScan={handleScan} audio={false}
components={{ finder: false }} videoConstraints={{
sound={false} deviceId: selectedDeviceId ? { exact: selectedDeviceId } : undefined,
classNames={{ container: "rounded-lg border-2 border-amber-500" }} }}
className="size-full object-cover rounded-2xl border-2 border-amber-500"
/> />
<QrFinder /> <QrFinder />
<canvas ref={canvasRef} className="hidden" />
</> </>
)} )}
</div> </div>
<div className="mt-4 flex justify-center"> <div className="mt-4 flex justify-center">
<button onClick={() => setIsOpen(false)} className="pill button"> <button type="button" onClick={() => setIsOpen(false)} className="pill button">
Cancel Cancel
</button> </button>
</div> </div>

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback } from "react"; import { useCallback, useRef } from "react";
import { FileWithPath, useDropzone } from "react-dropzone"; import { FileWithPath, useDropzone } from "react-dropzone";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import jsQR from "jsqr"; import jsQR from "jsqr";
@ -10,6 +10,8 @@ interface Props {
} }
export default function QrUpload({ setQrBytesRaw }: Props) { export default function QrUpload({ setQrBytesRaw }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const onDrop = useCallback((acceptedFiles: FileWithPath[]) => { const onDrop = useCallback((acceptedFiles: FileWithPath[]) => {
acceptedFiles.forEach((file) => { acceptedFiles.forEach((file) => {
// Scan QR code // Scan QR code
@ -17,7 +19,9 @@ export default function QrUpload({ setQrBytesRaw }: Props) {
reader.onload = async (event) => { reader.onload = async (event) => {
const image = new Image(); const image = new Image();
image.onload = () => { image.onload = () => {
const canvas = document.createElement("canvas"); const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx) return;
@ -26,9 +30,10 @@ export default function QrUpload({ setQrBytesRaw }: Props) {
ctx.drawImage(image, 0, 0, image.width, image.height); ctx.drawImage(image, 0, 0, image.width, image.height);
const imageData = ctx.getImageData(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; image.src = event.target!.result as string;
}; };
@ -59,6 +64,8 @@ export default function QrUpload({ setQrBytesRaw }: Props) {
or click to open or click to open
</p> </p>
</div> </div>
<canvas ref={canvasRef} className="hidden" />
</div> </div>
); );
} }