From bbb5bd4dea5891efb4e843ce8bd6c237d19afd06 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 1/2] Add barcode scanner and identifier editing to stock detail --- src/features/stock/stock-detail-page.js | 243 ++++++++++++++++++++++++ 1 file changed, 243 insertions(+) diff --git a/src/features/stock/stock-detail-page.js b/src/features/stock/stock-detail-page.js index a577ae7..cf3fd6e 100644 --- a/src/features/stock/stock-detail-page.js +++ b/src/features/stock/stock-detail-page.js @@ -1,8 +1,10 @@ import { adjustStockEntry, getStockEntry, + updateStockItem, 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'; @@ -27,6 +29,10 @@ function parseDateValue(value) { return new Date(year, month - 1, day); } +function normalizeIdentifierCode(value) { + return String(value || '').replace(/\s+/g, '').trim(); +} + function expirationInfo(entry) { if (!entry?.expire_date) { return { @@ -134,6 +140,42 @@ export function renderStockDetailPage() {
+
+

Identifier

+
+ + + +
+
Used for product identifier tracking and metadata lookups.
+ +
+

Nutrition

@@ -289,6 +331,39 @@ export function renderStockDetailPage() {
+ +
+
+
+
+
+

Scan barcode

+

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

+
+ +
+ +
+ +
+ +
+
Starting camera...
+
+ +
+ + +
+
+
`; } @@ -298,18 +373,30 @@ export function stockDetailPageData(store) { state: createAsyncState(), adjustmentState: createAsyncState(), printState: createAsyncState(), + identifierState: createAsyncState(), + scannerReader: null, + scannerControls: null, + scannerState: { + isOpen: false, + isLoading: false, + hasCamera: false, + error: '', + lastDetectedCode: '', + }, printFeedback: { type: '', message: '', }, entry: null, locationPathByUuid: {}, + identifierDraft: '', adjustment: { mode: 'increment', quantity: '1', level: 'plenty', }, async init() { + this.scannerState.hasCamera = this.canUseCameraScanner(); if (!store.isConnected) { return; } @@ -321,6 +408,7 @@ export function stockDetailPageData(store) { fetchLocations(store).catch(() => ({ flat: [] })), ]); this.entry = entry; + this.identifierDraft = normalizeIdentifierCode(entry?.identifier_code); this.locationPathByUuid = Object.fromEntries( (locations.flat || []) .filter((location) => location.uuid_b64) @@ -329,6 +417,159 @@ 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(() => {}); + }, async submitMeasuredAdjustment() { if (!this.entry) { return; @@ -351,6 +592,7 @@ export function stockDetailPageData(store) { this.entry = await adjustStockEntry(store, this.entry.uuid_b64, { quantity: exactQuantity, }); + this.identifierDraft = normalizeIdentifierCode(this.entry?.identifier_code); store.addAlert({ type: 'success', message: 'Stock quantity updated.' }); }).catch(() => {}); }, @@ -371,6 +613,7 @@ export function stockDetailPageData(store) { this.entry = await adjustStockEntry(store, this.entry.uuid_b64, { level: this.adjustment.level, }); + this.identifierDraft = normalizeIdentifierCode(this.entry?.identifier_code); store.addAlert({ type: 'success', message: 'Stock level updated.' }); }).catch(() => {}); }, From 9677e47680380f44bf28af8d78d9da3deaf8f04f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bla=C5=BE=20Bregar?= Date: Sun, 12 Apr 2026 00:24:41 +0200 Subject: [PATCH 2/2] Ignore scanner decode noise and log debug errors in dev --- src/features/labels/label-create-page.js | 20 +++++++++++++++----- src/features/stock/stock-detail-page.js | 20 +++++++++++++++----- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/features/labels/label-create-page.js b/src/features/labels/label-create-page.js index 1d592e4..3a55b3e 100644 --- a/src/features/labels/label-create-page.js +++ b/src/features/labels/label-create-page.js @@ -784,6 +784,9 @@ export function labelCreatePageData(store) { this.stopScanner(); this.scannerState.isLoading = true; + const shouldLogDecodeErrors = import.meta.env.DEV; + let lastDecodeErrorName = ''; + let lastDecodeErrorAt = 0; try { if (!this.scannerReader) { @@ -804,13 +807,20 @@ export function labelCreatePageData(store) { return; } - if (!error || error?.name === 'NotFoundException') { + if (error) { + // Continuous decode emits expected per-frame misses/errors before a valid barcode is found. + // Keep the modal quiet and only surface startup failures from the outer catch block. + if (shouldLogDecodeErrors) { + const errorName = String(error?.name || 'UnknownError'); + const now = Date.now(); + if (errorName !== lastDecodeErrorName || now - lastDecodeErrorAt > 2000) { + console.debug('[scanner] Ignoring frame decode error while scanning:', errorName, error?.message || ''); + lastDecodeErrorName = errorName; + lastDecodeErrorAt = now; + } + } return; } - - if (!this.scannerState.error) { - this.scannerState.error = this.normalizeScannerError(error); - } }, ); } catch (error) { diff --git a/src/features/stock/stock-detail-page.js b/src/features/stock/stock-detail-page.js index cf3fd6e..5a86760 100644 --- a/src/features/stock/stock-detail-page.js +++ b/src/features/stock/stock-detail-page.js @@ -470,6 +470,9 @@ export function stockDetailPageData(store) { this.stopScanner(); this.scannerState.isLoading = true; + const shouldLogDecodeErrors = import.meta.env.DEV; + let lastDecodeErrorName = ''; + let lastDecodeErrorAt = 0; try { if (!this.scannerReader) { @@ -490,13 +493,20 @@ export function stockDetailPageData(store) { return; } - if (!error || error?.name === 'NotFoundException') { + if (error) { + // Continuous decode emits expected per-frame misses/errors before a valid barcode is found. + // Keep the modal quiet and only surface startup failures from the outer catch block. + if (shouldLogDecodeErrors) { + const errorName = String(error?.name || 'UnknownError'); + const now = Date.now(); + if (errorName !== lastDecodeErrorName || now - lastDecodeErrorAt > 2000) { + console.debug('[scanner] Ignoring frame decode error while scanning:', errorName, error?.message || ''); + lastDecodeErrorName = errorName; + lastDecodeErrorAt = now; + } + } return; } - - if (!this.scannerState.error) { - this.scannerState.error = this.normalizeScannerError(error); - } }, ); } catch (error) {