feat(builder): storefront customizer

This commit is contained in:
type-two 2026-06-21 21:29:55 +10:00
parent 8c25b65d7e
commit a9076af19d
4 changed files with 339 additions and 0 deletions

11
.claude/launch.json Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "recordgod",
"runtimeExecutable": ".venv/bin/uvicorn",
"runtimeArgs": ["app.main:app", "--port", "8010"],
"port": 8010
}
]
}

108
app/layout_routes.py Normal file
View File

@ -0,0 +1,108 @@
import json
import re
from collections import Counter
from urllib.parse import urljoin
import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import text
from .auth import require_token
from .db import get_db
# Storefront customizer — per-store theme + header menu + which elements appear on cards /
# product pages, in what order. Lets RecordGod dress ANY shop without touching code.
router = APIRouter(prefix="/layout", tags=["layout"])
DEFAULTS = {
"theme": {"primary": "#ff5db1", "accent": "#46d18a", "bg": "#0c0c0e",
"panel": "#141418", "text": "#f0f0f2", "font": "system-ui", "logo": "",
"radius": 12, "cardCols": 4},
"menu": [{"label": "Home", "href": "/"}, {"label": "Vinyl", "href": "/vinyl"},
{"label": "Genres", "href": "/genres"}, {"label": "New in", "href": "/new"},
{"label": "Cart", "href": "/cart"}],
"card": ["cover", "title", "artist", "price", "condition", "cart"],
"product_page": ["cover", "title", "artist", "price", "condition", "genre",
"tracklist", "cart", "related"],
}
# Every element the editor can place, per surface.
PALETTE = {
"card": ["cover", "title", "artist", "price", "condition", "cart", "genre",
"format", "year", "stock_badge", "wishlist"],
"product_page": ["cover", "gallery", "title", "artist", "label", "price", "condition",
"genre", "format", "year", "country", "tracklist", "cart", "wishlist",
"description", "related", "dealgod_value"],
}
class ConfigIn(BaseModel):
config: dict
class UrlIn(BaseModel):
url: str
@router.get("")
async def get_layout(ident=Depends(require_token), db=Depends(get_db)):
row = (await db.execute(text("SELECT config FROM store_config WHERE store_id=:s"),
{"s": ident["store_id"]})).first()
cfg = row[0] if row else None
if isinstance(cfg, str):
cfg = json.loads(cfg)
return {"ok": True, "config": cfg or DEFAULTS, "defaults": DEFAULTS, "palette": PALETTE}
@router.post("")
async def save_layout(body: ConfigIn, ident=Depends(require_token), db=Depends(get_db)):
await db.execute(text("""
INSERT INTO store_config (store_id, config, updated_at)
VALUES (:s, CAST(:c AS jsonb), now())
ON CONFLICT (store_id) DO UPDATE SET config = EXCLUDED.config, updated_at = now()
"""), {"s": ident["store_id"], "c": json.dumps(body.config)})
await db.commit()
return {"ok": True}
@router.post("/import-brand")
async def import_brand(body: UrlIn, ident=Depends(require_token)):
"""Pull a store's brand from its website — theme colour, logo, font — the 'bam, adapt
colourway' move (same spirit as DealGod store-intel). Lightweight HTTP scrape."""
url = body.url.strip()
if not url.startswith("http"):
url = "https://" + url
try:
async with httpx.AsyncClient(timeout=15, follow_redirects=True,
headers={"User-Agent": "RecordGod/0.1 brand-import"}) as c:
html = (await c.get(url)).text
except Exception as e:
raise HTTPException(400, f"could not fetch {url}: {e}")
def meta(prop):
m = (re.search(rf'<meta[^>]+(?:name|property)=["\']{re.escape(prop)}["\'][^>]+content=["\']([^"\']+)', html, re.I)
or re.search(rf'<meta[^>]+content=["\']([^"\']+)["\'][^>]+(?:name|property)=["\']{re.escape(prop)}["\']', html, re.I))
return m.group(1) if m else None
theme_color = meta("theme-color")
logo = meta("og:image")
if not logo:
m = re.search(r'<link[^>]+rel=["\'][^"\']*icon[^"\']*["\'][^>]+href=["\']([^"\']+)', html, re.I)
logo = m.group(1) if m else None
if logo and not logo.startswith("http"):
logo = urljoin(url, logo)
counts = Counter(h.lower() for h in re.findall(r'#[0-9a-fA-F]{6}', html))
common = [h for h, _ in counts.most_common(24) if h not in ('#ffffff', '#000000')][:6]
gf = re.search(r'fonts\.googleapis\.com/css2?\?family=([A-Za-z0-9+]+)', html)
if gf:
font = gf.group(1).replace('+', ' ')
else:
fm = re.search(r'font-family:\s*["\']?([A-Za-z0-9 \-]+)', html, re.I)
font = fm.group(1).strip() if fm else None
return {"ok": True, "palette": {
"primary": theme_color or (common[0] if common else "#ff5db1"),
"colors": common, "logo": logo or "", "font": font or "system-ui",
}}

View File

@ -18,12 +18,26 @@ from .wowplatter_v1 import router as wowplatter_v1_router # noqa: E402
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 .db import engine # noqa: E402
from sqlalchemy import text as _sqltext # noqa: E402
app = FastAPI(title="RECORDGOD", version=__version__)
app.include_router(wowplatter_v1_router)
app.include_router(virtual_router)
app.include_router(settings_router)
app.include_router(admin_router)
app.include_router(layout_router)
@app.on_event("startup")
async def _ensure_tables():
# new tables that aren't part of the original schema.sql load — create lazily on deploy
async with engine.begin() as conn:
await conn.execute(_sqltext(
"CREATE TABLE IF NOT EXISTS store_config "
"(store_id int PRIMARY KEY, config jsonb NOT NULL, "
"updated_at timestamptz NOT NULL DEFAULT now())"))
@app.get("/healthz")

206
site/builder.html Normal file
View File

@ -0,0 +1,206 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RecordGod — storefront builder</title>
<style>
:root{--pink:#ff5db1;--ink:#0b0b0d;--panel:#141418;--line:#26262c;--mut:#9a9aa6;--ok:#46d18a}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--ink);color:#f0f0f2;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%}
#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:100vh}
header{display:flex;align-items:center;gap:14px;padding:11px 18px;border-bottom:1px solid var(--line);background:#0e0e11}
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}
.editor{overflow:auto;border-right:1px solid var(--line);padding:16px}
.preview{overflow:auto;background:#1a1a1e;padding:24px}
.panel{border:1px solid var(--line);border-radius:10px;padding:13px;margin-bottom:14px;background:var(--panel)}
.panel h3{margin:0 0 10px;font-size:13px;font-weight:600;color:#fff}
label.l{display:block;color:var(--mut);font-size:12px;margin:9px 0 3px}
.row{display:flex;gap:8px;align-items:center}
.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}
.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}
.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-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}
.pv-nav{display:flex;gap:16px;flex:1}.pv-nav a{color:var(--tx);opacity:.85;font-size:14px;text-decoration:none}
.pv-grid{display:grid;gap:16px;padding:20px}
.pv-card{background:var(--pn);border-radius:var(--r);padding:10px;display:flex;flex-direction:column;gap:6px}
.pv-cover{width:100%;aspect-ratio:1;border-radius:calc(var(--r) - 4px)}
.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-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}
.pv-bigcover{width:100%;aspect-ratio:1;border-radius:var(--r)}
.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}
.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>
<body>
<div id="gate"><div class="b"><h1>Record<b style="color:var(--pink)">God</b></h1><p style="color:var(--mut)">storefront builder</p>
<div class="row"><input id="tok" type="password" placeholder="admin token"><button class="btn" onclick="signin()">Enter</button></div>
<div id="gerr" style="color:var(--mut);font-size:12px;margin-top:8px"></div></div></div>
<div id="app">
<header>
<div class="t">Record<b>God</b> · storefront builder</div>
<div class="sp"></div>
<span id="status"></span>
<button class="ghost" onclick="resetDefaults()">Reset</button>
<button class="btn" onclick="save()">Save</button>
</header>
<div class="main">
<div class="editor" id="editor"></div>
<div class="preview"><div class="pv" id="pv"></div></div>
</div>
</div>
<script>
const $=s=>document.querySelector(s);
let TOKEN=localStorage.getItem('rg_token')||'';
let cfg=null, palette=null, defaults=null, surface='card';
const hdr=()=>({'Authorization':'Bearer '+TOKEN,'Content-Type':'application/json'});
async function signin(){
TOKEN=$('#tok').value.trim();
const r=await fetch('/layout',{headers:hdr()});
if(!r.ok){$('#gerr').textContent='invalid token';return;}
localStorage.setItem('rg_token',TOKEN);
const d=await r.json(); cfg=d.config; palette=d.palette; defaults=d.defaults;
$('#gate').style.display='none'; $('#app').style.display='grid';
renderEditor(); renderPreview();
}
function resetDefaults(){ cfg=JSON.parse(JSON.stringify(defaults)); renderEditor(); renderPreview(); }
// ---------- editor ----------
function renderEditor(){
const th=cfg.theme;
const colorRow=(k,lbl)=>`<div class="colorf"><input type="color" value="${th[k]||'#000000'}" oninput="setTheme('${k}',this.value)"><span style="font-size:12px;color:var(--mut)">${lbl}</span></div>`;
$('#editor').innerHTML=`
<div class="panel"><h3>Brand import <span style="color:var(--mut);font-weight:400">— bam</span></h3>
<label class="l">Pull theme from a store's website</label>
<div class="row"><input id="impurl" placeholder="monsterrobot.party"><button class="ghost" onclick="importBrand()">Import</button></div>
<div id="impres" style="margin-top:8px"></div></div>
<div class="panel"><h3>Theme</h3>
<div class="colors">${colorRow('primary','primary')}${colorRow('accent','accent')}${colorRow('bg','background')}${colorRow('panel','panel')}${colorRow('text','text')}</div>
<label class="l">Logo URL</label><input value="${th.logo||''}" oninput="setTheme('logo',this.value)" placeholder="https://…/logo.png">
<div class="row" style="margin-top:8px"><div style="flex:1"><label class="l">Font</label><input value="${th.font||'system-ui'}" oninput="setTheme('font',this.value)"></div>
<div style="width:90px"><label class="l">Radius</label><input type="number" value="${th.radius??12}" oninput="setTheme('radius',+this.value)"></div>
<div style="width:90px"><label class="l">Card cols</label><input type="number" min="2" max="6" value="${th.cardCols??4}" oninput="setTheme('cardCols',+this.value)"></div></div></div>
<div class="panel"><h3>Header menu</h3><div id="menu"></div>
<button class="ghost" style="margin-top:6px" onclick="addMenu()">+ menu item</button></div>
<div class="panel"><h3>Page elements</h3>
<div class="tabs"><span class="tab ${surface==='card'?'on':''}" onclick="setSurface('card')">Item card</span><span class="tab ${surface==='product_page'?'on':''}" onclick="setSurface('product_page')">Product page</span></div>
<label class="l">Click to add / remove · arrows to reorder</label>
<div class="chips" id="chips"></div>
<div id="order" style="margin-top:10px"></div></div>`;
renderMenu(); renderChips(); renderOrder();
}
function renderMenu(){
$('#menu').innerHTML=cfg.menu.map((m,i)=>`<div class="ord">
<input style="flex:1" value="${m.label}" oninput="setMenu(${i},'label',this.value)">
<input style="flex:1" value="${m.href}" oninput="setMenu(${i},'href',this.value)">
<button onclick="moveMenu(${i},-1)"></button><button onclick="moveMenu(${i},1)"></button><button onclick="rmMenu(${i})"></button></div>`).join('');
}
function renderChips(){
$('#chips').innerHTML=palette[surface].map(el=>`<span class="chip ${cfg[surface].includes(el)?'on':''}" onclick="toggleEl('${el}')">${el.replace(/_/g,' ')}</span>`).join('');
}
function renderOrder(){
$('#order').innerHTML=cfg[surface].map((el,i)=>`<div class="ord"><span class="nm">${el.replace(/_/g,' ')}</span>
<button onclick="moveEl(${i},-1)"></button><button onclick="moveEl(${i},1)"></button><button onclick="toggleEl('${el}')"></button></div>`).join('');
}
function setSurface(s){ surface=s; renderEditor(); }
function setTheme(k,v){ cfg.theme[k]=v; renderPreview(); }
function setMenu(i,k,v){ cfg.menu[i][k]=v; renderPreview(); }
function addMenu(){ cfg.menu.push({label:'New',href:'/'}); renderMenu(); renderPreview(); }
function rmMenu(i){ cfg.menu.splice(i,1); renderMenu(); renderPreview(); }
function moveMenu(i,d){ const j=i+d; if(j<0||j>=cfg.menu.length)return; [cfg.menu[i],cfg.menu[j]]=[cfg.menu[j],cfg.menu[i]]; renderMenu(); renderPreview(); }
function toggleEl(el){ const a=cfg[surface]; const i=a.indexOf(el); if(i>=0)a.splice(i,1); else a.push(el); renderChips(); renderOrder(); renderPreview(); }
function moveEl(i,d){ const a=cfg[surface], j=i+d; if(j<0||j>=a.length)return; [a[i],a[j]]=[a[j],a[i]]; renderOrder(); renderPreview(); }
async function importBrand(){
const url=$('#impurl').value.trim(); if(!url)return;
$('#impres').innerHTML='<span style="color:var(--mut)">importing…</span>';
const r=await fetch('/layout/import-brand',{method:'POST',headers:hdr(),body:JSON.stringify({url})});
const d=await r.json();
if(!r.ok||!d.ok){ $('#impres').innerHTML='<span style="color:#e66">'+(d.detail||'failed')+'</span>'; return; }
const p=d.palette;
cfg.theme.primary=p.primary||cfg.theme.primary;
if(p.colors&&p.colors[1]) cfg.theme.accent=p.colors[1];
if(p.logo) cfg.theme.logo=p.logo;
if(p.font) cfg.theme.font=p.font;
$('#impres').innerHTML='pulled: '+(p.colors||[]).map(c=>`<span class="swatch" title="${c}" style="display:inline-block;background:${c}" onclick="setTheme('primary','${c}');renderEditor()"></span>`).join(' ')+(p.logo?' · logo ✓':'');
renderEditor(); renderPreview();
}
// ---------- preview ----------
const SAMPLE=[
{t:'Hounds of Love',a:'Kate Bush',p:'42.00',c:'NM',g:'Art Pop',h:320},
{t:'Midnight Marauders',a:'A Tribe Called Quest',p:'55.00',c:'VG+',g:'Hip Hop',h:32},
{t:'Selected Ambient Works 85-92',a:'Aphex Twin',p:'38.50',c:'VG',g:'Electronic',h:175},
{t:'Rumours',a:'Fleetwood Mac',p:'29.95',c:'VG+',g:'Rock',h:265},
];
const cover=(r,cls='pv-cover')=>`<div class="${cls}" style="background:linear-gradient(135deg,hsl(${r.h} 50% 38%),hsl(${(r.h+40)%360} 45% 28%))"></div>`;
const cardEl={cover:r=>cover(r),title:r=>`<div class="pv-title">${r.t}</div>`,artist:r=>`<div class="pv-artist">${r.a}</div>`,
price:r=>`<div class="pv-price">$${r.p}</div>`,condition:r=>`<span class="pv-pill">${r.c}</span>`,genre:r=>`<span class="pv-pill">${r.g}</span>`,
format:()=>`<span class="pv-pill">LP</span>`,year:()=>`<span class="pv-pill">1985</span>`,stock_badge:()=>`<span class="pv-pill ok">in stock</span>`,
wishlist:()=>`<button class="pv-ghost"></button>`,cart:()=>`<button class="pv-btn">Add to cart</button>`};
const pageEl={cover:r=>cover(r,'pv-bigcover'),gallery:r=>cover(r,'pv-bigcover'),title:r=>`<h1 class="pv-h1">${r.t}</h1>`,artist:r=>`<div class="pv-artist big">${r.a}</div>`,
label:()=>`<div class="pv-meta">Label · 4AD</div>`,price:r=>`<div class="pv-price big">$${r.p}</div>`,condition:r=>`<div class="pv-meta">Condition · ${r.c}</div>`,
genre:r=>`<span class="pv-pill">${r.g}</span> `,format:()=>`<div class="pv-meta">Format · Vinyl LP</div>`,year:()=>`<div class="pv-meta">Year · 1985</div>`,
country:()=>`<div class="pv-meta">Country · UK</div>`,tracklist:()=>`<div class="pv-tracks">1. Running Up That Hill<br>2. Hounds of Love<br>3. The Big Sky<br>4. Mother Stands for Comfort</div>`,
cart:()=>`<button class="pv-btn big">Add to cart</button>`,wishlist:()=>`<button class="pv-ghost">♡ Wishlist</button>`,
description:()=>`<div class="pv-meta">A landmark 1985 art-pop record, first pressing, sleeve VG+.</div>`,
related:()=>`<div class="pv-meta" style="margin-top:14px">You may also like · 4 more →</div>`,dealgod_value:()=>`<div class="pv-dg">DealGod value · $48 · target $44 · buy-opportunity nearby</div>`};
function renderPreview(){
const t=cfg.theme, pv=$('#pv');
pv.style.setProperty('--p',t.primary);pv.style.setProperty('--a',t.accent);pv.style.setProperty('--bg',t.bg);
pv.style.setProperty('--pn',t.panel);pv.style.setProperty('--tx',t.text);pv.style.setProperty('--r',(t.radius??12)+'px');pv.style.setProperty('--font',t.font||'system-ui');
const logo=t.logo?`<img class="pv-logo" src="${t.logo}" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'pv-logo txt',textContent:'RecordGod'}))">`:`<span class="pv-logo txt">RecordGod</span>`;
const nav=cfg.menu.map(m=>`<a href="#">${m.label}</a>`).join('');
const r=SAMPLE[0];
pv.innerHTML=`<div class="lbl">Storefront preview · live</div><div class="pv-shop">
<div class="pv-head">${logo}<nav class="pv-nav">${nav}</nav><button class="pv-btn">Cart</button></div>
<div class="pv-grid" style="grid-template-columns:repeat(${t.cardCols??4},1fr)">
${SAMPLE.map(rec=>`<div class="pv-card">${cfg.card.map(k=>cardEl[k]?cardEl[k](rec):'').join('')}</div>`).join('')}</div>
<div class="pv-page"><div>${cfg.product_page.filter(k=>k==='cover'||k==='gallery').map(k=>pageEl[k](r)).join('')}</div>
<div>${cfg.product_page.filter(k=>k!=='cover'&&k!=='gallery').map(k=>pageEl[k]?pageEl[k](r):'').join('')}</div></div>
</div>`;
}
async function save(){
$('#status').textContent='saving…';
const r=await fetch('/layout',{method:'POST',headers:hdr(),body:JSON.stringify({config:cfg})});
$('#status').textContent=r.ok?'saved ✓':'save failed'; setTimeout(()=>$('#status').textContent='',2000);
}
if(TOKEN){ $('#tok').value=TOKEN; signin(); }
</script>
</body>
</html>