← back to Interiordesignershowroom
public/js/moodboard.js
406 lines
// Mood Board — collect pieces while shopping, then turn the whole board into ONE
// photoreal room setting on a button push. Distinct from the buy-cart: the cart is
// "things to purchase, each at its own store"; the mood board is "pieces I'm composing
// into a room". Pure client-side behavior layer: state in
// localStorage, styles + FAB + drawer injected here, per-card buttons wired by
// delegation so infinite-scroll-added cards work too. The heavy lifting (compose the
// selected products into an Architectural-Digest-grade room) is done server-side by the
// EXISTING POST /api/render (Gemini 2.5 Flash Image, ~$0.039/render).
(function () {
// Named mood-board PROJECTS: pick/create the active project from the toolbar
// <select id="mbProjectSel">. Storage model:
// ids_mb_projects → { "<name>": [items...] } ids_mb_active → active name
// The legacy single board (ids_moodboard) is migrated into "My Board" once.
var LEGACY_KEY = 'ids_moodboard';
var PKEY = 'ids_mb_projects';
var AKEY = 'ids_mb_active';
var DEFAULT_PROJECT = 'My Board';
var projectSel = null;
var COST = 0.039; // per render, mirrors scene.COST_PER_IMAGE — shown before + after
var SESSION_CAP = 8; // client-side spend guard: max renders per browser session (server also rate-limits per-IP)
function renderCount() { try { return Number(sessionStorage.getItem('ids_mb_renders') || 0); } catch (e) { return 0; } }
function bumpRenderCount() { try { sessionStorage.setItem('ids_mb_renders', String(renderCount() + 1)); } catch (e) {} }
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }
// ---------- project store ----------
function readProjects() {
try {
var raw = JSON.parse(localStorage.getItem(PKEY));
if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw;
} catch (e) {}
// first run: migrate the legacy single board (if any) into DEFAULT_PROJECT
var seed = {};
try { var old = JSON.parse(localStorage.getItem(LEGACY_KEY)); if (Array.isArray(old) && old.length) seed[DEFAULT_PROJECT] = old; } catch (e) {}
if (!seed[DEFAULT_PROJECT]) seed[DEFAULT_PROJECT] = [];
localStorage.setItem(PKEY, JSON.stringify(seed));
return seed;
}
function writeProjects(p) { localStorage.setItem(PKEY, JSON.stringify(p)); }
function getActive() {
var p = readProjects();
var a = localStorage.getItem(AKEY);
if (!a || !(a in p)) { a = Object.keys(p)[0] || DEFAULT_PROJECT; localStorage.setItem(AKEY, a); }
return a;
}
function setActive(name) {
var p = readProjects();
if (!(name in p)) { p[name] = []; writeProjects(p); }
localStorage.setItem(AKEY, name);
lastRender = null; // switching projects clears any shown render
sync();
if (!drawer.hidden) renderScroll();
}
function createProject(name) {
name = (name || '').trim().slice(0, 60);
if (!name) { populateProjectSel(); return; }
var p = readProjects();
if (!(name in p)) { p[name] = []; writeProjects(p); }
setActive(name);
}
function populateProjectSel() {
projectSel = document.getElementById('mbProjectSel');
if (!projectSel) return;
var p = readProjects();
var active = getActive();
var opts = Object.keys(p).map(function (n) {
return '<option value="' + esc(n) + '"' + (n === active ? ' selected' : '') + '>' + esc(n) + ' (' + p[n].length + ')</option>';
}).join('');
projectSel.innerHTML = opts + '<option value="__new__">+ New project…</option>';
}
// drawer tab open/closed state (persisted; tabs start COLLAPSED on first load)
function tabState() { try { return JSON.parse(localStorage.getItem('ids_mb_tabs')) || {}; } catch (e) { return {}; } }
function setTab(id, open) { var t = tabState(); t[id] = open; localStorage.setItem('ids_mb_tabs', JSON.stringify(t)); }
// load()/save() always operate on the ACTIVE project's item list
function load() { var p = readProjects(); return p[getActive()] || []; }
function save(b) { var p = readProjects(); p[getActive()] = b; writeProjects(p); sync(); }
function has(id) { return load().some(function (x) { return String(x.id) === String(id); }); }
function addItem(it) { var b = load(); if (!b.some(function (x) { return String(x.id) === String(it.id); })) { if (!b.some(function (x) { return x.primary; })) it.primary = true; b.push(it); save(b); } }
function removeItem(id) {
var b = load().filter(function (x) { return String(x.id) !== String(id); });
if (b.length && !b.some(function (x) { return x.primary; })) b[0].primary = true; // keep exactly one primary
save(b);
}
function setPrimary(id) { save(load().map(function (x) { x.primary = String(x.id) === String(id); return x; })); }
// ---------- injected styles ----------
var css = ''
// per-card add button (injected into each .card-body)
+ '.mb-add{margin-top:9px;width:100%;border:1px solid var(--line,#e8e2d9);background:var(--bg,#faf8f5);border-radius:2px;'
+ 'padding:7px 8px;font:.68rem/1 var(--sans,sans-serif);letter-spacing:.06em;text-transform:uppercase;cursor:pointer;color:#3a3530;'
+ 'transition:border-color .18s,color .18s,background .18s}'
+ '.mb-add:hover{border-color:#9b7b50;color:#9b7b50}'
+ '.mb-add[aria-pressed="true"]{border-color:#9b7b50;color:#9b7b50;background:rgba(155,123,80,.08)}'
// floating action button (bottom-left, clear of any bottom-right cart fab)
+ '.mbfab{position:fixed;left:20px;bottom:20px;z-index:300;display:flex;align-items:center;gap:8px;'
+ 'background:#16130f;color:#faf8f5;border:0;border-radius:30px;padding:11px 18px;font:500 .8rem/1 var(--sans,sans-serif);'
+ 'letter-spacing:.04em;cursor:pointer;box-shadow:0 8px 26px rgba(22,19,15,.28);transition:transform .15s,background .2s}'
+ '.mbfab:hover{transform:translateY(-2px);background:#9b7b50}'
+ '.mbfab .mbfab-n{display:inline-grid;place-items:center;min-width:19px;height:19px;padding:0 5px;border-radius:11px;'
+ 'background:#c9a96e;color:#16130f;font-size:.72rem;font-weight:600}'
+ '.mbfab:not(.has){opacity:.82}'
+ '.mb-backdrop{position:fixed;inset:0;z-index:340;background:rgba(22,19,15,.42);backdrop-filter:blur(2px)}'
// drawer (slides from left)
+ '.mbdrawer{position:fixed;top:0;left:0;height:100vh;width:400px;max-width:94vw;z-index:350;background:#fff;'
+ 'border-right:1px solid var(--line,#e8e2d9);box-shadow:10px 0 40px rgba(22,19,15,.18);display:flex;flex-direction:column;'
+ 'font-family:var(--sans,sans-serif);animation:mbslide .26s cubic-bezier(.22,1,.36,1)}'
+ '@keyframes mbslide{from{transform:translateX(-20px);opacity:.6}to{transform:none;opacity:1}}'
+ '.mbdrawer-head{display:flex;align-items:center;justify-content:space-between;padding:16px 18px;border-bottom:1px solid var(--line,#e8e2d9);'
+ 'font-family:var(--serif,serif);font-size:1.2rem;color:#16130f}'
+ '.mbdrawer-x{border:0;background:none;font-size:1.1rem;color:#7a7168;cursor:pointer;line-height:1}'
+ '.mb-scroll{flex:1;overflow-y:auto;padding:12px 16px}'
+ '.mb-empty{color:#7a7168;font-style:italic;text-align:center;padding:44px 20px;line-height:1.6;font-size:.86rem}'
+ '.mb-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}'
+ '.mb-tile{position:relative;border:1px solid var(--line,#e8e2d9);border-radius:6px;overflow:hidden;background:#f4f0e9}'
+ '.mb-tile.is-primary{border-color:#9b7b50;box-shadow:0 0 0 1px #9b7b50}'
+ '.mb-tile>img{display:block;width:100%;aspect-ratio:1/1;object-fit:cover}'
+ '.mb-tile-t{font-size:.68rem;color:#3a3530;line-height:1.3;padding:5px 6px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}'
+ '.mb-tile-rm{position:absolute;top:4px;right:4px;width:22px;height:22px;border:0;border-radius:50%;background:rgba(22,19,15,.62);'
+ 'color:#fff;cursor:pointer;font-size:.8rem;line-height:1;display:grid;place-items:center}'
+ '.mb-tile-rm:hover{background:#8b3a3a}'
+ '.mb-star{position:absolute;top:4px;left:4px;border:0;border-radius:20px;padding:2px 7px;font-size:.6rem;letter-spacing:.05em;'
+ 'text-transform:uppercase;cursor:pointer;background:rgba(255,255,255,.9);color:#7a7168;font-family:var(--sans,sans-serif)}'
+ '.mb-tile.is-primary .mb-star{background:#9b7b50;color:#fff}'
+ '.mb-foot{border-top:1px solid var(--line,#e8e2d9);padding:14px 16px;display:flex;flex-direction:column;gap:9px}'
+ '.mb-room{width:100%;padding:9px;border:1px solid var(--line,#e8e2d9);border-radius:2px;font:.8rem var(--sans,sans-serif);color:#3a3530;background:#fff}'
+ '.mb-make{background:#16130f;color:#faf8f5;border:0;border-radius:2px;padding:14px;font:500 .82rem/1 var(--sans,sans-serif);'
+ 'letter-spacing:.08em;text-transform:uppercase;cursor:pointer;transition:background .2s}'
+ '.mb-make:hover:not(:disabled){background:#9b7b50}'
+ '.mb-make:disabled{opacity:.55;cursor:not-allowed}'
+ '.mb-clear{background:none;border:0;color:#7a7168;font-size:.72rem;cursor:pointer;letter-spacing:.03em}'
+ '.mb-clear:hover{color:#8b3a3a}'
+ '.mb-note{margin:0;font-size:.66rem;color:#b0a898;line-height:1.5}'
// result view (rendered room)
+ '.mb-result{padding:0 0 8px}'
+ '.mb-result img.mb-room-img{display:block;width:100%;border-radius:8px;border:1px solid var(--line,#e8e2d9)}'
+ '.mb-result h4{font-family:var(--serif,serif);font-size:1rem;color:#16130f;margin:16px 2px 8px}'
+ '.mb-piece{display:flex;gap:10px;align-items:center;padding:7px 2px;border-bottom:1px solid var(--line-lt,#f1ece5);text-decoration:none}'
+ '.mb-piece img{width:44px;height:44px;object-fit:cover;border-radius:4px;border:1px solid var(--line,#e8e2d9);flex:0 0 44px;background:#f4f0e9}'
+ '.mb-piece span{font-size:.76rem;color:#3a3530;line-height:1.3}'
+ '.mb-spin{display:grid;place-items:center;gap:12px;padding:48px 20px;text-align:center;color:#7a7168;font-size:.85rem}'
+ '.mb-spin .ring{width:34px;height:34px;border:3px solid #e8e2d9;border-top-color:#9b7b50;border-radius:50%;animation:mbspin .8s linear infinite}'
+ '@keyframes mbspin{to{transform:rotate(360deg)}}'
+ '.mb-err{color:#8b3a3a;font-size:.8rem;padding:14px 2px;line-height:1.5}'
+ '.mb-back{background:none;border:0;color:#7a6038;font-size:.72rem;text-transform:uppercase;letter-spacing:.05em;cursor:pointer;padding:0;margin:4px 2px 10px}'
+ '.mb-back:hover{text-decoration:underline}'
// collapsible drawer tabs (Project / Pieces / Create) — start collapsed, mirror the left-rail pattern
+ '.mb-tab{border-bottom:1px solid var(--line,#e8e2d9)}'
+ '.mb-tab>summary{cursor:pointer;list-style:none;user-select:none;display:flex;justify-content:space-between;align-items:center;'
+ 'padding:12px 4px;font:600 .68rem/1.2 var(--sans,sans-serif);letter-spacing:.1em;text-transform:uppercase;color:#7a7168}'
+ '.mb-tab>summary::-webkit-details-marker{display:none}'
+ '.mb-tab>summary::after{content:"+";font-size:1rem;font-weight:300;color:#b0a898}'
+ '.mb-tab[open]>summary{color:#16130f}'
+ '.mb-tab[open]>summary::after{content:"\\2013"}'
+ '.mb-tabbody{padding:2px 2px 16px}'
+ '.mb-tabbody.mb-foot{border-top:0;padding:2px 2px 16px}'
+ '.mb-projects{display:flex;flex-direction:column;gap:4px;margin-bottom:10px}'
+ '.mb-proj{display:flex;justify-content:space-between;align-items:center;gap:8px;width:100%;text-align:left;'
+ 'border:1px solid var(--line,#e8e2d9);background:#fff;border-radius:2px;padding:8px 10px;cursor:pointer;'
+ 'font:.8rem var(--sans,sans-serif);color:#3a3530;transition:border-color .15s,background .15s}'
+ '.mb-proj:hover{border-color:#9b7b50}'
+ '.mb-proj.is-active{background:#16130f;color:#faf8f5;border-color:#16130f}'
+ '.mb-proj-c{font-size:.7rem;opacity:.7;font-variant-numeric:tabular-nums}'
+ '.mb-newproj{width:100%;border:1px dashed var(--line,#c9bfae);background:none;border-radius:2px;padding:8px;cursor:pointer;'
+ 'font:.72rem var(--sans,sans-serif);letter-spacing:.05em;text-transform:uppercase;color:#7a6038}'
+ '.mb-newproj:hover{border-color:#9b7b50;color:#9b7b50}'
+ '.mbdrawer[hidden],.mb-backdrop[hidden]{display:none}'
+ '@media(max-width:520px){.mbfab{left:14px;bottom:14px}.mb-grid{grid-template-columns:1fr 1fr}}';
var st = document.createElement('style'); st.textContent = css; document.head.appendChild(st);
// ---------- FAB + drawer scaffold ----------
var fab = document.createElement('button');
fab.className = 'mbfab'; fab.type = 'button'; fab.setAttribute('aria-label', 'Open your mood board');
fab.innerHTML = '🎨 Mood Board <span class="mbfab-n">0</span>';
var backdrop = document.createElement('div'); backdrop.className = 'mb-backdrop'; backdrop.hidden = true;
var drawer = document.createElement('div'); drawer.className = 'mbdrawer'; drawer.hidden = true;
drawer.setAttribute('role', 'dialog'); drawer.setAttribute('aria-label', 'Your mood board');
drawer.innerHTML = '<div class="mbdrawer-head"><span class="mbdrawer-title">Mood Board</span> <button class="mbdrawer-x" type="button" aria-label="Close">✕</button></div>'
+ '<div class="mb-scroll"></div>';
document.body.appendChild(fab); document.body.appendChild(backdrop); document.body.appendChild(drawer);
var ROOMS = ['Living Room', 'Bedroom', 'Dining Room', 'Home Office', 'Entryway', 'Kitchen', 'Bathroom', 'Nursery'];
var lastRender = null; // holds the current room result so it survives re-open until cleared
// ---------- drawer rendering: assemble view vs result view ----------
function renderScroll() {
var scroll = drawer.querySelector('.mb-scroll');
var head = drawer.querySelector('.mbdrawer-title');
if (head) head.textContent = getActive();
if (lastRender) return renderResult(scroll);
var b = load();
var ts = tabState();
var roomOpts = ROOMS.map(function (r) { return '<option value="' + esc(r) + '">' + esc(r) + '</option>'; }).join('');
// ---- Project tab: switch/create the active mood-board project (in-drawer twin of the toolbar select) ----
var p = readProjects(), active = getActive();
var projRows = Object.keys(p).map(function (n) {
return '<button class="mb-proj' + (n === active ? ' is-active' : '') + '" type="button" data-proj="' + esc(n) + '">'
+ '<span class="mb-proj-n">' + esc(n) + '</span><span class="mb-proj-c">' + p[n].length + '</span></button>';
}).join('');
var projectTab =
'<details class="mb-tab" data-tab="project"' + (ts.project ? ' open' : '') + '>'
+ '<summary>Project · ' + esc(active) + '</summary>'
+ '<div class="mb-tabbody"><div class="mb-projects">' + projRows + '</div>'
+ '<button class="mb-newproj" type="button">+ New project</button></div></details>';
// ---- Pieces tab: the collected tiles (or empty state) ----
var piecesInner = b.length
? '<div class="mb-grid">' + b.map(function (x) {
return '<div class="mb-tile' + (x.primary ? ' is-primary' : '') + '" data-id="' + esc(x.id) + '">'
+ '<button class="mb-star" type="button" data-star="' + esc(x.id) + '" aria-label="Make this the primary piece">' + (x.primary ? '★ Primary' : '☆ Set primary') + '</button>'
+ '<button class="mb-tile-rm" type="button" data-rm="' + esc(x.id) + '" aria-label="Remove from mood board">✕</button>'
+ '<img src="' + esc(x.image) + '" alt="" data-hide-on-error="1">'
+ '<div class="mb-tile-t">' + esc(x.title) + '</div></div>';
}).join('') + '</div>'
: '<p class="mb-empty">This project is empty.<br>While you shop, tap <strong>◇ Mood Board</strong> on any piece to collect it here.</p>';
// Pieces defaults OPEN (board visible on drawer open); Project/Create default
// collapsed. All respect an explicit user toggle once one is recorded.
var piecesOpen = (ts.pieces === undefined) ? true : ts.pieces;
var piecesTab =
'<details class="mb-tab" data-tab="pieces"' + (piecesOpen ? ' open' : '') + '>'
+ '<summary>Pieces (' + b.length + ')</summary>'
+ '<div class="mb-tabbody">' + piecesInner + '</div></details>';
// ---- Create tab: room-type + generate the room setting ----
var createTab =
'<details class="mb-tab" data-tab="create"' + (ts.create ? ' open' : '') + '>'
+ '<summary>Create room setting</summary>'
+ '<div class="mb-tabbody mb-foot">'
+ '<select class="mb-room" aria-label="Room type for the setting"><option value="">Room type (optional)…</option>' + roomOpts + '</select>'
+ '<button class="mb-make" type="button"' + (b.length ? '' : ' disabled') + '>Create Room Setting</button>'
+ '<p class="mb-note">' + (b.length
? 'Composes your ' + b.length + ' piece' + (b.length === 1 ? '' : 's') + ' into one photoreal room — your ★ primary piece anchors it. Est. cost ~$' + COST.toFixed(3) + ' per room.'
: 'Add pieces to this project first, then generate a room from them.') + '</p>'
+ (b.length ? '<button class="mb-clear" type="button">Clear this project</button>' : '')
+ '</div></details>';
scroll.innerHTML = projectTab + piecesTab + createTab;
// Persist each tab's open/closed state as the user toggles it. The `toggle`
// event doesn't bubble, so bind per-<details> (re-bound on every re-render).
scroll.querySelectorAll('.mb-tab').forEach(function (d) {
d.addEventListener('toggle', function () { setTab(d.getAttribute('data-tab'), d.open); });
});
}
function renderResult(scroll) {
var r = lastRender;
if (r.loading) {
scroll.innerHTML = '<div class="mb-spin"><div class="ring"></div><div>Designing your room setting…<br><small>Placing your pieces into one photoreal space (~15–30s).</small></div></div>';
return;
}
if (r.error) {
scroll.innerHTML = '<button class="mb-back" type="button">← Back to board</button><div class="mb-err">' + esc(r.error) + '</div>';
return;
}
var pieces = (r.products || []).map(function (p) {
return '<a class="mb-piece" href="/go/' + esc(p.id) + '" target="_blank" rel="nofollow sponsored">'
+ '<img src="' + esc(p.image_url) + '" alt="" data-hide-on-error="1">'
+ '<span>' + esc(p.title) + (p.advertiser ? ' — ' + esc(p.advertiser) : '') + '</span></a>';
}).join('');
scroll.innerHTML = '<button class="mb-back" type="button">← Back to board</button>'
+ '<div class="mb-result"><img class="mb-room-img" src="' + esc(r.url) + '" alt="Your generated room setting">'
+ '<div class="mb-foot" style="border:0;padding:12px 0 0">'
+ '<a class="mb-make" style="text-align:center;text-decoration:none" href="' + esc(r.url) + '" download="room-setting.png">Download image</a>'
+ '<p class="mb-note">Rendered for ~$' + (r.cost || COST).toFixed(3) + '. As an affiliate we earn from qualifying purchases.</p></div>'
+ (pieces ? '<h4>Shop the pieces in this room</h4>' + pieces : '') + '</div>';
}
function open() { renderScroll(); backdrop.hidden = false; drawer.hidden = false; document.body.style.overflow = 'hidden'; }
function close() { drawer.hidden = true; backdrop.hidden = true; document.body.style.overflow = ''; }
// two-step confirm on the make button so a tap can't silently spend ~$0.04.
// First tap arms it ("Generate for ~$0.039? Tap again"); second tap within 4s fires.
var confirmTimer = null;
function armOrFire(btn) {
if (renderCount() >= SESSION_CAP) {
btn.disabled = true;
btn.textContent = 'Session limit reached (' + SESSION_CAP + ' rooms)';
var note = drawer.querySelector('.mb-note');
if (note) note.textContent = 'You’ve made ' + SESSION_CAP + ' rooms this visit — reload the page to make more. (Keeps render costs in check.)';
return;
}
if (btn.dataset.armed === '1') { clearTimeout(confirmTimer); btn.dataset.armed = ''; return createRoom(); }
btn.dataset.armed = '1';
btn.textContent = 'Generate room — ~$' + COST.toFixed(3) + '? Tap again';
confirmTimer = setTimeout(function () { btn.dataset.armed = ''; btn.textContent = 'Create Room Setting'; }, 4000);
}
// ---------- create the room setting (calls the existing /api/render) ----------
function createRoom() {
var b = load(); if (!b.length) return;
bumpRenderCount();
var roomSel = drawer.querySelector('.mb-room');
var room_type = roomSel ? roomSel.value : '';
// primary piece first so it becomes the dominant reference (scene refs the first 4)
var ordered = b.slice().sort(function (a, c) { return (c.primary ? 1 : 0) - (a.primary ? 1 : 0); });
var product_ids = ordered.map(function (x) { return Number(x.id); }).filter(Boolean).slice(0, 12);
lastRender = { loading: true };
renderScroll();
fetch('/api/render', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product_ids: product_ids, room_type: (room_type || '').toLowerCase().replace(/\s+/g, '_') }),
}).then(function (res) {
if (res.status === 429) return res.json().then(function (j) { throw new Error(j.error || 'Render limit reached — try again shortly.'); });
if (!res.ok) return res.json().then(function (j) { throw new Error(j.error || 'Could not create the room.'); }).catch(function () { throw new Error('Could not create the room (' + res.status + ').'); });
return res.json();
}).then(function (j) {
lastRender = { url: j.url, cost: j.cost, products: j.products || [] };
renderScroll();
}).catch(function (e) {
lastRender = { error: e.message || 'Something went wrong. Please try again.' };
renderScroll();
});
}
// ---------- derive a mood-board item straight from a product card ----------
function itemFromCard(card) {
var id = card.getAttribute('data-id'); if (!id) return null;
var img = card.querySelector('.card-img img');
var brand = card.querySelector('.card-brand');
return {
id: id,
title: card.getAttribute('data-title') || (card.querySelector('.card-title') || {}).textContent || '',
price: card.getAttribute('data-price') || '',
image: (img && (img.getAttribute('src') || img.src)) || '',
advertiser: (card.getAttribute('data-network') || (brand ? brand.textContent : '') || '').trim(),
url: '/go/' + id,
primary: false,
};
}
// inject an "add to mood board" button into each product card (once)
function decorateCards() {
document.querySelectorAll('article.card[data-id]').forEach(function (card) {
if (card.querySelector('.mb-add')) return;
var body = card.querySelector('.card-body') || card;
var btn = document.createElement('button');
btn.className = 'mb-add'; btn.type = 'button';
btn.setAttribute('data-mb-id', card.getAttribute('data-id'));
body.appendChild(btn);
syncBtn(btn);
});
}
function syncBtn(btn) {
var on = has(btn.getAttribute('data-mb-id'));
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
btn.textContent = on ? '✓ On mood board' : '◇ Mood Board';
}
function sync() {
var n = load().length;
fab.querySelector('.mbfab-n').textContent = n;
fab.classList.toggle('has', n > 0);
document.querySelectorAll('.mb-add').forEach(syncBtn);
populateProjectSel();
if (!drawer.hidden) renderScroll();
}
// ---------- events ----------
fab.addEventListener('click', open);
backdrop.addEventListener('click', close);
document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !drawer.hidden) close(); });
drawer.addEventListener('click', function (e) {
if (e.target.closest('.mbdrawer-x')) return close();
if (e.target.closest('.mb-back')) { lastRender = null; return renderScroll(); }
var mk = e.target.closest('.mb-make');
if (mk && e.target.closest('.mb-foot') && !e.target.closest('.mb-result')) return armOrFire(mk);
if (e.target.closest('.mb-clear')) { lastRender = null; save([]); return; }
var proj = e.target.closest('.mb-proj'); if (proj) return setActive(proj.getAttribute('data-proj'));
if (e.target.closest('.mb-newproj')) {
var nm = window.prompt('Name your new mood board project:', '');
if (nm && nm.trim()) createProject(nm);
return;
}
var star = e.target.closest('[data-star]'); if (star) return setPrimary(star.getAttribute('data-star'));
var rm = e.target.closest('[data-rm]'); if (rm) return removeItem(rm.getAttribute('data-rm'));
});
drawer.addEventListener('change', function (e) { /* room select — read at create time */ });
// toolbar mood-board project switcher (select lives outside the drawer)
document.addEventListener('change', function (e) {
var sel = e.target.closest ? e.target.closest('#mbProjectSel') : null;
if (!sel) return;
if (sel.value === '__new__') {
var name = window.prompt('Name your new mood board project:', '');
if (name && name.trim()) createProject(name);
else populateProjectSel(); // user cancelled — restore the active selection
return;
}
setActive(sel.value);
});
// per-card add/remove (delegated so infinite-scroll cards work)
document.addEventListener('click', function (e) {
var b = e.target.closest('.mb-add'); if (!b) return;
e.preventDefault();
var card = b.closest('article.card'); if (!card) return;
var id = b.getAttribute('data-mb-id');
if (has(id)) removeItem(id);
else { var it = itemFromCard(card); if (it) addItem(it); }
});
// keep buttons decorated + in sync as cards stream in (infinite scroll / filters)
var t; new MutationObserver(function () { clearTimeout(t); t = setTimeout(function () { decorateCards(); sync(); }, 150); }).observe(document.body, { childList: true, subtree: true });
decorateCards(); sync();
})();