Add QR code generation and scanning for game join codes with camera integration
Implemented QR code generation for created games using qrcode library with custom dark/light color scheme. Added camera-based QR scanner using BarcodeDetector API with environment-facing camera preference, automatic code extraction, and 350ms polling interval. Added toggle buttons for QR display/scanner with bilingual labels, video preview with styled container, and fallback error handling for unsupported browsers or denied
This commit is contained in:
@@ -10,7 +10,8 @@
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"canvas-confetti": "^1.9.3"
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"qrcode": "^1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import QRCode from "qrcode";
|
||||
import { styles } from "../styles/styles";
|
||||
import { stylesTokens } from "../styles/theme";
|
||||
import { useLanguage } from "../i18n";
|
||||
@@ -21,6 +22,11 @@ export default function NewGameModal({
|
||||
const [err, setErr] = useState("");
|
||||
const [created, setCreated] = useState(null); // { code }
|
||||
const [toast, setToast] = useState("");
|
||||
const [qrOpen, setQrOpen] = useState(false);
|
||||
const [qrDataUrl, setQrDataUrl] = useState("");
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
const videoRef = useRef(null);
|
||||
const streamRef = useRef(null);
|
||||
|
||||
const canJoin = useMemo(() => joinCode.trim().length >= 4, [joinCode]);
|
||||
|
||||
@@ -32,6 +38,9 @@ export default function NewGameModal({
|
||||
setToast("");
|
||||
setJoinCode("");
|
||||
setCreated(null);
|
||||
setQrOpen(false);
|
||||
setQrDataUrl("");
|
||||
setScannerOpen(false);
|
||||
|
||||
// Wenn ein Spiel läuft (und nicht finished) -> nur Code anzeigen
|
||||
if (hasGame && !gameFinished) {
|
||||
@@ -41,6 +50,56 @@ export default function NewGameModal({
|
||||
}
|
||||
}, [open, hasGame, gameFinished]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!qrOpen || !created?.code) return;
|
||||
QRCode.toDataURL(created.code, { width: 280, margin: 2, errorCorrectionLevel: "M", color: { dark: "#17161b", light: "#f5efdc" } })
|
||||
.then(setQrDataUrl).catch(() => setQrDataUrl(""));
|
||||
}, [qrOpen, created?.code]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scannerOpen) return undefined;
|
||||
let alive = true;
|
||||
let intervalId;
|
||||
const startScanner = async () => {
|
||||
if (!("BarcodeDetector" in window) || !navigator.mediaDevices?.getUserMedia) {
|
||||
setErr(language === "en" ? "QR scanning is not supported here. Enter the code manually." : "QR-Scannen wird hier nicht unterstützt. Bitte Code manuell eingeben.");
|
||||
setScannerOpen(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const detector = new window.BarcodeDetector({ formats: ["qr_code"] });
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: "environment" } }, audio: false });
|
||||
if (!alive) { stream.getTracks().forEach((track) => track.stop()); return; }
|
||||
streamRef.current = stream;
|
||||
videoRef.current.srcObject = stream;
|
||||
await videoRef.current.play();
|
||||
intervalId = window.setInterval(async () => {
|
||||
if (!alive || !videoRef.current) return;
|
||||
try {
|
||||
const codes = await detector.detect(videoRef.current);
|
||||
const raw = codes?.[0]?.rawValue?.trim() || "";
|
||||
if (raw) {
|
||||
setJoinCode((raw.match(/[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{4,}/i)?.[0] || raw).toUpperCase());
|
||||
setScannerOpen(false);
|
||||
setToast(language === "en" ? "✅ QR code recognized" : "✅ QR-Code erkannt");
|
||||
}
|
||||
} catch { /* frame not ready */ }
|
||||
}, 350);
|
||||
} catch {
|
||||
setErr(language === "en" ? "Camera access was denied. Enter the code manually." : "Kamerazugriff wurde verweigert. Bitte Code manuell eingeben.");
|
||||
setScannerOpen(false);
|
||||
}
|
||||
};
|
||||
startScanner();
|
||||
return () => {
|
||||
alive = false;
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
if (streamRef.current) streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
if (videoRef.current) videoRef.current.srcObject = null;
|
||||
};
|
||||
}, [scannerOpen, language]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const showToast = (msg) => {
|
||||
@@ -202,6 +261,15 @@ export default function NewGameModal({
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<button type="button" onClick={() => { setErr(""); setScannerOpen((value) => !value); }} style={styles.secondaryBtn}>
|
||||
▣ {scannerOpen ? (language === "en" ? "Close scanner" : "Scanner schließen") : (language === "en" ? "Scan QR code" : "QR-Code scannen")}
|
||||
</button>
|
||||
|
||||
{scannerOpen && <div style={{ display: "grid", gap: 8 }}>
|
||||
<video ref={videoRef} muted playsInline style={{ width: "100%", maxHeight: 240, objectFit: "cover", borderRadius: 14, background: "#050507", border: `1px solid ${stylesTokens.panelBorder}` }} />
|
||||
<div style={{ color: stylesTokens.textDim, fontSize: 12, textAlign: "center" }}>{language === "en" ? "Point your camera at the game QR code." : "Richte die Kamera auf den QR-Code des Spiels."}</div>
|
||||
</div>}
|
||||
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
<button onClick={() => setMode("choice")} style={styles.secondaryBtn}>
|
||||
{language === "en" ? "Back" : "Zurück"}
|
||||
@@ -249,6 +317,13 @@ export default function NewGameModal({
|
||||
<button onClick={() => copyText(created?.code || "")} style={styles.primaryBtn}>
|
||||
⧉ {t("copy")} {language === "en" ? "code" : "Code"}
|
||||
</button>
|
||||
<button onClick={() => setQrOpen((value) => !value)} style={styles.secondaryBtn}>
|
||||
▣ {qrOpen ? (language === "en" ? "Hide QR code" : "QR-Code ausblenden") : (language === "en" ? "Create QR code" : "QR-Code erstellen")}
|
||||
</button>
|
||||
{qrOpen && qrDataUrl && <div style={{ marginTop: 4, display: "grid", justifyItems: "center", gap: 7 }}>
|
||||
<img src={qrDataUrl} alt={language === "en" ? "Game QR code" : "Spiel-QR-Code"} style={{ width: 220, height: 220, borderRadius: 10, padding: 8, background: "#f5efdc" }} />
|
||||
<div style={{ color: stylesTokens.textDim, fontSize: 12 }}>{language === "en" ? "Scan this code to join the game." : "Scanne diesen Code, um dem Spiel beizutreten."}</div>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
|
||||
Reference in New Issue
Block a user