feat(nav): light/dark switch every page + store→rack→crate navigator
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
180615c7e0
commit
53b1a64a48
154
RECORDGOD_NAVIGATOR_PLAN.md
Normal file
154
RECORDGOD_NAVIGATOR_PLAN.md
Normal file
@ -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 `<canvas>` + `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.
|
||||
@ -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():
|
||||
|
||||
101
app/navigator_routes.py
Normal file
101
app/navigator_routes.py
Normal file
@ -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}
|
||||
@ -4,27 +4,27 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>RecordGod — admin</title>
|
||||
<script defer src="/nav.js?v=3"></script>
|
||||
<script defer src="/nav.js?v=5"></script>
|
||||
<style>
|
||||
:root{--pink:#ff5db1;--ink:#0b0b0d;--panel:#141418;--line:#26262c;--mut:#9a9aa6;--ok:#46d18a;--warn:#e6a046}
|
||||
:root{--pink:#d10f7a;--ink:#f4f5f7;--panel:#ffffff;--line:#e2e2ea;--mut:#5f5f6c;--ok:#1a8f54;--warn:#b06a00}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;height:100%;background:var(--ink);color:#f0f0f2;font:14px/1.5 system-ui,sans-serif}
|
||||
html,body{margin:0;height:100%;background:var(--ink);color:#1b1b22;font:14px/1.5 system-ui,sans-serif}
|
||||
a{color:var(--pink);text-decoration:none}
|
||||
/* gate */
|
||||
#gate{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--ink);z-index:10}
|
||||
#gate .box{width:340px;text-align:center}
|
||||
#gate h1{color:var(--pink);font-weight:600}
|
||||
input,select{padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:#0e0e11;color:#eee;font:14px system-ui}
|
||||
input,select{padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:#ffffff;color:#1b1b22;font:14px system-ui}
|
||||
button{padding:9px 13px;border:0;border-radius:8px;background:var(--pink);color:#1a0a14;font:500 14px system-ui;cursor:pointer}
|
||||
button.ghost{background:#1d1d22;color:#ccc;border:1px solid var(--line)}
|
||||
button.ghost{background:#f3f3f6;color:#2a2a30;border:1px solid var(--line)}
|
||||
/* layout */
|
||||
#app{display:none;grid-template-columns:212px 1fr;height:calc(100vh - 44px)}
|
||||
nav{background:#0e0e11;border-right:1px solid var(--line);padding:14px 10px;overflow:auto}
|
||||
nav{background:#ffffff;border-right:1px solid var(--line);padding:14px 10px;overflow:auto}
|
||||
nav .brand{font-weight:600;font-size:18px;padding:6px 10px 14px}
|
||||
nav .brand b{color:var(--pink)}
|
||||
nav a{display:block;color:#cfcfd6;padding:9px 12px;border-radius:8px;margin:2px 0;cursor:pointer}
|
||||
nav a:hover{background:#17171c}
|
||||
nav a.on{background:#221a20;color:var(--pink)}
|
||||
nav a{display:block;color:#2a2a30;padding:9px 12px;border-radius:8px;margin:2px 0;cursor:pointer}
|
||||
nav a:hover{background:#f3f3f6}
|
||||
nav a.on{background:#fdeef6;color:var(--pink)}
|
||||
nav .sec{color:#55555f;font-size:11px;text-transform:uppercase;letter-spacing:.1em;padding:14px 12px 4px}
|
||||
main{overflow:auto;padding:22px 26px}
|
||||
h2{font-weight:600;font-size:20px;margin:0 0 16px}
|
||||
@ -38,9 +38,9 @@
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
th{text-align:left;color:var(--mut);font-weight:500;padding:8px 10px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--ink)}
|
||||
td{padding:8px 10px;border-bottom:1px solid #1b1b20}
|
||||
tr:hover td{background:#141418}
|
||||
.thumb{width:34px;height:34px;border-radius:4px;object-fit:cover;background:#222;vertical-align:middle}
|
||||
.pill{padding:2px 8px;border-radius:20px;font-size:11px;background:#1d1d22;color:#bbb}
|
||||
tr:hover td{background:#ffffff}
|
||||
.thumb{width:34px;height:34px;border-radius:4px;object-fit:cover;background:#ececf0;vertical-align:middle}
|
||||
.pill{padding:2px 8px;border-radius:20px;font-size:11px;background:#f3f3f6;color:#bbb}
|
||||
.pill.in{background:#15291f;color:var(--ok)} .pill.out{background:#2a1a1a;color:#e66}
|
||||
.price{font-weight:500}
|
||||
.muted{color:var(--mut)} .soon{color:#55555f}
|
||||
@ -267,12 +267,12 @@ function renderMap(highlight){
|
||||
const W=maxX-minX, D=maxZ-minZ, scale=Math.min(820/W, 560/D);
|
||||
const sx=x=>((x-minX)*scale).toFixed(1), sy=z=>((z-minZ)*scale).toFixed(1);
|
||||
const maxItems=Math.max(1, ...crates.map(c=>c.items||0));
|
||||
let svg = `<svg viewBox="0 0 ${(W*scale).toFixed(0)} ${(D*scale).toFixed(0)}" style="width:100%;height:auto;background:#0c0c0e;border-radius:8px">`;
|
||||
let svg = `<svg viewBox="0 0 ${(W*scale).toFixed(0)} ${(D*scale).toFixed(0)}" style="width:100%;height:auto;background:#f4f5f7;border-radius:8px">`;
|
||||
racks.forEach(r => { const w=(r.w||0.6)*scale, d=(r.d||0.4)*scale;
|
||||
svg += `<g transform="translate(${sx(r.x)},${sy(r.z)}) rotate(${r.rot||0})"><rect x="${(-w/2).toFixed(1)}" y="${(-d/2).toFixed(1)}" width="${w.toFixed(1)}" height="${d.toFixed(1)}" rx="2" fill="#16161b" stroke="#2a2a30" stroke-width="0.5"/></g>`; });
|
||||
crates.forEach(c => { const t=ct[c.crate_type_id]||{w:0.35,d:0.5}; const w=Math.max(7,t.w*scale), d=Math.max(7,t.d*scale);
|
||||
const fill = c.items ? `rgba(255,93,177,${(0.22+0.65*(c.items/maxItems)).toFixed(2)})` : '#22222a';
|
||||
const hl = highlight===c.id ? 'stroke="#fff" stroke-width="2.5"' : 'stroke="#0a0a0c" stroke-width="0.5"';
|
||||
const hl = highlight===c.id ? 'stroke="#fff" stroke-width="2.5"' : 'stroke="#ffffff" stroke-width="0.5"';
|
||||
svg += `<g transform="translate(${sx(c.x)},${sy(c.z)}) rotate(${c.rot||0})" style="cursor:pointer" onclick="openCrate(${c.id})"><title>${escapeH(c.label_text||c.name||('#'+c.id))} · ${c.items||0} items</title><rect x="${(-w/2).toFixed(1)}" y="${(-d/2).toFixed(1)}" width="${w.toFixed(1)}" height="${d.toFixed(1)}" rx="1.5" fill="${fill}" ${hl}/></g>`; });
|
||||
$('#mapwrap').innerHTML = svg + '</svg>';
|
||||
}
|
||||
|
||||
@ -4,19 +4,19 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>RecordGod — storefront builder</title>
|
||||
<script defer src="/nav.js?v=3"></script>
|
||||
<script defer src="/nav.js?v=5"></script>
|
||||
<style>
|
||||
:root{--pink:#ff5db1;--ink:#0b0b0d;--panel:#141418;--line:#26262c;--mut:#9a9aa6;--ok:#46d18a}
|
||||
:root{--pink:#d10f7a;--ink:#f4f5f7;--panel:#ffffff;--line:#e2e2ea;--mut:#5f5f6c;--ok:#1a8f54}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;height:100%;background:var(--ink);color:#f0f0f2;font:14px/1.5 system-ui,sans-serif}
|
||||
html,body{margin:0;height:100%;background:var(--ink);color:#1b1b22;font:14px/1.5 system-ui,sans-serif}
|
||||
button{cursor:pointer;border:0;border-radius:8px;font:500 13px system-ui}
|
||||
.btn{background:var(--pink);color:#1a0a14;padding:9px 14px}
|
||||
.ghost{background:#1d1d22;color:#ccc;border:1px solid var(--line);padding:7px 10px}
|
||||
input,select{padding:8px 9px;border:1px solid var(--line);border-radius:7px;background:#0e0e11;color:#eee;font:13px system-ui;width:100%}
|
||||
.ghost{background:#f3f3f6;color:#2a2a30;border:1px solid var(--line);padding:7px 10px}
|
||||
input,select{padding:8px 9px;border:1px solid var(--line);border-radius:7px;background:#ffffff;color:#1b1b22;font:13px system-ui;width:100%}
|
||||
#gate{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--ink);z-index:20}
|
||||
#gate .b{width:320px;text-align:center}#gate h1{color:var(--pink)}
|
||||
#app{display:none;grid-template-rows:auto 1fr;height:calc(100vh - 44px)}
|
||||
header{display:flex;align-items:center;gap:14px;padding:11px 18px;border-bottom:1px solid var(--line);background:#0e0e11}
|
||||
header{display:flex;align-items:center;gap:14px;padding:11px 18px;border-bottom:1px solid var(--line);background:#ffffff}
|
||||
header .t{font-weight:600}header .t b{color:var(--pink)}
|
||||
header .sp{flex:1}#status{color:var(--mut);font-size:12px}
|
||||
.main{display:grid;grid-template-columns:380px 1fr;overflow:hidden}
|
||||
@ -29,15 +29,15 @@
|
||||
.colors{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||
.colorf{display:flex;gap:6px;align-items:center}.colorf input[type=color]{width:34px;height:30px;padding:0;border-radius:6px}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:6px}
|
||||
.chip{padding:4px 9px;border-radius:16px;border:1px solid var(--line);background:#17171c;color:#bbb;font-size:12px;cursor:pointer}
|
||||
.chip.on{background:#221a20;border-color:var(--pink);color:var(--pink)}
|
||||
.ord{display:flex;align-items:center;gap:6px;padding:6px 8px;border:1px solid var(--line);border-radius:7px;margin:5px 0;background:#17171c}
|
||||
.chip{padding:4px 9px;border-radius:16px;border:1px solid var(--line);background:#f3f3f6;color:#bbb;font-size:12px;cursor:pointer}
|
||||
.chip.on{background:#fdeef6;border-color:var(--pink);color:var(--pink)}
|
||||
.ord{display:flex;align-items:center;gap:6px;padding:6px 8px;border:1px solid var(--line);border-radius:7px;margin:5px 0;background:#f3f3f6}
|
||||
.ord .nm{flex:1;font-size:13px}.ord button{background:none;color:var(--mut);padding:2px 5px}
|
||||
.tabs{display:flex;gap:6px;margin-bottom:10px}
|
||||
.tab{padding:6px 11px;border-radius:7px;background:#17171c;color:#bbb;font-size:12px}.tab.on{background:var(--pink);color:#1a0a14}
|
||||
.tab{padding:6px 11px;border-radius:7px;background:#f3f3f6;color:#bbb;font-size:12px}.tab.on{background:var(--pink);color:#1a0a14}
|
||||
.swatch{width:22px;height:22px;border-radius:5px;cursor:pointer;border:1px solid #0006}
|
||||
/* ---- preview, themed from config ---- */
|
||||
.pv{--p:#ff5db1;--a:#46d18a;--bg:#0c0c0e;--pn:#141418;--tx:#f0f0f2;--r:12px;font-family:var(--font,system-ui)}
|
||||
.pv{--p:#d10f7a;--a:#1a8f54;--bg:#f4f5f7;--pn:#ffffff;--tx:#1b1b22;--r:12px;font-family:var(--font,system-ui)}
|
||||
.pv-shop{background:var(--bg);color:var(--tx);border-radius:14px;overflow:hidden;border:1px solid #0006;max-width:1000px;margin:0 auto}
|
||||
.pv-head{display:flex;align-items:center;gap:18px;padding:14px 20px;border-bottom:1px solid #ffffff14}
|
||||
.pv-logo{height:30px;max-width:150px;object-fit:contain}.pv-logo.txt{font-weight:700;color:var(--p);font-size:20px}
|
||||
@ -48,7 +48,7 @@
|
||||
.pv-title{font-weight:600;font-size:14px;line-height:1.2}.pv-artist{color:#ffffff99;font-size:13px}
|
||||
.pv-price{font-weight:600;color:var(--p);font-size:15px}.pv-price.big{font-size:28px}
|
||||
.pv-pill{display:inline-block;padding:1px 8px;border-radius:14px;background:#ffffff1a;font-size:11px}
|
||||
.pv-pill.ok{background:#46d18a22;color:var(--a)}
|
||||
.pv-pill.ok{background:#1a8f5422;color:var(--a)}
|
||||
.pv-btn{background:var(--p);color:#160a10;padding:7px;border-radius:8px;font-weight:600;margin-top:2px}.pv-btn.big{padding:11px 22px;font-size:15px}
|
||||
.pv-ghost{background:#ffffff14;color:var(--tx);padding:6px 9px;border-radius:8px}
|
||||
.pv-page{display:grid;grid-template-columns:300px 1fr;gap:24px;padding:24px;border-top:1px solid #ffffff14}
|
||||
@ -56,7 +56,7 @@
|
||||
.pv-h1{margin:.1em 0;font-size:30px}.pv-artist.big{font-size:18px;margin-bottom:8px}
|
||||
.pv-meta{color:#ffffffaa;font-size:13px;margin:3px 0}
|
||||
.pv-tracks{margin:12px 0;color:#ffffffcc;font-size:13px;line-height:1.9}
|
||||
.pv-dg{margin:10px 0;padding:8px 12px;border-radius:8px;background:#46d18a18;color:var(--a);font-size:13px}
|
||||
.pv-dg{margin:10px 0;padding:8px 12px;border-radius:8px;background:#1a8f5418;color:var(--a);font-size:13px}
|
||||
.lbl{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.1em;margin:18px 0 8px;max-width:1000px;margin-inline:auto}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@ -4,11 +4,11 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>RecordGod — control room</title>
|
||||
<script defer src="/nav.js?v=3"></script>
|
||||
<script defer src="/nav.js?v=5"></script>
|
||||
<style>
|
||||
:root{--pink:#ff5db1;--ink:#0c0c0e;--mut:#9a9aa6;--card:#141418;--line:#26262c}
|
||||
:root{--pink:#d10f7a;--ink:#f4f5f7;--mut:#5f5f6c;--card:#ffffff;--line:#e2e2ea}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--ink);color:#f0f0f2;font:15px/1.55 system-ui,sans-serif}
|
||||
html,body{margin:0;background:var(--ink);color:#1b1b22;font:15px/1.55 system-ui,sans-serif}
|
||||
.wrap{max-width:660px;margin:0 auto;padding:6vh 22px}
|
||||
h1{font-size:28px;font-weight:600;margin:0}
|
||||
h1 b{color:var(--pink)}
|
||||
@ -16,12 +16,12 @@
|
||||
.card{border:1px solid var(--line);border-radius:12px;padding:18px;background:var(--card);margin:0 0 18px}
|
||||
.card h2{font-size:15px;font-weight:500;margin:0 0 12px;color:#fff}
|
||||
label{display:block;font-size:13px;color:var(--mut);margin:12px 0 4px}
|
||||
input{width:100%;padding:10px;border:1px solid var(--line);border-radius:8px;background:#0e0e11;color:#eee;font:14px system-ui}
|
||||
input{width:100%;padding:10px;border:1px solid var(--line);border-radius:8px;background:#ffffff;color:#1b1b22;font:14px system-ui}
|
||||
.field{display:flex;gap:8px;align-items:flex-end}
|
||||
.field input{flex:1}
|
||||
button{padding:10px 14px;border:0;border-radius:8px;background:var(--pink);color:#1a0a14;font:500 14px system-ui;cursor:pointer;white-space:nowrap}
|
||||
button.ghost{background:#1d1d22;color:#ccc;border:1px solid var(--line)}
|
||||
.ok{color:#46d18a;font-size:12px;margin-left:6px}
|
||||
button.ghost{background:#f3f3f6;color:#2a2a30;border:1px solid var(--line)}
|
||||
.ok{color:#1a8f54;font-size:12px;margin-left:6px}
|
||||
.gate{text-align:center;padding:30px 0}
|
||||
#app{display:none}
|
||||
.soon{color:#55555f;font-size:13px}
|
||||
@ -106,13 +106,13 @@ async function save(name, btn){
|
||||
}
|
||||
|
||||
async function testConn(svc){
|
||||
const el = document.getElementById('c-'+svc); el.textContent='testing…'; el.style.color='#9a9aa6';
|
||||
const el = document.getElementById('c-'+svc); el.textContent='testing…'; el.style.color='#5f5f6c';
|
||||
let r, d;
|
||||
try { r = await fetch('/settings/test/'+svc, {method:'POST', headers:hdr()}); d = await r.json(); }
|
||||
catch(e){ el.textContent='✕ network error'; el.style.color='#e66'; return; }
|
||||
if(!r.ok){ el.textContent='✕ '+(d.detail||r.status); el.style.color='#e6a046'; return; }
|
||||
if(d.connected){ el.textContent='✓ connected'+(d.as?(' as '+d.as):''); el.style.color='#46d18a'; }
|
||||
else { el.textContent='✕ '+(d.detail||('HTTP '+d.status)); el.style.color='#e6a046'; }
|
||||
if(!r.ok){ el.textContent='✕ '+(d.detail||r.status); el.style.color='#b06a00'; return; }
|
||||
if(d.connected){ el.textContent='✓ connected'+(d.as?(' as '+d.as):''); el.style.color='#1a8f54'; }
|
||||
else { el.textContent='✕ '+(d.detail||('HTTP '+d.status)); el.style.color='#b06a00'; }
|
||||
}
|
||||
|
||||
// auto-resume if a token is already stored
|
||||
|
||||
@ -5,15 +5,15 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>RecordGod</title>
|
||||
<style>
|
||||
:root{--pink:#ff5db1;--ink:#0b0b0d;--line:#26262c;--mut:#8a8a94}
|
||||
:root{--pink:#d10f7a;--ink:#f4f5f7;--line:#e2e2ea;--mut:#5f5f6c}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;height:100%;background:var(--ink);color:#f0f0f2;font:15px/1.5 system-ui,sans-serif}
|
||||
html,body{margin:0;height:100%;background:var(--ink);color:#1b1b22;font:15px/1.5 system-ui,sans-serif}
|
||||
.wrap{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
|
||||
.box{width:340px;text-align:center}
|
||||
h1{font-size:46px;font-weight:600;margin:0 0 4px}
|
||||
h1 b{color:var(--pink);font-weight:600}
|
||||
.sub{color:var(--mut);letter-spacing:.14em;text-transform:uppercase;font-size:12px;margin-bottom:26px}
|
||||
input{width:100%;padding:12px;border:1px solid var(--line);border-radius:10px;background:#0e0e11;color:#eee;font:15px system-ui;text-align:center}
|
||||
input{width:100%;padding:12px;border:1px solid var(--line);border-radius:10px;background:#ffffff;color:#1b1b22;font:15px system-ui;text-align:center}
|
||||
button{width:100%;margin-top:10px;padding:12px;border:0;border-radius:10px;background:var(--pink);color:#1a0a14;font:600 15px system-ui;cursor:pointer}
|
||||
.err{color:#e66;font-size:13px;margin-top:10px;min-height:18px}
|
||||
.foot{color:#44444c;font-size:12px;margin-top:28px}
|
||||
|
||||
61
site/nav.js
61
site/nav.js
@ -1,5 +1,6 @@
|
||||
// Unified RecordGod owner nav — included on the back-office pages (admin / builder / dash).
|
||||
// Unified RecordGod owner nav — included on the back-office pages (admin / builder / dash / pos / search).
|
||||
// One source of truth: edit the links here, every page updates. Kept OFF the customer shop/store.
|
||||
// Also owns the LIGHT/DARK theme switch for every page (default light — the shop-admin readability default).
|
||||
(function () {
|
||||
const LINKS = [
|
||||
{ href: '/admin', label: 'Admin' },
|
||||
@ -9,19 +10,50 @@
|
||||
{ href: '/shop', label: 'Shop' },
|
||||
{ href: '/store/', label: '3D store' },
|
||||
];
|
||||
|
||||
// ── theme: set the attribute ASAP (deferred script, so a brief light flash is possible) ──
|
||||
const savedTheme = (() => { try { return localStorage.getItem('rg_theme'); } catch (_) { return null; } })();
|
||||
document.documentElement.dataset.theme = savedTheme || 'light';
|
||||
|
||||
const here = (location.pathname.replace(/\/+$/, '') || '/');
|
||||
const css = document.createElement('style');
|
||||
css.textContent =
|
||||
// light nav (default)
|
||||
'#rg-nav{position:fixed;top:0;left:0;right:0;height:44px;display:flex;align-items:center;gap:4px;' +
|
||||
'padding:0 16px;background:#0a0a0c;border-bottom:1px solid #26262c;z-index:99999;font:14px system-ui}' +
|
||||
'#rg-nav .b{font-weight:700;color:#eee;text-decoration:none;margin-right:14px}' +
|
||||
'#rg-nav .b i{color:#ff5db1;font-style:normal}' +
|
||||
'#rg-nav a.l{color:#cfcfd6;text-decoration:none;padding:6px 11px;border-radius:7px}' +
|
||||
'#rg-nav a.l:hover{background:#17171c}' +
|
||||
'#rg-nav a.l.on{background:#221a20;color:#ff5db1}' +
|
||||
'padding:0 16px;background:#ffffff;border-bottom:1px solid #e2e2ea;z-index:99999;font:14px system-ui}' +
|
||||
'#rg-nav .b{font-weight:700;color:#1b1b22;text-decoration:none;margin-right:14px}' +
|
||||
'#rg-nav .b i{color:#d10f7a;font-style:normal}' +
|
||||
'#rg-nav a.l{color:#2a2a30;text-decoration:none;padding:6px 11px;border-radius:7px}' +
|
||||
'#rg-nav a.l:hover{background:#f3f3f6}' +
|
||||
'#rg-nav a.l.on{background:#fdeef6;color:#d10f7a}' +
|
||||
'#rg-nav .sp{flex:1}' +
|
||||
'body{padding-top:44px}';
|
||||
'#rg-theme{cursor:pointer;border:1px solid #e2e2ea;background:#fff;color:#2a2a30;border-radius:7px;' +
|
||||
'padding:5px 9px;font-size:14px;line-height:1}' +
|
||||
'#rg-theme:hover{background:#f3f3f6}' +
|
||||
'body{padding-top:44px}' +
|
||||
// ── DARK OVERRIDE — forces dark on the common surfaces across all (inline-styled, inconsistent) pages.
|
||||
// !important is deliberate: it has to beat each page's own inline :root tokens + literals. ──
|
||||
'html[data-theme="dark"]{color-scheme:dark}' +
|
||||
'html[data-theme="dark"] body{background:#0f0f13 !important;color:#e8e8ec !important}' +
|
||||
'html[data-theme="dark"] .panel,html[data-theme="dark"] .card,html[data-theme="dark"] .box,' +
|
||||
'html[data-theme="dark"] .drop,html[data-theme="dark"] #gate{background:#17171c !important;' +
|
||||
'border-color:#2a2a32 !important;color:#e8e8ec !important;box-shadow:none !important}' +
|
||||
'html[data-theme="dark"] #gate{background:#0f0f13 !important}' +
|
||||
'html[data-theme="dark"] input,html[data-theme="dark"] select,html[data-theme="dark"] textarea{' +
|
||||
'background:#0e0e11 !important;color:#e8e8ec !important;border-color:#2a2a32 !important}' +
|
||||
'html[data-theme="dark"] .ghost{background:#1d1d22 !important;color:#d8d8de !important;border-color:#2a2a32 !important}' +
|
||||
'html[data-theme="dark"] .muted,html[data-theme="dark"] .lbl,html[data-theme="dark"] th{color:#9a9aa6 !important}' +
|
||||
'html[data-theme="dark"] td,html[data-theme="dark"] th{border-color:#23232a !important}' +
|
||||
'html[data-theme="dark"] .ri:hover{background:#22222a !important}' +
|
||||
'html[data-theme="dark"] .panel h2,html[data-theme="dark"] h1.pg{color:#e6e6ec !important}' +
|
||||
'html[data-theme="dark"] .tabs button.on{background:#17171c !important}' +
|
||||
'html[data-theme="dark"] #rg-nav{background:#0a0a0c !important;border-color:#23232a !important}' +
|
||||
'html[data-theme="dark"] #rg-nav .b{color:#eee !important}' +
|
||||
'html[data-theme="dark"] #rg-nav a.l{color:#cfcfd6 !important}' +
|
||||
'html[data-theme="dark"] #rg-nav a.l:hover{background:#17171c !important}' +
|
||||
'html[data-theme="dark"] #rg-theme{background:#17171c !important;color:#cfcfd6 !important;border-color:#2a2a32 !important}';
|
||||
document.head.appendChild(css);
|
||||
|
||||
const bar = document.createElement('nav');
|
||||
bar.id = 'rg-nav';
|
||||
bar.innerHTML = '<a class="b" href="/">Record<i>God</i></a>' +
|
||||
@ -29,6 +61,17 @@
|
||||
const on = here === p.href.replace(/\/+$/, '');
|
||||
return `<a class="l${on ? ' on' : ''}" href="${p.href}">${p.label}</a>`;
|
||||
}).join('') +
|
||||
'<span class="sp"></span>';
|
||||
'<span class="sp"></span>' +
|
||||
'<button id="rg-theme" title="Toggle light / dark"></button>';
|
||||
document.body.insertBefore(bar, document.body.firstChild);
|
||||
|
||||
const btn = document.getElementById('rg-theme');
|
||||
const paint = () => { btn.textContent = document.documentElement.dataset.theme === 'dark' ? '☀️' : '🌙'; };
|
||||
paint();
|
||||
btn.addEventListener('click', () => {
|
||||
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.dataset.theme = next;
|
||||
try { localStorage.setItem('rg_theme', next); } catch (_) {}
|
||||
paint();
|
||||
});
|
||||
})();
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>RecordGod — sales</title>
|
||||
<script defer src="/nav.js?v=3"></script>
|
||||
<script defer src="/nav.js?v=5"></script>
|
||||
<style>
|
||||
:root{--pink:#d10f7a;--btn:#e0117f;--ink:#f4f5f7;--pn:#ffffff;--pn2:#ffffff;--line:#e2e2ea;--mut:#5f5f6c;--ok:#1a8f54;--warn:#b06a00}
|
||||
*{box-sizing:border-box}
|
||||
|
||||
299
site/search.html
299
site/search.html
@ -4,34 +4,37 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>RecordGod — search & locate</title>
|
||||
<script defer src="/nav.js?v=3"></script>
|
||||
<script defer src="/nav.js?v=5"></script>
|
||||
<style>
|
||||
:root{--pink:#d10f7a;--hot:#ff2e93;--bg:#f4f5f7;--card:#ffffff;--ink:#1b1b22;--line:#e2e2ea;--mut:#5f5f6c;--ok:#1a8f54}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 system-ui,sans-serif}
|
||||
button{cursor:pointer;border:0;border-radius:8px;font:500 13px system-ui}
|
||||
.ghost{background:#fff;color:#2a2a30;border:1px solid var(--line);padding:7px 10px}
|
||||
.ghost:hover{background:#f3f3f6}
|
||||
input{padding:11px 12px;border:1px solid var(--line);border-radius:9px;background:#fff;color:var(--ink);font:15px system-ui}
|
||||
input:focus{outline:none;border-color:var(--pink)}
|
||||
.ghost:hover{background:#f3f3f6}.ghost:disabled{opacity:.4;cursor:default}
|
||||
.ghost.on{background:#fdeef6;color:var(--pink);border-color:var(--pink)}
|
||||
input,select{padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:#fff;color:var(--ink);font:15px system-ui}
|
||||
input:focus,select:focus{outline:none;border-color:var(--pink)}
|
||||
#gate{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--bg);z-index:50}
|
||||
#gate .b{width:320px;text-align:center}#gate h1{color:var(--pink)}
|
||||
#app{display:none;grid-template-columns:1.25fr 1fr;gap:16px;padding:16px;max-width:1280px;margin:0 auto}
|
||||
.panel{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;box-shadow:0 1px 3px rgba(0,0,0,.05)}
|
||||
.bar{display:flex;gap:8px;align-items:center;margin-bottom:12px}
|
||||
.bar input{flex:1;font-size:16px}
|
||||
.res{display:flex;flex-direction:column;gap:6px;max-height:calc(100vh - 200px);overflow:auto}
|
||||
#app{display:none;grid-template-columns:1.1fr 1.2fr;gap:16px;padding:16px;max-width:1340px;margin:0 auto}
|
||||
.panel{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;box-shadow:0 1px 3px rgba(0,0,0,.05);margin-bottom:14px}
|
||||
.bar{display:flex;gap:8px;align-items:center}
|
||||
.res{display:flex;flex-direction:column;gap:6px;max-height:36vh;overflow:auto;margin-top:8px}
|
||||
.ri{display:flex;gap:11px;align-items:center;padding:9px;border-radius:9px;cursor:pointer;border:1px solid transparent}
|
||||
.ri:hover{background:#f5f5f8}.ri.sel{border-color:var(--pink);background:#fdeef6}
|
||||
.ri img,.ri .ph{width:46px;height:46px;border-radius:6px;object-fit:cover;background:#ececf0;flex-shrink:0}
|
||||
.ri img,.ri .ph{width:42px;height:42px;border-radius:6px;object-fit:cover;background:#ececf0;flex-shrink:0}
|
||||
.ri .t{flex:1;min-width:0}.ri .nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:600}
|
||||
.muted{color:var(--mut);font-size:12px}.pink{color:var(--pink)}.ok{color:var(--ok)}
|
||||
.muted{color:var(--mut);font-size:12px}.pink{color:var(--pink)}.ok{color:var(--ok)}.oos{color:#c0392b}
|
||||
.loc{display:inline-block;margin-top:2px;padding:1px 7px;border-radius:20px;background:#fdeef6;color:var(--pink);font-size:11px;font-weight:600}
|
||||
.oos{color:#c0392b}
|
||||
#map{width:100%;background:#fbfbfd;border:1px solid var(--line);border-radius:10px;display:block;cursor:crosshair}
|
||||
.maphdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
|
||||
#crateName{font-weight:600}
|
||||
.scan{font-size:12px}
|
||||
.navhdr{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px}
|
||||
.crumb{font-weight:600;flex:1}.crumb b{color:var(--pink)}
|
||||
.lvl{display:flex;align-items:center;gap:6px}
|
||||
#cv{width:100%;background:#fbfbfd;border:1px solid var(--line);border-radius:10px;display:block;cursor:pointer}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
td,th{padding:6px 5px;border-bottom:1px solid #eee;text-align:left}th{color:var(--mut);font-weight:500}
|
||||
.legend{display:flex;gap:14px;margin-top:8px;font-size:11px;color:var(--mut);flex-wrap:wrap}
|
||||
.sw{display:inline-block;width:11px;height:11px;border-radius:3px;vertical-align:-1px;margin-right:4px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -40,22 +43,38 @@
|
||||
<div id="gerr" class="muted"></div></div></div>
|
||||
|
||||
<div id="app">
|
||||
<div class="panel">
|
||||
<div class="bar">
|
||||
<input id="q" placeholder="🔍 search title / artist / SKU — or scan a barcode" autocomplete="off" autofocus>
|
||||
<button class="ghost" onclick="run()">Search</button>
|
||||
<!-- LEFT: search + crate contents -->
|
||||
<div>
|
||||
<div class="panel">
|
||||
<div class="bar">
|
||||
<input id="q" placeholder="🔍 search title / artist / SKU — or scan" autocomplete="off" autofocus style="flex:1">
|
||||
<button class="ghost" onclick="run()">Search</button>
|
||||
</div>
|
||||
<div class="muted" id="count" style="margin-top:6px">type to search — click a hit to locate it</div>
|
||||
<div id="res" class="res"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="bar" style="justify-content:space-between">
|
||||
<div><span id="ccName" class="muted">Crate contents</span> <span id="ccMeta" class="muted"></span></div>
|
||||
</div>
|
||||
<div id="ccList" style="max-height:34vh;overflow:auto;margin-top:8px"><div class="muted">pick a crate on the map →</div></div>
|
||||
</div>
|
||||
<div class="muted scan" id="count">type to search the whole store</div>
|
||||
<div id="res" class="res"></div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: the Store → Rack navigator -->
|
||||
<div class="panel">
|
||||
<div class="maphdr">
|
||||
<div><span id="crateName" class="muted">Store map</span> <span id="crateMeta" class="muted"></span></div>
|
||||
<button class="ghost" id="clearCrate" style="display:none" onclick="pickCrate(null)">show all</button>
|
||||
<div class="navhdr">
|
||||
<span class="crumb" id="crumb">Store map</span>
|
||||
<select id="spaceSel" style="padding:6px 8px"></select>
|
||||
<button class="ghost" id="backBtn" style="display:none" onclick="toStore()">◀ Store</button>
|
||||
<span class="lvl" id="lvlCtl" style="display:none">
|
||||
<button class="ghost" id="lvlPrev" onclick="stepLevel(-1)">◀</button>
|
||||
<span id="lvlLabel" class="muted">Level</span>
|
||||
<button class="ghost" id="lvlNext" onclick="stepLevel(1)">▶</button>
|
||||
</span>
|
||||
</div>
|
||||
<canvas id="map" width="520" height="520"></canvas>
|
||||
<div class="muted" style="margin-top:8px">Click a bin to list what's in it. A search hit lights up its bin 📍</div>
|
||||
<canvas id="cv" width="560" height="560"></canvas>
|
||||
<div class="legend" id="legend"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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=>`<option value="${s.id}">${esc(s.name||('Space '+s.id))}${s.is_default?' (default)':''}</option>`).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='<span><i class="sw" style="background:#8bc34a"></i>rack — click to open</span>';
|
||||
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 / <b>'+esc(ST.rack.name||('Rack '+ST.rack.id))+'</b>';
|
||||
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='<span><i class="sw" style="background:#9ecbff"></i>crate</span><span>↑↓←→ = crate facing · BACK/FRONT = rack frame · green=items</span>';
|
||||
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<cols;i++){ g.beginPath(); g.moveTo(oX+i*cellW,oY); g.lineTo(oX+i*cellW,oY+cH); g.stroke(); }
|
||||
for(let i=1;i<rows;i++){ g.beginPath(); g.moveTo(oX,oY+i*cellH); g.lineTo(oX+cW,oY+i*cellH); g.stroke(); }
|
||||
// crates on the current level (fall back: crates with null level show on level 1)
|
||||
const here=ST.crates.filter(c=>(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? '<table><thead><tr><th>Slot</th><th>Title</th><th>Artist</th><th>Price</th></tr></thead><tbody>'+
|
||||
d.items.map(i=>`<tr><td>${i.slot??'—'}</td><td>${esc(i.title||i.sku)}${i.in_stock?'':' <span class="oos">sold</span>'}</td><td class="muted">${esc(i.artist||'')}</td><td class="pink">${money(i.price)}</td></tr>`).join('')+'</tbody></table>'
|
||||
: '<div class="muted">empty crate</div>';
|
||||
}
|
||||
|
||||
// ── 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.r&&dd<bd){bd=dd;best=h;} }
|
||||
if(best) openRack(best.id);
|
||||
} else {
|
||||
for(const h of ST.hit){ if(mx>=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)=>`<div class="ri" data-crate="${it.crate_id||''}" onclick="selRow(this,${it.crate_id||'null'})">
|
||||
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=>`<div class="ri" onclick='locate(${it.crate_id||'null'})'>
|
||||
${it.thumb?`<img src="${it.thumb}">`:'<span class=ph></span>'}
|
||||
<div class="t"><div class="nm">${esc(it.title||it.sku)}</div>
|
||||
<div class="muted">${esc(it.artist||'')} ${it.condition?'· '+esc(it.condition):''} · <b class="${it.in_stock?'pink':'oos'}">${money(it.price)}</b> ${it.in_stock?'':'· <span class="oos">sold</span>'}</div>
|
||||
${it.crate?`<span class="loc">📍 ${esc(it.crate)}</span>`:'<span class="muted">· no bin assigned</span>'}</div>
|
||||
</div>`).join('') || '<div class="muted" style="padding:10px">no match anywhere in the store</div>';
|
||||
<div class="muted">${esc(it.artist||'')} ${it.condition?'· '+esc(it.condition):''} · <b class="${it.in_stock?'pink':'oos'}">${money(it.price)}</b></div>
|
||||
${it.crate?`<span class="loc">📍 ${esc(it.crate)}</span>`:'<span class="muted">· no bin</span>'}</div></div>`).join('')
|
||||
|| '<div class="muted" style="padding:8px">no match anywhere in the store</div>';
|
||||
}
|
||||
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<h.r&&dd<bd){bd=dd;best=h;} }
|
||||
if(best) pickCrate(best.c); else pickCrate(null);
|
||||
});
|
||||
function pickCrate(c){
|
||||
selCrate=c?c.id:null; hiCrate=null;
|
||||
$('#crateName').textContent=c?(c.label_text||c.name||('Bin '+c.id)):'Store map';
|
||||
$('#crateName').className=c?'pink':'muted';
|
||||
$('#crateMeta').textContent=c?`· ${c.items||0} in stock`:'';
|
||||
$('#clearCrate').style.display=c?'inline-block':'none';
|
||||
draw(); run();
|
||||
}
|
||||
if(TOKEN){ $('#tok').value=TOKEN; signin(); }
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user