From cf82d5b68fb8629df9bd40933005158be0fa9855 Mon Sep 17 00:00:00 2001 From: Donkie Date: Mon, 13 Jul 2026 11:04:20 +0200 Subject: [PATCH] Add camera QR scanner to v2 client Restores the old client's built-in scanner: a TopBar button opens a camera modal (qr-scanner) that reads Spoolman label codes and opens the matching spool in the Library. Parses both the WEB+SPOOLMAN:S- URI and the /spool/show/ URL forms. Camera-start errors are reported specifically: an insecure (non-HTTPS) context is detected up front via isSecureContext, and other failures are re-probed via getUserMedia to recover the real error (permission blocked, no camera, camera in use) since qr-scanner collapses them all into a generic string. --- client_v2/package-lock.json | 17 +- client_v2/package.json | 1 + .../src/lib/components/QrScannerModal.svelte | 234 ++++++++++++++++++ client_v2/src/lib/components/TopBar.svelte | 39 ++- client_v2/src/lib/stores/ui.svelte.ts | 10 + client_v2/src/lib/utils/spoolCode.ts | 19 ++ client_v2/src/routes/+layout.svelte | 5 +- 7 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 client_v2/src/lib/components/QrScannerModal.svelte create mode 100644 client_v2/src/lib/utils/spoolCode.ts diff --git a/client_v2/package-lock.json b/client_v2/package-lock.json index 553b5681..58b4d95b 100644 --- a/client_v2/package-lock.json +++ b/client_v2/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "konva": "^10.3.0", + "qr-scanner": "^1.4.2", "qrcode": "^1.5.4", "svelte-konva": "^1.0.1" }, @@ -957,11 +958,16 @@ "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~8.3.0" } }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, "node_modules/@types/qrcode": { "version": "1.5.6", "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", @@ -1518,6 +1524,15 @@ "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, + "node_modules/qr-scanner": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/qr-scanner/-/qr-scanner-1.4.2.tgz", + "integrity": "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==", + "license": "MIT", + "dependencies": { + "@types/offscreencanvas": "^2019.6.4" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", diff --git a/client_v2/package.json b/client_v2/package.json index cf45acda..8625ad8d 100644 --- a/client_v2/package.json +++ b/client_v2/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "konva": "^10.3.0", + "qr-scanner": "^1.4.2", "qrcode": "^1.5.4", "svelte-konva": "^1.0.1" } diff --git a/client_v2/src/lib/components/QrScannerModal.svelte b/client_v2/src/lib/components/QrScannerModal.svelte new file mode 100644 index 00000000..7aa3afda --- /dev/null +++ b/client_v2/src/lib/components/QrScannerModal.svelte @@ -0,0 +1,234 @@ + + +{#if open} +
e.key === 'Escape' && close()} + > + +
+{/if} + + diff --git a/client_v2/src/lib/components/TopBar.svelte b/client_v2/src/lib/components/TopBar.svelte index caa99155..7dc67c49 100644 --- a/client_v2/src/lib/components/TopBar.svelte +++ b/client_v2/src/lib/components/TopBar.svelte @@ -8,9 +8,10 @@ interface Props { onadd?: () => void; + onscan?: () => void; } - let { onadd }: Props = $props(); + let { onadd, onscan }: Props = $props(); // The search box lives in the layout (above the routed page), so it reads the // query straight from the URL and writes it back through the params helper. @@ -22,6 +23,25 @@
+
setQuery(v)} />
@@ -62,6 +82,23 @@ .spacer { flex: 1; } + .scan-btn { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: var(--radius-md); + border: 1px solid var(--border-strong); + background: none; + color: var(--text-2); + cursor: pointer; + } + .scan-btn:hover { + color: var(--text); + border-color: var(--accent); + } .mobile-search, .mobile-nav { diff --git a/client_v2/src/lib/stores/ui.svelte.ts b/client_v2/src/lib/stores/ui.svelte.ts index 46c52b0f..4196eb90 100644 --- a/client_v2/src/lib/stores/ui.svelte.ts +++ b/client_v2/src/lib/stores/ui.svelte.ts @@ -9,6 +9,9 @@ class UiState { addModalOpen = $state(false); addModalFilamentId = $state(null); + /** QR-code scanner modal (camera). */ + scannerOpen = $state(false); + /** Open the Add-spools modal, optionally pre-seeded with a filament. */ openAddModal(filamentId?: string) { this.addModalFilamentId = filamentId ?? null; @@ -18,6 +21,13 @@ class UiState { this.addModalOpen = false; this.addModalFilamentId = null; } + + openScanner() { + this.scannerOpen = true; + } + closeScanner() { + this.scannerOpen = false; + } } export const ui = new UiState(); diff --git a/client_v2/src/lib/utils/spoolCode.ts b/client_v2/src/lib/utils/spoolCode.ts new file mode 100644 index 00000000..a091c530 --- /dev/null +++ b/client_v2/src/lib/utils/spoolCode.ts @@ -0,0 +1,19 @@ +// Parse the payload of a scanned Spoolman label back into a spool id. Mirrors +// the two forms produced by the QR generator (lib/labels/qr.ts) and understood +// by the old client's scanner (client/src/components/qrCodeScanner.tsx): +// scheme → WEB+SPOOLMAN:S- (compact custom URI) +// url → /spool/show/ (opens in a browser) +// Anything that isn't a Spoolman code returns null, so unrelated codes in view +// are simply ignored. + +const SCHEME_RE = /^web\+spoolman:s-(?[0-9]+)$/i; +const URL_RE = /^https?:\/\/[^/]+(?:\/[^/]+)*\/spool\/show\/(?[0-9]+)\/?$/i; + +/** Extract the spool id from a scanned code, or null if it isn't a Spoolman code. */ +export function parseSpoolCode(raw: string): number | null { + const text = raw.trim(); + const match = SCHEME_RE.exec(text) ?? URL_RE.exec(text); + if (!match?.groups) return null; + const id = Number(match.groups.id); + return Number.isSafeInteger(id) ? id : null; +} diff --git a/client_v2/src/routes/+layout.svelte b/client_v2/src/routes/+layout.svelte index 54eff279..d4b28bc6 100644 --- a/client_v2/src/routes/+layout.svelte +++ b/client_v2/src/routes/+layout.svelte @@ -3,6 +3,7 @@ import TopBar from '$components/TopBar.svelte'; import Footer from '$components/Footer.svelte'; import AddSpoolModal from '$components/AddSpoolModal.svelte'; + import QrScannerModal from '$components/QrScannerModal.svelte'; import { ui } from '$lib/stores/ui.svelte'; import { settings } from '$lib/stores/settings.svelte'; import { startLiveSync } from '$lib/api/liveSync'; @@ -19,7 +20,7 @@
- ui.openAddModal()} /> + ui.openAddModal()} onscan={() => ui.openScanner()} />
{@render children()}
@@ -32,6 +33,8 @@ onclose={() => ui.closeAddModal()} /> + ui.closeScanner()} /> +