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
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue