← back to Wallco Ai
public/admin/elements.html
627 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>Elements — wallco.ai admin</title>
<style>
:root {
--ink:#1f1808; --line:#d8d0c0; --gold:#c9a14b;
--bg:#fbf8f1; --card:#fff; --faint:#7a6e5a;
--green:#1f6a2c; --red:#b3261e; --orange:#c0660b;
--sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
--serif:'Playfair Display','Didot',Georgia,serif;
--card-min: 220px;
}
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--ink); font:14px var(--sans); }
header {
padding:14px 24px; border-bottom:1px solid var(--line); background:#fff;
display:flex; gap:16px; align-items:baseline; position:sticky; top:0; z-index:10;
}
header h1 { font:400 24px/1.2 var(--serif); margin:0; letter-spacing:.5px; }
header .meta { color:var(--faint); font-size:12px; letter-spacing:.04em; }
header .stats { margin-left:auto; display:flex; gap:18px; font:600 12px ui-monospace,Menlo,monospace; }
header .stats b { color:var(--ink); }
header .shuffle-btn {
margin-left:18px; padding:8px 16px; background:var(--ink); color:#fff;
border:none; border-radius:4px; cursor:pointer;
font:600 11.5px var(--sans); letter-spacing:.12em; text-transform:uppercase;
}
header .shuffle-btn:hover { background:var(--gold); }
main { padding:16px 24px 80px; }
/* Controls */
.controls {
display:flex; gap:14px; align-items:center; padding:11px 14px;
background:#fff; border:1px solid var(--line); border-radius:8px;
margin-bottom:8px; flex-wrap:wrap; font-size:12px;
}
.controls label { font:600 10.5px var(--sans); letter-spacing:.16em;
text-transform:uppercase; color:var(--faint); }
.controls select, .controls input[type=text] {
padding:6px 10px; border:1px solid var(--line); border-radius:4px;
background:#fff; font:12px var(--sans); min-width:160px;
}
.controls input[type=range] { width:160px; }
.controls .density-val { font:600 11px ui-monospace,Menlo,monospace;
color:var(--faint); min-width:36px; }
.controls .toggle { display:flex; align-items:center; gap:6px; cursor:pointer; }
.controls .toggle input { margin:0; }
/* Active filter chips */
.chips { display:flex; gap:6px; flex-wrap:wrap; padding:0 14px 12px; }
.chip {
background:var(--ink); color:#fff;
font:600 10.5px var(--sans); letter-spacing:.06em;
padding:4px 8px 4px 10px; border-radius:999px;
display:inline-flex; align-items:center; gap:6px;
cursor:pointer;
}
.chip:hover { background:var(--red); }
.chip .x { font-size:14px; line-height:1; }
/* Empty state */
.empty-state {
text-align:center; padding:40px 20px;
background:#fff; border:1px dashed var(--line); border-radius:8px;
margin:14px 0;
}
.empty-state h3 { font:400 18px var(--serif); margin:0 0 6px; }
.empty-state p { color:var(--faint); margin:0 0 12px; font-size:13px; }
.empty-state button {
background:var(--gold); color:#fff; border:0; padding:8px 16px;
border-radius:4px; cursor:pointer; font:600 11.5px var(--sans);
letter-spacing:.12em; text-transform:uppercase;
}
/* Grid */
.grid {
display:grid;
grid-template-columns:repeat(auto-fill, minmax(var(--card-min), 1fr));
gap:14px; padding:14px 0;
}
.element-card {
background:#fff; border:1px solid var(--line); border-radius:8px;
overflow:hidden; transition:transform .12s, box-shadow .12s;
cursor:pointer; position:relative;
}
.element-card.selected {
border-color:var(--gold); border-width:2px;
box-shadow:0 0 0 1px var(--gold);
}
.element-card:hover {
transform:translateY(-2px);
box-shadow:0 6px 16px rgba(0,0,0,.12);
}
.card-thumb {
aspect-ratio:1; background:#f3eee2 url('/admin/elements-placeholder.svg') center/40% no-repeat;
background-size:cover;
display:block; width:100%;
position:relative; cursor:pointer;
}
.card-pick {
position:absolute; left:0; right:0; bottom:0;
padding:7px 10px; text-align:center;
background:linear-gradient(transparent, rgba(0,0,0,.55));
color:#fff; font:700 10.5px var(--sans); letter-spacing:.1em; text-transform:uppercase;
opacity:0; transition:opacity .12s;
pointer-events:none;
}
.element-card:hover .card-pick { opacity:1; }
.card-pick.on {
opacity:1; background:rgba(31,106,44,.92);
}
.card-id { text-decoration:none; }
.card-id:hover { color:var(--gold); }
.card-meta {
padding:10px 12px;
display:flex; flex-direction:column; gap:6px;
font-size:12px;
}
.card-cat {
display:inline-block;
background:#f3eee2; color:var(--ink);
padding:2px 8px; border-radius:999px;
font:600 10px var(--sans); letter-spacing:.08em;
text-transform:uppercase; align-self:flex-start;
max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.card-color {
display:flex; align-items:center; gap:6px;
color:var(--faint); font-size:11px;
}
.card-color .dot {
width:10px; height:10px; border-radius:50%;
border:1px solid rgba(0,0,0,.15);
}
.card-id-row {
display:flex; justify-content:space-between; align-items:center;
border-top:1px solid var(--line); padding-top:6px; margin-top:2px;
}
.card-id {
font:600 11px ui-monospace,Menlo,monospace; color:var(--faint);
}
.card-use-btn {
background:none; border:1px solid var(--line); color:var(--ink);
padding:3px 10px; border-radius:4px; cursor:pointer;
font:600 10px var(--sans); letter-spacing:.12em; text-transform:uppercase;
}
.card-use-btn:hover { background:var(--ink); color:#fff; border-color:var(--ink); }
.card-when {
color:var(--faint); font-size:10.5px;
font-variant-numeric:tabular-nums;
}
.card-tier {
position:absolute; top:8px; right:8px;
font:700 8.5px var(--sans); letter-spacing:.16em; text-transform:uppercase;
padding:3px 7px; border-radius:3px;
color:#fff; background:rgba(31,106,44,.92);
}
.card-tier.passed { background:rgba(122,110,90,.92); }
/* Loading */
.loading { padding:40px; text-align:center; color:var(--faint); }
/* Compose drawer */
.compose-drawer {
position:fixed; bottom:0; left:0; right:0;
background:#fff; border-top:2px solid var(--gold);
padding:14px 24px;
transform:translateY(100%); transition:transform .2s;
z-index:20;
box-shadow:0 -8px 24px rgba(0,0,0,.12);
}
.compose-drawer.open { transform:translateY(0); }
.drawer-inner {
max-width:1500px; margin:0 auto;
display:flex; gap:14px; align-items:center;
}
.drawer-thumbs { display:flex; gap:8px; flex:1; overflow-x:auto; }
.drawer-thumb {
width:64px; height:64px; border-radius:4px; flex-shrink:0;
background-size:cover; background-position:center;
border:1px solid var(--line); position:relative;
}
.drawer-thumb .remove {
position:absolute; top:-6px; right:-6px;
width:18px; height:18px; border-radius:50%;
background:var(--red); color:#fff; border:none;
font-size:12px; line-height:1; cursor:pointer;
}
.drawer-cta {
background:var(--ink); color:#fff; border:0;
padding:12px 22px; border-radius:4px; cursor:pointer;
font:600 12px var(--sans); letter-spacing:.16em; text-transform:uppercase;
}
.drawer-cta:hover { background:var(--gold); }
.drawer-close {
background:none; border:none; color:var(--faint); cursor:pointer;
font:14px var(--sans); padding:6px 10px;
}
</style>
</head>
<body>
<header>
<h1>Elements</h1>
<span class="meta">curated solid-opaque-middle building blocks</span>
<div class="stats">
<span>baked <b id="stat-baked">—</b></span>
<span>clean <b id="stat-clean">—</b></span>
<span>showing <b id="stat-showing">—</b></span>
</div>
<button class="shuffle-btn" id="shuffle-btn">Shuffle 12</button>
</header>
<main>
<div class="controls">
<label>Category</label>
<select id="f-category"><option value="">All</option></select>
<label>Colorway</label>
<select id="f-colorway"><option value="">All</option></select>
<label>Density</label>
<select id="f-density">
<option value="">All</option>
<option value="sparse">Sparse</option>
<option value="medium">Medium</option>
<option value="dense">Dense</option>
</select>
<label class="toggle" title="Solid screen-print only = baked elements. Off = also include candidates that passed seamless-scan but haven't been baked yet.">
<input type="checkbox" id="f-quality" checked>
Solid screen-print only
</label>
<label>Search</label>
<input type="text" id="f-search" placeholder="id, category, or hex…" style="min-width:200px">
<label>Sort</label>
<select id="f-sort">
<option value="shuffle">Shuffled</option>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="category">Category A→Z</option>
<option value="color">Color (by hue)</option>
<option value="id">ID ↑</option>
<option value="id_desc">ID ↓</option>
</select>
<label style="margin-left:auto">Density</label>
<input id="density-slider" type="range" min="140" max="380" step="20" value="220">
<span class="density-val" id="density-val">220</span>
</div>
<div class="chips" id="chips"></div>
<div id="grid" class="grid"><div class="loading">loading elements…</div></div>
</main>
<div class="compose-drawer" id="drawer">
<div class="drawer-inner">
<span style="font:600 11.5px var(--sans); letter-spacing:.16em; text-transform:uppercase; color:var(--faint);">
Selected
</span>
<div class="drawer-thumbs" id="drawer-thumbs"></div>
<button class="drawer-cta" id="drawer-cta">Use →</button>
<button class="drawer-close" id="drawer-close" title="Close drawer (selection persists)">×</button>
</div>
</div>
<script>
(function(){
function fmtDate(iso){
if(!iso) return '—';
const d = new Date(iso); if(isNaN(d)) return '—';
return d.toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
}
const PER = 200;
const state = {
items: [],
filtered: [],
selected: [],
filter: { category:'', colorway:'', density:'', quality:true, search:'' },
cardMin: parseInt(localStorage.getItem('el-card-min') || '220', 10),
shuffleSeed: 0,
sort: localStorage.getItem('el-sort') || 'shuffle',
};
// Sort the view (Steve standing rule). dominant_hex exists → color-by-hue is available.
function hexHue(hex) {
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex || ''); if (!m) return 1e6;
const n = parseInt(m[1], 16), r = (n >> 16 & 255) / 255, g = (n >> 8 & 255) / 255, b = (n & 255) / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min; let h = 0;
if (d === 0) h = 0; else if (max === r) h = ((g - b) / d) % 6; else if (max === g) h = (b - r) / d + 2; else h = (r - g) / d + 4;
h *= 60; if (h < 0) h += 360;
const s = max === 0 ? 0 : d / max;
if (s < 0.12) return 1e5 + (1 - max);
return h;
}
function sortView(arr) {
const r = arr.slice(), m = state.sort;
if (m === 'newest') r.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
else if (m === 'oldest') r.sort((a, b) => String(a.created_at || '').localeCompare(String(b.created_at || '')));
else if (m === 'category') r.sort((a, b) => categoryOf(a).localeCompare(categoryOf(b)) || (b.id - a.id));
else if (m === 'color') r.sort((a, b) => hexHue(a.dominant_hex) - hexHue(b.dominant_hex) || (b.id - a.id));
else if (m === 'id') r.sort((a, b) => a.id - b.id);
else if (m === 'id_desc') r.sort((a, b) => b.id - a.id);
return r;
}
function applyCardMin(px) {
document.documentElement.style.setProperty('--card-min', px + 'px');
document.getElementById('density-val').textContent = px;
}
function categoryOf(item) {
return (item.category || '').split(' · ')[0] || 'misc';
}
function colorwayOf(item) {
const parts = (item.category || '').split(' · ');
return parts.length >= 2 ? parts[1] : '';
}
function densityOf(item) {
if (item.bg_dominance == null) return null;
if (item.bg_dominance < 0.30) return 'dense';
if (item.bg_dominance < 0.70) return 'medium';
return 'sparse';
}
function applyFilters() {
const f = state.filter;
state.filtered = state.items.filter(it => {
if (f.quality && !it.element_motif_path) return false;
if (f.category && categoryOf(it) !== f.category) return false;
if (f.colorway && colorwayOf(it) !== f.colorway) return false;
if (f.density && densityOf(it) !== f.density) return false;
if (f.search) {
const s = f.search.toLowerCase();
const cat = (it.category || '').toLowerCase();
const hex = (it.dominant_hex || '').toLowerCase();
if (!cat.includes(s) && !hex.includes(s) && String(it.id) !== s) return false;
}
return true;
});
document.getElementById('stat-showing').textContent = state.filtered.length;
renderChips();
render();
}
function renderChips() {
const f = state.filter;
const chips = [];
if (f.category) chips.push({ k:'category', v:f.category });
if (f.colorway) chips.push({ k:'colorway', v:f.colorway });
if (f.density) chips.push({ k:'density', v:f.density });
if (!f.quality) chips.push({ k:'quality', v:'show all passed-seamless' });
if (f.search) chips.push({ k:'search', v:f.search });
const c = document.getElementById('chips');
c.innerHTML = chips.map(ch =>
`<span class="chip" data-k="${ch.k}">${ch.k}: ${ch.v} <span class="x">×</span></span>`
).join('');
c.querySelectorAll('.chip').forEach(el => {
el.addEventListener('click', () => {
const k = el.dataset.k;
if (k === 'category') document.getElementById('f-category').value = '';
if (k === 'colorway') document.getElementById('f-colorway').value = '';
if (k === 'density') document.getElementById('f-density').value = '';
if (k === 'quality') document.getElementById('f-quality').checked = true;
if (k === 'search') document.getElementById('f-search').value = '';
state.filter[k] = k === 'quality' ? true : '';
applyFilters();
});
});
}
function shuffleList(arr, seed) {
// Deterministic shuffle on seed so the same Shuffle press doesn't re-roll
// on every render. Mulberry32 PRNG.
let s = seed | 0;
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
const j = ((t ^ (t >>> 14)) >>> 0) % (i + 1);
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function render() {
const grid = document.getElementById('grid');
let view = state.filtered;
if (state.shuffleSeed) view = shuffleList(view, state.shuffleSeed);
else if (state.sort !== 'shuffle') view = sortView(view);
view = view.slice(0, PER);
if (view.length === 0) {
// 0-results — auto-suggest relaxing the most-constraining filter
let suggest = '';
const f = state.filter;
if (f.density) suggest = 'remove the Density filter';
else if (f.colorway) suggest = 'remove the Colorway filter';
else if (f.category) suggest = 'remove the Category filter';
else if (f.search) suggest = 'clear the Search box';
else if (f.quality) suggest = 'turn off "Solid screen-print only"';
grid.innerHTML = `
<div class="empty-state" style="grid-column: 1 / -1;">
<h3>No elements match these filters</h3>
<p>Try this: ${suggest || 'broaden the filters'}</p>
<button onclick="document.querySelectorAll('.chip').forEach(c=>c.click())">Clear all chips</button>
</div>`;
return;
}
grid.innerHTML = view.map(item => {
const selected = state.selected.includes(item.id);
const cat = categoryOf(item);
const cw = colorwayOf(item);
const hex = item.dominant_hex || '#cccccc';
const tier = item.element_motif_path ? 'solid' : 'passed';
// Prefer the baked 512 reference thumb if present; fall back to live tile
const img = item.element_ref_path
? `/elements/img/${item.id}_ref512.png`
: `/designs/img/by-id/${item.id}`;
return `
<div class="element-card ${selected ? 'selected' : ''}" data-id="${item.id}">
<span class="card-tier ${tier}">${tier === 'solid' ? 'Solid' : 'Passed'}</span>
<div class="card-thumb" data-id="${item.id}" title="Click to use this element in a new pattern" style="background-image:url('${img}')">
<span class="card-pick ${selected ? 'on' : ''}">${selected ? '✓ Selected' : '+ Use in new pattern'}</span>
</div>
<div class="card-meta">
<span class="card-cat" title="${item.category || ''}">${cat}</span>
${cw ? `<span class="card-color"><span class="dot" style="background:${hex}"></span>${cw}</span>` : `<span class="card-color"><span class="dot" style="background:${hex}"></span>${hex}</span>`}
<div class="card-id-row">
<a class="card-id" href="/design/${item.id}" target="_blank" rel="noopener" title="Open this design's page">#${item.id} ↗</a>
<button class="card-use-btn" data-id="${item.id}">${selected ? '✓ Selected' : '+ Use'}</button>
</div>
<div class="card-when" title="created ${item.created_at || ''}">🕓 ${fmtDate(item.created_at)}</div>
</div>
</div>`;
}).join('');
// Clicking the element IMAGE selects it for composing a NEW pattern
// (opens the compose drawer). The #id ↗ link still opens the PDP.
grid.querySelectorAll('.card-thumb').forEach(t => {
t.addEventListener('click', e => {
e.preventDefault();
toggleSelect(parseInt(t.dataset.id, 10));
});
});
grid.querySelectorAll('.card-use-btn').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
e.preventDefault();
toggleSelect(parseInt(b.dataset.id, 10));
});
});
grid.querySelectorAll('.card-id').forEach(a => {
a.addEventListener('click', e => { e.stopPropagation(); });
});
}
function toggleSelect(id) {
const i = state.selected.indexOf(id);
if (i >= 0) state.selected.splice(i, 1);
else state.selected.push(id);
renderDrawer();
render();
}
function renderDrawer() {
const d = document.getElementById('drawer');
const cta = document.getElementById('drawer-cta');
if (state.selected.length === 0) {
d.classList.remove('open');
return;
}
d.classList.add('open');
cta.textContent =
state.selected.length === 1 ? 'Use as reference →' :
state.selected.length === 2 ? 'Blend 2 →' :
`Compose ${state.selected.length} →`;
document.getElementById('drawer-thumbs').innerHTML = state.selected.map(id => {
const item = state.items.find(it => it.id === id);
if (!item) return '';
const img = item.element_ref_path
? `/elements/img/${item.id}_ref512.png`
: `/designs/img/by-id/${item.id}`;
return `<div class="drawer-thumb" style="background-image:url('${img}')">
<button class="remove" data-id="${id}">×</button>
</div>`;
}).join('');
document.getElementById('drawer-thumbs').querySelectorAll('.remove').forEach(b => {
b.addEventListener('click', () => toggleSelect(parseInt(b.dataset.id, 10)));
});
}
function populateFilterOptions() {
const cats = new Set();
const cws = new Set();
state.items.forEach(it => {
const c = categoryOf(it);
if (c) cats.add(c);
const cw = colorwayOf(it);
if (cw) cws.add(cw);
});
const catSel = document.getElementById('f-category');
const cwSel = document.getElementById('f-colorway');
[...cats].sort().forEach(c =>
catSel.insertAdjacentHTML('beforeend', `<option value="${c}">${c}</option>`));
[...cws].sort().forEach(c =>
cwSel.insertAdjacentHTML('beforeend', `<option value="${c}">${c}</option>`));
}
// Wire controls
['f-category','f-colorway','f-density'].forEach(id => {
document.getElementById(id).addEventListener('change', e => {
const key = id.split('-')[1];
state.filter[key] = e.target.value;
state.shuffleSeed = 0;
applyFilters();
});
});
document.getElementById('f-quality').addEventListener('change', e => {
state.filter.quality = e.target.checked;
state.shuffleSeed = 0;
applyFilters();
});
document.getElementById('f-search').addEventListener('input', e => {
state.filter.search = e.target.value.trim();
state.shuffleSeed = 0;
applyFilters();
});
document.getElementById('density-slider').addEventListener('input', e => {
state.cardMin = parseInt(e.target.value, 10);
localStorage.setItem('el-card-min', String(state.cardMin));
applyCardMin(state.cardMin);
});
document.getElementById('f-sort').addEventListener('change', e => {
state.sort = e.target.value;
localStorage.setItem('el-sort', state.sort);
state.shuffleSeed = 0; // an explicit sort overrides the random shuffle
render();
});
document.getElementById('shuffle-btn').addEventListener('click', () => {
state.sort = 'shuffle';
document.getElementById('f-sort').value = 'shuffle';
localStorage.setItem('el-sort', 'shuffle');
state.shuffleSeed = Date.now() & 0xffffffff;
render();
});
document.getElementById('drawer-cta').addEventListener('click', async () => {
const ids = state.selected.slice();
if (ids.length === 0) return;
const verb = ids.length === 1 ? 'use as reference' : ids.length === 2 ? 'blend' : `compose ${ids.length}`;
if (!confirm(`Generate 1 NEW design (${verb}) from element(s) ${ids.map(i => '#' + i).join(', ')}?\n\nThis spends one paid Gemini image call. The result lands UNPUBLISHED in the curator queue for review.`)) return;
const hint = (prompt('Optional art-director note (leave blank for none):', '') || '').trim();
const cta = document.getElementById('drawer-cta');
const orig = cta.textContent;
cta.disabled = true;
cta.textContent = 'Composing… (Gemini)';
try {
const r = await fetch('/api/admin/elements/compose', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, hint }),
});
const j = await r.json().catch(() => ({}));
if (!r.ok || !j.ok) {
alert('Compose failed: ' + (j.error || ('HTTP ' + r.status)));
return;
}
state.selected = [];
renderDrawer();
render();
alert(`✓ Generated #${j.new_id} (${j.mode}, ${j.elapsed_s}s).\nUNPUBLISHED — review it in the curator queue before it goes live.\n\nOpening the new design…`);
window.open('/design/' + j.new_id, '_blank', 'noopener');
} catch (e) {
alert('Compose error: ' + (e && e.message || e));
} finally {
cta.disabled = false;
cta.textContent = orig;
}
});
document.getElementById('drawer-close').addEventListener('click', () => {
document.getElementById('drawer').classList.remove('open');
});
// Boot
applyCardMin(state.cardMin);
document.getElementById('density-slider').value = state.cardMin;
document.getElementById('f-sort').value = state.sort;
fetch('/api/admin/elements/items')
.then(r => r.json())
.then(data => {
if (!data.ok) {
document.getElementById('grid').innerHTML =
`<div class="empty-state" style="grid-column:1/-1"><h3>Error</h3><p>${data.error || 'unknown'}</p></div>`;
return;
}
state.items = data.items || [];
document.getElementById('stat-baked').textContent = state.items.filter(i => i.element_motif_path).length;
document.getElementById('stat-clean').textContent = state.items.length;
populateFilterOptions();
// Cold-start shuffle only when no explicit sort is persisted (default 'shuffle').
if (state.sort === 'shuffle') state.shuffleSeed = Date.now() & 0xffffffff;
applyFilters();
})
.catch(err => {
document.getElementById('grid').innerHTML =
`<div class="empty-state" style="grid-column:1/-1"><h3>Fetch failed</h3><p>${err.message}</p></div>`;
});
})();
</script>
<script src="/admin/admin-modal-kit.js" defer></script>
</body>
</html>