feat: clean stub URLs (/builder /shop /admin /dash) + store geometry + storefront

This commit is contained in:
type-two 2026-06-21 23:16:57 +10:00
parent a9076af19d
commit 19e3cb131f
5 changed files with 225 additions and 25 deletions

View File

@ -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")

43
app/shop_routes.py Normal file
View File

@ -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}

View File

@ -34,7 +34,8 @@
<div class="row">
<a class="btn primary" href="/store/">Enter the virtual store →</a>
<a class="btn ghost" href="/dash.html">Control room</a>
<a class="btn ghost" href="/admin">Control room</a>
<a class="btn ghost" href="/builder">Storefront builder</a>
</div>
<div class="grid">

83
site/shop.html Normal file
View File

@ -0,0 +1,83 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RecordGod shop</title>
<style>
:root{--p:#ff5db1;--a:#46d18a;--bg:#0c0c0e;--pn:#141418;--tx:#f0f0f2;--r:12px}
*{box-sizing:border-box}
html,body{margin:0;min-height:100%;background:var(--bg);color:var(--tx);font:15px/1.5 var(--font,system-ui),sans-serif}
a{color:inherit;text-decoration:none}
.head{display:flex;align-items:center;gap:20px;padding:14px 24px;border-bottom:1px solid #ffffff14;position:sticky;top:0;background:var(--bg);z-index:5}
.logo{height:32px;max-width:170px;object-fit:contain}.logo.txt{font-weight:700;color:var(--p);font-size:21px}
nav{display:flex;gap:18px;flex:1;flex-wrap:wrap}nav a{opacity:.85;font-size:14px}
.srch{display:flex;gap:8px;padding:16px 24px}
.srch input{flex:1;max-width:420px;padding:10px;border:1px solid #ffffff22;border-radius:8px;background:#0e0e11;color:var(--tx)}
.srch button,.cartbtn{background:var(--p);color:#160a10;border:0;border-radius:8px;padding:9px 16px;font-weight:600;cursor:pointer}
.grid{display:grid;gap:16px;padding:8px 24px 40px}
.card{background:var(--pn);border-radius:var(--r);padding:10px;display:flex;flex-direction:column;gap:6px}
.cover{width:100%;aspect-ratio:1;border-radius:calc(var(--r) - 4px);object-fit:cover;background:#222}
.title{font-weight:600;font-size:14px;line-height:1.2}.artist{color:#ffffff99;font-size:13px}
.price{font-weight:600;color:var(--p)}
.pill{display:inline-block;padding:1px 8px;border-radius:14px;background:#ffffff1a;font-size:11px;width:fit-content}
.pill.ok{background:#46d18a22;color:var(--a)}
.cart{background:var(--p);color:#160a10;border:0;border-radius:8px;padding:7px;font-weight:600;cursor:pointer;margin-top:2px}
.pg{display:flex;gap:10px;align-items:center;justify-content:center;padding:0 0 40px}
.pg button{background:#1d1d22;color:#ccc;border:1px solid #ffffff22;border-radius:8px;padding:8px 14px;cursor:pointer}
.muted{color:#ffffff88}
</style>
</head>
<body>
<div class="head"><span id="logo"></span><nav id="nav"></nav><button class="cartbtn">Cart</button></div>
<div class="srch"><input id="q" placeholder="search the shop…"><button onclick="search()">Search</button></div>
<div id="grid" class="grid muted" style="padding:24px">loading…</div>
<div id="pg" class="pg"></div>
<script>
let cfg=null, page=1, q='';
const num=(v,d)=>v==null?d:v;
const el={
cover:r=>r.thumb?`<img class=cover src="${r.thumb}">`:`<div class=cover></div>`,
title:r=>`<div class="title">${esc(r.title||r.sku)}</div>`,
artist:r=>`<div class="artist">${esc(r.artist||'')}</div>`,
price:r=>`<div class="price">$${r.price==null?'—':r.price.toFixed(2)}</div>`,
condition:r=>r.condition?`<span class="pill">${esc(r.condition)}</span>`:'',
format:r=>`<span class="pill">${esc(r.kind||'vinyl')}</span>`,
genre:()=>'',year:()=>'',
stock_badge:()=>`<span class="pill ok">in stock</span>`,
wishlist:()=>`<button class="cart" style="background:#ffffff14;color:var(--tx)">♡ Wishlist</button>`,
cart:()=>`<button class="cart">Add to cart</button>`,
};
function esc(s){return (s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
async function boot(){
cfg=await fetch('/shop/config').then(r=>r.json());
const t=cfg.theme||{}, root=document.documentElement;
root.style.setProperty('--p',t.primary); root.style.setProperty('--a',t.accent);
root.style.setProperty('--bg',t.bg); root.style.setProperty('--pn',t.panel);
root.style.setProperty('--tx',t.text); root.style.setProperty('--r',(t.radius??12)+'px');
if(t.font) root.style.setProperty('--font',t.font);
document.getElementById('logo').innerHTML = t.logo
? `<img class="logo" src="${t.logo}" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'logo txt',textContent:'RecordGod'}))">`
: `<span class="logo txt">RecordGod</span>`;
document.getElementById('nav').innerHTML=(cfg.menu||[]).map(m=>`<a href="${m.href}">${esc(m.label)}</a>`).join('');
load();
}
async function load(){
document.getElementById('grid').className='grid muted';
const d=await fetch(`/shop/catalog?q=${encodeURIComponent(q)}&page=${page}`).then(r=>r.json());
const cols=(cfg.theme&&cfg.theme.cardCols)||4;
const g=document.getElementById('grid');
g.className='grid'; g.style.gridTemplateColumns=`repeat(${cols},1fr)`;
g.innerHTML=d.items.map(r=>`<div class="card">${(cfg.card||[]).map(k=>el[k]?el[k](r):'').join('')}</div>`).join('')||'<div class="muted">nothing found</div>';
const pages=Math.max(1,Math.ceil(d.total/d.per));
document.getElementById('pg').innerHTML=`<button ${page<=1?'disabled':''} onclick="go(-1)"> prev</button><span class="muted">page ${page} / ${pages} · ${d.total} items</span><button ${page>=pages?'disabled':''} onclick="go(1)">next </button>`;
}
function search(){ q=document.getElementById('q').value.trim(); page=1; load(); }
function go(n){ page+=n; load(); window.scrollTo(0,0); }
document.getElementById('q').addEventListener('keydown',e=>{if(e.key==='Enter')search();});
boot();
</script>
</body>
</html>

View File

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