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:
Mikkel Georgsen 2026-04-10 06:22:14 +00:00
parent 95a50f4abd
commit 75c91a5941
4 changed files with 270 additions and 3 deletions

View 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
View 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
View 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>
)
}

View file

@ -1,6 +1,17 @@
import { lazy, Suspense } from 'react'
import { createRouter, createRoute, createRootRoute, Outlet } from '@tanstack/react-router'
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
const rootRoute = createRootRoute({
component: () => (
@ -46,9 +57,9 @@ const scanRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/scan',
component: () => (
<div className="min-h-screen bg-canvas flex items-center justify-center">
<p className="text-volt font-display font-black text-2xl">QR Scanner loading</p>
</div>
<Suspense fallback={<Spinner />}>
<ScanPage />
</Suspense>
),
})