ui: simplify scanner workflow and host status
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
Admin 2026-09-23 09:50:27 +00:00
parent 41bc1a0ea7
commit 4a768555dc
7 changed files with 190 additions and 45 deletions

View File

@ -34,9 +34,10 @@ export function HostStatus() {
const pending = useLiveQuery(() => database.outbox.filter((event: OutboxRecord) => !event.syncedAt).count(), []) ?? 0 const pending = useLiveQuery(() => database.outbox.filter((event: OutboxRecord) => !event.syncedAt).count(), []) ?? 0
const errors = useLiveQuery(() => database.outbox.filter((event: OutboxRecord) => !event.syncedAt && Boolean(event.lastError)).count(), []) ?? 0 const errors = useLiveQuery(() => database.outbox.filter((event: OutboxRecord) => !event.syncedAt && Boolean(event.lastError)).count(), []) ?? 0
const [authenticated, setAuthenticated] = useState(Boolean(keycloak?.authenticated)) const [authenticated, setAuthenticated] = useState(Boolean(keycloak?.authenticated))
const [station, setStation] = useState('Local / unassigned') const [station, setStation] = useState('This device')
const [access, setAccess] = useState<AccessState | null>(null) const [access, setAccess] = useState<AccessState | null>(null)
const [requests, setRequests] = useState<AccessRequest[]>([]) const [requests, setRequests] = useState<AccessRequest[]>([])
const [expanded, setExpanded] = useState(false)
const [showRequests, setShowRequests] = useState(false) const [showRequests, setShowRequests] = useState(false)
async function refreshAccess() { async function refreshAccess() {
@ -85,34 +86,62 @@ export function HostStatus() {
if (next) await refreshRequests() if (next) await refreshRequests()
} }
const summary = !authenticated
? 'Sign in to sync'
: !access
? 'Checking access'
: access.status !== 'approved'
? access.status === 'pending'
? 'Access pending'
: access.status === 'rejected'
? 'Access rejected'
: 'Access required'
: errors
? `${errors} retrying`
: pending
? `${pending} waiting to sync`
: 'Synced'
const tone = !authenticated || access?.status !== 'approved'
? 'muted'
: errors
? 'error'
: pending
? 'pending'
: 'synced'
return ( return (
<> <>
<aside className="host-status" aria-label="Arrowstack synchronization status"> <aside className={`host-status ${expanded ? 'expanded' : ''}`} aria-label="Arrowstack synchronization status">
<span> <button className="host-status-trigger" type="button" aria-expanded={expanded} onClick={() => setExpanded((value) => !value)}>
<strong>{station}</strong> <i className={`sync-dot ${tone}`} aria-hidden="true" />
<small> <span className="host-status-copy">
{authenticated && access?.status !== 'approved' <strong>{station}</strong>
? access?.status === 'pending' ? 'Access pending approval' : access?.status === 'rejected' ? 'Access request rejected' : 'No FieldLens access' <small>{summary}</small>
: `${pending} pending${errors ? ` · ${errors} retrying` : ''}`} </span>
</small> <span className="host-status-more" aria-hidden="true">{expanded ? '×' : '•••'}</span>
</span> </button>
{!authenticated ? (
<> {expanded && (
<button type="button" onClick={() => void login()}>Sign in</button> <div className="host-status-menu">
<button type="button" className="secondary" onClick={() => void register()}>Create account</button> {authenticated && access?.username && <p className="host-user">{access.username}{access.email ? <small>{access.email}</small> : null}</p>}
</> {!authenticated ? (
) : ( <div className="host-actions">
<> <button type="button" onClick={() => void login()}>Sign in</button>
{(access?.status === 'none' || access?.status === 'rejected') && ( <button type="button" className="secondary" onClick={() => void register()}>Create account</button>
<button type="button" onClick={() => void submitRequest()}>Request access</button> </div>
) : (
<div className="host-actions">
{(access?.status === 'none' || access?.status === 'rejected') && (
<button type="button" onClick={() => void submitRequest()}>Request access</button>
)}
{access?.canApprove && (
<button type="button" className="secondary" onClick={() => void toggleRequests()}>Requests</button>
)}
<button type="button" className="secondary" onClick={() => void logout()}>Sign out</button>
</div>
)} )}
{access?.canApprove && ( </div>
<button type="button" className="secondary" onClick={() => void toggleRequests()}>
Requests
</button>
)}
<button type="button" className="secondary" onClick={() => void logout()}>Sign out</button>
</>
)} )}
</aside> </aside>

View File

@ -1 +1,112 @@
.host-status{position:fixed;z-index:80;top:max(10px,env(safe-area-inset-top));right:max(10px,env(safe-area-inset-right));display:flex;align-items:center;gap:8px;padding:7px 8px 7px 11px;border:1px solid #314039;border-radius:10px;background:#17211fe8;color:#fff;box-shadow:0 5px 20px #0003;backdrop-filter:blur(10px);font:11px/1.2 system-ui,sans-serif}.host-status span{display:grid;gap:2px;min-width:0}.host-status strong{max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.host-status small{color:#aebbb6}.host-status button,.access-panel button{border:0;border-radius:7px;padding:7px 9px;background:#f4d35e;color:#17211f;font-weight:800}.host-status button.secondary,.access-panel button.secondary{background:#314039;color:#fff}.access-panel{position:fixed;z-index:81;top:58px;right:max(10px,env(safe-area-inset-right));width:min(430px,calc(100vw - 20px));max-height:70vh;overflow:auto;padding:12px;border:1px solid #314039;border-radius:12px;background:#17211ff7;color:#fff;box-shadow:0 12px 36px #0006;font:12px/1.35 system-ui,sans-serif}.access-panel header,.access-panel article{display:flex;align-items:center;justify-content:space-between;gap:12px}.access-panel header{padding-bottom:10px;border-bottom:1px solid #314039}.access-panel header button{font-size:18px;line-height:1;padding:4px 8px}.access-panel article{padding:11px 0;border-bottom:1px solid #26332e}.access-panel article:last-child{border-bottom:0}.access-panel article span{display:grid;gap:3px;min-width:0}.access-panel article small{color:#aebbb6;overflow-wrap:anywhere}.access-panel article div{display:flex;gap:6px;flex-shrink:0}.access-panel p{color:#aebbb6}@media(max-width:760px){.host-status{top:max(7px,env(safe-area-inset-top));right:max(7px,env(safe-area-inset-right));left:max(7px,env(safe-area-inset-left));max-width:none;flex-wrap:wrap}.host-status span{margin-right:auto}.host-status strong{max-width:120px}.host-status button{padding:6px 8px;font-size:9px}.access-panel{top:94px;right:7px;width:calc(100vw - 14px)}} .host-status {
position: fixed;
z-index: 80;
top: calc(78px + env(safe-area-inset-top));
right: max(18px, env(safe-area-inset-right));
width: min(238px, calc(100vw - 36px));
font: 11px/1.25 system-ui, sans-serif;
}
.host-status-trigger {
width: 100%;
display: grid;
grid-template-columns: 9px minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
padding: 8px 9px 8px 11px;
border: 1px solid rgba(255,255,255,.16);
border-radius: 8px;
color: white;
background: rgba(23,33,31,.88);
box-shadow: 0 5px 18px rgba(0,0,0,.18);
backdrop-filter: blur(12px);
text-align: left;
}
.sync-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #87938e;
box-shadow: 0 0 0 3px rgba(135,147,142,.12);
}
.sync-dot.synced { background: #66cf9d; box-shadow: 0 0 0 3px rgba(102,207,157,.14); }
.sync-dot.pending { background: #f4d35e; box-shadow: 0 0 0 3px rgba(244,211,94,.14); }
.sync-dot.error { background: #ed736a; box-shadow: 0 0 0 3px rgba(237,115,106,.14); }
.host-status-copy { min-width: 0; display: grid; gap: 1px; }
.host-status-copy strong,
.host-status-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.host-status-copy strong { font-size: 11px; }
.host-status-copy small { color: #aebbb6; font-size: 9px; }
.host-status-more { color: #aebbb6; font-size: 12px; letter-spacing: 1px; }
.host-status-menu {
margin-top: 6px;
padding: 9px;
border: 1px solid #314039;
border-radius: 8px;
color: white;
background: rgba(23,33,31,.97);
box-shadow: 0 10px 30px rgba(0,0,0,.28);
backdrop-filter: blur(14px);
}
.host-user { margin: 0 0 8px; padding: 0 2px 8px; border-bottom: 1px solid #314039; font-weight: 800; }
.host-user small { display: block; margin-top: 2px; color: #aebbb6; font-size: 9px; font-weight: 500; overflow-wrap: anywhere; }
.host-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.host-actions button,
.access-panel button {
min-height: 31px;
border: 0;
border-radius: 6px;
padding: 0 9px;
background: #f4d35e;
color: #17211f;
font-size: 9px;
font-weight: 900;
}
.host-actions button.secondary,
.access-panel button.secondary { background: #314039; color: white; }
.access-panel {
position: fixed;
z-index: 81;
top: calc(170px + env(safe-area-inset-top));
right: max(18px, env(safe-area-inset-right));
width: min(430px, calc(100vw - 36px));
max-height: 65vh;
overflow: auto;
padding: 12px;
border: 1px solid #314039;
border-radius: 10px;
background: rgba(23,33,31,.98);
color: white;
box-shadow: 0 12px 36px rgba(0,0,0,.38);
font: 12px/1.35 system-ui, sans-serif;
}
.access-panel header,
.access-panel article { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.access-panel header { padding-bottom: 10px; border-bottom: 1px solid #314039; }
.access-panel header button { min-width: 30px; font-size: 18px; line-height: 1; }
.access-panel article { padding: 11px 0; border-bottom: 1px solid #26332e; }
.access-panel article:last-child { border-bottom: 0; }
.access-panel article span { display: grid; gap: 3px; min-width: 0; }
.access-panel article small { color: #aebbb6; overflow-wrap: anywhere; }
.access-panel article div { display: flex; gap: 6px; flex-shrink: 0; }
.access-panel p { color: #aebbb6; }
@media (max-width: 760px) {
.host-status {
top: calc(86px + env(safe-area-inset-top));
right: max(12px, env(safe-area-inset-right));
width: min(218px, calc(100vw - 24px));
}
.host-status-trigger { padding: 7px 8px 7px 10px; }
.host-status-menu { padding: 8px; }
.access-panel {
top: calc(184px + env(safe-area-inset-top));
right: max(12px, env(safe-area-inset-right));
width: calc(100vw - 24px);
}
}

View File

@ -42,7 +42,7 @@ main { min-width: 0; min-height: 100vh; min-height: 100dvh; background: var(--pa
.scanner-detecting .local-badge span { animation: status-pulse 1.4s ease-out infinite; } .scanner-detecting .local-badge span { animation: status-pulse 1.4s ease-out infinite; }
.scanner-duplicate .local-badge span, .scanner-permission-denied .local-badge span, .scanner-camera-error .local-badge span { background: #ed736a; box-shadow: 0 0 0 3px rgba(237,115,106,.16); } .scanner-duplicate .local-badge span, .scanner-permission-denied .local-badge span, .scanner-camera-error .local-badge span { background: #ed736a; box-shadow: 0 0 0 3px rgba(237,115,106,.16); }
.install-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 31px; padding: 0 10px; border: 1px solid var(--yellow); border-radius: 4px; color: var(--ink); background: var(--yellow); font-size: 10px; font-weight: 900; } .install-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 31px; padding: 0 10px; border: 1px solid var(--yellow); border-radius: 4px; color: var(--ink); background: var(--yellow); font-size: 10px; font-weight: 900; }
.camera-tools { position: absolute; z-index: 3; top: calc(112px + env(safe-area-inset-top)); right: max(40px, env(safe-area-inset-right)); display: flex; align-items: center; gap: 7px; } .camera-tools { position: absolute; z-index: 3; top: calc(142px + env(safe-area-inset-top)); right: max(40px, env(safe-area-inset-right)); display: flex; align-items: center; gap: 7px; }
.camera-select { display: flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 8px; border: 1px solid rgba(255,255,255,.24); border-radius: 4px; color: white; background: rgba(23,33,31,.72); } .camera-select { display: flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 8px; border: 1px solid rgba(255,255,255,.24); border-radius: 4px; color: white; background: rgba(23,33,31,.72); }
.camera-select select { width: min(26vw, 180px); border: 0; outline: 0; color: white; background: transparent; font-size: 10px; font-weight: 800; } .camera-select select { width: min(26vw, 180px); border: 0; outline: 0; color: white; background: transparent; font-size: 10px; font-weight: 800; }
.camera-select option { color: var(--ink); background: white; } .camera-select option { color: var(--ink); background: white; }
@ -180,7 +180,7 @@ button:focus-visible, select:focus-visible { outline: 3px solid white; outline-o
@media (max-width: 760px) { @media (max-width: 760px) {
.app-shell { display: block; padding-bottom: calc(70px + env(safe-area-inset-bottom)); } .app-shell { display: block; padding-bottom: calc(70px + env(safe-area-inset-bottom)); }
.sidebar { display: none; } .sidebar { display: none; }
.mobile-nav { position: fixed; z-index: 20; left: 0; right: 0; bottom: 0; display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); height: calc(70px + env(safe-area-inset-bottom)); padding-bottom: env(safe-area-inset-bottom); border-top: 1px solid #34413d; background: var(--ink); } .mobile-nav { position: fixed; z-index: 20; left: 0; right: 0; bottom: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); height: calc(70px + env(safe-area-inset-bottom)); padding-bottom: env(safe-area-inset-bottom); border-top: 1px solid #34413d; background: var(--ink); }
.mobile-nav button { display: grid; min-width: 0; place-items: center; align-content: center; gap: 4px; padding: 0 2px; border: 0; color: #8f9a96; background: transparent; font-size: 8px; font-weight: 800; } .mobile-nav button { display: grid; min-width: 0; place-items: center; align-content: center; gap: 4px; padding: 0 2px; border: 0; color: #8f9a96; background: transparent; font-size: 8px; font-weight: 800; }
.mobile-nav button span { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .mobile-nav button span { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mobile-nav button.active { color: var(--yellow); } .mobile-nav button.active { color: var(--yellow); }
@ -189,7 +189,7 @@ button:focus-visible, select:focus-visible { outline: 3px solid white; outline-o
.camera-heading { padding: calc(24px + env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) 24px max(20px, env(safe-area-inset-left)); } .camera-heading { padding: calc(24px + env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) 24px max(20px, env(safe-area-inset-left)); }
.camera-heading h1 { font-size: 29px; } .camera-heading h1 { font-size: 29px; }
.camera-actions { flex-direction: column; align-items: flex-end; } .camera-actions { flex-direction: column; align-items: flex-end; }
.camera-tools { top: calc(126px + env(safe-area-inset-top)); left: max(20px, env(safe-area-inset-left)); right: max(20px, env(safe-area-inset-right)); flex-wrap: wrap; justify-content: flex-end; } .camera-tools { top: calc(152px + env(safe-area-inset-top)); left: max(20px, env(safe-area-inset-left)); right: max(20px, env(safe-area-inset-right)); flex-wrap: wrap; justify-content: flex-end; }
.quality-panel { left: max(20px, env(safe-area-inset-left)); right: max(20px, env(safe-area-inset-right)); bottom: calc(86px + env(safe-area-inset-bottom)); width: auto; } .quality-panel { left: max(20px, env(safe-area-inset-left)); right: max(20px, env(safe-area-inset-right)); bottom: calc(86px + env(safe-area-inset-bottom)); width: auto; }
.camera-select select { width: min(34vw, 140px); } .camera-select select { width: min(34vw, 140px); }
.scan-frame { width: 88%; } .scan-frame { width: 88%; }

View File

@ -1,5 +1,5 @@
import { useLiveQuery } from 'dexie-react-hooks' import { useLiveQuery } from 'dexie-react-hooks'
import { ClipboardCheck, CloudDownload, FlaskConical, History, LoaderCircle, ScanLine, Settings, Share, Smartphone, SquarePlus, Wifi, WifiOff, X } from 'lucide-react' import { CloudDownload, FlaskConical, History, LoaderCircle, ScanLine, Settings, Share, Smartphone, SquarePlus, Wifi, WifiOff, X } from 'lucide-react'
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react' import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'
import { useRegisterSW } from 'virtual:pwa-register/react' import { useRegisterSW } from 'virtual:pwa-register/react'
@ -7,13 +7,11 @@ import './App.css'
import { database } from './lib/database' import { database } from './lib/database'
const ScannerView = lazy(() => import('./features/scanner/ScannerView').then((module) => ({ default: module.ScannerView }))) const ScannerView = lazy(() => import('./features/scanner/ScannerView').then((module) => ({ default: module.ScannerView })))
const EanPalletView = lazy(() => import('./features/ean-pallet/EanPalletView').then((module) => ({ default: module.EanPalletView })))
const LabelCheckView = lazy(() => import('./features/label-check/LabelCheckView').then((module) => ({ default: module.LabelCheckView })))
const HistoryView = lazy(() => import('./features/history/HistoryView').then((module) => ({ default: module.HistoryView }))) const HistoryView = lazy(() => import('./features/history/HistoryView').then((module) => ({ default: module.HistoryView })))
const CodeLab = lazy(() => import('./features/codes/CodeLab').then((module) => ({ default: module.CodeLab }))) const CodeLab = lazy(() => import('./features/codes/CodeLab').then((module) => ({ default: module.CodeLab })))
const SettingsView = lazy(() => import('./features/settings/SettingsView').then((module) => ({ default: module.SettingsView }))) const SettingsView = lazy(() => import('./features/settings/SettingsView').then((module) => ({ default: module.SettingsView })))
type View = 'scanner' | 'ean-pallet' | 'label-check' | 'history' | 'lab' | 'settings' type View = 'scanner' | 'history' | 'lab' | 'settings'
type NavigationItem = { id: View; label: string; mobileLabel?: string; icon: typeof ScanLine } type NavigationItem = { id: View; label: string; mobileLabel?: string; icon: typeof ScanLine }
const UPDATE_CHECK_INTERVAL = 60 * 60 * 1000 const UPDATE_CHECK_INTERVAL = 60 * 60 * 1000
@ -25,8 +23,6 @@ interface BeforeInstallPromptEvent extends Event {
const navigation: NavigationItem[] = [ const navigation: NavigationItem[] = [
{ id: 'scanner', label: 'Scanner', icon: ScanLine }, { id: 'scanner', label: 'Scanner', icon: ScanLine },
{ id: 'ean-pallet', label: 'EAN pallet', mobileLabel: 'EAN', icon: ScanLine },
{ id: 'label-check', label: 'Carton labels', mobileLabel: 'OCR', icon: ClipboardCheck },
{ id: 'history', label: 'History', icon: History }, { id: 'history', label: 'History', icon: History },
{ id: 'lab', label: 'Code lab', mobileLabel: 'Lab', icon: FlaskConical }, { id: 'lab', label: 'Code lab', mobileLabel: 'Lab', icon: FlaskConical },
{ id: 'settings', label: 'Settings', icon: Settings }, { id: 'settings', label: 'Settings', icon: Settings },
@ -190,8 +186,6 @@ function App() {
<main> <main>
<Suspense fallback={<div className="loading-view">Loading workspace</div>}> <Suspense fallback={<div className="loading-view">Loading workspace</div>}>
{view === 'scanner' && <ScannerView installAction={installAction} />} {view === 'scanner' && <ScannerView installAction={installAction} />}
{view === 'ean-pallet' && <EanPalletView />}
{view === 'label-check' && <LabelCheckView onOpenSettings={() => setView('settings')} />}
{view === 'history' && <HistoryView />} {view === 'history' && <HistoryView />}
{view === 'lab' && <CodeLab />} {view === 'lab' && <CodeLab />}
{view === 'settings' && ( {view === 'settings' && (

View File

@ -283,7 +283,7 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
const statusLabel = { const statusLabel = {
starting: 'STARTING CAMERA', starting: 'STARTING CAMERA',
detecting: 'AUTO DETECTING', detecting: 'READY TO SCAN',
captured: 'CAPTURED', captured: 'CAPTURED',
duplicate: 'DUPLICATE', duplicate: 'DUPLICATE',
'permission-denied': 'CAMERA BLOCKED', 'permission-denied': 'CAMERA BLOCKED',
@ -344,7 +344,7 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
<div className={`scan-frame ${latest && /QR|Data Matrix|Aztec|MaxiCode/i.test(latest.format) ? 'matrix' : ''}`} aria-hidden="true"><span /><span /><span /><span /></div> <div className={`scan-frame ${latest && /QR|Data Matrix|Aztec|MaxiCode/i.test(latest.format) ? 'matrix' : ''}`} aria-hidden="true"><span /><span /><span /><span /></div>
{detectionPoints.map((point, index) => <i className="detection-point" style={{ left: `${point.left}%`, top: `${point.top}%` }} key={index} />)} {detectionPoints.map((point, index) => <i className="detection-point" style={{ left: `${point.left}%`, top: `${point.top}%` }} key={index} />)}
<p className="camera-instruction" aria-live="polite"> <p className="camera-instruction" aria-live="polite">
{cameraError || (showQualitySettings ? 'Scanning paused while settings are open' : qualityMessage || (status === 'starting' ? 'Preparing the rear camera' : armed ? `Barcode type is detected automatically${confirmationProgress ? ` · ${confirmationProgress}/${confirmationFrames}` : ''}` : autoAdvance ? 'Captured · scanner will rearm automatically' : 'Capture held for review'))} {cameraError || (showQualitySettings ? 'Scanning paused while settings are open' : qualityMessage || (status === 'starting' ? 'Preparing the rear camera' : armed ? `Point the camera at a barcode${confirmationProgress ? ` · confirming ${confirmationProgress}/${confirmationFrames}` : ''}` : autoAdvance ? 'Captured · scanner will rearm automatically' : 'Captured · press Scan next when ready'))}
</p> </p>
</div> </div>
@ -357,7 +357,7 @@ export function ScannerView({ installAction }: { installAction?: ReactNode }) {
{status === 'storage-error' ? 'CAPTURE NOT SAVED' : cameraError ? 'CAMERA UNAVAILABLE' : latest?.isDuplicate ? 'DUPLICATE DETECTED' : latest ? 'CAPTURE SAVED' : 'SCANNER READY'} {status === 'storage-error' ? 'CAPTURE NOT SAVED' : cameraError ? 'CAMERA UNAVAILABLE' : latest?.isDuplicate ? 'DUPLICATE DETECTED' : latest ? 'CAPTURE SAVED' : 'SCANNER READY'}
</span> </span>
<strong>{status === 'storage-error' ? pendingCapture?.rawValue : cameraError ? 'Camera access required' : latest?.rawValue ?? 'Waiting for a code'}</strong> <strong>{status === 'storage-error' ? pendingCapture?.rawValue : cameraError ? 'Camera access required' : latest?.rawValue ?? 'Waiting for a code'}</strong>
<small>{status === 'storage-error' ? 'Local storage failed. Retry saving or discard this capture.' : cameraError ? (status === 'permission-denied' ? 'Allow camera access in browser settings, then try again.' : 'Check that a camera is available, then try again.') : latest ? `${latest.format} · stored on this device` : 'Nothing leaves the device until sync is configured.'}</small> <small>{status === 'storage-error' ? 'Local storage failed. Retry saving or discard this capture.' : cameraError ? (status === 'permission-denied' ? 'Allow camera access in browser settings, then try again.' : 'Check that a camera is available, then try again.') : latest ? `${latest.format} · saved locally first` : 'Captures are saved locally first and sync when signed in.'}</small>
</div> </div>
{status === 'storage-error' && pendingCapture ? ( {status === 'storage-error' && pendingCapture ? (
<div className="result-actions"> <div className="result-actions">

View File

@ -25,6 +25,14 @@ describe('scanner preferences', () => {
expect(loadScannerPreferences()).toEqual(preferences) expect(loadScannerPreferences()).toEqual(preferences)
}) })
it('migrates existing installs to one-at-a-time scanning by default', () => {
localStorage.setItem('fieldlens-scanner-preferences', JSON.stringify({
...defaultScannerPreferences,
autoAdvance: true,
}))
expect(loadScannerPreferences().autoAdvance).toBe(false)
})
it('falls back to defaults for invalid stored data', () => { it('falls back to defaults for invalid stored data', () => {
localStorage.setItem('fieldlens-scanner-preferences', '{invalid') localStorage.setItem('fieldlens-scanner-preferences', '{invalid')
expect(loadScannerPreferences()).toEqual(defaultScannerPreferences) expect(loadScannerPreferences()).toEqual(defaultScannerPreferences)

View File

@ -10,9 +10,12 @@ export interface ScannerPreferences {
} }
const STORAGE_KEY = 'fieldlens-scanner-preferences' const STORAGE_KEY = 'fieldlens-scanner-preferences'
const PREFERENCE_VERSION = 2
type StoredScannerPreferences = Partial<ScannerPreferences> & { preferenceVersion?: number }
export const defaultScannerPreferences: ScannerPreferences = { export const defaultScannerPreferences: ScannerPreferences = {
autoAdvance: true, autoAdvance: false,
soundEnabled: false, soundEnabled: false,
formatMode: 'standard', formatMode: 'standard',
confirmationFrames: 2, confirmationFrames: 2,
@ -22,9 +25,9 @@ export const defaultScannerPreferences: ScannerPreferences = {
export function loadScannerPreferences(): ScannerPreferences { export function loadScannerPreferences(): ScannerPreferences {
try { try {
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial<ScannerPreferences> const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as StoredScannerPreferences
return { return {
autoAdvance: typeof stored.autoAdvance === 'boolean' ? stored.autoAdvance : defaultScannerPreferences.autoAdvance, autoAdvance: stored.preferenceVersion === PREFERENCE_VERSION && typeof stored.autoAdvance === 'boolean' ? stored.autoAdvance : defaultScannerPreferences.autoAdvance,
soundEnabled: typeof stored.soundEnabled === 'boolean' ? stored.soundEnabled : defaultScannerPreferences.soundEnabled, soundEnabled: typeof stored.soundEnabled === 'boolean' ? stored.soundEnabled : defaultScannerPreferences.soundEnabled,
formatMode: stored.formatMode === 'standard' || stored.formatMode === 'products' || stored.formatMode === 'matrix' || stored.formatMode === 'all' ? stored.formatMode : defaultScannerPreferences.formatMode, formatMode: stored.formatMode === 'standard' || stored.formatMode === 'products' || stored.formatMode === 'matrix' || stored.formatMode === 'all' ? stored.formatMode : defaultScannerPreferences.formatMode,
confirmationFrames: stored.confirmationFrames === 3 ? 3 : 2, confirmationFrames: stored.confirmationFrames === 3 ? 3 : 2,
@ -41,7 +44,7 @@ export function loadScannerPreferences(): ScannerPreferences {
} }
export function saveScannerPreferences(preferences: ScannerPreferences) { export function saveScannerPreferences(preferences: ScannerPreferences) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences)) localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...preferences, preferenceVersion: PREFERENCE_VERSION }))
} }
export function resetScannerPreferences() { export function resetScannerPreferences() {