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:
2026-08-02 10:52:37 +02:00
parent b20056913b
commit ee8ae8829e
2 changed files with 78 additions and 2 deletions
+2 -1
View File
@@ -10,7 +10,8 @@
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"canvas-confetti": "^1.9.3" "canvas-confetti": "^1.9.3",
"qrcode": "^1.5.4"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
+76 -1
View File
@@ -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 { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n"; import { useLanguage } from "../i18n";
@@ -21,6 +22,11 @@ export default function NewGameModal({
const [err, setErr] = useState(""); const [err, setErr] = useState("");
const [created, setCreated] = useState(null); // { code } const [created, setCreated] = useState(null); // { code }
const [toast, setToast] = useState(""); 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]); const canJoin = useMemo(() => joinCode.trim().length >= 4, [joinCode]);
@@ -32,6 +38,9 @@ export default function NewGameModal({
setToast(""); setToast("");
setJoinCode(""); setJoinCode("");
setCreated(null); setCreated(null);
setQrOpen(false);
setQrDataUrl("");
setScannerOpen(false);
// Wenn ein Spiel läuft (und nicht finished) -> nur Code anzeigen // Wenn ein Spiel läuft (und nicht finished) -> nur Code anzeigen
if (hasGame && !gameFinished) { if (hasGame && !gameFinished) {
@@ -41,6 +50,56 @@ export default function NewGameModal({
} }
}, [open, hasGame, gameFinished]); }, [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; if (!open) return null;
const showToast = (msg) => { const showToast = (msg) => {
@@ -202,6 +261,15 @@ export default function NewGameModal({
autoFocus 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" }}> <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={() => setMode("choice")} style={styles.secondaryBtn}> <button onClick={() => setMode("choice")} style={styles.secondaryBtn}>
{language === "en" ? "Back" : "Zurück"} {language === "en" ? "Back" : "Zurück"}
@@ -249,6 +317,13 @@ export default function NewGameModal({
<button onClick={() => copyText(created?.code || "")} style={styles.primaryBtn}> <button onClick={() => copyText(created?.code || "")} style={styles.primaryBtn}>
{t("copy")} {language === "en" ? "code" : "Code"} {t("copy")} {language === "en" ? "code" : "Code"}
</button> </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>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}> <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>