← back to Wallco Ai
public/js/admin.js
198 lines
/* wallco.ai admin UI helpers */
(function () {
// Density slider
const dens = document.getElementById('density');
if (dens) {
dens.value = localStorage.getItem('wc.admin.density') || 5;
document.documentElement.style.setProperty('--cols', dens.value);
dens.addEventListener('input', () => {
document.documentElement.style.setProperty('--cols', dens.value);
localStorage.setItem('wc.admin.density', dens.value);
});
}
// "+ Inspiration" buttons on product cards
document.querySelectorAll('.p-card .p-add').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const card = btn.closest('.p-card');
const payload = {
vendor_code: card.dataset.vendor,
mfr_sku: card.dataset.sku,
pattern_name:card.dataset.pattern,
image_url: card.dataset.img,
product_url: card.dataset.url
};
btn.disabled = true; btn.textContent = '…';
try {
const r = await fetch('/api/admin/inspiration/add', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), credentials: 'include'
});
if (r.ok) { card.classList.add('is-picked'); btn.textContent = '✓ Added'; }
else { btn.textContent = '✗ failed'; btn.disabled = false; }
} catch (e2) { btn.textContent = '✗ ' + e2.message.slice(0,20); btn.disabled = false; }
});
});
// Inspiration page: remove buttons
document.querySelectorAll('.p-remove').forEach(btn => {
btn.addEventListener('click', async () => {
const id = parseInt(btn.dataset.id, 10);
btn.disabled = true; btn.textContent = '…';
const r = await fetch('/api/admin/inspiration/remove', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
if (r.ok) btn.closest('.p-card').remove(); else btn.disabled = false;
});
});
// Two-step Generate flow + Export decisions.json.
// Step 1 — Click gen-btn → POST /api/admin/inspiration/prompts → 3 SDXL
// prompts come back with per-prompt settlement_warnings. The
// admin can edit each prompt in a textarea before rendering.
// Step 2 — Click "Render →" on a card → POST /api/admin/inspiration/generate
// with { prompts:[edited] } → just THAT prompt is screened +
// sent to Replicate. The card swaps in either the new design
// tile or a clear error (blocked term, network, etc.).
// Every action lands in localStorage `wallco.admin.decisions` so the admin
// has an audit trail of which prompts were edited, which rendered, and
// which were blocked. "Export decisions.json" downloads that ledger.
const gen = document.getElementById('gen-btn');
if (gen) {
const DEC_KEY = 'wallco.admin.decisions';
const loadDecisions = () => { try { return JSON.parse(localStorage.getItem(DEC_KEY) || '[]'); } catch { return []; } };
const saveDecision = (d) => {
const a = loadDecisions();
a.push({ ts: new Date().toISOString(), ...d });
localStorage.setItem(DEC_KEY, JSON.stringify(a));
};
const escHtml = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const origGenText = gen.textContent;
function refreshDecCount() {
const el = document.getElementById('dec-count');
if (el) el.textContent = loadDecisions().length;
}
function renderPromptTray(container, prompts, warnings) {
container.innerHTML =
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">'
+ '<h2 style="font:300 22px/1 var(--serif,Georgia);margin:0;color:var(--ink,#1a1208)">'
+ prompts.length + ' prompt' + (prompts.length === 1 ? '' : 's') + ' — edit, then render</h2>'
+ '<button id="dec-export" type="button" style="padding:6px 12px;border:1px solid var(--gold,#c69b3e);background:transparent;color:var(--gold,#c69b3e);border-radius:4px;font:600 11px var(--sans,system-ui);letter-spacing:.08em;text-transform:uppercase;cursor:pointer">'
+ 'Export decisions.json (<span id="dec-count">' + loadDecisions().length + '</span>)</button>'
+ '</div>'
+ prompts.map(function (p, i) {
const w = warnings[i] || {};
const warnBanner = w.verdict === 'BLOCK'
? '<div style="margin:8px 0;padding:8px 10px;background:#3a2419;border-left:3px solid #c46a3a;color:#e6b896;font:12px var(--sans,system-ui);border-radius:0 4px 4px 0">⚖ Contains "<b>' + escHtml(w.term) + '</b>" — the settlement gate will BLOCK this. Edit it out, then render.</div>'
: '<div style="margin:4px 0 8px;font:11px var(--sans,system-ui);color:#7fc77f;letter-spacing:.05em">✓ settlement-safe</div>';
return ''
+ '<div class="dec-card" data-i="' + i + '" data-original="' + escHtml(p) + '"'
+ ' style="margin-bottom:16px;padding:14px;background:var(--card-bg,#1a140c);border:1px solid var(--card-border,#3a2f1a);border-radius:6px">'
+ '<div style="font:600 11px var(--sans,system-ui);letter-spacing:.1em;text-transform:uppercase;color:var(--ink-muted,#8a7960);margin-bottom:6px">Prompt ' + (i + 1) + '</div>'
+ warnBanner
+ '<textarea class="dec-prompt" rows="4" style="width:100%;min-height:80px;padding:9px 11px;background:#0f0c08;color:var(--ink,#f4ece0);border:1px solid var(--card-border,#3a2f1a);border-radius:4px;font:13px/1.5 var(--sans,system-ui);box-sizing:border-box;resize:vertical">' + escHtml(p) + '</textarea>'
+ '<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px">'
+ '<span style="font:11px var(--sans,system-ui);color:var(--ink-muted,#8a7960)">≈ $0.003 to render via Replicate · ~30 s</span>'
+ '<button class="dec-render" type="button" style="padding:7px 14px;border:0;border-radius:4px;background:var(--gold,#c69b3e);color:#1a1208;font:600 11px var(--sans,system-ui);letter-spacing:.08em;text-transform:uppercase;cursor:pointer">Render →</button>'
+ '</div>'
+ '<div class="dec-result" style="margin-top:10px"></div>'
+ '</div>';
}).join('');
container.querySelectorAll('.dec-render').forEach(btn => {
btn.addEventListener('click', async () => {
const card = btn.closest('.dec-card');
const original = card.dataset.original;
const ta = card.querySelector('.dec-prompt');
const edited = (ta.value || '').trim();
const resultEl = card.querySelector('.dec-result');
if (edited.length < 10) { resultEl.innerHTML = '<div style="color:#c46a3a;font:12px var(--sans,system-ui)">Prompt too short — write at least 10 chars.</div>'; return; }
btn.disabled = true; btn.textContent = 'Rendering…';
resultEl.innerHTML = '<div style="color:var(--ink-muted,#8a7960);font:12px var(--sans,system-ui)">~30 s on Replicate…</div>';
const decBase = { original_prompt: original, edited_prompt: edited, edited: edited !== original, action: 'render' };
try {
const r = await fetch('/api/admin/inspiration/generate', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompts: [edited] }), credentials: 'include'
});
const j = await r.json().catch(() => ({}));
if (!r.ok) {
const msg = j.error || ('HTTP ' + r.status);
const hint = j.hint ? ' — ' + j.hint : (j.url ? ' (url: ' + j.url + ')' : '');
resultEl.innerHTML = '<div style="color:#c46a3a;font:12px var(--sans,system-ui)">' + escHtml(msg) + escHtml(hint) + '</div>';
saveDecision({ ...decBase, result: { error: msg, url: j.url || null } });
} else {
const d = (j.designs || [])[0] || {};
if (d.id) {
resultEl.innerHTML =
'<a href="/design/' + d.id + '" style="display:flex;gap:12px;align-items:center;padding:10px;background:#0f0c08;border:1px solid var(--card-border,#3a2f1a);border-radius:4px;text-decoration:none;color:inherit">'
+ '<div style="width:60px;height:60px;background:url(\'' + escHtml(d.image_url) + '\') center/cover;border-radius:3px;flex:none"></div>'
+ '<div><div style="font:600 13px var(--sans,system-ui)">Design #' + d.id + ' ✓</div>'
+ '<div style="font:11px var(--sans,system-ui);color:var(--ink-muted,#8a7960)"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' + escHtml(d.dominant_hex || '#888') + ';vertical-align:middle;margin-right:4px"></span>' + escHtml(d.dominant_hex || '') + '</div></div>'
+ '</a>';
saveDecision({ ...decBase, result: { id: d.id, image_url: d.image_url, dominant_hex: d.dominant_hex } });
} else {
const err = d.error || 'no_design_returned';
const term = d.term ? ' — blocked term: "' + d.term + '"' : '';
resultEl.innerHTML = '<div style="color:#c46a3a;font:12px var(--sans,system-ui)">⚠ ' + escHtml(err) + escHtml(term) + '. Edit the prompt and try again.</div>';
saveDecision({ ...decBase, result: { error: err, blocked_term: d.term || null } });
}
}
} catch (e) {
resultEl.innerHTML = '<div style="color:#c46a3a;font:12px var(--sans,system-ui)">Network error: ' + escHtml(e.message) + '</div>';
saveDecision({ ...decBase, result: { error: 'network: ' + e.message } });
}
btn.disabled = false; btn.textContent = 'Render →';
refreshDecCount();
});
});
document.getElementById('dec-export').addEventListener('click', () => {
const data = { exported_at: new Date().toISOString(), decisions: loadDecisions() };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'wallco-decisions-' + new Date().toISOString().slice(0, 10) + '.json';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
});
}
gen.addEventListener('click', async () => {
gen.disabled = true;
gen.textContent = 'Fetching 3 prompts from Ollama… (~15 s)';
try {
const r = await fetch('/api/admin/inspiration/prompts', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
const j = await r.json().catch(() => ({}));
if (!r.ok) {
const msg = j.error || ('HTTP ' + r.status);
const hint = j.hint ? '\n\n' + j.hint : '';
const url = j.url ? '\n\nURL tried: ' + j.url : '';
alert('Prompt fetch failed: ' + msg + hint + url);
gen.disabled = false; gen.textContent = origGenText; return;
}
saveDecision({ action: 'fetched_prompts', prompts: j.prompts, settlement_warnings: j.settlement_warnings });
const prev = document.getElementById('dec-tray'); if (prev) prev.remove();
const out = document.createElement('div');
out.id = 'dec-tray';
out.style.cssText = 'margin:20px 0';
gen.after(out);
renderPromptTray(out, j.prompts, j.settlement_warnings || []);
gen.textContent = '↺ Re-fetch prompts';
gen.disabled = false;
} catch (e) {
alert('Network error: ' + e.message);
gen.disabled = false; gen.textContent = origGenText;
}
});
}
})();