← back to Dw Collections Viewer
public/products.html
324 lines
<!doctype html>
<html lang="en"><head>
<meta charset="utf-8" /><title>All Products — DW</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
:root{--bg:#0c0a08;--bg2:#15110d;--ink:#f4ecd8;--muted:#a39888;--faint:#5a5246;--rule:#2a241c;--gold:#c9b687;--green:#6b8e6f;--red:#c66c4d;}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:300 14px -apple-system,sans-serif;min-height:100vh}
header{padding:22px 28px;border-bottom:1px solid var(--rule);display:flex;justify-content:space-between;align-items:center;background:var(--bg2);position:sticky;top:0;z-index:5;gap:14px;flex-wrap:wrap}
h1{font:600 12px/1 inherit;letter-spacing:.32em;text-transform:uppercase;margin:0;color:var(--gold)}
nav a{color:var(--muted);font-size:11px;letter-spacing:.16em;text-transform:uppercase;margin-right:18px}
nav a:hover,nav a.active{color:var(--gold)}
.toolbar{padding:14px 28px;background:var(--bg2);border-bottom:1px solid var(--rule);display:flex;gap:12px;align-items:center;flex-wrap:wrap}
input,select,button{background:var(--bg);color:var(--ink);border:1px solid var(--rule);padding:8px 14px;font:inherit;font-size:13px;border-radius:2px;outline:none}
button{cursor:pointer;letter-spacing:.06em}
button.primary{background:var(--gold);color:var(--bg);border-color:var(--gold);font-weight:600}
button.danger{border-color:var(--red);color:var(--red)}
button.danger:hover{background:var(--red);color:var(--bg)}
button:disabled{opacity:.4;cursor:not-allowed}
.pill{padding:5px 12px;border:1px solid var(--rule);font-size:10px;letter-spacing:.14em;text-transform:uppercase;border-radius:999px;color:var(--muted)}
.pill.live{color:var(--green);border-color:var(--green)}
.pill.warn{color:var(--gold);border-color:var(--gold)}
main{padding:18px 28px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--pcol,180px),1fr));gap:14px}
.card{background:var(--bg2);border:1px solid var(--rule);cursor:pointer;transition:all .15s;position:relative;display:flex;flex-direction:column}
.card:hover{border-color:var(--gold)}
.card.selected{border-color:var(--gold);box-shadow:0 0 0 2px var(--gold) inset}
.card .hero{aspect-ratio:1/1;background:#000;overflow:hidden;position:relative}
.card .hero img{width:100%;height:100%;object-fit:cover;display:block}
.card .check{position:absolute;top:6px;left:6px;width:20px;height:20px;border:1px solid var(--gold);background:rgba(12,10,8,0.7);border-radius:2px;display:flex;align-items:center;justify-content:center;color:var(--gold);font-size:13px;line-height:1}
.card.selected .check::after{content:"✓"}
.card .meta{padding:8px 10px}
.card .title{font-size:11px;line-height:1.3;color:var(--ink);height:2.6em;overflow:hidden}
.card .sub{font-size:10px;color:var(--muted);margin-top:4px;letter-spacing:.05em;display:flex;justify-content:space-between}
.card .price{color:var(--gold)}
.batch-bar{position:fixed;bottom:0;left:0;right:0;padding:14px 28px;background:var(--bg2);border-top:1px solid var(--gold);display:none;gap:12px;align-items:center;z-index:10}
.batch-bar.show{display:flex}
.batch-bar .count{font-size:12px;letter-spacing:.14em;color:var(--gold)}
.empty{padding:80px 0;text-align:center;color:var(--muted);font-size:13px}
.modal{position:fixed;inset:0;background:rgba(12,10,8,0.85);z-index:50;display:none;align-items:center;justify-content:center;padding:30px}
.modal.show{display:flex}
.modal-box{background:var(--bg2);border:1px solid var(--gold);padding:24px;max-width:520px;width:100%}
.modal h3{margin:0 0 14px;font:600 12px/1 inherit;letter-spacing:.24em;text-transform:uppercase;color:var(--gold)}
.modal-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:20px}
.notice{padding:10px 14px;background:rgba(201,108,77,0.08);border:1px solid var(--red);color:var(--red);border-radius:2px;font-size:12px;line-height:1.5;margin-bottom:14px}
.notice.warn{background:rgba(201,182,135,0.08);border-color:var(--gold);color:var(--gold)}
</style></head><body>
<header>
<div style="display:flex;align-items:center;gap:18px">
<h1>All Products</h1>
<nav><a href="/">Sites</a><a class="active" href="/products">Products</a></nav>
</div>
<div style="display:flex;gap:8px;align-items:center">
<span class="pill" id="store-pill">…</span>
<span class="pill" id="count-pill">… loaded</span>
<span class="pill" id="admin-pill">…</span>
</div>
</header>
<div class="toolbar">
<input id="search" placeholder="filter by title, handle, vendor, type, tag…" style="flex:1;min-width:220px" />
<select id="status-filter">
<option value="">all status</option>
<option value="published">published only</option>
<option value="draft">draft only</option>
</select>
<select id="vendor-filter"><option value="">all vendors</option></select>
<select id="type-filter"><option value="">all types</option></select>
<select id="coll-filter"><option value="">all collections</option></select>
<select id="sort-by" title="Sort products">
<option value="newest">Newest</option>
<option value="title">Title A→Z</option>
<option value="sku">SKU/Handle A→Z</option>
<option value="vendor">Vendor A→Z</option>
<option value="price-asc">Price ↑</option>
<option value="price-desc">Price ↓</option>
</select>
<input id="density" type="range" min="120" max="320" step="10" value="180" title="Card density" style="flex:0 0 120px" />
<button id="select-all">Select all visible</button>
<button id="clear-sel">Clear</button>
<button id="reload">↻ Reload</button>
</div>
<main>
<div id="grid" class="grid"><div class="empty">loading products…</div></div>
</main>
<div class="batch-bar" id="batch-bar">
<span class="count" id="batch-count">0 selected</span>
<button id="batch-add" class="primary">Add to collection…</button>
<button id="batch-remove" id="batch-remove-collection">Remove from current collection</button>
<button id="batch-delete" class="danger">Delete selected</button>
<button id="batch-clear">Clear selection</button>
</div>
<!-- Add-to-collection modal -->
<div class="modal" id="add-modal">
<div class="modal-box">
<h3>Add <span id="add-count">0</span> products to collection</h3>
<div id="add-notice" class="notice warn" style="display:none"></div>
<select id="coll-pick" style="width:100%"><option>Loading collections…</option></select>
<div class="modal-actions">
<button id="add-cancel">Cancel</button>
<button id="add-confirm" class="primary">Add to collection</button>
</div>
</div>
</div>
<!-- Delete confirm modal -->
<div class="modal" id="del-modal">
<div class="modal-box">
<h3>Delete <span id="del-count">0</span> products</h3>
<div class="notice">This permanently removes products from the live Shopify store. Cannot be undone.</div>
<p style="font-size:12px;line-height:1.55">Type <strong style="color:var(--red)">DELETE</strong> to confirm:</p>
<input id="del-confirm-input" placeholder="DELETE" autocomplete="off" />
<div class="modal-actions">
<button id="del-cancel">Cancel</button>
<button id="del-confirm" class="danger" disabled>Delete permanently</button>
</div>
</div>
</div>
<script>
let PRODUCTS = [], FILTERED = [], SELECTED = new Set(), COLLECTIONS = [], COLL_FILTER_IDS = null;
const $ = id => document.getElementById(id);
// Sort + density state — persisted in localStorage so the choice survives reloads.
let PRODUCTS_SORT = localStorage.getItem('dwcv_prod_sort') || 'newest';
let PRODUCTS_SIZE = parseInt(localStorage.getItem('dwcv_prod_size') || '180', 10);
function sortProducts(list) {
const arr = list.slice();
const num = v => { const n = parseFloat(v); return isNaN(n) ? 0 : n; };
const txt = v => String(v == null ? '' : v).toLowerCase();
switch (PRODUCTS_SORT) {
case 'title': arr.sort((a, b) => txt(a.title).localeCompare(txt(b.title))); break;
case 'sku': arr.sort((a, b) => txt(a.sku || a.handle).localeCompare(txt(b.sku || b.handle))); break;
case 'vendor': arr.sort((a, b) => txt(a.vendor).localeCompare(txt(b.vendor))); break;
case 'price-asc': arr.sort((a, b) => num(a.price) - num(b.price)); break;
case 'price-desc': arr.sort((a, b) => num(b.price) - num(a.price)); break;
case 'newest': default: break; // server's natural order
}
return arr;
}
function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
async function load() {
$('grid').innerHTML = '<div class="empty">loading products…</div>';
try {
const r = await fetch('/api/products/all').then(r => r.json());
PRODUCTS = r.products || [];
$('store-pill').textContent = r.store;
$('count-pill').textContent = `${PRODUCTS.length} products`;
$('count-pill').classList.add('live');
populateFilters();
render();
// Probe admin token availability via collections endpoint
fetch('/api/admin/collections').then(async x => {
if (x.ok) {
const c = await x.json(); COLLECTIONS = c.collections || [];
$('admin-pill').textContent = `admin: ${c.custom||0} custom + ${c.smart||0} smart`; $('admin-pill').classList.add('live');
// Group filter dropdown by type. Custom listed first (writable). Smart marked "auto" (read-only filter).
const custom = COLLECTIONS.filter(c => c.type === 'custom');
const smart = COLLECTIONS.filter(c => c.type === 'smart');
let html = '<option value="">all collections</option>';
if (custom.length) html += '<optgroup label="Custom (manual)">' + custom.map(c => `<option value="${c.id}" data-type="custom">${esc(c.title)} (${c.products_count || 0})</option>`).join('') + '</optgroup>';
if (smart.length) html += '<optgroup label="Smart (auto-rules)">' + smart.map(c => `<option value="${c.id}" data-type="smart">${esc(c.title)} (${c.products_count || 0}) ⚙</option>`).join('') + '</optgroup>';
$('coll-filter').innerHTML = html;
}
else { $('admin-pill').textContent = 'admin token missing'; $('admin-pill').classList.add('warn'); }
}).catch(() => { $('admin-pill').textContent = 'admin: offline'; $('admin-pill').classList.add('warn'); });
} catch (e) {
$('grid').innerHTML = `<div class="empty">load failed: ${esc(e.message)}</div>`;
}
}
function populateFilters() {
const vendors = [...new Set(PRODUCTS.map(p => p.vendor).filter(Boolean))].sort();
const types = [...new Set(PRODUCTS.map(p => p.product_type).filter(Boolean))].sort();
$('vendor-filter').innerHTML = '<option value="">all vendors</option>' + vendors.map(v => `<option>${esc(v)}</option>`).join('');
$('type-filter').innerHTML = '<option value="">all types</option>' + types.map(t => `<option>${esc(t)}</option>`).join('');
}
function render() {
const q = $('search').value.toLowerCase();
const stat = $('status-filter').value;
const vend = $('vendor-filter').value;
const typ = $('type-filter').value;
FILTERED = PRODUCTS.filter(p => {
if (stat && p.status !== stat) return false;
if (vend && p.vendor !== vend) return false;
if (typ && p.product_type !== typ) return false;
if (COLL_FILTER_IDS && !COLL_FILTER_IDS.has(p.id)) return false;
if (q) {
const hay = `${p.title} ${p.handle} ${p.vendor} ${p.product_type} ${p.tags}`.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
$('grid').innerHTML = FILTERED.length ? FILTERED.map(p => `
<div class="card ${SELECTED.has(p.id)?'selected':''}" data-id="${p.id}">
<div class="check"></div>
<div class="hero">${p.image ? `<img src="${esc(p.image)}" alt="" loading="lazy">` : ''}</div>
<div class="meta">
<div class="title">${esc(p.title)}</div>
<div class="sub"><span>${esc(p.vendor || '')}</span><span class="price">$${esc(p.price || '—')}</span></div>
</div>
</div>
`).join('') : '<div class="empty">no products match filters</div>';
$('grid').querySelectorAll('.card').forEach(c => c.addEventListener('click', () => {
const id = parseInt(c.dataset.id, 10);
if (SELECTED.has(id)) SELECTED.delete(id); else SELECTED.add(id);
c.classList.toggle('selected');
updateBatch();
}));
updateBatch();
}
function updateBatch() {
const bar = $('batch-bar');
if (SELECTED.size) { bar.classList.add('show'); $('batch-count').textContent = `${SELECTED.size} selected`; }
else bar.classList.remove('show');
}
['search','status-filter','vendor-filter','type-filter'].forEach(id => $(id).addEventListener('input', render));
$('coll-filter').addEventListener('change', async e => {
const cid = e.target.value;
if (!cid) { COLL_FILTER_IDS = null; render(); return; }
$('grid').innerHTML = '<div class="empty">loading collection products…</div>';
try {
const r = await fetch(`/api/admin/collection/${cid}/products`).then(r => r.json());
if (r.error) throw new Error(r.error);
COLL_FILTER_IDS = new Set(r.ids);
render();
} catch (e) { COLL_FILTER_IDS = null; alert('failed: ' + e.message); render(); }
});
$('reload').addEventListener('click', load);
$('select-all').addEventListener('click', () => { FILTERED.forEach(p => SELECTED.add(p.id)); render(); });
$('clear-sel').addEventListener('click', () => { SELECTED.clear(); render(); });
$('batch-clear').addEventListener('click', () => { SELECTED.clear(); render(); });
// Add-to-collection flow
$('batch-add').addEventListener('click', () => {
if (!SELECTED.size) return;
$('add-count').textContent = SELECTED.size;
// Add-to: only custom collections are writable. Smart collections auto-include via rules.
const writable = COLLECTIONS.filter(c => c.type === 'custom');
if (!writable.length) {
$('add-notice').style.display = 'block';
$('add-notice').textContent = 'No custom (writable) collections found. Smart collections are auto-rule-based — products land in them automatically based on tags/title.';
$('coll-pick').innerHTML = '<option>(no writable collections)</option>';
$('add-confirm').disabled = true;
} else {
$('add-notice').style.display = 'none';
$('coll-pick').innerHTML = writable.map(c => `<option value="${c.id}">${esc(c.title)} (${c.products_count || 0} items)</option>`).join('');
$('add-confirm').disabled = false;
}
$('add-modal').classList.add('show');
});
$('add-cancel').addEventListener('click', () => $('add-modal').classList.remove('show'));
$('batch-remove').addEventListener('click', async () => {
if (!SELECTED.size) return;
const sel = $('coll-filter');
const cid = sel.value;
if (!cid) return alert('Pick a collection in the filter dropdown first — the "Remove" action removes from that collection.');
// Smart collections aren't manually editable; rules drive membership.
if (sel.options[sel.selectedIndex]?.dataset.type === 'smart') {
return alert("Smart collections are auto-rule-based — Shopify won't let us remove products manually. Edit the collection's tag/title rules to change membership.");
}
if (!confirm(`Remove ${SELECTED.size} products from this collection? Products themselves are not deleted.`)) return;
try {
const r = await fetch('/api/products/remove-from-collection', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ product_ids:[...SELECTED], collection_id: parseInt(cid,10) }) });
const j = await r.json();
alert(`${j.ok}/${j.count} removed from collection`);
SELECTED.clear();
// Refresh collection filter
$('coll-filter').dispatchEvent(new Event('change'));
} catch (e) { alert('failed: ' + e.message); }
});
$('add-confirm').addEventListener('click', async () => {
const cid = parseInt($('coll-pick').value, 10);
if (!cid || !SELECTED.size) return;
$('add-confirm').disabled = true; $('add-confirm').textContent = 'Adding…';
try {
const r = await fetch('/api/products/add-to-collection', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ product_ids:[...SELECTED], collection_id: cid }) });
const j = await r.json();
alert(`${j.ok}/${j.count} products added`);
$('add-modal').classList.remove('show');
SELECTED.clear(); render();
} catch (e) { alert('failed: ' + e.message); }
$('add-confirm').disabled = false; $('add-confirm').textContent = 'Add to collection';
});
// Delete flow
$('batch-delete').addEventListener('click', () => {
if (!SELECTED.size) return;
$('del-count').textContent = SELECTED.size;
$('del-confirm-input').value = '';
$('del-confirm').disabled = true;
$('del-modal').classList.add('show');
});
$('del-confirm-input').addEventListener('input', e => {
$('del-confirm').disabled = e.target.value !== 'DELETE';
});
$('del-cancel').addEventListener('click', () => $('del-modal').classList.remove('show'));
$('del-confirm').addEventListener('click', async () => {
$('del-confirm').disabled = true; $('del-confirm').textContent = 'Deleting…';
try {
const r = await fetch('/api/products/delete', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ ids:[...SELECTED] }) });
const j = await r.json();
alert(`${j.ok}/${j.count} products deleted`);
$('del-modal').classList.remove('show');
SELECTED.clear();
load(); // refetch
} catch (e) { alert('failed: ' + e.message); }
$('del-confirm').disabled = false; $('del-confirm').textContent = 'Delete permanently';
});
load();
</script>
</body></html>