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:
Naiyuan Qing 2026-02-04 15:41:12 +08:00
parent 1dc7539018
commit 2f5dbcdde9
5 changed files with 361 additions and 40 deletions

View file

@ -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>
)
);
}

View 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>
)
}