← back to 4square Agentabrams
4Square v2: live-hero iframe + server-backed 4-grid sync — drag products into the squares, save pushes to live site
75c0907dd881ddfa256e8c657970a7712ca7c54d · 2026-05-14 09:45:43 -0700 · Steve
- Adds /api/site/:domain/hero-grid GET/POST endpoints (reads + writes /var/www/<site>.com/public/hero-4grid.json on prod, ~/Projects/<site>/public/ on Mac2)
- Frontend: iframe of https://<domain>/ above the search bar shows the live front page, reloads after each push so the rotator picks up changes
- Moodboard now hydrates from the site's real hero-4grid.json on site-select (no more stale localStorage)
- saveLayout() POSTs to /api/site/:domain/hero-grid for wired sites; falls back to localStorage for non-wired (e.g. agentabrams.com subdomains)
- nginx dw-frame-allow.conf snippet extended to allow 4square.agentabrams.com in frame-ancestors
- Push-status pill shows real-time push state per drop
Files touched
M .yolo-loop.shM public/index.htmlM server.js
Diff
commit 75c0907dd881ddfa256e8c657970a7712ca7c54d
Author: Steve <steve@designerwallcoverings.com>
Date: Thu May 14 09:45:43 2026 -0700
4Square v2: live-hero iframe + server-backed 4-grid sync — drag products into the squares, save pushes to live site
- Adds /api/site/:domain/hero-grid GET/POST endpoints (reads + writes /var/www/<site>.com/public/hero-4grid.json on prod, ~/Projects/<site>/public/ on Mac2)
- Frontend: iframe of https://<domain>/ above the search bar shows the live front page, reloads after each push so the rotator picks up changes
- Moodboard now hydrates from the site's real hero-4grid.json on site-select (no more stale localStorage)
- saveLayout() POSTs to /api/site/:domain/hero-grid for wired sites; falls back to localStorage for non-wired (e.g. agentabrams.com subdomains)
- nginx dw-frame-allow.conf snippet extended to allow 4square.agentabrams.com in frame-ancestors
- Push-status pill shows real-time push state per drop
---
.yolo-loop.sh | 2 +-
public/index.html | 176 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 84 ++++++++++++++++++++++++++
3 files changed, 261 insertions(+), 1 deletion(-)
diff --git a/.yolo-loop.sh b/.yolo-loop.sh
index a8d9572..527dbf6 100755
--- a/.yolo-loop.sh
+++ b/.yolo-loop.sh
@@ -22,7 +22,7 @@ while true; do
PROMPT+="\n\n## State from previous iterations\n$(cat "$STATE")"
fi
- echo "$PROMPT" | /usr/bin/claude --print --dangerously-skip-permissions --max-turns 80 >> "$LOG" 2>&1
+ echo "$PROMPT" | /Users/stevestudio2/.local/bin/claude --print --dangerously-skip-permissions --max-turns 80 >> "$LOG" 2>&1
EXIT=$?
print -- "$(date -u +%Y-%m-%dT%H:%M:%SZ) === iter $ITER END exit=$EXIT ===" >> "$LOG"
diff --git a/public/index.html b/public/index.html
index 6eb2c6d..2acc8f9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -212,6 +212,47 @@
.slot.filled .clear { display: flex; }
.slot .clear:hover { background: #c54; color: #fff; }
+ /* ─ Live hero preview ─ */
+ .live-hero {
+ position: relative;
+ border-bottom: 1px solid var(--rule);
+ background: #0a0908;
+ min-height: 240px;
+ 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; }
+ .live-hero iframe {
+ width: 100%; height: 360px; border: 0; background: #15140f;
+ flex: 0 0 auto;
+ }
+ .live-hero .no-preview {
+ padding: 28px 20px; color: var(--muted); text-align: center;
+ font: 0.86rem var(--font-ui); line-height: 1.5;
+ }
+ .live-hero .no-preview h3 {
+ font-family: var(--font-display); color: var(--fg);
+ margin: 0 0 4px; font-size: 1.05rem;
+ }
+ .live-hero.collapsed iframe { 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; }
@@ -220,6 +261,7 @@
.search-bar .count { display: none; }
.grid-scroll { padding: 12px 14px; }
.moodboard-body { grid-template-columns: repeat(2, 1fr); }
+ .live-hero iframe { height: 220px; }
}
</style>
</head>
@@ -247,6 +289,21 @@
<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">Live preview of the front page. Drop products into the 4 squares below to publish.</span>
+ <span class="push-status" id="push-status"></span>
+ <a id="open-live" class="live-hero-toggle" target="_blank" rel="noopener">Open ↗</a>
+ <button class="live-hero-toggle" id="hero-toggle">hide preview</button>
+ <button class="live-hero-toggle" id="hero-reload" title="Reload iframe">↻</button>
+ </div>
+ <iframe id="hero-iframe" src="about:blank" loading="lazy" referrerpolicy="no-referrer"></iframe>
+ <div class="no-preview" id="no-preview" style="display:none">
+ <h3>This site doesn't have the 2×2 hero installed yet.</h3>
+ <p>Front page renders but isn't wired into the rotator. Drop products into the squares below to seed it, then deploy.</p>
+ </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">
@@ -300,6 +357,11 @@
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');
@@ -456,9 +518,123 @@
localStorage.setItem(SITES_KEY, s.domain);
document.getElementById('source-label').textContent = s.domain;
renderSites();
+ showLiveHero(s);
+ hydrateMoodboardFromServer(s);
fetchProducts();
}
+ // ── Live hero preview iframe ───────────────────────────────────
+ function showLiveHero(s) {
+ const wrap = document.getElementById('live-hero');
+ const pill = document.getElementById('hero-domain');
+ const hint = document.getElementById('hero-hint');
+ const iframe = document.getElementById('hero-iframe');
+ const noPrev = document.getElementById('no-preview');
+ const openA = document.getElementById('open-live');
+ wrap.style.display = '';
+ pill.textContent = s.domain;
+ openA.href = 'https://' + s.domain + '/';
+ const isWired = !!(s.source && s.source.endpoint);
+ hint.textContent = isWired
+ ? 'Live preview. Drop products into the 4 squares below — saves push to ' + s.domain + ' in real time.'
+ : 'Front page is live. Product drag-drop is local-only until ' + s.domain + ' has /api/products wired.';
+ noPrev.style.display = 'none';
+ iframe.style.display = '';
+ // Bust both browser + CF cache for the live preview
+ iframe.src = 'https://' + s.domain + '/?fsq=' + Date.now();
+ }
+ document.getElementById('hero-reload').addEventListener('click', () => {
+ const iframe = document.getElementById('hero-iframe');
+ if (selectedSite) iframe.src = 'https://' + selectedSite.domain + '/?fsq=' + Date.now();
+ });
+ 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';
+ });
+
+ // ── 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();
+ } catch (e) {
+ layout = loadLayout();
+ renderSlots();
+ }
+ }
+
+ 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');
diff --git a/server.js b/server.js
index fa6e318..a3c9818 100644
--- a/server.js
+++ b/server.js
@@ -152,6 +152,90 @@ app.get('/img', (req, res) => {
upstream.end();
});
+// ── Hero 4-grid read/write for a wired site ───────────────────────────────
+// Domain → on-disk path. Prefers /var/www/<domain>/public/ (Kamatera prod),
+// falls back to ~/Projects/<basename>/public/ for Mac2 local dev.
+function heroGridPath(domain) {
+ const safe = String(domain || '').replace(/[^a-z0-9.-]/gi, '');
+ if (!/^[a-z0-9-]+\.[a-z.]+$/i.test(safe)) return null;
+ const prod = `/var/www/${safe}/public/hero-4grid.json`;
+ if (fs.existsSync(`/var/www/${safe}`)) return prod;
+ const proj = path.join(process.env.HOME || '/root', 'Projects', safe.replace(/\.com$/, ''), 'public', 'hero-4grid.json');
+ return proj;
+}
+
+function defaultGridFromProducts(products) {
+ const items = (products || []).filter(p => p.image_url && /^https/.test(p.image_url));
+ const cells = [0,1,2,3].map(i => {
+ const p = items[i];
+ return {
+ kind: i % 2 === 0 ? 'room' : 'pattern',
+ sku: p?.sku || null,
+ image_url: p?.image_url || null,
+ title: p?.title || null,
+ };
+ });
+ const rotation_pool = items.slice(0, 16).map(p => ({ sku: p.sku, image_url: p.image_url, title: p.title }));
+ return { cells, rotation_pool, autocycle_seconds: 6, updated_at: null };
+}
+
+app.get('/api/site/:domain/hero-grid', async (req, res) => {
+ const domain = req.params.domain;
+ const cfg = wiredSites[domain];
+ if (!cfg) return res.status(404).json({ error: 'not wired: ' + domain });
+ const gp = heroGridPath(domain);
+ // Try to read existing grid
+ try {
+ const buf = fs.readFileSync(gp, 'utf8');
+ return res.json({ ok: true, grid: JSON.parse(buf), path: gp });
+ } catch (e) {
+ // No grid yet — synthesize from /api/products
+ try {
+ const url = new URL('https://' + domain + cfg.endpoint + '?limit=120');
+ const lib = url.protocol === 'https:' ? https : http;
+ const upstream = lib.request({
+ method: 'GET', hostname: url.hostname, port: 443,
+ path: url.pathname + url.search,
+ headers: { 'User-Agent': '4square/0.2', 'Accept': 'application/json' },
+ }, (r) => {
+ let raw = '';
+ r.on('data', (c) => raw += c);
+ r.on('end', () => {
+ let products = [];
+ try {
+ const parsed = JSON.parse(raw);
+ products = Array.isArray(parsed) ? parsed : (parsed.items || []);
+ } catch (e2) { /* ignore */ }
+ const grid = defaultGridFromProducts(products);
+ res.json({ ok: true, grid, path: gp, synthesized: true });
+ });
+ });
+ upstream.on('error', (e2) => res.status(502).json({ ok: false, error: 'upstream ' + e2.message }));
+ upstream.end();
+ } catch (e2) {
+ res.status(500).json({ ok: false, error: e2.message });
+ }
+ }
+});
+
+app.post('/api/site/:domain/hero-grid', express.json({ limit: '2mb' }), (req, res) => {
+ const domain = req.params.domain;
+ const cfg = wiredSites[domain];
+ if (!cfg) return res.status(404).json({ error: 'not wired: ' + domain });
+ const grid = req.body || {};
+ if (!grid.cells || !Array.isArray(grid.cells)) return res.status(400).json({ error: 'invalid grid (missing cells[])' });
+ grid.updated_at = new Date().toISOString();
+ const gp = heroGridPath(domain);
+ try {
+ const dir = path.dirname(gp);
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(gp, JSON.stringify(grid, null, 2));
+ return res.json({ ok: true, path: gp, bytes: fs.statSync(gp).size });
+ } catch (e) {
+ return res.status(500).json({ ok: false, error: e.message, path: gp });
+ }
+});
+
// ── Static frontend ───────────────────────────────────────────────────────
app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] }));
← 7bebaa2 4square: bust sites.json cache + re-resolve selectedSite fro
·
back to 4square Agentabrams
·
4Square: replace cross-origin iframe with in-DOM 2x2 hero ca c0661b1 →