feat(03-05): QR scanner page with @zxing/browser and router wiring
- web/src/pages/ScanPage.tsx: camera QR scanner with volt reticle overlay - extractHWID() parses both URL format and bare HW-XXXXX patterns - rear camera preference (back/rear/environment label matching) - debounce via lastScanned state prevents duplicate navigation - graceful camera permission denied error state - web/src/router.tsx: lazy-loads ScanPage with Suspense fallback spinner - web/src/lib/api.ts: typed fetch wrappers (fetchInventory, fetchInventoryItem) - web/src/components/layout/AppShell.tsx: minimal page wrapper (stub for plan 03-03)
This commit is contained in:
parent
95a50f4abd
commit
75c91a5941
4 changed files with 270 additions and 3 deletions
18
web/src/components/layout/AppShell.tsx
Normal file
18
web/src/components/layout/AppShell.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface AppShellProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppShell — full-page layout wrapper with consistent padding and background.
|
||||||
|
// This is a minimal version created by Plan 03-05 for ScanPage use.
|
||||||
|
// Plan 03-03 will extend this with TopBar navigation and grid layout.
|
||||||
|
export function AppShell({ children }: AppShellProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-canvas text-white">
|
||||||
|
<main className="mx-auto max-w-2xl px-4 py-8">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
web/src/lib/api.ts
Normal file
33
web/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// API client — typed fetch wrappers for the HWLab Go backend
|
||||||
|
// This file is created by Plan 03-05 as a minimal stub for ScanPage.
|
||||||
|
// Plan 03-03 will extend this with full dashboard/item-detail support.
|
||||||
|
|
||||||
|
export interface InventoryItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
asset_tag: string | null
|
||||||
|
hw_id: string | null
|
||||||
|
catalog_status: string | null
|
||||||
|
product_url: string | null
|
||||||
|
firmware_version: string | null
|
||||||
|
test_date: string | null
|
||||||
|
test_data: string | null
|
||||||
|
ai_notes: string | null
|
||||||
|
photo_urls: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInventory(): Promise<InventoryItem[]> {
|
||||||
|
const res = await fetch('/api/inventory')
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`GET /api/inventory failed: ${res.status}`)
|
||||||
|
}
|
||||||
|
return res.json() as Promise<InventoryItem[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInventoryItem(id: number): Promise<InventoryItem> {
|
||||||
|
const res = await fetch(`/api/inventory/${id}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`GET /api/inventory/${id} failed: ${res.status}`)
|
||||||
|
}
|
||||||
|
return res.json() as Promise<InventoryItem>
|
||||||
|
}
|
||||||
205
web/src/pages/ScanPage.tsx
Normal file
205
web/src/pages/ScanPage.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
import { BrowserQRCodeReader, type IScannerControls } from '@zxing/browser'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { AppShell } from '@/components/layout/AppShell'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Camera, Loader2, QrCode, AlertCircle } from 'lucide-react'
|
||||||
|
import { fetchInventory } from '@/lib/api'
|
||||||
|
import { useUIStore } from '@/store/ui'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
function extractHWID(text: string): string | null {
|
||||||
|
// Match full URL: http://mac-mini.mg:8080/hw/HW-00001
|
||||||
|
const urlMatch = text.match(/\/hw\/(HW-\d{5,})/i)
|
||||||
|
if (urlMatch) return urlMatch[1].toUpperCase()
|
||||||
|
// Match bare HW ID: HW-00001
|
||||||
|
const bareMatch = text.match(/^(HW-\d{5,})$/i)
|
||||||
|
if (bareMatch) return bareMatch[1].toUpperCase()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScanState = 'idle' | 'requesting' | 'scanning' | 'resolving' | 'error'
|
||||||
|
|
||||||
|
export function ScanPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const controlsRef = useRef<IScannerControls | null>(null)
|
||||||
|
const readerRef = useRef<BrowserQRCodeReader | null>(null)
|
||||||
|
const { setScannerActive } = useUIStore()
|
||||||
|
|
||||||
|
const [scanState, setScanState] = useState<ScanState>('idle')
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||||
|
const [lastScanned, setLastScanned] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const stopScanner = useCallback(() => {
|
||||||
|
controlsRef.current?.stop()
|
||||||
|
controlsRef.current = null
|
||||||
|
readerRef.current = null
|
||||||
|
setScannerActive(false)
|
||||||
|
setScanState('idle')
|
||||||
|
}, [setScannerActive])
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => () => {
|
||||||
|
controlsRef.current?.stop()
|
||||||
|
controlsRef.current = null
|
||||||
|
readerRef.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startScanner = useCallback(async () => {
|
||||||
|
setScanState('requesting')
|
||||||
|
setErrorMsg(null)
|
||||||
|
setLastScanned(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reader = new BrowserQRCodeReader()
|
||||||
|
readerRef.current = reader
|
||||||
|
|
||||||
|
const devices = await BrowserQRCodeReader.listVideoInputDevices()
|
||||||
|
if (devices.length === 0) {
|
||||||
|
throw new Error('No camera found on this device')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer rear camera (contains "back", "rear", or "environment" in label)
|
||||||
|
const rear = devices.find((d) =>
|
||||||
|
/back|rear|environment/i.test(d.label)
|
||||||
|
)
|
||||||
|
const deviceId = rear?.deviceId ?? devices[devices.length - 1].deviceId
|
||||||
|
|
||||||
|
setScanState('scanning')
|
||||||
|
setScannerActive(true)
|
||||||
|
|
||||||
|
const controls = await reader.decodeFromVideoDevice(
|
||||||
|
deviceId,
|
||||||
|
videoRef.current!,
|
||||||
|
async (result, _err) => {
|
||||||
|
if (!result) return
|
||||||
|
const text = result.getText()
|
||||||
|
const hwid = extractHWID(text)
|
||||||
|
|
||||||
|
if (!hwid) {
|
||||||
|
// Not an HWLab QR code — ignore silently
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce: skip if the same code was scanned recently
|
||||||
|
if (hwid === lastScanned) return
|
||||||
|
setLastScanned(hwid)
|
||||||
|
|
||||||
|
controls.stop()
|
||||||
|
setScanState('resolving')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = await fetchInventory()
|
||||||
|
const item = items.find(
|
||||||
|
(i) =>
|
||||||
|
i.hw_id?.toUpperCase() === hwid ||
|
||||||
|
i.asset_tag?.toUpperCase() === hwid
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
toast.error(`${hwid} not found in inventory`)
|
||||||
|
setScanState('scanning')
|
||||||
|
// Restart scanner for next scan attempt
|
||||||
|
void startScanner()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
controlsRef.current?.stop()
|
||||||
|
controlsRef.current = null
|
||||||
|
setScannerActive(false)
|
||||||
|
void navigate({ to: '/item/$id', params: { id: String(item.id) } })
|
||||||
|
} catch (_e) {
|
||||||
|
toast.error('Failed to look up item')
|
||||||
|
setScanState('scanning')
|
||||||
|
void startScanner()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
controlsRef.current = controls
|
||||||
|
} catch (e) {
|
||||||
|
setErrorMsg((e as Error).message)
|
||||||
|
setScanState('error')
|
||||||
|
setScannerActive(false)
|
||||||
|
}
|
||||||
|
}, [lastScanned, navigate, setScannerActive])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="font-display font-black text-3xl text-white mb-1">Scan QR Code</h1>
|
||||||
|
<p className="text-sm text-[#a0a0a0]">Point camera at an HWLab label to open the item</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-sm mx-auto space-y-4">
|
||||||
|
{/* Camera viewfinder */}
|
||||||
|
<div className="relative aspect-square bg-near-black rounded-xl overflow-hidden border border-charcoal/80">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Scan reticle overlay */}
|
||||||
|
{scanState === 'scanning' && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="relative w-48 h-48">
|
||||||
|
<div className="absolute inset-0 border-2 border-volt/30 rounded-sm" />
|
||||||
|
{/* Corner brackets */}
|
||||||
|
<div className="absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 border-volt" />
|
||||||
|
<div className="absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-volt" />
|
||||||
|
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 border-volt" />
|
||||||
|
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 border-volt" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Idle state overlay */}
|
||||||
|
{scanState === 'idle' && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-[#a0a0a0]">
|
||||||
|
<QrCode className="w-16 h-16" />
|
||||||
|
<p className="text-sm">Camera not started</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading overlays */}
|
||||||
|
{(scanState === 'requesting' || scanState === 'resolving') && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-canvas/80">
|
||||||
|
<Loader2 className="w-10 h-10 animate-spin text-volt" />
|
||||||
|
<p className="text-sm text-white">
|
||||||
|
{scanState === 'requesting' ? 'Starting camera…' : 'Looking up item…'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error overlay */}
|
||||||
|
{scanState === 'error' && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
<AlertCircle className="w-10 h-10 text-red-400" />
|
||||||
|
<p className="text-sm text-red-400">{errorMsg}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
{(scanState === 'idle' || scanState === 'error') && (
|
||||||
|
<Button variant="forest" className="w-full" onClick={() => void startScanner()}>
|
||||||
|
<Camera className="w-4 h-4 mr-2" />
|
||||||
|
{scanState === 'error' ? 'Try Again' : 'Start Camera'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{scanState === 'scanning' && (
|
||||||
|
<Button variant="secondary" className="w-full" onClick={stopScanner}>
|
||||||
|
Stop Scanner
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-center text-[#585858]">
|
||||||
|
Scans QR codes printed on HWLab labels (HW-XXXXX format)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
|
import { lazy, Suspense } from 'react'
|
||||||
import { createRouter, createRoute, createRootRoute, Outlet } from '@tanstack/react-router'
|
import { createRouter, createRoute, createRootRoute, Outlet } from '@tanstack/react-router'
|
||||||
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
|
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
|
||||||
|
|
||||||
|
const ScanPage = lazy(() => import('./pages/ScanPage').then((m) => ({ default: m.ScanPage })))
|
||||||
|
|
||||||
|
function Spinner() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
||||||
|
<div className="w-8 h-8 border-2 border-volt border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Root layout — wraps all routes with the app shell
|
// Root layout — wraps all routes with the app shell
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
component: () => (
|
component: () => (
|
||||||
|
|
@ -46,9 +57,9 @@ const scanRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/scan',
|
path: '/scan',
|
||||||
component: () => (
|
component: () => (
|
||||||
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
<Suspense fallback={<Spinner />}>
|
||||||
<p className="text-volt font-display font-black text-2xl">QR Scanner loading…</p>
|
<ScanPage />
|
||||||
</div>
|
</Suspense>
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue