feat: replace react-webcam

This commit is contained in:
trafficlunar 2026-02-01 22:15:47 +00:00
parent df30270cc6
commit 98fffcc396
7 changed files with 62 additions and 74 deletions

1
.gitignore vendored
View file

@ -16,6 +16,7 @@
# next.js # next.js
/.next/ /.next/
/out/ /out/
certificates/
# production # production
/build /build

View file

@ -30,7 +30,6 @@
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-webcam": "^7.2.0",
"redis": "^5.10.0", "redis": "^5.10.0",
"satori": "^0.19.1", "satori": "^0.19.1",
"seedrandom": "^3.0.5", "seedrandom": "^3.0.5",

View file

@ -62,9 +62,6 @@ importers:
react-dropzone: react-dropzone:
specifier: ^14.3.8 specifier: ^14.3.8
version: 14.3.8(react@19.2.4) version: 14.3.8(react@19.2.4)
react-webcam:
specifier: ^7.2.0
version: 7.2.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
redis: redis:
specifier: ^5.10.0 specifier: ^5.10.0
version: 5.10.0 version: 5.10.0
@ -2533,12 +2530,6 @@ packages:
redux: redux:
optional: true optional: true
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.2.4: react@19.2.4:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -5299,11 +5290,6 @@ snapshots:
'@types/react': 19.2.10 '@types/react': 19.2.10
redux: 5.0.1 redux: 5.0.1
react-webcam@7.2.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react@19.2.4: {} react@19.2.4: {}
readdirp@4.1.2: {} readdirp@4.1.2: {}

View file

@ -43,15 +43,15 @@ body {
} }
.button:disabled { .button:disabled {
@apply text-zinc-600 !bg-zinc-100 !border-zinc-300 cursor-auto; @apply text-zinc-600 bg-zinc-100! border-zinc-300! cursor-auto;
} }
.input { .input {
@apply !bg-orange-200 outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40; @apply bg-orange-200! outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40;
} }
.input:disabled { .input:disabled {
@apply text-zinc-600 !bg-zinc-100 !border-zinc-300; @apply text-zinc-600 bg-zinc-100! border-zinc-300!;
} }
.checkbox { .checkbox {

View file

@ -32,8 +32,13 @@ export default async function SubmitPage() {
if (activePunishment) redirect("/off-the-island"); if (activePunishment) redirect("/off-the-island");
// Check if submissions are disabled // Check if submissions are disabled
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`); let value: boolean | null = true;
const { value } = await response.json(); try {
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
value = await response.json();
} catch (error) {
return <p>An error occurred!</p>;
}
if (!value) if (!value)
return ( return (

View file

@ -10,8 +10,6 @@ import { searchSchema } from "@/lib/schemas";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import GenderSelect from "./gender-select";
import TagFilter from "./tag-filter";
import SortSelect from "./sort-select"; import SortSelect from "./sort-select";
import Carousel from "../carousel"; import Carousel from "../carousel";
import LikeButton from "../like-button"; import LikeButton from "../like-button";

View file

@ -1,7 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import Webcam from "react-webcam";
import jsQR from "jsqr"; import jsQR from "jsqr";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
@ -22,7 +21,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]); const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null); const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
const webcamRef = useRef<Webcam>(null); const videoRef = useRef<HTMLVideoElement>(null);
const requestRef = useRef<number>(null); const requestRef = useRef<number>(null);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
@ -52,12 +51,9 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
// Continue scanning in a loop // Continue scanning in a loop
requestRef.current = requestAnimationFrame(scanQRCode); requestRef.current = requestAnimationFrame(scanQRCode);
const webcam = webcamRef.current; const video = videoRef.current;
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!webcam || !canvas) return; if (!video || video.videoWidth === 0 || video.videoHeight === 0 || !canvas) return;
const video = webcam.video;
if (!video || video.videoWidth === 0 || video.videoHeight === 0) return;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx) return;
@ -68,7 +64,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
const imageData = ctx.getImageData(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); const code = jsQR(imageData.data, imageData.width, imageData.height);
if (!code) return; if (!code || !code.binaryData) return;
// Cancel animation frame to stop scanning // Cancel animation frame to stop scanning
if (requestRef.current) { if (requestRef.current) {
@ -76,15 +72,20 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
requestRef.current = null; requestRef.current = null;
} }
setQrBytesRaw(code.binaryData!); setQrBytesRaw(code.binaryData);
setIsOpen(false); setIsOpen(false);
}, [isOpen, setIsOpen, setQrBytesRaw]); }, [isOpen, setIsOpen, setQrBytesRaw]);
const requestPermission = async () => { const requestPermission = () => {
if (!navigator.mediaDevices) return;
navigator.mediaDevices navigator.mediaDevices
.getUserMedia({ video: true }) .getUserMedia({ video: true, audio: false })
.then(() => setPermissionGranted(true)) .then(() => setPermissionGranted(true))
.catch(() => setPermissionGranted(false)); .catch((err) => {
setPermissionGranted(false);
console.error("An error occurred trying to access the camera", err);
});
}; };
const close = () => { const close = () => {
@ -98,34 +99,50 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
if (isOpen) { if (isOpen) {
// slight delay to trigger animation // slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10); setTimeout(() => setIsVisible(true), 10);
requestPermission();
} }
}, [isOpen]); }, [isOpen]);
useEffect(() => {
if (!isOpen) return;
requestPermission();
if (!navigator.mediaDevices.enumerateDevices) return;
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, selectedDeviceId]);
useEffect(() => { useEffect(() => {
if (!isOpen || !permissionGranted) return; if (!isOpen || !permissionGranted) return;
navigator.mediaDevices
.enumerateDevices()
.then((devices) => {
const videoDevices = devices.filter((d) => d.kind === "videoinput");
setDevices(videoDevices);
const targetDeviceId = selectedDeviceId || videoDevices[0]?.deviceId;
if (!targetDeviceId) return;
setSelectedDeviceId(targetDeviceId);
// start camera stream
return navigator.mediaDevices.getUserMedia({
video: { deviceId: targetDeviceId },
audio: false,
});
})
.then((stream) => {
if (!stream || !videoRef.current) return;
videoRef.current.srcObject = stream;
videoRef.current.play();
})
.catch((err) => console.error("Camera error", err));
requestRef.current = requestAnimationFrame(scanQRCode); requestRef.current = requestAnimationFrame(scanQRCode);
// cleanup
return () => { return () => {
if (requestRef.current) { if (requestRef.current) {
cancelAnimationFrame(requestRef.current); cancelAnimationFrame(requestRef.current);
} }
if (videoRef.current?.srcObject) {
const stream = videoRef.current.srcObject as MediaStream;
stream.getTracks().forEach((track) => track.stop());
videoRef.current.srcObject = null;
}
}; };
}, [isOpen, permissionGranted, scanQRCode]); }, [isOpen, permissionGranted, selectedDeviceId, scanQRCode]);
if (!isOpen) return null; if (!isOpen) return null;
@ -133,9 +150,7 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40"> <div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div <div
onClick={close} onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${ className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
isVisible ? "opacity-100" : "opacity-0"
}`}
/> />
<div <div
@ -189,35 +204,19 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
)} )}
<div className="relative w-full aspect-square"> <div className="relative w-full aspect-square">
{!permissionGranted ? ( {!permissionGranted && (
<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="absolute inset-0 flex flex-col items-center justify-center rounded-2xl bg-amber-50 border-2 border-amber-500 text-center p-8">
<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>
<button type="button" onClick={requestPermission} className="pill button text-xs mt-2 py-0.5! px-2!"> <button type="button" onClick={requestPermission} className="pill button text-xs mt-2 py-0.5! px-2!">
Request Permission Request Permission
</button> </button>
</div> </div>
) : (
<>
<Webcam
key={selectedDeviceId}
ref={webcamRef}
audio={false}
videoConstraints={{
deviceId: selectedDeviceId ? { exact: selectedDeviceId } : undefined,
...(selectedDeviceId ? {} : { facingMode: { ideal: "environment" } }),
}}
onUserMedia={async () => {
const newDevices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = newDevices.filter((d) => d.kind === "videoinput");
setDevices(videoDevices);
}}
className="size-full object-cover rounded-2xl border-2 border-amber-500"
/>
<QrFinder />
<canvas ref={canvasRef} className="hidden" />
</>
)} )}
<video ref={videoRef} className="size-full object-cover rounded-2xl border-2 border-amber-500" />
<QrFinder />
<canvas ref={canvasRef} className="hidden" />
</div> </div>
<div className="mt-4 flex justify-center"> <div className="mt-4 flex justify-center">