feat(builder): storefront customizer
This commit is contained in:
parent
8c25b65d7e
commit
a9076af19d
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal 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
108
app/layout_routes.py
Normal 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",
|
||||
}}
|
||||
14
app/main.py
14
app/main.py
@ -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
206
site/builder.html
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user