← back to Dw Hero Admin
public/index.html
563 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>DW Hero Grid Admin</title>
<style>
:root {
--bg: #0a0a0a;
--panel: #14110c;
--paper: #f5f1e8;
--muted: #8a8378;
--line: rgba(245,241,232,0.10);
--accent: #B0A080;
--slot-bg: #1c1814;
--slot-bd: rgba(176,160,128,0.35);
--hot: #d49a5c;
}
* { box-sizing: border-box; }
html,body { margin:0; height:100%; background:var(--bg); color:var(--paper); font:14px/1.4 -apple-system,"SF Pro Text",system-ui,sans-serif; }
a { color:var(--accent); }
.app { display:grid; grid-template-columns: 240px 1fr; height:100vh; min-height: 0; }
/* ─ sidebar ─ */
.sidebar { background:var(--panel); border-right:1px solid var(--line); overflow-y:auto; padding:16px 0; }
.sidebar h2 { font-size:11px; letter-spacing:0.18em; text-transform:uppercase; color:var(--muted); margin:0 16px 12px; font-weight:500; }
.site-list { list-style:none; padding:0; margin:0; }
.site-list li { padding:8px 16px; cursor:pointer; border-left:3px solid transparent; transition:background .14s,border-color .14s; font-size:13px; }
.site-list li:hover { background:rgba(245,241,232,0.04); }
.site-list li.active { background:rgba(176,160,128,0.10); border-left-color:var(--accent); color:#fff; }
.site-list li small { display:block; font-size:10px; color:var(--muted); margin-top:2px; }
.sidebar-search { padding:0 16px 12px; }
.sidebar-search input { width:100%; padding:8px 10px; background:transparent; border:1px solid var(--line); color:var(--paper); font:inherit; outline:none; border-radius:3px; }
/* ─ main ─ */
.main { display:flex; flex-direction:column; overflow:hidden; }
.topbar { flex-shrink:0; padding:14px 24px; border-bottom:1px solid var(--line); display:flex; align-items:center; justify-content:space-between; }
.topbar h1 { margin:0; font-size:18px; font-weight:500; letter-spacing:-0.005em; }
.topbar .meta { color:var(--muted); font-size:12px; letter-spacing:0.04em; }
.save-status { font-size:12px; color:var(--muted); }
.save-status.ok { color:#7ed3a3; }
.save-status.err { color:#ff8c8c; }
/* ─ brand picker (custom dropdown — native <select> can't render color dots) ─ */
.brand-picker { position:relative; }
.brand-picker-btn { display:inline-flex; align-items:center; gap:10px; background:transparent; border:1px solid var(--line); color:var(--paper); padding:7px 12px; cursor:pointer; font:inherit; font-size:12px; letter-spacing:0.04em; border-radius:3px; min-width:240px; justify-content:space-between; }
.brand-picker-btn:hover { border-color:var(--accent); }
.brand-picker-btn .dots { display:inline-flex; gap:3px; }
.brand-picker-btn .dot { width:12px; height:12px; border-radius:50%; border:1px solid rgba(255,255,255,0.15); }
.brand-picker-btn .label { flex:1; text-align:left; }
.brand-picker-btn .caret { opacity:0.5; }
.brand-menu { position:absolute; top:calc(100% + 4px); right:0; background:#15110b; border:1px solid var(--slot-bd); border-radius:6px; padding:6px; min-width:340px; max-height:min(70vh, 540px); overflow-y:auto; z-index:30; box-shadow:0 12px 32px rgba(0,0,0,0.5); }
.brand-menu[hidden] { display:none; }
.brand-option { display:flex; align-items:center; gap:12px; padding:8px 10px; cursor:pointer; border-radius:4px; transition:background .12s; }
.brand-option:hover { background:rgba(176,160,128,0.10); }
.brand-option.selected { background:rgba(176,160,128,0.18); }
.brand-option .name { flex:1; font-size:13px; color:var(--paper); }
.brand-option .name small { display:block; font-size:10px; color:var(--muted); margin-top:2px; letter-spacing:0.05em; }
.brand-option .dots { display:inline-flex; gap:4px; }
.brand-option .dot { width:18px; height:18px; border-radius:50%; border:1px solid rgba(255,255,255,0.18); }
.brand-option.clear { color:var(--muted); font-style:italic; border-top:1px solid var(--line); margin-top:4px; padding-top:10px; }
/* applied theme — palette swap on admin chrome */
body.brand-theme { background:var(--theme-bg, var(--bg)); }
body.brand-theme .topbar { background:var(--theme-panel, transparent); border-bottom-color:var(--theme-line, var(--line)); }
body.brand-theme .sidebar { background:var(--theme-panel, var(--panel)); }
body.brand-theme .slots-wrap { background:var(--theme-panel, #0d0a07); }
body.brand-theme .save-status:not(.ok):not(.err) { color:var(--theme-accent, var(--muted)); }
body.brand-theme .toolbar button.primary { background:var(--theme-accent, var(--accent)); border-color:var(--theme-accent, var(--accent)); color:#0a0a0a; }
/* ─ slot grid ─ */
.slots-wrap { flex-shrink:0; padding:18px 24px; border-bottom:1px solid var(--line); background:#0d0a07; }
.slots-label { display:flex; align-items:center; justify-content:space-between; margin-bottom:10px; }
.slots-label h3 { margin:0; font-size:12px; letter-spacing:0.18em; text-transform:uppercase; color:var(--muted); font-weight:500; }
.slots-label .hint { font-size:11px; color:var(--muted); }
.slots { display:grid; grid-template-columns:1fr 1fr; grid-template-rows:1fr 1fr; gap:8px; width:100%; max-width:520px; aspect-ratio:1.6/1; margin:0 auto; }
.slot { position:relative; background:var(--slot-bg); border:1px dashed var(--slot-bd); overflow:hidden; cursor:default; transition:border-color .14s, background .14s; }
.slot.over { border-color:var(--hot); border-style:solid; background:rgba(212,154,92,0.08); }
.slot img { width:100%; height:100%; object-fit:cover; object-position:center 12%; display:block; }
.slot .empty { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:11px; letter-spacing:0.12em; text-transform:uppercase; pointer-events:none; }
.slot .badge { position:absolute; top:6px; left:6px; background:rgba(10,10,10,0.78); padding:3px 8px; font-size:9px; letter-spacing:0.18em; text-transform:uppercase; border-radius:2px; }
.slot .clear { position:absolute; top:6px; right:6px; background:rgba(10,10,10,0.78); color:#fff; border:0; cursor:pointer; width:22px; height:22px; border-radius:50%; font-size:12px; line-height:1; display:none; }
.slot.has-img:hover .clear { display:block; }
.slot .title-strip { position:absolute; bottom:0; left:0; right:0; padding:6px 8px; background:linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.7) 100%); font-size:10px; line-height:1.2; }
.toolbar { display:flex; gap:8px; margin-top:12px; align-items:center; justify-content:center; }
.toolbar button { background:transparent; border:1px solid var(--line); color:var(--paper); padding:6px 14px; cursor:pointer; font:inherit; font-size:12px; letter-spacing:0.06em; border-radius:3px; transition:all .14s; }
.toolbar button:hover { background:rgba(245,241,232,0.06); border-color:var(--accent); }
.toolbar button.primary { background:var(--accent); color:#0a0a0a; border-color:var(--accent); font-weight:500; }
.toolbar button.primary:hover { background:#c0b090; }
/* ─ products grid ─ */
.products-wrap { flex:1; overflow-y:auto; padding:18px 24px; }
.products-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; gap:12px; flex-wrap:wrap; }
.products-header h3 { margin:0; font-size:12px; letter-spacing:0.18em; text-transform:uppercase; color:var(--muted); font-weight:500; }
.products-controls { display:flex; align-items:center; gap:14px; flex-wrap:wrap; }
.products-controls label { display:inline-flex; align-items:center; gap:6px; font-size:11px; letter-spacing:0.1em; text-transform:uppercase; color:var(--muted); }
.products-controls select { padding:5px 10px; background:transparent; border:1px solid var(--line); color:var(--paper); font:inherit; font-size:12px; outline:none; border-radius:3px; }
.products-controls input[type=range] { width:120px; accent-color:var(--accent); }
.product-search input { padding:6px 10px; background:transparent; border:1px solid var(--line); color:var(--paper); font:inherit; outline:none; border-radius:3px; width:220px; font-size:12px; }
.products { display:grid; grid-template-columns: repeat(auto-fill, minmax(var(--card-w, 140px), 1fr)); gap:10px; }
.product { position:relative; aspect-ratio:1/1.15; background:var(--slot-bg); border:1px solid var(--line); overflow:hidden; cursor:grab; transition:border-color .14s; }
.product:active { cursor:grabbing; }
.product:hover { border-color:var(--accent); }
.product.dragging { opacity:0.4; }
.product img { width:100%; height:100%; object-fit:cover; object-position:center 12%; display:block; pointer-events:none; }
.product .strip { position:absolute; bottom:0; left:0; right:0; padding:5px 7px; background:linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.85) 100%); font-size:10px; line-height:1.2; }
.product .strip .sku { color:var(--muted); font-size:9px; letter-spacing:0.04em; }
.empty-state { display:flex; align-items:center; justify-content:center; height:60vh; color:var(--muted); }
.loading { padding:40px; text-align:center; color:var(--muted); }
/* ─ controls ─ */
.kind-toggle { background:rgba(10,10,10,0.78); border:0; color:#fff; padding:3px 7px; font-size:9px; letter-spacing:0.18em; text-transform:uppercase; cursor:pointer; border-radius:2px; margin-left:6px; }
.kind-toggle:hover { background:var(--accent); color:#0a0a0a; }
/* mobile — let the whole page scroll naturally instead of trapping the user
inside a 40px products-wrap. Shrink sidebar + slot grid so the products
grid lands inside the first viewport without scrolling. */
@media (max-width:780px) {
html, body { height:auto; min-height:100%; }
.app { grid-template-columns: 1fr; grid-template-rows: auto auto; height:auto; min-height:100vh; }
.sidebar { max-height:22vh; }
.main { overflow:visible; }
.products-wrap { overflow:visible; flex:none; }
.slots { max-width:360px; aspect-ratio:1.6/1; }
.slots-label .hint { display:none; }
}
</style>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
</head>
<body>
<div class="app">
<aside class="sidebar">
<h2>DW Fleet</h2>
<div class="sidebar-search"><input id="siteSearch" type="search" placeholder="Filter sites..."></div>
<ul class="site-list" id="siteList"></ul>
</aside>
<main class="main">
<div class="topbar">
<div>
<h1 id="siteName">— pick a site —</h1>
<div class="meta" id="siteMeta"></div>
</div>
<div style="display:flex; align-items:center; gap:14px;">
<div class="brand-picker" id="brandPicker" hidden>
<button id="brandBtn" class="brand-picker-btn" type="button">
<span class="label">Apply style guide…</span>
<span class="dots" id="brandBtnDots"></span>
<span class="caret">▾</span>
</button>
<div id="brandMenu" class="brand-menu" hidden></div>
</div>
<div class="save-status" id="saveStatus"></div>
</div>
</div>
<div class="slots-wrap" id="slotsWrap" style="display:none">
<div class="slots-label">
<h3>Hero 2×2 — drag products in</h3>
<span class="hint">crop 12% from top · rule: never display image with header</span>
</div>
<div class="slots" id="slots">
<div class="slot" data-idx="0"><span class="badge">Room 1</span><div class="empty">Drop room photo</div></div>
<div class="slot" data-idx="1"><span class="badge">Pattern 1</span><div class="empty">Drop pattern</div></div>
<div class="slot" data-idx="2"><span class="badge">Room 2</span><div class="empty">Drop room photo</div></div>
<div class="slot" data-idx="3"><span class="badge">Pattern 2</span><div class="empty">Drop pattern</div></div>
</div>
<div class="toolbar">
<button id="saveBtn" class="primary">Save & push to prod</button>
<button id="resetBtn">Reset to newest 4</button>
<button id="openSiteBtn">Open live site ↗</button>
</div>
</div>
<div class="products-wrap" id="productsWrap">
<div class="empty-state">Pick a site on the left to start.</div>
</div>
</main>
</div>
<script>
(function () {
'use strict';
var LS = 'dw-hero-admin.';
var state = {
site: null,
products: [],
grid: null,
filter: '',
sort: localStorage.getItem(LS + 'sort') || 'newest',
cardW: parseInt(localStorage.getItem(LS + 'cardW') || '140', 10),
brands: [],
assignments: {}, // { site_id: { brand_id, updated_at } }
};
document.documentElement.style.setProperty('--card-w', state.cardW + 'px');
var els = {
siteList: document.getElementById('siteList'),
siteSearch: document.getElementById('siteSearch'),
siteName: document.getElementById('siteName'),
siteMeta: document.getElementById('siteMeta'),
slotsWrap: document.getElementById('slotsWrap'),
slots: document.getElementById('slots'),
productsWrap: document.getElementById('productsWrap'),
saveStatus: document.getElementById('saveStatus'),
saveBtn: document.getElementById('saveBtn'),
resetBtn: document.getElementById('resetBtn'),
openSiteBtn: document.getElementById('openSiteBtn'),
brandPicker: document.getElementById('brandPicker'),
brandBtn: document.getElementById('brandBtn'),
brandBtnDots: document.getElementById('brandBtnDots'),
brandMenu: document.getElementById('brandMenu'),
};
function api(url, opts) {
return fetch(url, opts).then(function (r) {
return r.json().catch(function () { return {}; }).then(function (body) {
if (!r.ok) {
var err = new Error((body && body.error) || ('HTTP ' + r.status));
err.body = body;
throw err;
}
return body;
});
});
}
function loadSites() {
return api('/api/sites').then(function (data) {
var sites = data.sites || [];
els.siteList.innerHTML = sites.map(function (s) {
return '<li data-site="' + s + '">' + s + '<small>' + s + '.com</small></li>';
}).join('');
Array.prototype.forEach.call(els.siteList.querySelectorAll('li'), function (li) {
li.addEventListener('click', function () { pickSite(li.dataset.site); });
});
});
}
function loadBrandsAndAssignments() {
return Promise.all([
api('/api/brands').catch(function () { return { brands: [] }; }),
api('/api/brand-assignments').catch(function () { return {}; }),
]).then(function (results) {
state.brands = (results[0] && results[0].brands) || [];
state.assignments = results[1] || {};
renderBrandMenu();
});
}
function renderBrandMenu() {
if (!state.brands.length) return;
var current = state.site ? (state.assignments[state.site] || {}).brand_id : null;
var rows = state.brands.map(function (b) {
var dots = b.palette.slice(0, 5).map(function (c) {
return '<span class="dot" style="background:' + escapeHtml(c.hex) + '" title="' + escapeHtml(c.name) + '"></span>';
}).join('');
var cls = 'brand-option' + (b.id === current ? ' selected' : '');
return '<div class="' + cls + '" data-brand="' + escapeHtml(b.id) + '">\
<div class="name">' + escapeHtml(b.name) + '<small>' + escapeHtml(b.signature_motif || '').slice(0, 60) + '</small></div>\
<div class="dots">' + dots + '</div>\
</div>';
}).join('');
rows += '<div class="brand-option clear" data-brand=""><div class="name">— clear assignment —</div></div>';
els.brandMenu.innerHTML = rows;
Array.prototype.forEach.call(els.brandMenu.querySelectorAll('.brand-option'), function (row) {
row.addEventListener('click', function () {
applyBrand(row.dataset.brand || null);
els.brandMenu.hidden = true;
});
});
refreshBrandBtn();
}
function refreshBrandBtn() {
var assign = state.site ? state.assignments[state.site] : null;
var brand = assign ? state.brands.find(function (b) { return b.id === assign.brand_id; }) : null;
var label = els.brandBtn.querySelector('.label');
var dots = els.brandBtnDots;
if (brand) {
label.textContent = brand.name;
dots.innerHTML = brand.palette.slice(0, 4).map(function (c) {
return '<span class="dot" style="background:' + escapeHtml(c.hex) + '"></span>';
}).join('');
applyTheme(brand);
} else {
label.textContent = 'Apply style guide…';
dots.innerHTML = '';
applyTheme(null);
}
}
function applyTheme(brand) {
var root = document.documentElement.style;
if (!brand) {
document.body.classList.remove('brand-theme');
['--theme-bg','--theme-panel','--theme-accent','--theme-line'].forEach(function (k) { root.removeProperty(k); });
return;
}
document.body.classList.add('brand-theme');
var primary = brand.palette.find(function (c) { return c.role === 'primary'; }) || brand.palette[0];
var accent = brand.palette.find(function (c) { return c.role === 'accent'; }) ||
brand.palette.find(function (c) { return c.role === 'accent-soft'; }) ||
brand.palette[1] || primary;
var ground = brand.palette.find(function (c) { return c.role === 'ground'; }) || null;
// Mix the primary at ~10% with the dark base so admin chrome remains dark-readable
root.setProperty('--theme-bg', mixWithBlack(primary.hex, 0.92));
root.setProperty('--theme-panel', mixWithBlack(primary.hex, 0.86));
root.setProperty('--theme-accent', accent.hex);
root.setProperty('--theme-line', mixWithBlack(ground ? ground.hex : primary.hex, 0.7));
}
function mixWithBlack(hex, blackAmount) {
var c = hex.replace('#','');
var r = parseInt(c.slice(0,2), 16);
var g = parseInt(c.slice(2,4), 16);
var b = parseInt(c.slice(4,6), 16);
r = Math.round(r * (1 - blackAmount));
g = Math.round(g * (1 - blackAmount));
b = Math.round(b * (1 - blackAmount));
return 'rgb(' + r + ',' + g + ',' + b + ')';
}
function applyBrand(brandId) {
if (!state.site) return;
if (brandId) state.assignments[state.site] = { brand_id: brandId, updated_at: new Date().toISOString() };
else delete state.assignments[state.site];
refreshBrandBtn();
renderBrandMenu();
// persist server-side
api('/api/site/' + encodeURIComponent(state.site) + '/brand', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ brand_id: brandId }),
}).then(function (r) {
flashStatus(brandId ? ('✓ applied ' + (state.brands.find(function (b) { return b.id === brandId; }) || {}).name) : '✓ cleared style guide', 'ok');
}).catch(function (e) { flashStatus('✗ save assignment: ' + e.message, 'err'); });
}
function pickSite(site) {
state.site = site;
Array.prototype.forEach.call(els.siteList.querySelectorAll('li'), function (li) {
li.classList.toggle('active', li.dataset.site === site);
});
els.siteName.textContent = site;
els.siteMeta.textContent = 'loading…';
els.productsWrap.innerHTML = '<div class="loading">Loading products…</div>';
return api('/api/site/' + encodeURIComponent(site)).then(function (data) {
state.products = data.products || [];
state.grid = data.grid;
els.siteMeta.textContent = data.productCount + ' products · newest first';
els.slotsWrap.style.display = 'block';
els.brandPicker.hidden = false;
renderSlots();
renderProductsHeader();
renderProductsGrid();
renderBrandMenu();
refreshBrandBtn();
}).catch(function (e) {
els.productsWrap.innerHTML = '<div class="empty-state">Error: ' + e.message + '</div>';
});
}
function renderSlots() {
var cells = (state.grid && state.grid.cells) || [];
var kinds = ['Room 1', 'Pattern 1', 'Room 2', 'Pattern 2'];
Array.prototype.forEach.call(els.slots.querySelectorAll('.slot'), function (slot, idx) {
slot.innerHTML = '<span class="badge">' + kinds[idx] + '<button class="kind-toggle" title="Toggle room/pattern">⇆</button></span>';
var cell = cells[idx];
if (cell && cell.image_url) {
slot.classList.add('has-img');
var img = document.createElement('img');
img.src = cell.image_url;
img.alt = cell.title || '';
slot.appendChild(img);
var clear = document.createElement('button');
clear.className = 'clear';
clear.textContent = '×';
clear.title = 'Clear';
clear.addEventListener('click', function (ev) { ev.stopPropagation(); clearSlot(idx); });
slot.appendChild(clear);
var strip = document.createElement('div');
strip.className = 'title-strip';
strip.textContent = cell.title || cell.sku || '';
slot.appendChild(strip);
} else {
slot.classList.remove('has-img');
var emp = document.createElement('div');
emp.className = 'empty';
emp.textContent = idx % 2 === 0 ? 'Drop room photo' : 'Drop pattern';
slot.appendChild(emp);
}
// toggle kind
var tog = slot.querySelector('.kind-toggle');
if (tog) tog.addEventListener('click', function (ev) {
ev.stopPropagation();
if (!state.grid) return;
var c = state.grid.cells[idx] || {};
c.kind = (c.kind === 'room') ? 'pattern' : 'room';
state.grid.cells[idx] = c;
flashStatus('toggled — click Save', '');
});
// drag handlers
slot.addEventListener('dragover', function (ev) { ev.preventDefault(); slot.classList.add('over'); });
slot.addEventListener('dragleave', function () { slot.classList.remove('over'); });
slot.addEventListener('drop', function (ev) {
ev.preventDefault();
slot.classList.remove('over');
var sku = ev.dataTransfer.getData('text/sku');
var hit = state.products.find(function (p) { return p.sku === sku; });
if (!hit) return;
setSlot(idx, hit, idx % 2 === 0 ? 'room' : 'pattern');
});
});
}
function setSlot(idx, product, kind) {
if (!state.grid) state.grid = { cells: [{}, {}, {}, {}], rotation_pool: [], autocycle_seconds: 6 };
state.grid.cells[idx] = {
kind: kind || state.grid.cells[idx]?.kind || (idx % 2 === 0 ? 'room' : 'pattern'),
sku: product.sku,
image_url: product.image_url,
title: product.title,
};
renderSlots();
flashStatus('unsaved — click Save to push to prod', '');
}
function clearSlot(idx) {
if (!state.grid || !state.grid.cells) return;
state.grid.cells[idx] = { kind: state.grid.cells[idx]?.kind || (idx % 2 === 0 ? 'room' : 'pattern'), sku: null, image_url: null, title: null };
renderSlots();
flashStatus('unsaved', '');
}
// Header is rendered ONCE per site pick — preserves focus on the search
// input across keystroke-driven re-renders of the grid.
function renderProductsHeader() {
els.productsWrap.innerHTML = '\
<div class="products-header">\
<h3 id="prodCount">All products</h3>\
<div class="products-controls">\
<label>Sort\
<select id="prodSort">\
<option value="newest">Newest first</option>\
<option value="title">Title A→Z</option>\
<option value="sku">SKU A→Z</option>\
<option value="vendor">Vendor A→Z</option>\
</select>\
</label>\
<label>Size\
<input id="prodDensity" type="range" min="90" max="320" step="10" value="' + state.cardW + '">\
</label>\
<div class="product-search"><input id="prodFilter" type="search" placeholder="Filter pattern, vendor, tag..." value="' + escapeHtml(state.filter || '') + '"></div>\
</div>\
</div>\
<div class="products" id="prodGrid"></div>\
';
var pf = document.getElementById('prodFilter');
pf.addEventListener('input', function () {
state.filter = pf.value;
renderProductsGrid();
});
var ps = document.getElementById('prodSort');
ps.value = state.sort;
ps.addEventListener('change', function () {
state.sort = ps.value;
localStorage.setItem(LS + 'sort', state.sort);
renderProductsGrid();
});
var pd = document.getElementById('prodDensity');
pd.addEventListener('input', function () {
state.cardW = parseInt(pd.value, 10);
localStorage.setItem(LS + 'cardW', state.cardW);
document.documentElement.style.setProperty('--card-w', state.cardW + 'px');
});
}
// Only re-renders the grid + count — never touches the header DOM, so
// the user's keystroke focus stays in #prodFilter while typing.
function renderProductsGrid() {
var q = (state.filter || '').toLowerCase();
var items = state.products.filter(function (p) {
if (!q) return true;
return ((p.title || '') + ' ' + (p.sku || '') + ' ' + (p.vendor || '') + ' ' + (p.tags || []).join(' ')).toLowerCase().indexOf(q) !== -1;
});
var cmp = function (a, b, key) { return (a[key] || '').localeCompare(b[key] || ''); };
if (state.sort === 'title') items = items.slice().sort(function (a, b) { return cmp(a, b, 'title'); });
else if (state.sort === 'sku') items = items.slice().sort(function (a, b) { return cmp(a, b, 'sku'); });
else if (state.sort === 'vendor') items = items.slice().sort(function (a, b) { return cmp(a, b, 'vendor') || cmp(a, b, 'title'); });
// newest = leave api ordering (server already reversed products.json)
var label = ({ newest:'newest first', title:'title A→Z', sku:'SKU A→Z', vendor:'vendor A→Z' })[state.sort] || 'newest first';
var count = document.getElementById('prodCount');
if (count) count.textContent = 'All products — ' + label + ' (' + items.length + ')';
var grid = document.getElementById('prodGrid');
if (!grid) return;
grid.innerHTML = items.slice(0, 400).map(function (p) {
return '<div class="product" draggable="true" data-sku="' + escapeHtml(p.sku) + '">\
<img src="' + escapeHtml(p.image_url) + '" alt="' + escapeHtml(p.title || '') + '" loading="lazy">\
<div class="strip">' + escapeHtml((p.title || '').slice(0, 36)) + '<div class="sku">' + escapeHtml(p.sku) + '</div></div>\
</div>';
}).join('');
Array.prototype.forEach.call(grid.querySelectorAll('.product'), function (el) {
el.addEventListener('dragstart', function (ev) {
el.classList.add('dragging');
ev.dataTransfer.setData('text/sku', el.dataset.sku);
ev.dataTransfer.effectAllowed = 'copy';
});
el.addEventListener('dragend', function () { el.classList.remove('dragging'); });
});
}
function flashStatus(msg, cls) {
els.saveStatus.textContent = msg;
els.saveStatus.className = 'save-status ' + (cls || '');
}
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, function (c) {
return ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' })[c];
});
}
els.saveBtn.addEventListener('click', function () {
if (!state.site || !state.grid) return;
flashStatus('saving…', '');
api('/api/site/' + encodeURIComponent(state.site) + '/grid', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(state.grid),
}).then(function () {
flashStatus('✓ saved + pushed to prod', 'ok');
}).catch(function (e) { flashStatus('✗ ' + e.message, 'err'); });
});
els.resetBtn.addEventListener('click', function () {
if (!state.site) return;
var first4 = state.products.slice(0, 4);
state.grid = {
cells: first4.map(function (p, i) {
return { kind: i % 2 === 0 ? 'room' : 'pattern', sku: p.sku, image_url: p.image_url, title: p.title };
}),
rotation_pool: state.products.slice(0, 16).map(function (p) { return { sku: p.sku, image_url: p.image_url, title: p.title }; }),
autocycle_seconds: 6,
};
renderSlots();
flashStatus('reset (unsaved)', '');
});
els.openSiteBtn.addEventListener('click', function () {
if (state.site) window.open('https://' + state.site + '.com', '_blank', 'noopener,noreferrer');
});
els.siteSearch.addEventListener('input', function () {
var q = els.siteSearch.value.toLowerCase();
Array.prototype.forEach.call(els.siteList.querySelectorAll('li'), function (li) {
li.style.display = !q || li.dataset.site.indexOf(q) !== -1 ? '' : 'none';
});
});
els.brandBtn.addEventListener('click', function (ev) {
ev.stopPropagation();
els.brandMenu.hidden = !els.brandMenu.hidden;
});
document.addEventListener('click', function (ev) {
if (!els.brandPicker.contains(ev.target)) els.brandMenu.hidden = true;
});
loadSites();
loadBrandsAndAssignments();
})();
</script>
</body>
</html>