Enhance routing and layout in SvelteKit project by adding new routes for accounts and transactions, updating existing routes, and improving layout parameters. Refactor item handling and integrate barcode scanning functionality for better user experience.
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m49s

This commit is contained in:
eewing
2026-02-18 10:39:29 -06:00
parent 27f297f9ec
commit a82c89674a
27 changed files with 1080 additions and 309 deletions
+185
View File
@@ -0,0 +1,185 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import * as Dialog from "$lib/components/ui/dialog";
import { Search, X } from "@lucide/svelte";
let {
open = $bindable(false),
onResult,
title = "Scan barcode",
}: {
open?: boolean;
onResult?: (value: string) => void;
title?: string;
} = $props();
let scanError = $state<string | null>(null);
let searchInput = $state("");
let videoEl = $state<HTMLVideoElement | undefined>(undefined);
let stream = $state<MediaStream | null>(null);
let scanRAF = $state(0);
function submitSearch() {
const value = searchInput.trim();
if (!value) return;
stopScan();
open = false;
searchInput = "";
onResult?.(value);
}
async function ensureBarcodeDetector(): Promise<boolean> {
if (typeof globalThis === "undefined" || "BarcodeDetector" in globalThis) return true;
try {
const mod = await import("barcode-detector-api-polyfill");
(globalThis as unknown as { BarcodeDetector: typeof BarcodeDetector }).BarcodeDetector =
mod.BarcodeDetector;
return true;
} catch {
return false;
}
}
async function startScan() {
scanError = null;
const hasDetector = await ensureBarcodeDetector();
if (!hasDetector) {
scanError = "Failed to load barcode scanner. Try again or use Chrome/Edge.";
return;
}
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } },
});
} catch (e) {
scanError = e instanceof Error ? e.message : "Could not access camera.";
}
}
function stopScan() {
if (scanRAF) {
cancelAnimationFrame(scanRAF);
scanRAF = 0;
}
if (stream) {
stream.getTracks().forEach((t) => t.stop());
stream = null;
}
if (videoEl?.srcObject) {
videoEl.srcObject = null;
}
scanError = null;
}
$effect(() => {
if (!open) {
stopScan();
searchInput = "";
return;
}
startScan();
return () => stopScan();
});
$effect(() => {
if (!open || !stream || !videoEl) return;
videoEl.srcObject = stream;
videoEl.play().catch(() => {});
return () => {
if (videoEl?.srcObject) videoEl.srcObject = null;
};
});
const SCAN_INTERVAL_MS = 300;
function scanLoop() {
if (!videoEl || !("BarcodeDetector" in globalThis)) return;
const detector = new (globalThis as unknown as { BarcodeDetector: typeof BarcodeDetector }).BarcodeDetector();
let lastScan = 0;
async function detect() {
if (!open || !videoEl) return;
if (videoEl.readyState < 2) {
scanRAF = requestAnimationFrame(detect);
return;
}
const now = Date.now();
if (now - lastScan >= SCAN_INTERVAL_MS) {
lastScan = now;
try {
const barcodes = await detector.detect(videoEl);
if (barcodes.length > 0) {
const value = barcodes[0].rawValue?.trim() ?? "";
stopScan();
open = false;
onResult?.(value);
return;
}
} catch {
// ignore
}
}
scanRAF = requestAnimationFrame(detect);
}
detect();
}
$effect(() => {
if (!open || !stream || !videoEl) return;
scanLoop();
return () => {
if (scanRAF) cancelAnimationFrame(scanRAF);
};
});
</script>
<Dialog.Root bind:open>
<Dialog.Content
class="flex max-h-[100dvh] w-[100vw] max-w-none flex-col gap-4 rounded-none border-0 p-4 sm:max-h-[90dvh] sm:w-auto sm:max-w-md sm:rounded-lg sm:border"
onclose={() => stopScan()}
>
<Dialog.Header class="shrink-0">
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>Scan a barcode or type a code to look up an item.</Dialog.Description>
</Dialog.Header>
<div class="flex min-h-0 flex-1 flex-col gap-4">
<div class="flex shrink-0 gap-2">
<Input
type="text"
placeholder="Type barcode or SKU to search…"
bind:value={searchInput}
onkeydown={(e) => e.key === "Enter" && submitSearch()}
class="min-h-11 flex-1"
/>
<Button type="button" class="min-h-11 shrink-0" onclick={submitSearch} disabled={!searchInput.trim()}>
<Search class="mr-2 size-4" />
Search
</Button>
</div>
<p class="text-muted-foreground shrink-0 text-center text-xs">or use camera below</p>
{#if scanError}
<p class="text-destructive text-sm">{scanError}</p>
{:else if stream}
<video
bind:this={videoEl}
class="border-input max-h-[50vh] w-full flex-1 rounded-lg border object-cover sm:max-h-none"
muted
playsinline
></video>
{:else}
<p class="text-muted-foreground text-sm">Starting camera…</p>
{/if}
<div class="flex shrink-0 gap-2">
<Button
type="button"
variant="outline"
class="min-h-12 flex-1"
onclick={() => { stopScan(); open = false; }}
>
<X class="mr-2 size-4" />
Cancel
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>