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 { 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>
|
||||
),
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue