RECORDGOD/app/layout_routes.py
2026-06-21 21:29:55 +10:00

109 lines
4.4 KiB
Python

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",
}}