Point your camera at a Spoolman QR code to open its spool.
+
+
+
+
+ {#if error}
+
{error}
+ {:else if starting}
+
Starting camera…
+ {/if}
+
+
+
+{/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 @@