feat(ui): redesign QR scanner with state machine and mobile sheet
Rewrite QrScannerView with full state machine (idle/requesting/scanning/ detected/success/error), corner bracket animations snapping to QR cornerPoints, flash toggle, and haptic feedback. Add QrScannerSheet as bottom sheet wrapper for mobile. Simplify ConnectPrompt to use useIsMobile() — mobile shows scan button + sheet, desktop shows paste-only UI. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d0681ef46d
commit
845e503899
3 changed files with 397 additions and 152 deletions
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, lazy, Suspense, useRef } from "react";
|
||||
import { useState, useCallback } 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,40 +9,17 @@ import {
|
|||
parseConnectionCode,
|
||||
saveConnection,
|
||||
} from "@multica/store";
|
||||
import { useIsMobile } from "@multica/ui/hooks/use-mobile";
|
||||
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";
|
||||
import { Camera01Icon } from "@hugeicons/core-free-icons";
|
||||
import { QrScannerSheet } from "@multica/ui/components/qr-scanner-sheet";
|
||||
|
||||
export function ConnectPrompt() {
|
||||
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 [scanOpen, setScanOpen] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle paste-mode connect
|
||||
const handleConnect = useCallback(() => {
|
||||
const trimmed = codeInput.trim();
|
||||
if (!trimmed) return;
|
||||
|
|
@ -56,26 +33,11 @@ export function ConnectPrompt() {
|
|||
}
|
||||
}, [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");
|
||||
// Promise-based handler for QrScannerView — resolve = success, reject = error
|
||||
const handleScanResult = useCallback(async (data: string) => {
|
||||
const info = parseConnectionCode(data);
|
||||
saveConnection(info);
|
||||
useConnectionStore.getState().connect(info);
|
||||
}, []);
|
||||
|
||||
const isConnecting = gwState === "connecting" || gwState === "connected";
|
||||
|
|
@ -84,8 +46,8 @@ export function ConnectPrompt() {
|
|||
<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">
|
||||
{mode === "scan"
|
||||
? "Scan QR code to connect"
|
||||
{isMobile
|
||||
? "Scan or paste a connection code"
|
||||
: "Paste a connection code to start"}
|
||||
</p>
|
||||
{isConnecting && (
|
||||
|
|
@ -95,70 +57,54 @@ export function ConnectPrompt() {
|
|||
)}
|
||||
</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">
|
||||
{mode === "scan" ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-[280px] animate-pulse bg-muted rounded-xl" />
|
||||
}
|
||||
>
|
||||
<LazyQrScannerView
|
||||
onScan={handleQrScan}
|
||||
onError={handleScanError}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
{/* Mobile: scan button + sheet */}
|
||||
{isMobile && (
|
||||
<>
|
||||
<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"
|
||||
onClick={() => setScanOpen(true)}
|
||||
className="w-full text-xs gap-2"
|
||||
>
|
||||
Connect
|
||||
<HugeiconsIcon icon={Camera01Icon} className="size-4" />
|
||||
Scan QR Code
|
||||
</Button>
|
||||
<QrScannerSheet
|
||||
open={scanOpen}
|
||||
onOpenChange={setScanOpen}
|
||||
onResult={handleScanResult}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Paste UI (always shown) */}
|
||||
<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>
|
||||
|
||||
{/* Mobile: paste fallback hint */}
|
||||
{isMobile && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
or paste code above instead
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
40
packages/ui/src/components/qr-scanner-sheet.tsx
Normal file
40
packages/ui/src/components/qr-scanner-sheet.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@multica/ui/components/ui/sheet"
|
||||
import { QrScannerView } from "@multica/ui/components/qr-scanner-view"
|
||||
|
||||
export interface QrScannerSheetProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onResult: (data: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function QrScannerSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
onResult,
|
||||
}: QrScannerSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="bottom" className="px-4 pb-8">
|
||||
{/* Drag handle */}
|
||||
<div className="mx-auto mt-2 mb-1 h-1 w-10 rounded-full bg-muted-foreground/30" />
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-center">Scan Connection Code</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-4">
|
||||
<QrScannerView
|
||||
open={open}
|
||||
onResult={onResult}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,82 +1,341 @@
|
|||
"use client"
|
||||
|
||||
import { useQrScanner } from "@multica/ui/hooks/use-qr-scanner"
|
||||
import "./qr-scanner.css"
|
||||
|
||||
interface QrScannerViewProps {
|
||||
onScan: (data: string) => void
|
||||
onError?: (error: string) => void
|
||||
import { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { useQrScanner, type Point } from "@multica/ui/hooks/use-qr-scanner"
|
||||
import { Spinner } from "@multica/ui/components/spinner"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import {
|
||||
Camera01Icon,
|
||||
CheckmarkCircle02Icon,
|
||||
Alert02Icon,
|
||||
FlashlightIcon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
|
||||
type ScannerState =
|
||||
| "idle"
|
||||
| "requesting"
|
||||
| "scanning"
|
||||
| "detected"
|
||||
| "success"
|
||||
| "error"
|
||||
|
||||
export interface QrScannerProps {
|
||||
onResult: (data: string) => Promise<void>
|
||||
onClose?: () => void
|
||||
open?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera viewfinder for QR code scanning.
|
||||
* Standalone QR scanner with full state machine.
|
||||
*
|
||||
* 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.
|
||||
* States: idle → requesting → scanning → detected → success → (auto-close)
|
||||
* ↑ ↓
|
||||
* └── error ──┘
|
||||
*/
|
||||
export function QrScannerView({ onScan, onError }: QrScannerViewProps) {
|
||||
const { videoRef, isScanning, error, hasCamera } = useQrScanner({
|
||||
onScan,
|
||||
onError,
|
||||
enabled: true,
|
||||
export function QrScannerView({ onResult, onClose, open }: QrScannerProps) {
|
||||
const [state, setState] = useState<ScannerState>("idle")
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [mappedPoints, setMappedPoints] = useState<Point[] | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const stateRef = useRef(state)
|
||||
stateRef.current = state
|
||||
const startRef = useRef<(() => Promise<void>) | null>(null)
|
||||
|
||||
const handleScan = useCallback(
|
||||
(data: string) => {
|
||||
if (stateRef.current !== "scanning") return
|
||||
|
||||
setState("detected")
|
||||
navigator.vibrate?.(50)
|
||||
|
||||
// Brief detected state then validate
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await onResult(data)
|
||||
setState("success")
|
||||
navigator.vibrate?.(50)
|
||||
// Auto-close after success
|
||||
setTimeout(() => onClose?.(), 800)
|
||||
} catch (e) {
|
||||
setErrorMessage((e as Error).message || "Invalid code")
|
||||
setState("error")
|
||||
navigator.vibrate?.([30, 50, 30])
|
||||
// Auto-retry after error
|
||||
setTimeout(() => {
|
||||
setErrorMessage(null)
|
||||
setState("scanning")
|
||||
startRef.current?.()
|
||||
}, 1500)
|
||||
}
|
||||
}, 200)
|
||||
},
|
||||
[onResult, onClose],
|
||||
)
|
||||
|
||||
const {
|
||||
videoRef,
|
||||
hasCamera,
|
||||
cornerPoints,
|
||||
hasFlash,
|
||||
toggleFlash,
|
||||
start: scannerStart,
|
||||
stop: scannerStop,
|
||||
pause: scannerPause,
|
||||
} = useQrScanner({
|
||||
onScan: handleScan,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
startRef.current = scannerStart
|
||||
|
||||
// Map corner points from video coordinates to container coordinates
|
||||
useEffect(() => {
|
||||
if (!cornerPoints || !containerRef.current || !videoRef.current) {
|
||||
setMappedPoints(null)
|
||||
return
|
||||
}
|
||||
|
||||
const video = videoRef.current
|
||||
const container = containerRef.current
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
|
||||
const videoWidth = video.videoWidth || 1
|
||||
const videoHeight = video.videoHeight || 1
|
||||
const scaleX = containerRect.width / videoWidth
|
||||
const scaleY = containerRect.height / videoHeight
|
||||
|
||||
setMappedPoints(
|
||||
cornerPoints.map((p) => ({
|
||||
x: p.x * scaleX,
|
||||
y: p.y * scaleY,
|
||||
})),
|
||||
)
|
||||
}, [cornerPoints, videoRef])
|
||||
|
||||
// Pause video on detected state
|
||||
useEffect(() => {
|
||||
if (state === "detected" || state === "success") {
|
||||
scannerPause()
|
||||
}
|
||||
}, [state, scannerPause])
|
||||
|
||||
// Reset state when `open` toggles
|
||||
useEffect(() => {
|
||||
if (open === false) {
|
||||
scannerStop()
|
||||
setState("idle")
|
||||
setErrorMessage(null)
|
||||
setMappedPoints(null)
|
||||
}
|
||||
}, [open, scannerStop])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => scannerStop()
|
||||
}, [scannerStop])
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
setState("requesting")
|
||||
|
||||
// Check camera permission (try/catch for Safari which doesn't support camera query)
|
||||
try {
|
||||
const perm = await navigator.permissions?.query({
|
||||
name: "camera" as PermissionName,
|
||||
})
|
||||
if (perm?.state === "denied") {
|
||||
setState("idle")
|
||||
setErrorMessage("Camera access denied. Please enable it in your browser settings.")
|
||||
onClose?.()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Safari doesn't support camera permission query — proceed anyway
|
||||
}
|
||||
|
||||
await scannerStart()
|
||||
setState("scanning")
|
||||
}, [scannerStart, onClose])
|
||||
|
||||
if (!hasCamera) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[280px] rounded-xl bg-muted">
|
||||
<div className="flex items-center justify-center h-[320px] 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>
|
||||
)
|
||||
}
|
||||
// Compute bounding box from corner points for detected/success/error bracket positioning
|
||||
const bracketBounds = mappedPoints
|
||||
? {
|
||||
left: Math.min(...mappedPoints.map((p) => p.x)),
|
||||
top: Math.min(...mappedPoints.map((p) => p.y)),
|
||||
right: Math.max(...mappedPoints.map((p) => p.x)),
|
||||
bottom: Math.max(...mappedPoints.map((p) => p.y)),
|
||||
}
|
||||
: null
|
||||
|
||||
const bracketColor =
|
||||
state === "success"
|
||||
? "border-[color:var(--tool-success)]"
|
||||
: state === "error"
|
||||
? "border-[color:var(--tool-error)]"
|
||||
: state === "detected"
|
||||
? "border-primary"
|
||||
: "border-white/30"
|
||||
|
||||
const bracketAnimation =
|
||||
state === "scanning"
|
||||
? "animate-scan-breathe"
|
||||
: state === "error"
|
||||
? "animate-scan-shake"
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-[280px] mx-auto">
|
||||
{/* Camera feed */}
|
||||
<div className="relative aspect-square rounded-xl overflow-hidden bg-black">
|
||||
<div className="relative w-full max-w-[320px] mx-auto">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative aspect-square rounded-xl overflow-hidden bg-muted"
|
||||
>
|
||||
{/* Camera feed (always rendered for ref stability) */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
className={`absolute inset-0 w-full h-full object-cover ${
|
||||
state === "idle" || state === "requesting" ? "invisible" : ""
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* 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" />
|
||||
{/* Idle state */}
|
||||
{state === "idle" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStart}
|
||||
className="flex items-center justify-center size-16 rounded-full bg-foreground/10 hover:bg-foreground/20 transition-colors"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Camera01Icon}
|
||||
className="size-7 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
<p className="text-xs text-muted-foreground">Tap to scan</p>
|
||||
</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...
|
||||
{/* Requesting state */}
|
||||
{state === "requesting" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
|
||||
<Spinner className="text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Requesting camera...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Corner brackets overlay */}
|
||||
{(state === "scanning" ||
|
||||
state === "detected" ||
|
||||
state === "success" ||
|
||||
state === "error") && (
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{bracketBounds ? (
|
||||
// Position brackets around detected QR code
|
||||
<div
|
||||
className={`absolute ${bracketAnimation}`}
|
||||
style={{
|
||||
left: bracketBounds.left - 8,
|
||||
top: bracketBounds.top - 8,
|
||||
width: bracketBounds.right - bracketBounds.left + 16,
|
||||
height: bracketBounds.bottom - bracketBounds.top + 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`absolute -top-1 -left-1 w-5 h-5 border-t-2 border-l-2 ${bracketColor} rounded-tl-md transition-colors duration-200`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute -top-1 -right-1 w-5 h-5 border-t-2 border-r-2 ${bracketColor} rounded-tr-md transition-colors duration-200`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute -bottom-1 -left-1 w-5 h-5 border-b-2 border-l-2 ${bracketColor} rounded-bl-md transition-colors duration-200`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute -bottom-1 -right-1 w-5 h-5 border-b-2 border-r-2 ${bracketColor} rounded-br-md transition-colors duration-200`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// Default centered brackets
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
className={`relative w-3/4 h-3/4 ${bracketAnimation}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute -top-1 -left-1 w-5 h-5 border-t-2 border-l-2 ${bracketColor} rounded-tl-md transition-colors duration-200`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute -top-1 -right-1 w-5 h-5 border-t-2 border-r-2 ${bracketColor} rounded-tr-md transition-colors duration-200`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute -bottom-1 -left-1 w-5 h-5 border-b-2 border-l-2 ${bracketColor} rounded-bl-md transition-colors duration-200`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute -bottom-1 -right-1 w-5 h-5 border-b-2 border-r-2 ${bracketColor} rounded-br-md transition-colors duration-200`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flash toggle */}
|
||||
{state === "scanning" && hasFlash && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFlash}
|
||||
className="absolute top-3 right-3 flex items-center justify-center size-8 rounded-full bg-black/40 hover:bg-black/60 transition-colors pointer-events-auto"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={FlashlightIcon}
|
||||
className="size-4 text-white"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Success overlay */}
|
||||
{state === "success" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<HugeiconsIcon
|
||||
icon={CheckmarkCircle02Icon}
|
||||
className="size-12 text-[color:var(--tool-success)] animate-in fade-in zoom-in duration-300"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error overlay */}
|
||||
{state === "error" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
|
||||
<HugeiconsIcon
|
||||
icon={Alert02Icon}
|
||||
className="size-10 text-[color:var(--tool-error)]"
|
||||
/>
|
||||
{errorMessage && (
|
||||
<p className="text-xs text-white bg-black/60 px-3 py-1 rounded-full">
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hint text */}
|
||||
<p className="text-xs text-muted-foreground text-center mt-3">
|
||||
Point camera at QR code on desktop
|
||||
</p>
|
||||
{state === "scanning" && (
|
||||
<p className="text-xs text-muted-foreground text-center mt-3">
|
||||
Point at QR code on desktop
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue