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 @@
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(); }
}
});