From b65514bd0f7dba8bb41d93130f299d5b505f7a6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bla=C5=BE=20Bregar?= Date: Sun, 12 Apr 2026 00:18:25 +0200 Subject: [PATCH] Add barcode scanner and identifier editing to stock detail --- src/features/stock/stock-detail-page.js | 236 +++++++++++++++++++++--- 1 file changed, 208 insertions(+), 28 deletions(-) diff --git a/src/features/stock/stock-detail-page.js b/src/features/stock/stock-detail-page.js index 72c7e46..ed0a6c6 100644 --- a/src/features/stock/stock-detail-page.js +++ b/src/features/stock/stock-detail-page.js @@ -5,6 +5,7 @@ import { patchStockItem, useStockItem, } from '../../api/stock.js'; +import { BrowserMultiFormatReader } from '@zxing/browser'; import { formatPrintErrorMessage, printItemLabel } from '../../api/labels.js'; import { fetchLocations } from '../../api/locations.js'; import { getRouteContext } from '../../app/router.js'; @@ -147,6 +148,7 @@ export function renderStockDetailPage() { class="form-control" type="text" x-model="identifierDraft" + @input="identifierState.error = ''" inputmode="numeric" autocomplete="off" placeholder="EAN / UPC / GTIN" @@ -160,8 +162,16 @@ export function renderStockDetailPage() { Save identifier Saving... + -
Used for OpenFoodFacts lookups and product metadata refresh.
+
Used for product identifier tracking and metadata lookups.
@@ -200,7 +210,6 @@ export function renderStockDetailPage() { > -

Nutrition

@@ -356,6 +365,39 @@ export function renderStockDetailPage() {
+ +
+
+
+
+
+

Scan barcode

+

Point your camera at the barcode to fill the identifier field.

+
+ +
+ +
+ +
+ +
+
Starting camera...
+
+ +
+ + +
+
+
`; } @@ -366,6 +408,15 @@ export function stockDetailPageData(store) { adjustmentState: createAsyncState(), printState: createAsyncState(), identifierState: createAsyncState(), + scannerReader: null, + scannerControls: null, + scannerState: { + isOpen: false, + isLoading: false, + hasCamera: false, + error: '', + lastDetectedCode: '', + }, lookupDetailsState: createAsyncState(), printFeedback: { type: '', @@ -384,6 +435,7 @@ export function stockDetailPageData(store) { level: 'plenty', }, async init() { + this.scannerState.hasCamera = this.canUseCameraScanner(); if (!store.isConnected) { return; } @@ -404,6 +456,160 @@ export function stockDetailPageData(store) { this.adjustment.level = this.entry?.level || 'plenty'; }).catch(() => {}); }, + destroy() { + this.stopScanner(); + }, + canUseCameraScanner() { + return Boolean( + typeof navigator !== 'undefined' + && navigator.mediaDevices + && typeof navigator.mediaDevices.getUserMedia === 'function', + ); + }, + normalizeScannerError(error) { + const message = String(error?.message || ''); + const normalized = message.toLowerCase(); + + if (error?.name === 'NotAllowedError' || normalized.includes('permission')) { + return 'Camera access was denied. Allow access to scan, or enter the code manually.'; + } + + if (error?.name === 'NotFoundError' || normalized.includes('requested device not found')) { + return 'No camera was found on this device. Enter the identifier code manually.'; + } + + if (error?.name === 'NotReadableError' || normalized.includes('could not start video source')) { + return 'Camera is busy in another app. Close it there and try scanning again.'; + } + + return 'Could not start barcode scanning. Enter the identifier code manually.'; + }, + async openScanner() { + this.scannerState.error = ''; + this.scannerState.lastDetectedCode = ''; + this.scannerState.isOpen = true; + await this.$nextTick(); + await this.startScanner(); + }, + async startScanner() { + this.scannerState.error = ''; + this.scannerState.lastDetectedCode = ''; + + if (!this.canUseCameraScanner()) { + this.scannerState.hasCamera = false; + this.scannerState.error = 'Camera scanning is not supported in this browser. Enter the identifier code manually.'; + return; + } + + const videoElement = this.$refs.scannerVideo; + if (!videoElement) { + this.scannerState.error = 'Scanner video element is unavailable. Close and reopen scanner.'; + return; + } + + this.stopScanner(); + this.scannerState.isLoading = true; + + try { + if (!this.scannerReader) { + this.scannerReader = new BrowserMultiFormatReader(); + } + + this.scannerControls = await this.scannerReader.decodeFromConstraints( + { + audio: false, + video: { + facingMode: { ideal: 'environment' }, + }, + }, + videoElement, + (result, error) => { + if (result) { + this.onBarcodeDetected(result.getText?.() || ''); + return; + } + + if (!error || error?.name === 'NotFoundException') { + return; + } + + if (!this.scannerState.error) { + this.scannerState.error = this.normalizeScannerError(error); + } + }, + ); + } catch (error) { + this.scannerState.error = this.normalizeScannerError(error); + } finally { + this.scannerState.isLoading = false; + } + }, + stopScanner() { + try { + this.scannerControls?.stop?.(); + } catch { + // Ignore cleanup errors when scanner is already stopped. + } + this.scannerControls = null; + + try { + this.scannerReader?.reset?.(); + } catch { + // Ignore cleanup errors from stale reader state. + } + + const videoElement = this.$refs.scannerVideo; + const stream = videoElement?.srcObject; + if (stream && typeof stream.getTracks === 'function') { + stream.getTracks().forEach((track) => track.stop()); + } + if (videoElement) { + videoElement.srcObject = null; + } + }, + closeScanner() { + this.stopScanner(); + this.scannerState.isOpen = false; + this.scannerState.isLoading = false; + this.scannerState.error = ''; + }, + onBarcodeDetected(rawCode) { + const code = normalizeIdentifierCode(rawCode); + if (!code || !this.scannerState.isOpen) { + return; + } + + this.identifierDraft = code; + this.scannerState.lastDetectedCode = code; + this.closeScanner(); + store.addAlert({ + type: 'success', + message: `Scanned identifier code: ${code}`, + }); + }, + async saveIdentifierCode() { + if (!this.entry?.uuid_b64) { + return; + } + + this.identifierState.error = ''; + await runAsyncState(this.identifierState, async () => { + const identifierCode = normalizeIdentifierCode(this.identifierDraft); + const updated = await updateStockItem(store, this.entry.uuid_b64, { + identifier_code: identifierCode || null, + }); + + this.entry = updated; + this.identifierDraft = normalizeIdentifierCode(updated?.identifier_code || identifierCode); + store.addAlert({ + type: 'success', + message: identifierCode + ? `Identifier code saved for ${this.entry.name}.` + : `Identifier code cleared for ${this.entry.name}.`, + }); + }).catch(() => {}); + }, + normalizedIdentifierDraft() { return normalizeIdentifierCode(this.identifierDraft); }, @@ -471,32 +677,6 @@ export function stockDetailPageData(store) { return parts.join(' '); }, - async saveIdentifierCode() { - if (!this.entry?.uuid_b64) { - return; - } - - this.identifierState.error = ''; - await runAsyncState(this.identifierState, async () => { - const identifierCode = this.normalizedIdentifierDraft(); - const updated = await patchStockItem(store, this.entry.uuid_b64, { - identifier_code: identifierCode || null, - }); - - this.entry = updated; - this.identifierDraft = normalizeIdentifierCode(updated?.identifier_code || identifierCode); - this.offLookupFeedback = { - type: '', - message: '', - }; - store.addAlert({ - type: 'success', - message: identifierCode - ? `Identifier code saved for ${this.entry.name}.` - : `Identifier code cleared for ${this.entry.name}.`, - }); - }).catch(() => {}); - }, async runItemLookup(update) { if (!this.entry?.uuid_b64) { return;