From 19e3cb131f771871ff3e74092755a0a0d3aa11e0 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 21 Jun 2026 23:16:57 +1000 Subject: [PATCH] feat: clean stub URLs (/builder /shop /admin /dash) + store geometry + storefront --- app/main.py | 15 +++++++ app/shop_routes.py | 43 ++++++++++++++++++ site/index.html | 3 +- site/shop.html | 83 ++++++++++++++++++++++++++++++++++ webstore/index.html | 106 ++++++++++++++++++++++++++++++++++---------- 5 files changed, 225 insertions(+), 25 deletions(-) create mode 100644 app/shop_routes.py create mode 100644 site/shop.html diff --git a/app/main.py b/app/main.py index 9437691..714e1e5 100644 --- a/app/main.py +++ b/app/main.py @@ -12,6 +12,7 @@ if _ENV.exists(): from fastapi import FastAPI # noqa: E402 from fastapi.staticfiles import StaticFiles # noqa: E402 +from fastapi.responses import FileResponse # noqa: E402 from . import __version__ # noqa: E402 from .wowplatter_v1 import router as wowplatter_v1_router # noqa: E402 @@ -19,6 +20,7 @@ from .virtual import router as virtual_router # noqa: E402 from .settings_routes import router as settings_router # noqa: E402 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 .db import engine # noqa: E402 from sqlalchemy import text as _sqltext # noqa: E402 @@ -28,6 +30,7 @@ app.include_router(virtual_router) app.include_router(settings_router) app.include_router(admin_router) app.include_router(layout_router) +app.include_router(shop_router) @app.on_event("startup") @@ -46,6 +49,18 @@ async def healthz(): _ROOT = pathlib.Path(__file__).resolve().parent.parent + + +# Pretty URLs: /builder /shop /admin /dash → the .html pages (registered before the "/" mount wins). +def _page(filename): + async def _serve(): + return FileResponse(_ROOT / "site" / filename) + return _serve + + +for _stub in ("shop", "builder", "admin", "dash"): + app.add_api_route(f"/{_stub}", _page(_stub + ".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(): app.mount("/store", StaticFiles(directory=str(_ROOT / "webstore"), html=True), name="store") diff --git a/app/shop_routes.py b/app/shop_routes.py new file mode 100644 index 0000000..8ded1da --- /dev/null +++ b/app/shop_routes.py @@ -0,0 +1,43 @@ +import json + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import text + +from .db import get_db +from .layout_routes import DEFAULTS + +# PUBLIC storefront API — the customer-facing shop reads its theme/layout + catalog here. +# No auth (theme + in-stock catalog aren't secret); driven by what the builder saves. +router = APIRouter(prefix="/shop", tags=["shop"]) + + +@router.get("/config") +async def shop_config(db=Depends(get_db)): + row = (await db.execute(text("SELECT config FROM store_config WHERE store_id=1"))).first() + cfg = row[0] if row else None + if isinstance(cfg, str): + cfg = json.loads(cfg) + return cfg or DEFAULTS + + +@router.get("/catalog") +async def shop_catalog(q: str = Query(""), page: int = Query(1, ge=1), db=Depends(get_db)): + per = 24 + where = ["i.in_stock", "i.store_id = 1"] + params = {"per": per, "off": (page - 1) * per} + if q: + where.append("(coalesce(i.title, dc.title) ILIKE :q OR dc.artist ILIKE :q)") + params["q"] = f"%{q}%" + w = " AND ".join(where) + items = [dict(r) for r in (await db.execute(text(f""" + SELECT i.sku, coalesce(i.title, dc.title) AS title, dc.artist, dc.thumb, + i.price::float AS price, i.condition, i.kind + FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id = i.release_id + WHERE {w} + ORDER BY i.updated_at DESC NULLS LAST + LIMIT :per OFFSET :off"""), params)).mappings()] + cparams = {k: v for k, v in params.items() if k not in ("per", "off")} + total = (await db.execute(text( + f"SELECT count(*) FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id=i.release_id WHERE {w}" + ), cparams)).scalar() + return {"items": items, "page": page, "per": per, "total": total} diff --git a/site/index.html b/site/index.html index 26fddd3..128b61f 100644 --- a/site/index.html +++ b/site/index.html @@ -34,7 +34,8 @@
Enter the virtual store → - Control room + Control room + Storefront builder
diff --git a/site/shop.html b/site/shop.html new file mode 100644 index 0000000..e9d5790 --- /dev/null +++ b/site/shop.html @@ -0,0 +1,83 @@ + + + + + +RecordGod shop + + + +
+
+
loading…
+
+ + + + diff --git a/webstore/index.html b/webstore/index.html index 58a7ee9..2da9f28 100644 --- a/webstore/index.html +++ b/webstore/index.html @@ -74,6 +74,73 @@ function material(color, fallback) { return new THREE.MeshStandardMaterial({ color: new THREE.Color(color || fallback), roughness: 0.85 }); } +const yn = (v, def = 'y') => String(v ?? def).toLowerCase() !== 'n'; + +// rack: bench (slab) | rack (posts + frame + shelves + panels) | cupboard. Built base-at-0 then +// centered on the group origin so it's a drop-in for the old centered box (identical placement). +function buildRack(t, levels) { + const inner = new THREE.Group(); + const w = num(t.width, 1), h = num(t.height, 0.9), d = num(t.depth, 0.4); + const baseH = num(t.base_height, 0), th = Math.max(0.01, num(t.thickness, 0.02)); + const c = t.material_color || '#7a6a55', rough = num(t.material_roughness, 0.6); + const m = new THREE.MeshStandardMaterial({ color: new THREE.Color(c), roughness: rough }); + const px = w / 2 - th / 2, pz = d / 2 - th / 2; + const add = (geo, x, y, z) => { const me = new THREE.Mesh(geo, m); me.position.set(x, y, z); inner.add(me); }; + const type = t.type || 'bench'; + if (type === 'rack') { + [[-px, -pz], [-px, pz], [px, -pz], [px, pz]].forEach(([x, z]) => add(new THREE.BoxGeometry(th, h, th), x, baseH + h / 2, z)); + [baseH, baseH + h].forEach(y => { add(new THREE.BoxGeometry(w - 2 * th, th, th), 0, y, -pz); add(new THREE.BoxGeometry(w - 2 * th, th, th), 0, y, pz); add(new THREE.BoxGeometry(th, th, d - 2 * th), -px, y, 0); add(new THREE.BoxGeometry(th, th, d - 2 * th), px, y, 0); }); + if (yn(t.left_panel, 'n')) add(new THREE.BoxGeometry(th, h, d), -px, baseH + h / 2, 0); + if (yn(t.right_panel, 'n')) add(new THREE.BoxGeometry(th, h, d), px, baseH + h / 2, 0); + if (yn(t.back_panel, 'n')) add(new THREE.BoxGeometry(w, h, th), 0, baseH + h / 2, -pz); + (levels || []).forEach(lv => { + const lt = Math.max(0.005, num(lv.thickness, 0.02)), ly = baseH + num(lv.y_pos, 0); + const lw = num(lv.width, 0) || (w - 2 * th), ld = num(lv.depth, 0) || (d - 2 * th); + const lm = new THREE.MeshStandardMaterial({ color: new THREE.Color(lv.material_color || c), roughness: rough }); + const p = new THREE.Mesh(new THREE.BoxGeometry(lw, lt, ld), lm); p.position.set(0, ly, 0); inner.add(p); + }); + } else if (type === 'cupboard') { + add(new THREE.BoxGeometry(w, th, d), 0, baseH + h - th / 2, 0); add(new THREE.BoxGeometry(w, th, d), 0, baseH + th / 2, 0); + if (yn(t.left_panel)) add(new THREE.BoxGeometry(th, h, d), -px, baseH + h / 2, 0); + if (yn(t.right_panel)) add(new THREE.BoxGeometry(th, h, d), px, baseH + h / 2, 0); + if (yn(t.back_panel)) add(new THREE.BoxGeometry(w, h, th), 0, baseH + h / 2, -pz); + } else { + const tt = Math.max(0.02, h); add(new THREE.BoxGeometry(w, tt, d), 0, baseH + tt / 2, 0); + } + inner.position.y = -(baseH + h / 2); + const g = new THREE.Group(); g.add(inner); return g; +} + +// crate: open record bin — floor + short front + full back + sloped sides (front-low/back-high). +// Naturally centered on the origin → drop-in for the old centered box. +function slopedSide(x, ch, cd, hf, th, m) { + const geom = new THREE.BufferGeometry(); + const yB = -ch / 2 + th / 2, yFT = -ch / 2 + hf - th / 2, yBT = ch / 2 - th / 2, zF = cd / 2 - th / 2, zB = -cd / 2 + th / 2; + geom.setAttribute('position', new THREE.BufferAttribute(new Float32Array([x, yB, zF, x, yFT, zF, x, yB, zB, x, yBT, zB]), 3)); + geom.setIndex([0, 1, 2, 2, 1, 3]); geom.computeVertexNormals(); + return new THREE.Mesh(geom, m); +} +function buildCrate(t, front) { + const g = new THREE.Group(); + const w = num(t.width, 0.34), h = num(t.height, 0.2), d = num(t.depth, 0.53), th = num(t.wall_thickness, 0.01); + const hf = (t.front_height != null && +t.front_height > 0) ? +t.front_height : null; + const m = new THREE.MeshStandardMaterial({ color: new THREE.Color(t.material_color || '#b9b9c2'), roughness: num(t.material_roughness, 0.6), metalness: num(t.material_metalness, 0.1), side: THREE.DoubleSide }); + const add = (geo, x, y, z) => { const me = new THREE.Mesh(geo, m); me.position.set(x, y, z); g.add(me); }; + add(new THREE.BoxGeometry(w, th, d), 0, -h / 2 + th / 2, 0); + if (yn(t.back_panel)) add(new THREE.BoxGeometry(w, h, th), 0, 0, -d / 2 + th / 2); + if (yn(t.front_panel)) add(new THREE.BoxGeometry(w, hf || h, th), 0, hf ? (-h / 2 + hf / 2) : 0, d / 2 - th / 2); + if (yn(t.left_panel)) { if (hf) g.add(slopedSide(-(w / 2 - th / 2), h, d, hf, th, m)); else add(new THREE.BoxGeometry(th, h, d), -(w / 2 - th / 2), 0, 0); } + if (yn(t.right_panel)) { if (hf) g.add(slopedSide(w / 2 - th / 2, h, d, hf, th, m)); else add(new THREE.BoxGeometry(th, h, d), w / 2 - th / 2, 0, 0); } + if (front && front.thumb) { + const pm = new THREE.MeshStandardMaterial({ roughness: 0.55 }); + texLoader.load(front.thumb, tx => { pm.map = tx; pm.needsUpdate = true; }, undefined, () => {}); + const s = Math.min(w, d) * 0.92; + const cov = new THREE.Mesh(new THREE.PlaneGeometry(s, s), pm); + cov.position.set(0, hf ? (-h / 2 + hf * 0.55) : 0, 0); cov.rotation.x = -Math.PI / 2.3; g.add(cov); + } + return g; +} + // text → CanvasTexture (shared by text decals + portal labels) function textTexture(text, { color = '#fff', bg = null, font = 64, pad = 24 } = {}) { const lines = String(text).split('\n'); @@ -118,36 +185,27 @@ function buildScene(data) { }); if (!(data.lights || []).length) { const d = new THREE.DirectionalLight(0xffffff, 0.7); d.position.set(3, 6, 2); roomGroup.add(d); } - // racks (box per rack, sized by its rack_type) + // racks — proper shelving (bench / rack / cupboard) from rack_type + its levels const rackTypes = Object.fromEntries((data.rack_types || []).map(t => [t.id, t])); + const levelsByType = {}; (data.rack_type_levels || []).forEach(l => { (levelsByType[l.rack_type_id] ||= []).push(l); }); (data.racks || []).forEach(rk => { const t = rackTypes[rk.rack_type_id] || {}; - const w = num(t.width, 1), h = num(t.height, 1.8), dp = num(t.depth, 0.4); - const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, dp), material(t.material_color, '#5a4634')); - m.position.set(num(rk.pos_x), num(rk.pos_y, h / 2), num(rk.pos_z)); - m.rotation.set(rad(num(rk.rotation_x)), rad(num(rk.rotation_y)), rad(num(rk.rotation_z))); - roomGroup.add(m); + const g = buildRack(t, levelsByType[rk.rack_type_id] || []); + g.position.set(num(rk.pos_x), num(rk.pos_y, num(t.height, 0.9) / 2), num(rk.pos_z)); + g.rotation.set(rad(num(rk.rotation_x)), rad(num(rk.rotation_y)), rad(num(rk.rotation_z))); + roomGroup.add(g); }); - // crates (box per crate, sized by crate_type) + front-cover texture + // crates — open record bins (floor + short front + sloped sides) with the front cover laid in const crateTypes = Object.fromEntries((data.crate_types || []).map(t => [t.id, t])); const frontByCrate = Object.fromEntries((data.records || []).filter(r => r.thumb).map(r => [r.crate_id, r])); (data.crates || []).forEach(cr => { const t = crateTypes[cr.crate_type_id] || {}; - const w = num(t.width, 0.35), h = num(t.height, 0.2), dp = num(t.depth, 0.5); - const body = material(t.material_color, '#c9c9cf'); - const mats = [body, body, body, body, body, body]; - const front = frontByCrate[cr.id]; - if (front) { - const mat = new THREE.MeshStandardMaterial({ roughness: 0.6 }); - texLoader.load(front.thumb, tx => { mat.map = tx; mat.needsUpdate = true; }, undefined, () => {}); - mats[4] = mat; // +Z face shows the front cover - } - const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, dp), mats); - m.position.set(num(cr.pos_x), num(cr.pos_y, h / 2), num(cr.pos_z)); - m.rotation.set(rad(num(cr.rotation_x)), rad(num(cr.rotation_y)), rad(num(cr.rotation_z))); - m.userData = { crateId: cr.id, name: cr.label_text || cr.name }; - roomGroup.add(m); crateMeshes.push(m); + const g = buildCrate(t, frontByCrate[cr.id]); + g.position.set(num(cr.pos_x), num(cr.pos_y, num(t.height, 0.2) / 2), num(cr.pos_z)); + g.rotation.set(rad(num(cr.rotation_x)), rad(num(cr.rotation_y)), rad(num(cr.rotation_z))); + g.userData = { crateId: cr.id, name: cr.label_text || cr.name }; + roomGroup.add(g); crateMeshes.push(g); }); // decals — wall art / signage as textured planes (image) or canvas text. mesh decals skipped. @@ -199,10 +257,10 @@ const ray = new THREE.Raycaster(); renderer.domElement.addEventListener('click', () => { if (!controls.isLocked || dig.active) return; ray.setFromCamera(new THREE.Vector2(0, 0), camera); - const hit = ray.intersectObjects(crateMeshes, false)[0]; + const hit = ray.intersectObjects(crateMeshes, true)[0]; if (hit && hit.distance < 3.5) { - dig.open(hit.object.userData.crateId); // enter the spring-physics dig view - controls.unlock(); + let o = hit.object; while (o && !(o.userData && o.userData.crateId)) o = o.parent; + if (o) { dig.open(o.userData.crateId); controls.unlock(); } } });