From 53b1a64a48af921f9a07d68c58fe2d3fd2dabf14 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 22 Jun 2026 19:33:24 +1000 Subject: [PATCH] =?UTF-8?q?feat(nav):=20light/dark=20switch=20every=20page?= =?UTF-8?q?=20+=20store=E2=86=92rack=E2=86=92crate=20navigator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nav.js owns a light/dark toggle (persisted, default light) across all owner pages - /nav API: spaces, store-layout, rack/{id}, crate/{id} over migrated virtual_* geometry - /search rebuilt as Store→Rack→Crate drill-down (true rack rotation, per-level slot grid, crate facing arrows + BACK/FRONT/L/R, search→locate) - facing convention (directionYaw, slot-grid, rotatedArrows, world composition) in RECORDGOD_NAVIGATOR_PLAN.md - no-cache HTML routes so deploys show instantly Co-Authored-By: Claude Opus 4.8 --- RECORDGOD_NAVIGATOR_PLAN.md | 154 +++++++++++++++++++ app/main.py | 8 +- app/navigator_routes.py | 101 ++++++++++++ site/admin.html | 28 ++-- site/builder.html | 26 ++-- site/dash.html | 20 +-- site/index.html | 6 +- site/nav.js | 61 ++++++-- site/pos.html | 2 +- site/search.html | 299 +++++++++++++++++++++++++----------- 10 files changed, 563 insertions(+), 142 deletions(-) create mode 100644 RECORDGOD_NAVIGATOR_PLAN.md create mode 100644 app/navigator_routes.py diff --git a/RECORDGOD_NAVIGATOR_PLAN.md b/RECORDGOD_NAVIGATOR_PLAN.md new file mode 100644 index 0000000..131cc43 --- /dev/null +++ b/RECORDGOD_NAVIGATOR_PLAN.md @@ -0,0 +1,154 @@ +# RecordGod — Search & Inventory Management (Navigator) port + +Faithful port of WowPlatter's **Search & Inventory Management** workstation onto RecordGod's +Postgres + the already-migrated `virtual_*` geometry. This is the real thing — Store→Rack→Crate→Item +drill-down, per-level slot layouts with orientation, scan-to-slot, reorganize, returns, stock finder — +NOT the toy `/search` that ships today (that becomes Phase 0's store view). + +Source studied: `wowplatter/admin/partials/search.php` (844 lines), `admin/js/search/main.js` (8,482 lines, +the live renderer), `admin/js/src/navigator/{diagram,scanner,views,state}.js`, +`includes/repositories/class-wowplatter-virtual-store-repository.php`, schema-manager virtual tables. + +UI rule: **light/clean shop-admin theme** (see memory `recordgod-admin-light-ui`), not the dark dash. + +--- + +## 1. The workstation shape + +- **Top bar:** Space selector (`booth-room (default)` …), **view zoom** = `Store · Rack · Crate · Item` + **Auto**, + level controls `◀ Level 1 of 2 (BOTTOM) ▶`, rack-rotate, refresh. +- **5 tabs:** `Scanner · Returns · Reorganize · Collections · Stock Finder`. +- **Center:** the canvas diagram (changes with the zoom level). +- **Right:** Crate Contents list (items in the active crate) + collapsible side panels: + **Quick Insert · Release Info · Crate Info · Rack Info**. + +The 4 zoom levels are ONE canvas that re-renders: +- **Store** — top-down room: racks at their positions, doors/portals, compass. Click a rack → Rack. +- **Rack** — one **level** of the selected rack (prev/next level), crates laid in **slots** with an + **orientation arrow** + genre label + slot number, BACK/FRONT/L/R around the rack. Click a crate → Crate. +- **Crate** — the crate's slot grid (rows×cols from crate_type) + Crate Contents (items by `slot_number`). +- **Item** — individual slot/record detail. + +--- + +## 2. Data model — ALREADY in RecordGod (migrated verbatim by `migrate_virtual.py`) + +Everything below is already in `recordgod-db`; the work is API + front-end, not schema. + +| table | the bits that matter | +|---|---| +| `virtual_space` | `room_width, room_depth, is_default, visible` — the room | +| `virtual_rack_type` | `width, height, depth`, panel flags, material; **levels** via `virtual_rack_type_level` | +| `virtual_rack_type_level` | per-type level rows (level index, shelf height/thickness) — defines how many levels a rack has | +| `virtual_rack` | **`pos_x/pos_y/pos_z`**, `rotation_x/y/z`, `space_id`, `rack_type_id`, **`direction` (front/back/left/right)**, `genre_ids`, `style_ids`, `attach_wall`, `name` | +| `virtual_crate_type` | `width/height/depth`, **`record_rows`, `record_columns`** (the slot grid), material | +| `virtual_crate` | `rack_id`, **`rack_level_index`**, `crate_type_id`, **`pos_x/y/z` (relative to rack origin, m)**, **`direction` (forward/back/left/right) = the crate's facing**, `rotation_x/y/z` (all 0 in data — IGNORE for facing), `layer (upper/lower/floor)`, `name` | +| `inventory` | **`crate_id` + `slot_number`** — each record's home crate + its slot | + +Hierarchy: **Space → Racks(positioned) → Levels(`rack_level_index`) → Crates(in slots, oriented) → Items(`slot_number`)**. + +Genre/style: `virtual_rack.genre_ids`/`style_ids` are comma-sep id lists; crate-level tags via a tag table +(WowPlatter `wowplatter_get_crate_tags`). Confirm the migrated tag table name in P2. + +--- + +## 3. The geometry, decoded (the tricky x/y/z) + +**Units.** Rack `width/depth` are **cm** (`/100` = metres, `/200` = half). Crate dims + crate `pos_*` are **metres**. +Rack `pos_x/pos_z` are **room metres**. + +**Facing composes — the slippery bit (confirmed in data 2026-06-22).** There are TWO independent `direction` +columns and they STACK: +- `virtual_rack.direction` ∈ `front/back/left/right` — which way the whole rack faces in the room. +- `virtual_crate.direction` ∈ `forward/back/left/right` — which way THAT crate faces (data: forward 59 / back 57 / + left 114 / right 122). **This is the facing field — NOT `rotation_y` (it's 0.0 on every row).** +- **A crate's world facing = `rack.direction` ∘ `crate.direction`.** In the rack-level (front-on) view the + orientation arrow is the crate's OWN direction in the rack frame; in the store/3D world view you must compose + it with the rack's direction or the arrows point the wrong way. Never read one without the other. +- Vocab differs on purpose: crate says `forward`, rack says `front` — don't treat the strings as interchangeable. + +**Render convention (copied from production `main.js`, do not reinvent):** +- `directionYaw(dir)` → radians: `forward/front`=`0`, `back`=`π`, `left`=`π/2`, `right`=`−π/2`. (north/east/south/west alias n=0/e=−π/2-ish via the same table.) +- **Crate placement in the rack-level view is by `slot_number`, not pos_x.** Grid: `cell=0.34m`, + `cols=floor(rackW/0.34)`, `rows=round(rackD/0.34)`, slot is **1-indexed row-major**; + `posX=-rackW/2 + cellX/2 + col*cellX`, `posZ=-rackD/2 + cellZ/2 + row*cellZ`. Fall back to `pos_x/pos_z` only if no slot_number. +- Crate rect yaw = `directionYaw(crate.direction) + crate.rotation_y` (rotation_y≈0, so direction drives it). +- **View-rotation** (the rack-rotate button, 0/90/180/270) composes on top: `rotatePoint` = `90:(-z,x) · 180:(-x,-z) · 270:(z,-x)`, effective W/D swap at 90/270. +- **Facing arrow** = `rotatedArrows[viewRot][crate.direction]`, where `rotatedArrows[0] = {forward:↓, back:↑, left:←, right:→}` and 90/180/270 cycle it. The arrow (not the square) is what the eye reads. +- Edge labels at viewRot 0: **BACK top, FRONT bottom, L left, R right** — rotate with the view. +- **World facing (store/3D view)** = `directionYaw(rack.direction) + directionYaw(crate.direction) + crate.rotation_y`. In the drilldown you're head-on to the rack, so only `crate.direction` + view-rotation apply. + +**Store view** (`renderStoreView`, `diagram.js`): +1. bounds over all racks in the active space (`pos ± half`), `createWorldToCanvas(min,max,pad)` → fit+centre. +2. each rack = 4 corners `{±halfW,±halfD}` → `rotatePoint(yaw)` → `+pos` → toCanvas → filled rotated rect + name. +3. portals = door-span line + facing triangle at `(x/roomW·W, z/roomD·H)`, `facing_deg`; orange=paired, red=unpaired. +4. compass top-right. Hit-test by polygon (`pointInPolygon`) — crates first, then racks. + +**Rack view** (`renderShelfFrontView` / `renderRackDrilldown`): show **one `rack_level_index` at a time**. +Crates on that level are ordered into **slots** along the rack width (`virtual_crate.pos_x` ascending); +each slot draws the crate square with: crate id, **slot number** (sequential), **genre label**, and an +**orientation arrow** derived from the crate's `direction`/`rotation_y` (← → ↑ ↓ = which way the spine faces). +BACK/FRONT and L/R labels come from the rack's `direction`. Level prev/next steps `rack_level_index`. + +**Crate view** (`renderCrateLookupDiagram`): a `record_rows × record_columns` grid (from `crate_type`); +items placed by `slot_number`; empties dimmed. Crate Contents list = `inventory WHERE crate_id=?` ordered by `slot_number`. + +--- + +## 4. API surface (RecordGod routes ← WowPlatter ajax actions) + +All admin-gated, JSON, under `/nav/*` (new `navigator_routes.py`). Read side first, then writes. + +| RecordGod route | replaces ajax | returns / does | +|---|---|---| +| `GET /nav/spaces` | get spaces | the room list for the selector | +| `GET /nav/store-layout?space_id=` | `get_store_layout` | racks (+pos/rot/type/dims) + portals for the store view | +| `GET /nav/rack/{id}` | `get_rack_details` | rack + its levels + crates-per-level (+pos/slot/orientation) | +| `GET /nav/crate/{id}` | `get_crate_details` + `get_crate_contents` | crate info (name/label/rack/level/last-scanned) + items by slot | +| `GET /nav/crate/{id}/breakdown` | `get_crate_content_breakdown`/`analytics` | genre/style mix, counts | +| `GET /nav/genres` `GET /nav/styles` | `get_all_genres/styles` | tag pickers | +| `POST /nav/crate/{id}` | save crate | rename / relabel / set genres+styles | +| `POST /nav/rack/{id}` | save rack | rename / set genres+styles | +| `POST /nav/scan` | scan-to-slot | assign Release IDs/SKUs to a crate's slots sequentially (mode: replace/append) | +| `POST /nav/crate/{id}/compress` | `compress_crate_slots` | close gaps in slot numbering | +| `POST /nav/compress-all` | `compress_all_crate_slots` | compress every crate | +| `POST /nav/reorg` | `apply_crate_reorganization` | move/merge crates between racks/levels; distribute items across slots/crates | +| `GET /nav/collections` | `get_collections` | collection boxes (ties into the Discogs collection-box pricing flow) | +| `GET /nav/stock-finder?q=` | stock finder | locate any item → its crate/slot (this is today's `/search` logic, kept) | + +`inventory` writes (`crate_id`/`slot_number`) reuse the non-destructive UPSERT rule — never clobber sold/hand-edited rows. + +--- + +## 5. Front-end + +New `site/navigator.html` (light theme) — **replaces `/search`**, keeps the same `nav.js` top bar. +One `` + `state.js`-style module: `{ view, spaceId, rackId, levelIndex, crateId }`. +Port the four renderers from `main.js`/`diagram.js` (store / rack-level-slots / crate-grid / item), +the polygon hit-test, level prev/next, rack-rotate, and the side panels (Crate Info / Rack Info editors, +Release Info, Quick Insert). Workflows: scanner scan-to-slot, returns, reorganize (move/merge/distribute), +compress, stock finder. `release_id` stays the universal key. + +--- + +## 6. Phases (ship-thin, each usable) + +- **P0 — store view.** `/nav/store-layout` + the top-down room render (racks at real pos/rot, click a rack). + Upgrades today's flat `/search` map. _Zero risk, immediate value._ +- **P1 — rack drill-down + levels + slots.** `/nav/rack/{id}`; render one level, prev/next, slots with + orientation arrows + genre labels. **This is the tricky-geometry phase — do it carefully.** +- **P2 — crate view + info editors.** contents by slot, Crate/Rack Info panels (name/label/genre/style/last-scanned), compress slots. +- **P3 — Scanner.** active crate → scan Release IDs/SKUs → sequential slot assign (replace/append), Test/Process/Clear. +- **P4 — Reorganize + Returns.** move/merge crates, distribute items across slots; returns back to home crate. +- **P5 — Collections + Stock Finder + polish.** collection boxes, item view, rack-rotate, the "dope" pass. + +Start P0→P1: that already gives a clickable store → rack → level → crate locate, which is the daily driver. + +--- + +## 7. Open checks before building +1. Confirm `virtual_rack_type_level` + the crate-tag table actually came across in the migration (column names). +2. Confirm the slot-ordering rule WowPlatter uses (pos_x ascending vs an explicit `slot_number` on the crate). +3. ~~Crate facing field / composition~~ **RESOLVED 2026-06-22** — full render convention captured in §3 + ("Render convention"): `directionYaw` table, slot→grid placement, view-rotation matrix, the `rotatedArrows` + facing table, and the world-facing composition. P1 copies it verbatim. diff --git a/app/main.py b/app/main.py index c6d706e..e0e93de 100644 --- a/app/main.py +++ b/app/main.py @@ -22,6 +22,7 @@ from .admin_routes import router as admin_router # noqa: E402 from .layout_routes import router as layout_router # noqa: E402 from .shop_routes import router as shop_router # noqa: E402 from .sales_routes import router as sales_router # noqa: E402 +from .navigator_routes import router as navigator_router # noqa: E402 from .db import engine # noqa: E402 from sqlalchemy import text as _sqltext # noqa: E402 @@ -33,6 +34,7 @@ app.include_router(admin_router) app.include_router(layout_router) app.include_router(shop_router) app.include_router(sales_router) +app.include_router(navigator_router) _STARTUP_DDL = [ @@ -83,14 +85,18 @@ _ROOT = pathlib.Path(__file__).resolve().parent.parent # Pretty URLs: /builder /shop /admin /dash → the .html pages (registered before the "/" mount wins). +# no-cache so a deploy shows immediately — the browser revalidates the HTML each load (no stale dark pages). def _page(filename): async def _serve(): - return FileResponse(_ROOT / "site" / filename) + return FileResponse(_ROOT / "site" / filename, + headers={"Cache-Control": "no-cache, must-revalidate"}) return _serve for _stub in ("shop", "builder", "admin", "dash", "pos", "search"): app.add_api_route(f"/{_stub}", _page(_stub + ".html"), methods=["GET"], include_in_schema=False) +# the login landing too (no-cache), matched before the "/" static mount below +app.add_api_route("/", _page("index.html"), methods=["GET"], include_in_schema=False) # Mounts come last so the API routes + /healthz match first; "/" serves the landing/dash site. if (_ROOT / "webstore").is_dir(): diff --git a/app/navigator_routes.py b/app/navigator_routes.py new file mode 100644 index 0000000..0fa1dee --- /dev/null +++ b/app/navigator_routes.py @@ -0,0 +1,101 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import text + +from .auth import require_token +from .db import get_db + +# Store Navigator — the WowPlatter "Search & Inventory Management" drill-down, RecordGod-side. +# Store → Rack → Crate → Item, over the migrated virtual_* geometry. See RECORDGOD_NAVIGATOR_PLAN.md. +# Dims are METRES here (the 3D-store convention the data was migrated into), not WowPlatter's old cm. +router = APIRouter(prefix="/nav", tags=["navigator"]) + + +@router.get("/spaces") +async def spaces(ident=Depends(require_token), db=Depends(get_db)): + rows = await db.execute(text(""" + SELECT id, name, room_width::float AS room_width, room_depth::float AS room_depth, + (is_default = 'y') AS is_default + FROM virtual_space WHERE visible = 'y' + ORDER BY (is_default = 'y') DESC, id + """)) + return {"spaces": [dict(r) for r in rows.mappings()]} + + +@router.get("/store-layout") +async def store_layout(space_id: int | None = None, ident=Depends(require_token), db=Depends(get_db)): + """All racks (position + facing + type dims + counts) for one space — the top-down store view.""" + if space_id is None: + sp = (await db.execute(text( + "SELECT id FROM virtual_space WHERE visible='y' ORDER BY (is_default='y') DESC, id LIMIT 1" + ))).first() + space_id = sp[0] if sp else None + space = (await db.execute(text(""" + SELECT id, name, room_width::float AS room_width, room_depth::float AS room_depth + FROM virtual_space WHERE id = :s + """), {"s": space_id})).mappings().first() + racks = [dict(r) for r in (await db.execute(text(""" + SELECT r.id, r.name, r.pos_x::float AS x, r.pos_z::float AS z, + r.rotation_y::float AS rot, r.direction, + rt.type AS rack_type, rt.name AS type_name, + rt.width::float AS w, rt.depth::float AS d, coalesce(rt.levels,1)::int AS levels, + (SELECT count(*) FROM virtual_crate c WHERE c.rack_id = r.id AND c.visible = 'y') AS crates, + (SELECT count(*) FROM inventory i + WHERE i.crate_id IN (SELECT id FROM virtual_crate c WHERE c.rack_id = r.id) + AND i.in_stock) AS items + FROM virtual_rack r LEFT JOIN virtual_rack_type rt ON rt.id = r.rack_type_id + WHERE r.visible = 'y' AND r.space_id = :s + """), {"s": space_id})).mappings()] + return {"ok": True, "space": dict(space) if space else None, "racks": racks} + + +@router.get("/rack/{rack_id}") +async def rack_detail(rack_id: int, ident=Depends(require_token), db=Depends(get_db)): + """One rack: its type dims/levels + every crate (level, slot, facing, label, item count).""" + rack = (await db.execute(text(""" + SELECT r.id, r.name, r.direction, r.pos_x::float AS x, r.pos_z::float AS z, + r.rotation_y::float AS rot, r.space_id, + rt.type AS rack_type, rt.name AS type_name, + rt.width::float AS w, rt.depth::float AS d, + coalesce(rt.levels,1)::int AS levels, coalesce(rt.slots_per_level,0)::int AS slots_per_level + FROM virtual_rack r LEFT JOIN virtual_rack_type rt ON rt.id = r.rack_type_id + WHERE r.id = :id + """), {"id": rack_id})).mappings().first() + if not rack: + raise HTTPException(404, "rack not found") + crates = [dict(r) for r in (await db.execute(text(""" + SELECT c.id, c.name, c.label_text, c.rack_level_index::int AS level, c.slot_number::int AS slot, + c.direction, c.pos_x::float AS x, c.pos_z::float AS z, c.crate_purpose, + (SELECT count(*) FROM inventory i WHERE i.crate_id = c.id AND i.in_stock) AS items + FROM virtual_crate c + WHERE c.rack_id = :id AND c.visible = 'y' + ORDER BY c.rack_level_index NULLS FIRST, c.slot_number NULLS LAST + """), {"id": rack_id})).mappings()] + levels = [dict(r) for r in (await db.execute(text(""" + SELECT l.level_index::int AS level, coalesce(l.slots,0)::int AS slots + FROM virtual_rack_type_level l + JOIN virtual_rack r ON r.rack_type_id = l.rack_type_id + WHERE r.id = :id ORDER BY l.level_index + """), {"id": rack_id})).mappings()] + return {"ok": True, "rack": dict(rack), "crates": crates, "levels": levels} + + +@router.get("/crate/{crate_id}") +async def crate_contents(crate_id: int, ident=Depends(require_token), db=Depends(get_db)): + """A crate's records, in slot order — the Crate Contents list.""" + crate = (await db.execute(text(""" + SELECT c.id, c.name, c.label_text, c.rack_id, c.rack_level_index::int AS level, + c.slot_number::int AS slot, c.direction, c.crate_purpose, + r.name AS rack_name + FROM virtual_crate c LEFT JOIN virtual_rack r ON r.id = c.rack_id + WHERE c.id = :id + """), {"id": crate_id})).mappings().first() + if not crate: + raise HTTPException(404, "crate not found") + items = [dict(r) for r in (await db.execute(text(""" + SELECT i.sku, i.slot_number::int AS slot, coalesce(i.title, dc.title) AS title, + dc.artist, i.price::float AS price, i.condition, i.in_stock + FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id = i.release_id + WHERE i.crate_id = :id + ORDER BY i.slot_number NULLS LAST, i.sku + """), {"id": crate_id})).mappings()] + return {"ok": True, "crate": dict(crate), "items": items} diff --git a/site/admin.html b/site/admin.html index 638de60..953153f 100644 --- a/site/admin.html +++ b/site/admin.html @@ -4,27 +4,27 @@ RecordGod — admin - + diff --git a/site/dash.html b/site/dash.html index 1d6dfac..6682c36 100644 --- a/site/dash.html +++ b/site/dash.html @@ -4,11 +4,11 @@ RecordGod — control room - + @@ -40,22 +43,38 @@
-
-
- - + +
+
+
+ + +
+
type to search — click a hit to locate it
+
+
+
+
+
Crate contents
+
+
pick a crate on the map →
-
type to search the whole store
-
+
-
-
Store map
- + - -
Click a bin to list what's in it. A search hit lights up its bin 📍
+ +
@@ -63,89 +82,187 @@ const $=s=>document.querySelector(s); let TOKEN=localStorage.getItem('rg_token')||''; const hdr=()=>({'Authorization':'Bearer '+TOKEN}); +const get=u=>fetch(u,{headers:hdr()}).then(r=>r.json()); const money=n=>n==null?'—':'$'+Number(n).toFixed(2); const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); -let MAP=null, selCrate=null, hiCrate=null, hit={}; // hit = bounding boxes for click test + +// ── facing convention (copied from WowPlatter production, see RECORDGOD_NAVIGATOR_PLAN §3) ── +const CELL=0.34; +function directionYaw(d){ d=(d||'').toLowerCase(); + if(d==='back'||d==='south') return Math.PI; + if(d==='left'||d==='west') return Math.PI/2; + if(d==='right'||d==='east') return -Math.PI/2; + return 0; } // forward/front/north +const ARROW={forward:'↓',front:'↓',north:'↓',back:'↑',south:'↑',left:'←',west:'←',right:'→',east:'→'}; + +let ST={view:'store', space:null, racks:[], rack:null, level:1, crates:[], levels:[], selCrate:null, hit:[]}; async function signin(){ TOKEN=$('#tok').value.trim(); - const r=await fetch('/admin/stats',{headers:hdr()}); - if(!r.ok){$('#gerr').textContent='invalid token';return;} + if(!(await fetch('/admin/stats',{headers:hdr()})).ok){ $('#gerr').textContent='invalid token'; return; } localStorage.setItem('rg_token',TOKEN); $('#gate').style.display='none'; $('#app').style.display='grid'; - $('#q').focus(); loadMap(); + $('#q').focus(); await loadSpaces(); toStore(); } +async function loadSpaces(){ + const d=await get('/nav/spaces'); + $('#spaceSel').innerHTML=(d.spaces||[]).map(s=>``).join(''); + $('#spaceSel').onchange=()=>toStore(); +} + +// ── STORE VIEW ── +async function toStore(){ + ST.view='store'; ST.rack=null; ST.selCrate=null; + $('#backBtn').style.display='none'; $('#lvlCtl').style.display='none'; + $('#crumb').textContent='Store map'; + const sid=$('#spaceSel').value; + const d=await get('/nav/store-layout'+(sid?('?space_id='+sid):'')); + ST.space=d.space; ST.racks=d.racks||[]; + $('#legend').innerHTML='rack — click to open'; + drawStore(); +} +function drawStore(){ + const cv=$('#cv'), g=cv.getContext('2d'), W=cv.width, H=cv.height; + g.clearRect(0,0,W,H); g.fillStyle='#fbfbfd'; g.fillRect(0,0,W,H); + ST.hit=[]; + if(!ST.racks.length){ g.fillStyle='#888'; g.font='14px system-ui'; g.textAlign='center'; g.fillText('No racks in this space',W/2,H/2); return; } + // bounds over racks (pos ± half-dims) → fit + let minX=1e9,maxX=-1e9,minZ=1e9,maxZ=-1e9; + ST.racks.forEach(r=>{ const hw=(r.w||0.5)/2, hd=(r.d||0.5)/2; + minX=Math.min(minX,r.x-hw); maxX=Math.max(maxX,r.x+hw); minZ=Math.min(minZ,r.z-hd); maxZ=Math.max(maxZ,r.z+hd); }); + const pad=34, sc=Math.min((W-2*pad)/((maxX-minX)||1),(H-2*pad)/((maxZ-minZ)||1)); + const X=x=>pad+(x-minX)*sc, Y=z=>pad+(z-minZ)*sc; + ST.racks.forEach(r=>{ + const yaw=directionYaw(r.direction)+(r.rot||0)*Math.PI/180; + const hw=(r.w||0.5)/2*sc, hd=(r.d||0.5)/2*sc, cx=X(r.x), cy=Y(r.z); + const cos=Math.cos(yaw), sin=Math.sin(yaw); + const corners=[[-hw,-hd],[hw,-hd],[hw,hd],[-hw,hd]].map(([px,pz])=>[cx+px*cos-pz*sin, cy+px*sin+pz*cos]); + g.beginPath(); g.moveTo(corners[0][0],corners[0][1]); corners.slice(1).forEach(c=>g.lineTo(c[0],c[1])); g.closePath(); + g.fillStyle=r.items? '#8bc34a':'#cfe3b0'; g.fill(); g.strokeStyle='#558b2f'; g.lineWidth=1.5; g.stroke(); + g.fillStyle='#1b1b22'; g.font='bold 11px system-ui'; g.textAlign='center'; g.textBaseline='middle'; + g.fillText(r.name||('Rack '+r.id), cx, cy); + g.fillStyle='#3a3a3a'; g.font='9px system-ui'; g.fillText((r.items||0)+' items', cx, cy+13); + ST.hit.push({type:'rack', id:r.id, cx, cy, r:Math.max(hw,hd)+4}); + }); +} + +// ── RACK VIEW ── +async function openRack(id, focusCrate){ + const d=await get('/nav/rack/'+id); + ST.view='rack'; ST.rack=d.rack; ST.crates=d.crates||[]; ST.levels=d.levels||[]; + // default to the level holding the most crates (or the focus crate's level) + const byLvl={}; ST.crates.forEach(c=>{ const l=c.level==null?1:c.level; byLvl[l]=(byLvl[l]||0)+1; }); + ST.level = focusCrate!=null ? (ST.crates.find(c=>c.id===focusCrate)?.level ?? 1) + : (+Object.keys(byLvl).sort((a,b)=>byLvl[b]-byLvl[a])[0] || 1); + ST.selCrate=focusCrate||null; + $('#backBtn').style.display=''; + $('#crumb').innerHTML='Store / '+esc(ST.rack.name||('Rack '+ST.rack.id))+''; + drawRack(); +} +function levelMeta(){ + const lv=ST.rack.levels||1; + const cur=ST.level, label=lv>1?`Level ${cur} of ${lv}${cur===1?' (BOTTOM)':(cur===lv?' (TOP)':'')}`:'Single level'; + return {lv, label}; +} +function drawRack(){ + const {lv,label}=levelMeta(); + $('#lvlCtl').style.display = lv>1?'inline-flex':'none'; + $('#lvlLabel').textContent=label; $('#lvlPrev').disabled=ST.level<=1; $('#lvlNext').disabled=ST.level>=lv; + $('#legend').innerHTML='crate↑↓←→ = crate facing · BACK/FRONT = rack frame · green=items'; + const cv=$('#cv'), g=cv.getContext('2d'), W=cv.width, H=cv.height; + g.clearRect(0,0,W,H); g.fillStyle='#fbfbfd'; g.fillRect(0,0,W,H); + ST.hit=[]; + const rw=ST.rack.w||1.7, rd=ST.rack.d||0.7; + const cols=Math.max(1,Math.floor(rw/CELL)), rows=Math.max(1,Math.round(rd/CELL)); + const pad=42, sc=0.92*Math.min((W-2*pad)/rw,(H-2*pad)/rd); + const cW=rw*sc, cH=rd*sc, oX=(W-cW)/2, oY=(H-cH)/2, cellW=cW/cols, cellH=cH/rows; + // boundary + g.fillStyle='#fff'; g.strokeStyle='#222'; g.lineWidth=3; g.beginPath(); g.rect(oX,oY,cW,cH); g.fill(); g.stroke(); + // edge labels (viewRot 0: BACK top, FRONT bottom, L left, R right) + g.fillStyle='#888'; g.font='11px system-ui'; g.textAlign='center'; + g.fillText('BACK',oX+cW/2,oY-8); g.fillText('FRONT',oX+cW/2,oY+cH+16); + g.save(); g.translate(oX-10,oY+cH/2); g.rotate(-Math.PI/2); g.fillText('L',0,0); g.restore(); + g.save(); g.translate(oX+cW+12,oY+cH/2); g.rotate(Math.PI/2); g.fillText('R',0,0); g.restore(); + // grid + g.strokeStyle='#eee'; g.lineWidth=1; + for(let i=1;i(c.level==null?1:c.level)===ST.level); + let autoSlot=0; + here.forEach(c=>{ + let slot=c.slot; if(!slot||slot<1){ autoSlot++; slot=autoSlot; } // ponytail: place slotless crates sequentially + const idx=slot-1, col=idx%cols, row=Math.floor(idx/cols); + if(row>=rows) return; + const x=oX+col*cellW+3, y=oY+row*cellH+3, w=cellW-6, h=cellH-6; + const sel=ST.selCrate===c.id; + g.fillStyle=sel?'rgba(255,46,147,.30)':'rgba(30,144,255,.16)'; + g.strokeStyle=sel?'#d10f7a':'#1e90ff'; g.lineWidth=sel?3:1.5; + g.beginPath(); g.rect(x,y,w,h); g.fill(); g.stroke(); + g.fillStyle='#1b1b22'; g.font='bold 10px system-ui'; g.textAlign='center'; g.textBaseline='top'; + g.fillText('#'+c.id, x+w/2, y+4); + const gl=(c.label_text||c.crate_purpose||'').toString().split(',')[0].slice(0,12); + g.fillStyle='#555'; g.font='9px system-ui'; g.fillText(gl||'—', x+w/2, y+16); + // facing arrow (big, centre-bottom) + g.fillStyle=sel?'#d10f7a':'#1e90ff'; g.font='bold 20px system-ui'; g.textBaseline='middle'; + g.fillText(ARROW[(c.direction||'').toLowerCase()]||'•', x+w/2, y+h-13); + // slot number top-right, item count bottom-left + g.fillStyle='#1a8f54'; g.font='9px system-ui'; g.textAlign='right'; g.textBaseline='top'; + g.fillText(slot, x+w-3, y+3); + g.fillStyle='#888'; g.textAlign='left'; g.fillText((c.items||0), x+3, y+h-12); + ST.hit.push({type:'crate', id:c.id, x, y, w, h}); + }); +} +function stepLevel(d){ ST.level=Math.max(1,Math.min(ST.rack.levels||1, ST.level+d)); drawRack(); } + +// ── crate contents ── +async function loadCrate(id){ + ST.selCrate=id; if(ST.view==='rack') drawRack(); + const d=await get('/nav/crate/'+id); const c=d.crate; + $('#ccName').innerHTML='📦 '+esc(c.label_text||c.name||('Crate '+c.id)); + $('#ccName').className='pink'; + $('#ccMeta').textContent=`· ${c.rack_name||''} L${c.level??'?'} slot ${c.slot??'?'} · ${d.items.length} items`; + $('#ccList').innerHTML=d.items.length? ''+ + d.items.map(i=>``).join('')+'
SlotTitleArtistPrice
${i.slot??'—'}${esc(i.title||i.sku)}${i.in_stock?'':' sold'}${esc(i.artist||'')}${money(i.price)}
' + : '
empty crate
'; +} + +// ── canvas clicks ── +$('#cv').addEventListener('click',e=>{ + const r=$('#cv').getBoundingClientRect(), sx=$('#cv').width/r.width, sy=$('#cv').height/r.height; + const mx=(e.clientX-r.left)*sx, my=(e.clientY-r.top)*sy; + if(ST.view==='store'){ + let best=null,bd=1e9; for(const h of ST.hit){ const dd=Math.hypot(mx-h.cx,my-h.cy); if(dd=h.x&&mx<=h.x+h.w&&my>=h.y&&my<=h.y+h.h){ loadCrate(h.id); return; } } + } +}); + +// ── search → locate ── let st; $('#q').addEventListener('input',e=>{ clearTimeout(st); st=setTimeout(run,200); }); $('#q').addEventListener('keydown',e=>{ if(e.key==='Enter'){clearTimeout(st);run();} }); - async function run(){ const q=$('#q').value.trim(); - const qs=new URLSearchParams(); if(q)qs.set('q',q); if(selCrate)qs.set('crate_id',selCrate); - if(!q && !selCrate){ $('#res').innerHTML=''; $('#count').textContent='type to search the whole store'; return; } - const d=await fetch('/admin/inventory?'+qs,{headers:hdr()}).then(r=>r.json()); - $('#count').textContent=`${d.total} match${d.total===1?'':'es'}${selCrate?' in this bin':''}`; - $('#res').innerHTML=d.items.map((it,i)=>`
+ if(!q){ $('#res').innerHTML=''; $('#count').textContent='type to search — click a hit to locate it'; return; } + const d=await get('/admin/inventory?q='+encodeURIComponent(q)); + $('#count').textContent=`${d.total} match${d.total===1?'':'es'} — click to locate`; + $('#res').innerHTML=(d.items||[]).map(it=>`
${it.thumb?``:''}
${esc(it.title||it.sku)}
-
${esc(it.artist||'')} ${it.condition?'· '+esc(it.condition):''} · ${money(it.price)} ${it.in_stock?'':'· sold'}
- ${it.crate?`📍 ${esc(it.crate)}`:'· no bin assigned'}
-
`).join('') || '
no match anywhere in the store
'; +
${esc(it.artist||'')} ${it.condition?'· '+esc(it.condition):''} · ${money(it.price)}
+ ${it.crate?`📍 ${esc(it.crate)}`:'· no bin'}
`).join('') + || '
no match anywhere in the store
'; } -function selRow(el,crateId){ - document.querySelectorAll('.ri.sel').forEach(n=>n.classList.remove('sel')); - el.classList.add('sel'); hiCrate=crateId; draw(); +async function locate(crateId){ + if(!crateId){ alert('That item has no bin assigned yet.'); return; } + const d=await get('/nav/crate/'+crateId); const c=d.crate; + if(c.rack_id){ await openRack(c.rack_id, crateId); } + loadCrate(crateId); } -async function loadMap(){ - MAP=await fetch('/admin/storemap',{headers:hdr()}).then(r=>r.json()); - draw(); -} -function draw(){ - if(!MAP) return; - const cv=$('#map'), g=cv.getContext('2d'), W=cv.width, H=cv.height; - g.clearRect(0,0,W,H); - const rw=MAP.space.room_width, rd=MAP.space.room_depth; - const sc=Math.min(W/rw,H/rd)*0.86; - const X=x=>W/2 + x*sc, Y=z=>H/2 + z*sc; - // room - g.strokeStyle='#c9c9d4'; g.lineWidth=2; - g.strokeRect(X(-rw/2),Y(-rd/2),rw*sc,rd*sc); - // racks (shelving) — grey blocks - g.fillStyle='#dcdce4'; - (MAP.racks||[]).forEach(r=>block(g,X(r.x),Y(r.z),(r.w||1)*sc,(r.d||0.4)*sc,r.rot||0)); - // crates (bins) — coloured by stock, the selected hit glows pink - hit={}; - (MAP.crates||[]).forEach(c=>{ - const ct=MAP.crate_types[c.crate_type_id]||{w:0.5,d:0.5}; - const w=(ct.w||0.5)*sc, d=(ct.d||0.5)*sc, px=X(c.x), py=Y(c.z); - const isHi=hiCrate&&c.id===hiCrate, isSel=selCrate&&c.id===selCrate; - g.fillStyle = isHi?'#ff2e93' : isSel?'#f3a6cd' : c.items? '#9a9aac' : '#e7e7ee'; - block(g,px,py,Math.max(w,9),Math.max(d,9),c.rot||0); - if(isHi){ g.fillStyle='#ff2e93'; g.beginPath(); g.arc(px,py-14,4,0,7); g.fill(); } - hit[c.id]={x:px,y:py,r:Math.max(w,d,11)/2+3,c}; - }); -} -function block(g,cx,cy,w,h,rotDeg){ - g.save(); g.translate(cx,cy); g.rotate(rotDeg*Math.PI/180); - g.fillRect(-w/2,-h/2,w,h); g.restore(); -} -$('#map').addEventListener('click',e=>{ - const r=$('#map').getBoundingClientRect(), sx=$('#map').width/r.width, sy=$('#map').height/r.height; - const mx=(e.clientX-r.left)*sx, my=(e.clientY-r.top)*sy; - let best=null,bd=1e9; - for(const id in hit){ const h=hit[id], dd=Math.hypot(mx-h.x,my-h.y); if(dd