feat(ui): add QR code scanner to ConnectPrompt
Add browser-based QR scanning as an alternative to paste mode in the ConnectPrompt component. Mobile users can scan the Desktop QR code directly instead of manually copying connection codes. - Add qr-scanner dependency (WebWorker-based decoding, BarcodeDetector support) - Create use-qr-scanner hook wrapping camera lifecycle and cleanup - Create QrScannerView component with viewfinder overlay - ConnectPrompt auto-detects mobile (touch + narrow viewport) and defaults to scan mode - Lazy-load scanner component for zero initial bundle impact - Graceful fallback to paste mode on permission denial or no camera Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1dc7539018
commit
2f5dbcdde9
5 changed files with 361 additions and 40 deletions
|
|
@ -22,6 +22,7 @@
|
|||
"clsx": "^2.1.1",
|
||||
"linkify-it": "^5.0.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, lazy, Suspense, useRef } from "react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Textarea } from "@multica/ui/components/ui/textarea";
|
||||
import { toast } from "@multica/ui/components/ui/sonner";
|
||||
|
|
@ -9,54 +9,157 @@ import {
|
|||
parseConnectionCode,
|
||||
saveConnection,
|
||||
} from "@multica/store";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Camera01Icon, TextIcon } from "@hugeicons/core-free-icons";
|
||||
|
||||
const LazyQrScannerView = lazy(() =>
|
||||
import("@multica/ui/components/qr-scanner-view").then((m) => ({
|
||||
default: m.QrScannerView,
|
||||
})),
|
||||
);
|
||||
|
||||
type Mode = "scan" | "paste";
|
||||
|
||||
export function ConnectPrompt() {
|
||||
const gwState = useConnectionStore((s) => s.connectionState)
|
||||
const [codeInput, setCodeInput] = useState("")
|
||||
const gwState = useConnectionStore((s) => s.connectionState);
|
||||
const [codeInput, setCodeInput] = useState("");
|
||||
const [mode, setMode] = useState<Mode>("paste"); // SSR-safe default
|
||||
const [canScan, setCanScan] = useState(false);
|
||||
const scannedRef = useRef(false);
|
||||
|
||||
const handleConnect = useCallback(() => {
|
||||
const trimmed = codeInput.trim()
|
||||
if (!trimmed) return
|
||||
try {
|
||||
const info = parseConnectionCode(trimmed)
|
||||
saveConnection(info)
|
||||
useConnectionStore.getState().connect(info)
|
||||
setCodeInput("")
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message)
|
||||
// Detect mobile + camera capability, auto-switch to scan mode
|
||||
useEffect(() => {
|
||||
const isTouchDevice =
|
||||
"ontouchstart" in window || navigator.maxTouchPoints > 0;
|
||||
const isNarrow = window.innerWidth < 768;
|
||||
const hasGetUserMedia = !!navigator.mediaDevices?.getUserMedia;
|
||||
|
||||
if (hasGetUserMedia) {
|
||||
setCanScan(true);
|
||||
if (isTouchDevice && isNarrow) {
|
||||
setMode("scan");
|
||||
}
|
||||
}
|
||||
}, [codeInput])
|
||||
}, []);
|
||||
|
||||
// Handle paste-mode connect
|
||||
const handleConnect = useCallback(() => {
|
||||
const trimmed = codeInput.trim();
|
||||
if (!trimmed) return;
|
||||
try {
|
||||
const info = parseConnectionCode(trimmed);
|
||||
saveConnection(info);
|
||||
useConnectionStore.getState().connect(info);
|
||||
setCodeInput("");
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}, [codeInput]);
|
||||
|
||||
// Handle QR scan result — auto-connect, no button needed
|
||||
const handleQrScan = useCallback((data: string) => {
|
||||
// Prevent duplicate connects from rapid successive scans
|
||||
if (scannedRef.current) return;
|
||||
scannedRef.current = true;
|
||||
|
||||
try {
|
||||
const info = parseConnectionCode(data);
|
||||
saveConnection(info);
|
||||
useConnectionStore.getState().connect(info);
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
// Allow re-scan on error (invalid/expired code)
|
||||
scannedRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleScanError = useCallback((msg: string) => {
|
||||
toast.error(msg);
|
||||
setMode("paste");
|
||||
}, []);
|
||||
|
||||
const isConnecting = gwState === "connecting" || gwState === "connected";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-4">
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Paste a connection code to start</p>
|
||||
{(gwState === "connecting" || gwState === "connected") && (
|
||||
<p className="text-xs text-muted-foreground/60 animate-pulse">Connecting...</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{mode === "scan"
|
||||
? "Scan QR code to connect"
|
||||
: "Paste a connection code to start"}
|
||||
</p>
|
||||
{isConnecting && (
|
||||
<p className="text-xs text-muted-foreground/60 animate-pulse">
|
||||
Connecting...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mode toggle — only show if camera is available */}
|
||||
{canScan && (
|
||||
<div className="flex gap-1 bg-muted rounded-lg p-1">
|
||||
<Button
|
||||
variant={mode === "scan" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="text-xs gap-1.5 h-7 px-3"
|
||||
onClick={() => {
|
||||
scannedRef.current = false;
|
||||
setMode("scan");
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={Camera01Icon} className="size-3.5" />
|
||||
Scan
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === "paste" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="text-xs gap-1.5 h-7 px-3"
|
||||
onClick={() => setMode("paste")}
|
||||
>
|
||||
<HugeiconsIcon icon={TextIcon} className="size-3.5" />
|
||||
Paste
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<Textarea
|
||||
value={codeInput}
|
||||
onChange={(e) => setCodeInput(e.target.value)}
|
||||
placeholder="Paste connection code here..."
|
||||
className="text-xs font-mono min-h-[100px] resize-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
handleConnect()
|
||||
{mode === "scan" ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-[280px] animate-pulse bg-muted rounded-xl" />
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleConnect}
|
||||
disabled={!codeInput.trim() || gwState === "connecting"}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
>
|
||||
<LazyQrScannerView
|
||||
onScan={handleQrScan}
|
||||
onError={handleScanError}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<>
|
||||
<Textarea
|
||||
value={codeInput}
|
||||
onChange={(e) => setCodeInput(e.target.value)}
|
||||
placeholder="Paste connection code here..."
|
||||
className="text-xs font-mono min-h-[100px] resize-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleConnect();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleConnect}
|
||||
disabled={!codeInput.trim() || gwState === "connecting"}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
82
packages/ui/src/components/qr-scanner-view.tsx
Normal file
82
packages/ui/src/components/qr-scanner-view.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"use client"
|
||||
|
||||
import { useQrScanner } from "@multica/ui/hooks/use-qr-scanner"
|
||||
|
||||
interface QrScannerViewProps {
|
||||
onScan: (data: string) => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera viewfinder for QR code scanning.
|
||||
*
|
||||
* Renders a live camera feed with a decorative scan frame overlay.
|
||||
* Uses getUserMedia via the qr-scanner library (WebWorker-based decoding).
|
||||
* iOS requires playsinline + muted + autoplay on the <video> element.
|
||||
*/
|
||||
export function QrScannerView({ onScan, onError }: QrScannerViewProps) {
|
||||
const { videoRef, isScanning, error, hasCamera } = useQrScanner({
|
||||
onScan,
|
||||
onError,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
if (!hasCamera) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[280px] rounded-xl bg-muted">
|
||||
<p className="text-sm text-muted-foreground">No camera available</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[280px] rounded-xl bg-muted gap-2">
|
||||
<p className="text-sm text-muted-foreground">Camera access denied</p>
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
Switch to paste mode below
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-[280px] mx-auto">
|
||||
{/* Camera feed */}
|
||||
<div className="relative aspect-square rounded-xl overflow-hidden bg-black">
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
{/* Scan frame overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="relative w-3/4 h-3/4">
|
||||
{/* Corner accents */}
|
||||
<div className="absolute -top-1 -left-1 w-5 h-5 border-t-2 border-l-2 border-white/70 rounded-tl-md" />
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 border-t-2 border-r-2 border-white/70 rounded-tr-md" />
|
||||
<div className="absolute -bottom-1 -left-1 w-5 h-5 border-b-2 border-l-2 border-white/70 rounded-bl-md" />
|
||||
<div className="absolute -bottom-1 -right-1 w-5 h-5 border-b-2 border-r-2 border-white/70 rounded-br-md" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{!isScanning && !error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
|
||||
<p className="text-xs text-white/80 animate-pulse">
|
||||
Starting camera...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hint text */}
|
||||
<p className="text-xs text-muted-foreground text-center mt-3">
|
||||
Point camera at QR code on desktop
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
packages/ui/src/hooks/use-qr-scanner.ts
Normal file
118
packages/ui/src/hooks/use-qr-scanner.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"use client"
|
||||
|
||||
import { useRef, useState, useEffect, useCallback } from "react"
|
||||
import type QrScannerLib from "qr-scanner"
|
||||
|
||||
export interface UseQrScannerOptions {
|
||||
onScan: (data: string) => void
|
||||
onError?: (error: string) => void
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface UseQrScannerResult {
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>
|
||||
isScanning: boolean
|
||||
error: string | null
|
||||
hasCamera: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook wrapping qr-scanner lifecycle.
|
||||
*
|
||||
* - Dynamically imports qr-scanner (keeps it out of SSR bundles)
|
||||
* - Creates/destroys scanner instance based on `enabled`
|
||||
* - Releases camera stream on cleanup
|
||||
*/
|
||||
export function useQrScanner({
|
||||
onScan,
|
||||
onError,
|
||||
enabled = true,
|
||||
}: UseQrScannerOptions): UseQrScannerResult {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const scannerRef = useRef<QrScannerLib | null>(null)
|
||||
const [isScanning, setIsScanning] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [hasCamera, setHasCamera] = useState(true)
|
||||
|
||||
// Stable callback refs to avoid re-creating scanner on every render
|
||||
const onScanRef = useRef(onScan)
|
||||
onScanRef.current = onScan
|
||||
const onErrorRef = useRef(onError)
|
||||
onErrorRef.current = onError
|
||||
|
||||
// Check camera availability once
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
import("qr-scanner").then((mod) => {
|
||||
const QrScanner = mod.default
|
||||
QrScanner.hasCamera().then((has) => {
|
||||
if (!cancelled) setHasCamera(has)
|
||||
})
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
// Start/stop scanner based on `enabled` and video element
|
||||
useEffect(() => {
|
||||
if (!enabled || !videoRef.current || !hasCamera) return
|
||||
|
||||
let destroyed = false
|
||||
const video = videoRef.current
|
||||
|
||||
import("qr-scanner").then((mod) => {
|
||||
if (destroyed) return
|
||||
const QrScanner = mod.default
|
||||
|
||||
const scanner = new QrScanner(
|
||||
video,
|
||||
(result) => {
|
||||
console.log("[QrScanner] scanned:", result.data)
|
||||
onScanRef.current(result.data)
|
||||
},
|
||||
{
|
||||
preferredCamera: "environment",
|
||||
maxScansPerSecond: 5,
|
||||
returnDetailedScanResult: true,
|
||||
highlightScanRegion: false,
|
||||
highlightCodeOutline: false,
|
||||
onDecodeError: (err) => {
|
||||
// "No QR code found" fires every frame — ignore it
|
||||
if (typeof err === "string" && err.includes("No QR code found")) return
|
||||
console.warn("[QrScanner] decode error:", err)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
scannerRef.current = scanner
|
||||
|
||||
scanner
|
||||
.start()
|
||||
.then(() => {
|
||||
if (!destroyed) {
|
||||
console.log("[QrScanner] started successfully")
|
||||
setIsScanning(true)
|
||||
setError(null)
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (destroyed) return
|
||||
const msg = err.message || "Camera access failed"
|
||||
setError(msg)
|
||||
setIsScanning(false)
|
||||
onErrorRef.current?.(msg)
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
destroyed = true
|
||||
if (scannerRef.current) {
|
||||
scannerRef.current.stop()
|
||||
scannerRef.current.destroy()
|
||||
scannerRef.current = null
|
||||
}
|
||||
setIsScanning(false)
|
||||
}
|
||||
}, [enabled, hasCamera])
|
||||
|
||||
return { videoRef, isScanning, error, hasCamera }
|
||||
}
|
||||
23
pnpm-lock.yaml
generated
23
pnpm-lock.yaml
generated
|
|
@ -482,6 +482,9 @@ importers:
|
|||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
qr-scanner:
|
||||
specifier: ^1.4.2
|
||||
version: 1.4.2
|
||||
react:
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.3
|
||||
|
|
@ -3315,6 +3318,9 @@ packages:
|
|||
'@types/node@25.0.10':
|
||||
resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==}
|
||||
|
||||
'@types/offscreencanvas@2019.7.3':
|
||||
resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
|
||||
|
||||
'@types/plist@3.0.5':
|
||||
resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==}
|
||||
|
||||
|
|
@ -5464,11 +5470,13 @@ packages:
|
|||
|
||||
glob@10.5.0:
|
||||
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
hasBin: true
|
||||
|
||||
glob@11.1.0:
|
||||
resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==}
|
||||
engines: {node: 20 || >=22}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
hasBin: true
|
||||
|
||||
glob@13.0.0:
|
||||
|
|
@ -5477,7 +5485,7 @@ packages:
|
|||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
global-agent@3.0.0:
|
||||
resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
|
||||
|
|
@ -7354,6 +7362,9 @@ packages:
|
|||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
qr-scanner@1.4.2:
|
||||
resolution: {integrity: sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==}
|
||||
|
||||
qrcode-terminal@0.11.0:
|
||||
resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==}
|
||||
hasBin: true
|
||||
|
|
@ -12506,6 +12517,8 @@ snapshots:
|
|||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
'@types/offscreencanvas@2019.7.3': {}
|
||||
|
||||
'@types/plist@3.0.5':
|
||||
dependencies:
|
||||
'@types/node': 25.0.10
|
||||
|
|
@ -14363,7 +14376,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
|
|
@ -14394,7 +14407,7 @@ snapshots:
|
|||
doctrine: 2.1.0
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
|
|
@ -17470,6 +17483,10 @@ snapshots:
|
|||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qr-scanner@1.4.2:
|
||||
dependencies:
|
||||
'@types/offscreencanvas': 2019.7.3
|
||||
|
||||
qrcode-terminal@0.11.0: {}
|
||||
|
||||
qrcode.react@4.2.0(react@19.2.3):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue