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:
Naiyuan Qing 2026-02-05 09:23:46 +08:00
parent 21efbf943a
commit 8e8a0d58d1
2 changed files with 283 additions and 244 deletions

View file

@ -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>

View file

@ -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>