diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 5ef6a520..1ec5eb93 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -39,3 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +certificates \ No newline at end of file diff --git a/packages/ui/package.json b/packages/ui/package.json index 49f3133c..a1299e4d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -22,6 +22,7 @@ "clsx": "^2.1.1", "linkify-it": "^5.0.0", "next-themes": "^0.4.6", + "qr-scanner": "^1.4.2", "react": "catalog:", "react-dom": "catalog:", "react-markdown": "^10.1.0", diff --git a/packages/ui/src/components/connect-prompt.tsx b/packages/ui/src/components/connect-prompt.tsx index 8d3fbb0b..decfbb81 100644 --- a/packages/ui/src/components/connect-prompt.tsx +++ b/packages/ui/src/components/connect-prompt.tsx @@ -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("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 (
-

Paste a connection code to start

- {(gwState === "connecting" || gwState === "connected") && ( -

Connecting...

+

+ {mode === "scan" + ? "Scan QR code to connect" + : "Paste a connection code to start"} +

+ {isConnecting && ( +

+ Connecting... +

)}
+ + {/* Mode toggle — only show if camera is available */} + {canScan && ( +
+ + +
+ )} + + {/* Content */}
-