diff --git a/.gitignore b/.gitignore index 5b4a962..b2f284f 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ import-meta.d.ts # dev artifacts mprocs.log .playwright-mcp/ +/*.png + +/certificates diff --git a/README.md b/README.md index 290f960..623c1e2 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,20 @@ Then open and create the first admin account. `pnpm pb:bootstrap` upserts a PocketBase superuser from the env vars — run it once after editing credentials, or whenever you rotate them. +### Phone testing (HTTPS) + +iOS Safari will not hand out camera access over plain HTTP on a LAN IP — `getUserMedia` needs a secure context. For the scan feature: + +```bash +pnpm dev:https # next + pocketbase, Next on HTTPS bound to 127.0.0.1 (loopback) +# or, if you need to reach it from another device on your LAN: +pnpm dev:next:lan # binds Next to 0.0.0.0 — only on a trusted network +``` + +Only use `dev:next:lan` on a network you control: the `/pb/*` rewrite forwards requests to PocketBase at the same origin, so any device that can reach your laptop's IP (and accept the self-signed cert) can hit PB directly. Bind to loopback whenever possible. + +Set `NEXT_PUBLIC_POCKETBASE_URL=/pb` in `.env.local` so the browser hits PocketBase through Next's origin (a rewrite in `next.config.ts` proxies `/pb/*` → the real PB instance — development builds only). Open `https://:3000` on the phone and accept the self-signed cert. `pnpm pb:seed` populates the database with demo assets, locations, and service schedules for the walkthrough. + ## Run with Docker ```bash diff --git a/branch-docs/2026-04-21-mobile-and-scan-design.md b/branch-docs/2026-04-21-mobile-and-scan-design.md new file mode 100644 index 0000000..1c634d3 --- /dev/null +++ b/branch-docs/2026-04-21-mobile-and-scan-design.md @@ -0,0 +1,404 @@ +# Mobile-first shell and `/scan` feature + +**Branch:** `feat/mobile-scan` +**Date:** 2026-04-21 +**Scope:** Make Stowage mobile-friendly and add a camera-driven `/scan` page. + +## Summary + +Stowage is desktop-only today. This work adds: + +1. A responsive shell that swaps the sidebar for a bottom navigation bar on screens below `lg` (1024px). +2. A new `/scan` route that uses the device camera to read Stowage barcodes and opens a quick-actions sheet for the scanned asset. +3. Mobile-friendly layouts for the field flows (assets, asset detail, services, dashboard, locations) and "usable, not polished" treatment for admin flows. +4. A web-app manifest so users can install Stowage to their home screen. + +No service worker, no offline support, no push notifications in this scope. + +## Navigation + +### Desktop (≥1024px) + +Unchanged. Existing `AppShell` renders `AppSidebar` + `Topbar` + `
`. SCAN does not appear in the sidebar — it is mobile-only. + +### Mobile (<1024px) + +Sidebar is hidden. A fixed `BottomNav` spans the bottom of the viewport with five slots: + +``` +Home · Assets · SCAN · Services · More +``` + +- Home → `/dashboard` +- Assets → `/assets` +- SCAN → `/scan` (elevated center button) +- Services → `/services` +- More → opens a bottom sheet with Locations, Taxonomy, Labels, Settings, Sign out, Theme toggle + +The topbar stays but shrinks to logo + search icon + avatar. The existing `SidebarTrigger` is hidden below `lg`. `
` gets `pb-20` below `lg` to clear the nav. + +### SCAN button + +- Icon: `ScanLine` from `lucide-react` (the universal "laser through a frame" icon). +- Shape: 60×60px circle, floats ~18px above the bar on a notched background. +- Colour: a new token `--scan` that mirrors the existing primary orange (`#c2410c` light / `#ea580c` dark) with `--scan-foreground` white. The button still stands out through its elevation, ring, and shadow glow, but the colour stays inside the warm palette used everywhere else. The token is kept (rather than referencing `--primary` directly) so the SCAN identity can diverge later without touching every call-site. +- 4px `ring-background`, `shadow-lg` + `shadow-[--scan]/30` glow, `active:scale-95`, `navigator.vibrate(10)` on tap. +- No label beneath; the icon alone carries it. +- No idle animation on the icon. + +## `/scan` page + +### Route + +`src/app/(app)/scan/page.tsx` → renders `ScanPageClient`. Sits inside the authed app group so the existing `AppLayout` handles access control. + +### Layout + +Immersive, edge-to-edge. Shell (topbar + bottom nav) stays visible — SCAN must be exitable. + +``` +┌─────────────────────────────────────┐ +│ ← Back 🔦 │ +├─────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────┐ │ +│ │ [camera preview] │ │ +│ │ ╭─╮ ╭─╮ │ │ +│ │ │ │ +│ │ ← animated scan line → │ │ +│ │ │ │ +│ │ ╭─╮ ╭─╮ │ │ +│ └─────────────────────────┘ │ +│ │ +│ Point at a Stowage label │ +│ │ +│ ┌───────────────────────┐ │ +│ │ Enter asset tag │ │ +│ └───────────────────────┘ │ +└─────────────────────────────────────┘ +``` + +- **Reticle.** Four L-shaped corners in `--scan`, 2px stroke, 18px arms, framing a ~260px square. +- **No scan-line sweep.** The four corner brackets are the only chrome over the camera feed. An animated sweeping line was considered and rejected as visual noise. +- **Manual entry.** "Enter asset tag" button is always visible. Opens a sheet with a focused text input that runs the same resolver as a scan. +- **Torch.** Top-right icon. Shown only when the active `MediaStreamTrack` reports `getCapabilities().torch === true`. Toggles via `applyConstraints({ advanced: [{ torch }] })`. +- **Back.** Stops the stream and calls `router.back()`. +- **Permission denied / insecure context.** Replace the camera viewport with a neutral state: `CameraOff` icon, one-line explainer, help link to browser settings, manual-entry button remains. + +## Scanner implementation + +### `@zxing/browser` + +One dependency, one code path, works everywhere including Firefox. Imported dynamically inside the hook so it does not enter the main bundle. + +### Hook + +`useBarcodeScanner` in `src/hooks/use-barcode-scanner.ts`: + +```ts +type ScanResult = { + text: string; + format: "CODE_128" | "DATA_MATRIX" | "QR_CODE"; +}; + +function useBarcodeScanner(options: { + videoRef: RefObject; + onResult: (result: ScanResult) => void; + enabled: boolean; +}): { + state: "idle" | "requesting" | "scanning" | "denied" | "insecure" | "error"; + error: string | null; + torch: { supported: boolean; on: boolean; toggle: () => void }; + restart: () => void; +}; +``` + +- Dynamic-imports `BrowserMultiFormatReader` restricted to `CODE_128`, `DATA_MATRIX`, `QR_CODE`. +- Camera constraint: `{ facingMode: { ideal: "environment" } }`. +- Decode cadence: ~200ms per frame. +- Duplicate suppression: ignores repeat `text` values within a 2-second window. +- Always stops every `MediaStreamTrack` on unmount and on `enabled: false`. + +### Resolver + +`src/lib/scan.ts` exports `resolveScanTarget(text, appOrigin)`: + +1. If `text` is a URL with matching origin and path `/assets/` → fetch asset detail. +2. Otherwise → unresolved. +3. If asset fetch 404s or is forbidden → unresolved. + +Returns `{ status: "asset", asset } | { status: "unresolved", rawText }`. Strict by design — we do not pretend to understand foreign barcodes. + +### Post-scan state + +The page owns a `target` state. While `target !== null`, the scanner is paused. Dismissing the sheet clears `target` and resumes scanning so the user can walk a row of shelves scanning in sequence. + +- Success haptic: `navigator.vibrate?.(15)`. +- Unresolved haptic: `[10, 60, 10]`. +- Asset fetches go through TanStack Query keyed `["asset", id]`, so "View details" opens the asset page from cache. + +## Result sheet and quick actions + +When `target.status === "asset"`: + +``` +┌─────────────────────────────────────┐ +│ ─── drag handle ─── │ +├─────────────────────────────────────┤ +│ [STATUS] Drill #47 │ +│ AST-0047 · Workshop / Bench 3 │ +├─────────────────────────────────────┤ +│ ┌───────┐ ┌───────┐ ┌───────┐ │ +│ │ View │ │Status │ │ Move │ │ +│ └───────┘ └───────┘ └───────┘ │ +│ ┌───────┐ ┌───────┐ ┌───────┐ │ +│ │ Photo │ │ Note │ │Service│ │ +│ └───────┘ └───────┘ └───────┘ │ +├─────────────────────────────────────┤ +│ Scan another ↓ │ +└─────────────────────────────────────┘ +``` + +The six actions: + +1. **View details** → `router.push('/assets/' + id)`. +2. **Status** → nested sheet with the five `ASSET_STATUS_OPTIONS` as a radio list → `updateAsset({ status })`. +3. **Move** → nested sheet with a searchable location list → `updateAsset({ locationId })`. +4. **Photo** → `` launched programmatically → `attachments.uploadAttachment(assetId, file)`. +5. **Note** → single `