feat(ui): redesign connect prompt with scan/paste tabs and fullscreen QR
Replace sheet-based scanner with inline QrScannerView, add scan/paste mode toggle for desktop, and fullscreen camera overlay for mobile. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
21efbf943a
commit
8e8a0d58d1
2 changed files with 283 additions and 244 deletions
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, 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";
|
||||
|
|
@ -11,29 +11,45 @@ import {
|
|||
} from "@multica/store";
|
||||
import { useIsMobile } from "@multica/ui/hooks/use-mobile";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Camera01Icon } from "@hugeicons/core-free-icons";
|
||||
import { QrScannerSheet } from "@multica/ui/components/qr-scanner-sheet";
|
||||
import { Camera01Icon, TextIcon } from "@hugeicons/core-free-icons";
|
||||
import { QrScannerView } from "@multica/ui/components/qr-scanner-view";
|
||||
|
||||
type Mode = "scan" | "paste";
|
||||
|
||||
export function ConnectPrompt() {
|
||||
const gwState = useConnectionStore((s) => s.connectionState);
|
||||
const [mode, setMode] = useState<Mode>("scan");
|
||||
const [codeInput, setCodeInput] = useState("");
|
||||
const [scanOpen, setScanOpen] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const validatingRef = useRef(false);
|
||||
|
||||
const handleConnect = useCallback(() => {
|
||||
const trimmed = codeInput.trim();
|
||||
if (!trimmed) return;
|
||||
const tryConnect = useCallback((raw: string) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed || validatingRef.current) return;
|
||||
validatingRef.current = true;
|
||||
try {
|
||||
const info = parseConnectionCode(trimmed);
|
||||
saveConnection(info);
|
||||
useConnectionStore.getState().connect(info);
|
||||
setCodeInput("");
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
validatingRef.current = false;
|
||||
}
|
||||
}, [codeInput]);
|
||||
}, []);
|
||||
|
||||
// Promise-based handler for QrScannerView — resolve = success, reject = error
|
||||
// Auto-validate on paste
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent) => {
|
||||
const text = e.clipboardData.getData("text");
|
||||
if (!text.trim()) return;
|
||||
// Let the textarea update visually first, then validate
|
||||
setTimeout(() => tryConnect(text), 50);
|
||||
},
|
||||
[tryConnect],
|
||||
);
|
||||
|
||||
// Promise-based handler for QrScannerView
|
||||
const handleScanResult = useCallback(async (data: string) => {
|
||||
const info = parseConnectionCode(data);
|
||||
saveConnection(info);
|
||||
|
|
@ -42,68 +58,87 @@ export function ConnectPrompt() {
|
|||
|
||||
const isConnecting = gwState === "connecting" || gwState === "connected";
|
||||
|
||||
// Mobile: scanner only, no tabs, no paste
|
||||
if (isMobile) {
|
||||
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-base font-medium">Scan to start</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Scan a QR code to use an Agent
|
||||
</p>
|
||||
{isConnecting && (
|
||||
<p className="text-xs text-muted-foreground/60 animate-pulse">
|
||||
Connecting to Agent...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<QrScannerView onResult={handleScanResult} fullscreen />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: tab toggle (scan / paste), same-size panels
|
||||
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">
|
||||
{isMobile
|
||||
? "Scan or paste a connection code"
|
||||
: "Paste a connection code to start"}
|
||||
<p className="text-base font-medium">
|
||||
{mode === "scan" ? "Scan to start" : "Paste to start"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{mode === "scan"
|
||||
? "Scan a QR code to use an Agent"
|
||||
: "Paste a connection code to use an Agent"}
|
||||
</p>
|
||||
{isConnecting && (
|
||||
<p className="text-xs text-muted-foreground/60 animate-pulse">
|
||||
Connecting...
|
||||
Connecting to Agent...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
{/* Mobile: scan button + sheet */}
|
||||
{isMobile && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setScanOpen(true)}
|
||||
className="w-full text-xs gap-2"
|
||||
>
|
||||
<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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* Mode toggle */}
|
||||
<div className="flex gap-1 bg-muted rounded-lg p-1">
|
||||
<Button
|
||||
variant={mode === "scan" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={handleConnect}
|
||||
disabled={!codeInput.trim() || gwState === "connecting"}
|
||||
className="w-full text-xs"
|
||||
className="text-xs gap-1.5 h-7 px-3"
|
||||
onClick={() => setMode("scan")}
|
||||
>
|
||||
Connect
|
||||
<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>
|
||||
|
||||
{/* Mobile: paste fallback hint */}
|
||||
{isMobile && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
or paste code above instead
|
||||
</p>
|
||||
{/* Content — same max-width for both modes */}
|
||||
<div className="w-full max-w-[320px]">
|
||||
{mode === "scan" ? (
|
||||
<QrScannerView onResult={handleScanResult} />
|
||||
) : (
|
||||
<div className="aspect-square rounded-xl bg-muted flex flex-col items-center justify-center p-6">
|
||||
<Textarea
|
||||
value={codeInput}
|
||||
onChange={(e) => setCodeInput(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
placeholder="Paste connection code here..."
|
||||
className="text-xs font-mono flex-1 resize-none bg-transparent border-0 focus-visible:ring-0 shadow-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
tryConnect(codeInput);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@
|
|||
import "./qr-scanner.css"
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { useQrScanner, type Point } from "@multica/ui/hooks/use-qr-scanner"
|
||||
import { useQrScanner } from "@multica/ui/hooks/use-qr-scanner"
|
||||
import { Spinner } from "@multica/ui/components/spinner"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import {
|
||||
Camera01Icon,
|
||||
Cancel01Icon,
|
||||
CheckmarkCircle02Icon,
|
||||
Alert02Icon,
|
||||
FlashlightIcon,
|
||||
|
|
@ -25,20 +26,25 @@ export interface QrScannerProps {
|
|||
onResult: (data: string) => Promise<void>
|
||||
onClose?: () => void
|
||||
open?: boolean
|
||||
/** When true, scanning state renders as a fullscreen overlay (mobile). */
|
||||
fullscreen?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone QR scanner with full state machine.
|
||||
*
|
||||
* States: idle → requesting → scanning → detected → success → (auto-close)
|
||||
* ↑ ↓
|
||||
* └── error ──┘
|
||||
*/
|
||||
export function QrScannerView({ onResult, onClose, open }: QrScannerProps) {
|
||||
const ACTIVE_STATES: ScannerState[] = [
|
||||
"scanning",
|
||||
"detected",
|
||||
"success",
|
||||
"error",
|
||||
]
|
||||
|
||||
export function QrScannerView({
|
||||
onResult,
|
||||
onClose,
|
||||
open,
|
||||
fullscreen = false,
|
||||
}: 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)
|
||||
|
|
@ -50,34 +56,29 @@ export function QrScannerView({ onResult, onClose, open }: QrScannerProps) {
|
|||
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)
|
||||
}, 3000)
|
||||
}
|
||||
}, 200)
|
||||
},
|
||||
[onResult, onClose],
|
||||
[onResult],
|
||||
)
|
||||
|
||||
const {
|
||||
videoRef,
|
||||
hasCamera,
|
||||
cornerPoints,
|
||||
hasFlash,
|
||||
toggleFlash,
|
||||
start: scannerStart,
|
||||
|
|
@ -90,73 +91,63 @@ export function QrScannerView({ onResult, onClose, open }: QrScannerProps) {
|
|||
|
||||
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")
|
||||
// Double-rAF: wait for video element to mount before starting scanner
|
||||
useEffect(() => {
|
||||
if (state !== "requesting") return
|
||||
const raf = requestAnimationFrame(() => {
|
||||
requestAnimationFrame(async () => {
|
||||
try {
|
||||
await scannerStart()
|
||||
setState("scanning")
|
||||
} catch {
|
||||
setState("idle")
|
||||
}
|
||||
})
|
||||
})
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [state, scannerStart])
|
||||
|
||||
// Check camera permission (try/catch for Safari which doesn't support camera query)
|
||||
const handleStart = useCallback(async () => {
|
||||
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.")
|
||||
setErrorMessage(
|
||||
"Camera access denied. Please enable it in your browser settings.",
|
||||
)
|
||||
onClose?.()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Safari doesn't support camera permission query — proceed anyway
|
||||
// Safari doesn't support camera permission query
|
||||
}
|
||||
setState("requesting")
|
||||
}, [onClose])
|
||||
|
||||
await scannerStart()
|
||||
setState("scanning")
|
||||
}, [scannerStart, onClose])
|
||||
const handleClose = useCallback(() => {
|
||||
scannerStop()
|
||||
setState("idle")
|
||||
setErrorMessage(null)
|
||||
}, [scannerStop])
|
||||
|
||||
if (!hasCamera) {
|
||||
return (
|
||||
|
|
@ -166,15 +157,7 @@ export function QrScannerView({ onResult, onClose, open }: QrScannerProps) {
|
|||
)
|
||||
}
|
||||
|
||||
// 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 isActive = ACTIVE_STATES.includes(state)
|
||||
|
||||
const bracketColor =
|
||||
state === "success"
|
||||
|
|
@ -192,148 +175,169 @@ export function QrScannerView({ onResult, onClose, open }: QrScannerProps) {
|
|||
? "animate-scan-shake"
|
||||
: ""
|
||||
|
||||
return (
|
||||
<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) */}
|
||||
const viewfinder = (
|
||||
<div
|
||||
className={
|
||||
fullscreen && isActive
|
||||
? "relative w-full h-full"
|
||||
: "relative aspect-square rounded-xl overflow-hidden bg-muted"
|
||||
}
|
||||
>
|
||||
{/* Video — only mounted after idle */}
|
||||
{state !== "idle" && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
className={`absolute inset-0 w-full h-full object-cover ${
|
||||
state === "idle" || state === "requesting" ? "invisible" : ""
|
||||
}`}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
{/* Idle */}
|
||||
{state === "idle" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
|
||||
<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"
|
||||
onClick={handleStart}
|
||||
className="flex items-center justify-center size-16 rounded-full bg-foreground/10 hover:bg-foreground/20 transition-colors"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={FlashlightIcon}
|
||||
className="size-4 text-white"
|
||||
icon={Camera01Icon}
|
||||
className="size-7 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">Tap to open camera</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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"
|
||||
{/* Requesting */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Fixed centered brackets — always same position, color changes per state */}
|
||||
{isActive && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div
|
||||
className={`relative w-3/4 h-3/4 max-w-[280px] max-h-[280px] ${bracketAnimation}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 ${bracketColor} rounded-tl-md transition-colors duration-200`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 ${bracketColor} rounded-tr-md transition-colors duration-200`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 ${bracketColor} rounded-bl-md transition-colors duration-200`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 ${bracketColor} rounded-br-md transition-colors duration-200`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</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 */}
|
||||
{/* Center crosshair */}
|
||||
{state === "scanning" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
className="text-white/30"
|
||||
>
|
||||
<line x1="12" y1="4" x2="12" y2="20" stroke="currentColor" strokeWidth="1" />
|
||||
<line x1="4" y1="12" x2="20" y2="12" stroke="currentColor" strokeWidth="1" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close button */}
|
||||
{(state === "scanning" || state === "detected") && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute top-3 left-3 flex items-center justify-center size-8 rounded-full bg-black/40 hover:bg-black/60 transition-colors z-10"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Cancel01Icon}
|
||||
className="size-4 text-white"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={FlashlightIcon}
|
||||
className="size-4 text-white"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Success — full overlay */}
|
||||
{state === "success" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-[color:var(--tool-success)]/15 animate-in fade-in duration-200">
|
||||
<HugeiconsIcon
|
||||
icon={CheckmarkCircle02Icon}
|
||||
className="size-14 text-[color:var(--tool-success)] animate-in zoom-in duration-300"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error — full overlay */}
|
||||
{state === "error" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-[color:var(--tool-error)]/15 animate-in fade-in duration-200">
|
||||
<HugeiconsIcon
|
||||
icon={Alert02Icon}
|
||||
className="size-12 text-[color:var(--tool-error)]"
|
||||
/>
|
||||
{errorMessage && (
|
||||
<p className="text-xs text-white bg-black/60 px-3 py-1.5 rounded-full">
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fullscreen hint */}
|
||||
{state === "scanning" && fullscreen && (
|
||||
<p className="absolute bottom-8 inset-x-0 text-xs text-white/60 text-center">
|
||||
Align QR code within the frame
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (fullscreen && isActive) {
|
||||
return (
|
||||
<>
|
||||
<div className="relative w-full max-w-[320px] mx-auto">
|
||||
<div className="aspect-square rounded-xl bg-muted" />
|
||||
</div>
|
||||
<div className="fixed inset-0 z-50 bg-black">{viewfinder}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-[320px] mx-auto">
|
||||
{viewfinder}
|
||||
{state === "scanning" && !fullscreen && (
|
||||
<p className="text-xs text-muted-foreground text-center mt-3">
|
||||
Point at QR code on desktop
|
||||
Point at a Multica QR code
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue