← back to 4square Agentabrams
public/index.html
857 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>4Square — Agent Abrams</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=Inter:wght@400;500;600&display=swap">
<style>
:root {
--bg: #0e0d0b;
--panel: #14130f;
--card: #19181640;
--rule: #2a2825;
--rule-strong: #3a3631;
--fg: #f3ecdd;
--muted: #9d9586;
--muted-dim: #6e665a;
--accent: #d9b14e;
--accent-dim: #8a6f30;
--selected: #2a2415;
--font-display: 'Playfair Display', serif;
--font-ui: 'Inter', system-ui, sans-serif;
--rad: 12px;
--rail-w: 240px;
--moodboard-h: 200px;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); font-family: var(--font-ui); }
body { display: flex; flex-direction: column; min-height: 100vh; }
/* ─ Header ─ */
header.top {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px; border-bottom: 1px solid var(--rule);
background: linear-gradient(180deg, #1c1a17 0%, #14130f 100%);
flex: 0 0 auto;
}
header.top h1 { font-family: var(--font-display); margin: 0; font-size: 1.4rem; color: var(--accent); }
header.top .sub { color: var(--muted); font-size: 0.78rem; letter-spacing: 0.06em; text-transform: uppercase; }
header .save-status { color: var(--muted); font-size: 0.75rem; }
header .save-status.flash { color: var(--accent); }
.action-btn {
background: transparent; color: var(--fg); border: 1px solid var(--rule);
padding: 6px 12px; border-radius: 8px; cursor: pointer; font: 600 0.75rem var(--font-ui);
letter-spacing: 0.05em;
}
.action-btn:hover { border-color: var(--accent); color: var(--accent); }
/* ─ Main 3-pane (left rail + splitter + main + bottom moodboard) ─ */
.workspace { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
.panes { display: flex; flex: 1 1 auto; min-height: 0; }
/* Left rail — site nav */
.rail {
width: var(--rail-w); min-width: 180px; max-width: 50vw;
background: var(--panel); border-right: 1px solid var(--rule);
display: flex; flex-direction: column; flex: 0 0 auto; min-height: 0;
}
.rail h2 {
font-family: var(--font-display); margin: 0; font-size: 0.95rem;
letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted);
padding: 14px 16px 8px;
}
.rail .site-filter {
margin: 0 12px 8px; padding: 7px 10px; background: #100f0d; color: var(--fg);
border: 1px solid var(--rule); border-radius: 8px; font: 0.84rem var(--font-ui);
}
.rail .site-filter:focus { outline: 0; border-color: var(--accent-dim); }
.rail .meta { color: var(--muted-dim); font-size: 0.7rem; padding: 0 16px 6px; }
.site-list { overflow-y: auto; flex: 1 1 auto; padding: 0 6px 12px; }
.site-item {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px; border-radius: 8px; cursor: pointer;
font: 0.84rem var(--font-ui); color: var(--fg);
border-left: 2px solid transparent;
margin: 1px 0;
}
.site-item:hover { background: #1a1815; }
.site-item.active { background: var(--selected); border-left-color: var(--accent); }
.site-item .dot {
width: 7px; height: 7px; border-radius: 999px;
background: var(--rule-strong); flex: 0 0 7px;
}
.site-item.wired .dot { background: var(--accent); }
.site-item .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1 1 auto; }
.site-item .kind {
font-size: 0.62rem; color: var(--muted-dim);
letter-spacing: 0.06em; text-transform: uppercase;
}
/* Splitter — drag to resize left rail */
.splitter {
width: 6px; flex: 0 0 6px; cursor: col-resize;
background: var(--bg); border-right: 1px solid var(--rule);
transition: background 0.12s ease;
}
.splitter:hover, .splitter.dragging { background: var(--accent-dim); }
/* Main — search + grid */
.main {
flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column;
background: var(--bg);
}
.search-bar {
display: flex; align-items: center; gap: 12px;
padding: 14px 22px; border-bottom: 1px solid var(--rule);
background: var(--panel);
}
.search-bar .search-icon { color: var(--muted); font-size: 1.1rem; }
.search-bar input[type=search] {
flex: 1 1 auto; background: #100f0d; color: var(--fg);
border: 1px solid var(--rule); border-radius: 10px;
padding: 10px 14px; font: 0.95rem var(--font-ui);
}
.search-bar input[type=search]:focus { outline: 0; border-color: var(--accent-dim); }
.search-bar .count { color: var(--muted); font-size: 0.78rem; min-width: 110px; text-align: right; }
.search-bar .source-label {
color: var(--accent); font: 600 0.78rem var(--font-ui); letter-spacing: 0.05em;
text-transform: uppercase; padding: 4px 10px; border: 1px solid var(--accent-dim); border-radius: 999px;
max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.grid-scroll { flex: 1 1 auto; overflow-y: auto; padding: 18px 22px 22px; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 14px;
}
.card {
aspect-ratio: 1/1.15; background: #100f0d; border: 1px solid var(--rule);
border-radius: var(--rad); overflow: hidden; position: relative; cursor: grab;
transition: border-color 0.15s ease, transform 0.15s ease;
}
.card:active { cursor: grabbing; }
.card:hover { border-color: var(--accent-dim); transform: translateY(-1px); }
.card.dragging { opacity: 0.4; }
.card img { width: 100%; height: 100%; object-fit: cover; display: block; }
.card .card-label {
position: absolute; left: 0; right: 0; bottom: 0; padding: 6px 9px;
font: 600 0.7rem var(--font-ui); color: #fff;
background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.78) 100%);
letter-spacing: 0.02em;
}
.card .card-label .by { color: rgba(255,255,255,0.65); font-weight: 400; font-size: 0.62rem; display: block; margin-top: 1px; }
.empty-state {
padding: 60px 30px; text-align: center; color: var(--muted);
max-width: 480px; margin: 0 auto;
}
.empty-state h3 { font-family: var(--font-display); color: var(--fg); margin: 0 0 8px; }
.empty-state .lead { font-size: 0.92rem; line-height: 1.5; }
.empty-state .domain { color: var(--accent); font-weight: 600; }
/* Moodboard dock at bottom */
.moodboard {
flex: 0 0 auto; min-height: var(--moodboard-h);
border-top: 1px solid var(--rule); background: var(--panel);
display: flex; flex-direction: column;
}
.moodboard.collapsed { min-height: 38px; }
.moodboard-header {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 18px; border-bottom: 1px solid var(--rule);
cursor: pointer; user-select: none;
}
.moodboard-header h2 {
font-family: var(--font-display); margin: 0; font-size: 0.95rem;
letter-spacing: 0.04em; color: var(--accent);
}
.moodboard-header .toggle {
color: var(--muted); font-size: 0.8rem; transition: transform 0.18s ease;
}
.moodboard.collapsed .toggle { transform: rotate(180deg); }
.moodboard-body {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
padding: 12px 18px 14px; flex: 1 1 auto;
}
.moodboard.collapsed .moodboard-body { display: none; }
.slot {
position: relative; background: #131210; border: 2px dashed var(--rule); border-radius: 10px;
overflow: hidden; min-height: 150px;
transition: border-color 0.15s ease;
}
.slot.empty::after {
content: attr(data-empty-label); position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
color: var(--muted-dim); font: 700 1.4rem var(--font-display);
pointer-events: none;
}
.slot.over { border-color: var(--accent); }
.slot.filled { border-style: solid; border-color: var(--rule); cursor: grab; }
.slot.filled:active { cursor: grabbing; }
.slot.dragging-source { opacity: 0.5; }
.slot img { width: 100%; height: 100%; object-fit: cover; display: block; }
.slot .badge {
position: absolute; top: 6px; left: 6px; background: rgba(20,18,15,0.85);
color: var(--accent); font: 700 0.6rem var(--font-ui); letter-spacing: 0.1em;
padding: 2px 7px; border-radius: 999px;
}
.slot .info {
position: absolute; left: 0; right: 0; bottom: 0; padding: 6px 10px;
font: 0.72rem var(--font-ui); color: #fff;
background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.82) 100%);
}
.slot .clear {
position: absolute; top: 6px; right: 6px;
background: rgba(20,18,15,0.85); color: var(--fg); border: 1px solid var(--rule);
width: 22px; height: 22px; border-radius: 999px; cursor: pointer;
display: none; align-items: center; justify-content: center; font-size: 0.9rem; line-height: 1;
}
.slot.filled .clear { display: flex; }
.slot .clear:hover { background: #c54; color: #fff; }
/* ─ Live hero preview (rendered inside 4Square — no iframe) ─ */
.live-hero {
position: relative;
border-bottom: 1px solid var(--rule);
background: #0a0908;
display: flex; flex-direction: column;
}
.live-hero-header {
display: flex; align-items: center; gap: 12px;
padding: 8px 18px; border-bottom: 1px solid var(--rule);
font: 0.78rem var(--font-ui);
}
.live-hero-header .domain-pill {
color: var(--accent); font-weight: 600; letter-spacing: 0.04em;
padding: 4px 10px; border: 1px solid var(--accent-dim); border-radius: 999px;
}
.live-hero-header .hint { color: var(--muted); flex: 1 1 auto; }
.live-hero-header .push-status { color: var(--muted); font-size: 0.72rem; }
.live-hero-header .push-status.ok { color: #7dd29e; }
.live-hero-header .push-status.err { color: #e57373; }
/* The 2×2 hero canvas — same rendering rules the live rotator uses:
crop top 12% (never show image headers), high-res cover, gucci-style
wordmark beneath. */
.hero-canvas-wrap { padding: 14px 18px; }
.hero-canvas {
width: 100%; aspect-ratio: 16/7;
display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr;
gap: 2px; background: #1a1815;
border-radius: 10px; overflow: hidden;
box-shadow: 0 6px 22px rgba(0,0,0,0.45);
}
.hero-cell {
position: relative; background: #15140f;
background-size: cover; background-position: center 12%;
transition: outline 0.14s ease, transform 0.14s ease;
outline: 2px solid transparent; outline-offset: -2px;
cursor: pointer;
}
.hero-cell:empty::after, .hero-cell.empty::after {
content: attr(data-empty-label);
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: var(--muted-dim); font: 700 1.6rem var(--font-display);
pointer-events: none;
}
.hero-cell.over { outline-color: var(--accent); transform: scale(0.98); }
.hero-cell .cell-info {
position: absolute; left: 0; right: 0; bottom: 0; padding: 6px 10px;
font: 0.7rem var(--font-ui); color: #fff; line-height: 1.2;
background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.78) 100%);
pointer-events: none;
}
.hero-cell .cell-kind {
position: absolute; top: 6px; left: 6px;
background: rgba(10,10,10,0.75); color: var(--accent);
font: 700 0.58rem var(--font-ui); letter-spacing: 0.16em;
padding: 3px 8px; border-radius: 999px; text-transform: uppercase;
}
.hero-cell .cell-clear {
position: absolute; top: 6px; right: 6px;
width: 22px; height: 22px; border-radius: 999px;
background: rgba(20,18,15,0.85); color: var(--fg); border: 1px solid var(--rule);
cursor: pointer; display: none; align-items: center; justify-content: center;
font-size: 0.95rem; line-height: 1;
}
.hero-cell:hover .cell-clear { display: inline-flex; }
.hero-cell .cell-clear:hover { background: #c54; color: #fff; }
/* Wordmark + tagline below the canvas — same anatomy as the live site */
.hero-wordmark-preview {
text-align: center; padding: 14px 8px 18px;
font-family: 'Cormorant Garamond', 'Playfair Display', Georgia, serif;
font-size: clamp(28px, 4vw, 56px); font-weight: 500; letter-spacing: -0.005em;
line-height: 1; color: var(--fg);
}
.hero-wordmark-preview small {
display: block; margin-top: 6px;
font-family: 'Playfair Display', Georgia, serif; font-style: italic;
font-weight: 400; font-size: 0.78rem; color: var(--muted);
letter-spacing: 0.04em; text-transform: none;
}
.live-hero.collapsed .hero-canvas-wrap,
.live-hero.collapsed .hero-wordmark-preview { display: none; }
.live-hero-toggle {
background: transparent; border: 1px solid var(--rule); color: var(--muted);
padding: 3px 9px; border-radius: 6px; font: 0.7rem var(--font-ui);
cursor: pointer; letter-spacing: 0.04em;
}
.live-hero-toggle:hover { color: var(--accent); border-color: var(--accent-dim); }
@media (max-width: 880px) {
.panes { flex-direction: column; }
.rail { width: 100% !important; max-width: none; max-height: 36vh; }
.splitter { display: none; }
.search-bar { padding: 10px 14px; }
.search-bar .count { display: none; }
.grid-scroll { padding: 12px 14px; }
.moodboard-body { grid-template-columns: repeat(2, 1fr); }
.live-hero iframe { height: 220px; }
}
</style>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
</head>
<body>
<header class="top">
<div>
<h1>4Square</h1>
<div class="sub">Agent Abrams · pick a site → drag products into the moodboard</div>
</div>
<div style="display:flex;align-items:center;gap:14px">
<a class="action-btn" href="/wall" title="See every site's hero on one page">Hero Wall</a>
<span class="save-status" id="save-status">moodboard autosaves to this browser</span>
<button class="action-btn" id="reset-all" title="Clear all four moodboard squares">Reset moodboard</button>
</div>
</header>
<div class="workspace">
<div class="panes">
<aside class="rail" id="rail">
<h2>Sites</h2>
<input class="site-filter" id="site-filter" type="search" placeholder="Filter domains…" autocomplete="off">
<div class="meta" id="rail-meta">loading…</div>
<div class="site-list" id="site-list"></div>
</aside>
<div class="splitter" id="splitter" aria-label="Drag to resize"></div>
<main class="main">
<section class="live-hero" id="live-hero" style="display:none">
<div class="live-hero-header">
<span class="domain-pill" id="hero-domain">—</span>
<span class="hint" id="hero-hint">2×2 hero on this site. Drop products into the squares — each drop publishes to the live front page.</span>
<span class="push-status" id="push-status"></span>
<a id="open-live" class="live-hero-toggle" target="_blank" rel="noopener noreferrer">View live ↗</a>
<button class="live-hero-toggle" id="hero-toggle">hide preview</button>
</div>
<div class="hero-canvas-wrap">
<div class="hero-canvas" id="hero-canvas">
<div class="hero-cell empty" data-hero-idx="0" data-empty-label="Room 1"></div>
<div class="hero-cell empty" data-hero-idx="1" data-empty-label="Pattern 1"></div>
<div class="hero-cell empty" data-hero-idx="2" data-empty-label="Room 2"></div>
<div class="hero-cell empty" data-hero-idx="3" data-empty-label="Pattern 2"></div>
</div>
</div>
<div class="hero-wordmark-preview" id="hero-wordmark-preview">linenwallpaper<small>curated wallcoverings · free memo samples</small></div>
</section>
<div class="search-bar">
<span class="search-icon" aria-hidden="true">⌕</span>
<input id="q" type="search" placeholder="Type anything to bring a product up…" autocomplete="off">
<span class="source-label" id="source-label">no site selected</span>
<span class="count" id="result-count"></span>
</div>
<div class="grid-scroll" id="grid-scroll">
<div class="grid" id="grid"></div>
<div class="empty-state" id="empty-state" style="display:none">
<h3>Pick a site from the left rail.</h3>
<p class="lead">Wired sites have a yellow dot. Other sites are listed by name and will populate when their catalog API is connected.</p>
</div>
</div>
</main>
</div>
<section class="moodboard" id="moodboard">
<div class="moodboard-header" id="moodboard-header">
<h2>Moodboard — 4 Squares</h2>
<span class="toggle">▾</span>
</div>
<div class="moodboard-body">
<div class="slot empty" data-slot="1" data-empty-label="1"></div>
<div class="slot empty" data-slot="2" data-empty-label="2"></div>
<div class="slot empty" data-slot="3" data-empty-label="3"></div>
<div class="slot empty" data-slot="4" data-empty-label="4"></div>
</div>
</section>
</div>
<script>
const STORAGE_KEY = '4square.agentabrams.layout.v1';
const SITES_KEY = '4square.agentabrams.selectedSite.v1';
const RAIL_W_KEY = '4square.agentabrams.railWidth.v1';
const MOOD_KEY = '4square.agentabrams.moodCollapsed.v1';
let layout = loadLayout();
let sites = [];
let selectedSite = null;
let railItems = [];
let queryToken = 0;
function loadLayout() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { 1:null, 2:null, 3:null, 4:null };
const p = JSON.parse(raw);
return { 1: p[1]||null, 2: p[2]||null, 3: p[3]||null, 4: p[4]||null };
} catch { return { 1:null, 2:null, 3:null, 4:null }; }
}
function saveLayout() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(layout)); flashSaved(); }
catch (e) { document.getElementById('save-status').textContent = 'autosave failed'; }
// If this site is wired, ALSO push to the live site's hero-4grid.json.
// The local-state save is the optimistic UI; the network push is the truth.
if (selectedSite && selectedSite.source && selectedSite.source.endpoint) {
pushHeroGridToServer();
}
}
function flashSaved() {
const el = document.getElementById('save-status');
el.textContent = 'saved ✓'; el.classList.add('flash');
clearTimeout(flashSaved._t);
flashSaved._t = setTimeout(() => {
el.classList.remove('flash');
el.textContent = 'moodboard autosaves to this browser';
}, 1200);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
}
// ── Resizable rail ─────────────────────────────────────────────
(function rail() {
const saved = parseInt(localStorage.getItem(RAIL_W_KEY) || '240', 10);
if (saved >= 180 && saved <= 600) document.documentElement.style.setProperty('--rail-w', saved + 'px');
const split = document.getElementById('splitter');
let dragging = false, startX = 0, startW = 0;
split.addEventListener('mousedown', (e) => {
dragging = true; split.classList.add('dragging');
startX = e.clientX;
startW = document.getElementById('rail').getBoundingClientRect().width;
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (!dragging) return;
const newW = Math.max(180, Math.min(600, startW + (e.clientX - startX)));
document.documentElement.style.setProperty('--rail-w', newW + 'px');
});
window.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false; split.classList.remove('dragging');
const w = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--rail-w'), 10);
localStorage.setItem(RAIL_W_KEY, String(w));
});
})();
// ── Moodboard ──────────────────────────────────────────────────
const board = document.querySelector('.moodboard-body');
function renderSlots() {
board.querySelectorAll('.slot').forEach(slot => {
const n = slot.dataset.slot;
const item = layout[n];
slot.innerHTML = '';
slot.classList.toggle('filled', !!item);
slot.classList.toggle('empty', !item);
slot.setAttribute('draggable', item ? 'true' : 'false');
if (item) {
const img = document.createElement('img');
img.src = item.imageProxy; img.alt = item.title || ''; img.loading = 'lazy';
const badge = document.createElement('span'); badge.className = 'badge'; badge.textContent = '#'+n;
const info = document.createElement('div'); info.className = 'info';
info.innerHTML = escapeHtml(item.title || 'Untitled');
const clear = document.createElement('button'); clear.className='clear'; clear.title='Clear'; clear.textContent='×';
clear.addEventListener('click', (e) => {
e.stopPropagation();
layout[n] = null; saveLayout(); renderSlots();
});
slot.append(img, badge, info, clear);
}
});
}
function attachSlotDnD() {
board.querySelectorAll('.slot').forEach(slot => {
slot.addEventListener('dragstart', (e) => {
const n = slot.dataset.slot;
if (!layout[n]) { e.preventDefault(); return; }
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('application/x-4sq', JSON.stringify({ kind:'slot', from:n }));
slot.classList.add('dragging-source');
});
slot.addEventListener('dragend', () => slot.classList.remove('dragging-source'));
slot.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect='move'; slot.classList.add('over'); });
slot.addEventListener('dragleave', () => slot.classList.remove('over'));
slot.addEventListener('drop', (e) => {
e.preventDefault(); slot.classList.remove('over');
const raw = e.dataTransfer.getData('application/x-4sq');
if (!raw) return;
let payload; try { payload = JSON.parse(raw); } catch { return; }
const target = slot.dataset.slot;
if (payload.kind === 'tile') layout[target] = payload.item;
else if (payload.kind === 'slot' && payload.from !== target) {
const tmp = layout[target];
layout[target] = layout[payload.from];
layout[payload.from] = tmp;
}
saveLayout(); renderSlots(); renderHeroCanvas();
});
});
}
attachSlotDnD(); renderSlots(); renderHeroCanvas();
// Moodboard collapse toggle
(function moodCollapse() {
const mb = document.getElementById('moodboard');
const hdr = document.getElementById('moodboard-header');
if (localStorage.getItem(MOOD_KEY) === 'true') mb.classList.add('collapsed');
hdr.addEventListener('click', () => {
mb.classList.toggle('collapsed');
localStorage.setItem(MOOD_KEY, mb.classList.contains('collapsed') ? 'true' : 'false');
});
})();
document.getElementById('reset-all').addEventListener('click', () => {
if (!confirm('Clear all 4 moodboard squares?')) return;
layout = { 1:null, 2:null, 3:null, 4:null };
saveLayout(); renderSlots();
});
// ── Sites rail ─────────────────────────────────────────────────
const $list = document.getElementById('site-list');
const $railMeta = document.getElementById('rail-meta');
const $siteFilter = document.getElementById('site-filter');
async function loadSites() {
try {
// Cache-bust on every load so wiring changes (e.g. new site added to
// sites.json) show up without a hard-refresh. 4Square owns the wiring
// list and updates frequently; the prod cache layer must NOT pin a
// stale copy in front of the user.
const r = await fetch('/data/sites.json?cb=' + Date.now(), { cache: 'no-store' });
const j = await r.json();
sites = j.sites || [];
const wired = sites.filter(s => s.source).length;
$railMeta.textContent = `${sites.length} sites · ${wired} wired`;
renderSites();
// restore selection (skip if deep-link will override below)
if (!new URLSearchParams(location.search).get('site')) {
const saved = localStorage.getItem(SITES_KEY);
const initial = sites.find(s => s.domain === saved) || sites.find(s => s.source) || sites[0];
if (initial) selectSite(initial);
}
} catch (e) {
$railMeta.textContent = 'failed to load sites';
}
}
function renderSites() {
const filter = $siteFilter.value.toLowerCase().trim();
const matches = filter ? sites.filter(s => s.domain.toLowerCase().includes(filter)) : sites;
$list.innerHTML = '';
for (const s of matches) {
const div = document.createElement('div');
div.className = 'site-item' + (s.source ? ' wired' : '') + (selectedSite && selectedSite.domain === s.domain ? ' active' : '');
div.title = s.domain + (s.source ? ' (catalog wired)' : ' (catalog pending)');
div.innerHTML = `<span class="dot"></span><span class="name">${escapeHtml(s.domain)}</span><span class="kind">${escapeHtml(s.kind)}</span>`;
div.addEventListener('click', () => selectSite(s));
$list.appendChild(div);
}
}
$siteFilter.addEventListener('input', renderSites);
function selectSite(s) {
selectedSite = s;
localStorage.setItem(SITES_KEY, s.domain);
document.getElementById('source-label').textContent = s.domain;
renderSites();
showLiveHero(s);
hydrateMoodboardFromServer(s);
fetchProducts();
}
// ── Live hero preview (in-DOM 2×2 canvas + wordmark, no iframe) ──
function showLiveHero(s) {
const wrap = document.getElementById('live-hero');
const pill = document.getElementById('hero-domain');
const hint = document.getElementById('hero-hint');
const openA = document.getElementById('open-live');
const word = document.getElementById('hero-wordmark-preview');
wrap.style.display = '';
pill.textContent = s.domain;
openA.href = 'https://' + s.domain + '/?fsq=' + Date.now();
const isWired = !!(s.source && s.source.endpoint);
hint.textContent = isWired
? 'Drop products from the rail below into the 4 squares — each drop pushes to ' + s.domain + ' in real time.'
: 'Drag-publish unavailable until ' + s.domain + ' has /api/products wired. Picks are saved locally for now.';
// Update the wordmark preview to match the site's domain
const stem = s.domain.replace(/\.[a-z]+$/, '');
word.innerHTML = escapeHtml(stem) + '<small>' + escapeHtml(s.tagline || pickTagline(s)) + '</small>';
}
function pickTagline(s) {
const k = s.kind || '';
if (k === 'wallcovering') return 'curated wallcoverings · free memo samples';
if (k === 'film') return 'set decoration · archived & sold';
return 'browse · drop · publish';
}
document.getElementById('hero-toggle').addEventListener('click', () => {
const wrap = document.getElementById('live-hero');
const btn = document.getElementById('hero-toggle');
wrap.classList.toggle('collapsed');
btn.textContent = wrap.classList.contains('collapsed') ? 'show preview' : 'hide preview';
});
// Render the 2×2 hero canvas from the current `layout` object (which is
// already hydrated from the site's hero-4grid.json by hydrateMoodboardFromServer).
// Each cell is a drop target — drop a product card onto it → updates layout → saveLayout
// pushes to /api/site/:domain/hero-grid → live site picks up on next /hero-4grid.json fetch.
function renderHeroCanvas() {
const cells = document.querySelectorAll('#hero-canvas .hero-cell');
cells.forEach((cell, i) => {
const n = String(i + 1);
const item = layout[n];
cell.innerHTML = '';
cell.classList.toggle('empty', !item);
if (item) {
cell.style.backgroundImage = "url('" + item.imageProxy + "')";
const kind = document.createElement('div');
kind.className = 'cell-kind';
kind.textContent = item.kind || (i % 2 === 0 ? 'Room' : 'Pattern');
const info = document.createElement('div');
info.className = 'cell-info';
info.textContent = item.title || '';
const clear = document.createElement('button');
clear.className = 'cell-clear'; clear.title = 'Clear'; clear.textContent = '×';
clear.addEventListener('click', (e) => {
e.stopPropagation();
layout[n] = null; saveLayout(); renderHeroCanvas(); renderSlots();
});
cell.append(kind, info, clear);
} else {
cell.style.backgroundImage = '';
}
});
}
function attachHeroDnD() {
document.querySelectorAll('#hero-canvas .hero-cell').forEach((cell) => {
cell.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; cell.classList.add('over'); });
cell.addEventListener('dragleave', () => cell.classList.remove('over'));
cell.addEventListener('drop', (e) => {
e.preventDefault(); cell.classList.remove('over');
const raw = e.dataTransfer.getData('application/x-4sq');
if (!raw) return;
let payload; try { payload = JSON.parse(raw); } catch { return; }
const target = String(parseInt(cell.dataset.heroIdx, 10) + 1);
if (payload.kind === 'tile') {
layout[target] = payload.item;
} else if (payload.kind === 'slot' && payload.from !== target) {
const tmp = layout[target];
layout[target] = layout[payload.from];
layout[payload.from] = tmp;
}
saveLayout(); renderHeroCanvas(); renderSlots();
});
});
}
attachHeroDnD();
// ── Server-backed hero-grid sync ───────────────────────────────
// For wired sites, the moodboard mirrors /api/site/:domain/hero-grid
// (which is the same hero-4grid.json the live site renders).
// For non-wired sites, fall back to the legacy localStorage layout.
async function hydrateMoodboardFromServer(s) {
if (!s || !s.source || !s.source.endpoint) {
// Non-wired: keep localStorage layout
layout = loadLayout();
renderSlots();
return;
}
try {
const r = await fetch('/api/site/' + encodeURIComponent(s.domain) + '/hero-grid?cb=' + Date.now(), { cache: 'no-store' });
const j = await r.json();
if (!j || !j.grid || !Array.isArray(j.grid.cells)) {
layout = { 1:null, 2:null, 3:null, 4:null };
} else {
layout = { 1:null, 2:null, 3:null, 4:null };
j.grid.cells.forEach((c, i) => {
if (!c || !c.image_url) return;
layout[String(i+1)] = {
id: c.sku || ('cell-' + i),
title: c.title || c.kind || '',
artist: c.kind || '',
imageProxy: c.image_url,
sku: c.sku || null,
kind: c.kind || (i % 2 === 0 ? 'room' : 'pattern'),
};
});
}
renderSlots();
renderHeroCanvas();
} catch (e) {
layout = loadLayout();
renderSlots();
renderHeroCanvas();
}
}
async function pushHeroGridToServer() {
if (!selectedSite || !selectedSite.source || !selectedSite.source.endpoint) return false;
const cells = ['1','2','3','4'].map((n, i) => {
const item = layout[n];
return {
kind: (item && item.kind) || (i % 2 === 0 ? 'room' : 'pattern'),
sku: (item && item.sku) || null,
image_url: (item && item.imageProxy) || null,
title: (item && item.title) || null,
};
});
// Pull rotation_pool from the current product rail (newest first)
const rotation_pool = (railItems || []).slice(0, 16).filter(p => p.image_url).map(p => ({
sku: p.sku || p.handle || null,
image_url: p.image_url,
title: p.title || '',
}));
const grid = { cells, rotation_pool, autocycle_seconds: 6 };
const $ps = document.getElementById('push-status');
$ps.textContent = 'pushing…'; $ps.className = 'push-status';
try {
const r = await fetch('/api/site/' + encodeURIComponent(selectedSite.domain) + '/hero-grid', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify(grid),
});
const j = await r.json();
if (j.ok) {
$ps.textContent = '✓ live on ' + selectedSite.domain; $ps.className = 'push-status ok';
// Reload iframe after a beat so the new JSON serves
setTimeout(() => {
const iframe = document.getElementById('hero-iframe');
iframe.src = 'https://' + selectedSite.domain + '/?fsq=' + Date.now();
}, 700);
return true;
} else {
$ps.textContent = '✗ ' + (j.error || 'push failed'); $ps.className = 'push-status err';
return false;
}
} catch (e) {
$ps.textContent = '✗ ' + e.message; $ps.className = 'push-status err';
return false;
}
}
// ── Product grid ───────────────────────────────────────────────
const $grid = document.getElementById('grid');
const $count = document.getElementById('result-count');
const $empty = document.getElementById('empty-state');
const $q = document.getElementById('q');
let qDebounce;
$q.addEventListener('input', () => {
clearTimeout(qDebounce);
qDebounce = setTimeout(fetchProducts, 200);
});
async function fetchProducts() {
if (!selectedSite) { $grid.innerHTML = ''; $empty.style.display = ''; return; }
$empty.style.display = 'none';
// Always re-lookup the freshest source for this domain from `sites` —
// protects against a stale selectedSite snapshot where source was null
// before the site got wired. (Caused the "Catalog connection pending"
// misfire on 2026-05-14 — metallicwallpaper was wired in sites.json
// but a cached selectedSite still showed pending.)
const fresh = sites.find(s => s.domain === selectedSite.domain);
if (fresh) selectedSite = fresh;
const src = selectedSite.source;
if (!src) {
$grid.innerHTML = '';
$empty.innerHTML = `<h3>Catalog connection pending</h3>
<p class="lead">Site <span class="domain">${escapeHtml(selectedSite.domain)}</span> is in the directory, but its product catalog isn't wired into 4Square yet. Connect its API to enable browsing here.</p>`;
$empty.style.display = '';
$count.textContent = '';
return;
}
const token = ++queryToken;
$count.textContent = 'loading…';
const params = new URLSearchParams();
params.set('site', selectedSite.domain);
if ($q.value) params.set('q', $q.value);
params.set('limit', '120');
try {
const r = await fetch('/api/site-products?' + params.toString());
const j = await r.json();
if (token !== queryToken) return;
// Normalize shape: TSD returns {total, items}, DW returns either {items} or [].
const items = Array.isArray(j) ? j : (j.items || []);
const total = (j && j.total) || items.length;
railItems = items;
$count.textContent = `${items.length} of ${total.toLocaleString()}`;
renderGrid();
} catch (e) { $count.textContent = 'load failed: ' + e.message; }
}
function renderGrid() {
$grid.innerHTML = '';
if (!railItems.length) {
$empty.innerHTML = `<h3>No matches</h3><p class="lead">Try a different search term.</p>`;
$empty.style.display = '';
return;
}
$empty.style.display = 'none';
for (const it of railItems) {
const div = document.createElement('div');
div.className = 'card';
div.draggable = true;
const rawImg = it.image_url || it.image || '';
if (!rawImg) continue; // skip items with no image — keeps the grid clean
// Absolute https URLs (Shopify CDN, etc.) used directly; TSD /api/art-archive paths go through our /img proxy.
const imageProxy = /^https?:\/\//.test(rawImg) ? rawImg : ('/img?path=' + encodeURIComponent(rawImg));
const lite = {
id: it.id || it.sku || it.handle,
title: it.title || it.product_title || 'Untitled',
artist: it.artist || it.vendor || '',
year: it.year, era: it.era,
imageProxy,
};
div.dataset.item = JSON.stringify(lite);
const img = document.createElement('img');
img.src = imageProxy; img.loading = 'lazy'; img.alt = it.title || '';
const label = document.createElement('div');
label.className = 'card-label';
label.innerHTML = `${escapeHtml(it.title || 'Untitled')}<span class="by">${escapeHtml(it.artist || '')}${it.year ? ' · ' + it.year : ''}</span>`;
div.append(img, label);
div.addEventListener('dragstart', (e) => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('application/x-4sq', JSON.stringify({ kind:'tile', item: lite }));
div.classList.add('dragging');
});
div.addEventListener('dragend', () => div.classList.remove('dragging'));
// Double-click = place into first empty slot
div.addEventListener('dblclick', () => {
for (const n of ['1','2','3','4']) {
if (!layout[n]) { layout[n] = lite; saveLayout(); renderSlots(); return; }
}
});
$grid.appendChild(div);
}
}
// ── boot ───────────────────────────────────────────────────────
loadSites().then(() => {
// Deep-link support: /?site=<domain> jumps straight to that site (used by wall.html)
const params = new URLSearchParams(location.search);
const deepSite = params.get('site');
if (deepSite) {
const hit = sites.find(s => s.domain === deepSite);
if (hit) selectSite(hit);
}
});
</script>
</body>
</html>