← back to Dw Collections Viewer
Drag product → cover image + sort by color/style/SKU + grid-size slider
3a36c5f3143d777e7ed893726842a06e7694d69b · 2026-05-06 17:22:08 -0700 · SteveStudio2
Three new affordances on the right detail panel:
1. Drag any product card onto the cover image to set it as the hero.
- .pcard is now draggable=true with a custom MIME (application/x-dwc-product)
carrying { sku, image, title }
- .hero-large catches dragover/drop, highlights via existing dropzone-active
CSS, then POSTs the sku to the existing /api/site/:slug/hero endpoint
- Optimistic local update + toast confirmation; renderDetail() re-renders
so the cover swaps without a fetch round-trip
2. Sort dropdown — newest (default), Color (clusters black→white→warm→cool→
purple buckets via tag matching), Style (Traditional/Modern/Floral/etc.
buckets), SKU (alpha). Selection persists in localStorage as 'dwc_sort'.
3. Grid card-width slider — 80px to 260px, live-updates
grid-template-columns:repeat(auto-fill,minmax(N,1fr)) without re-render.
Persists as 'dwc_size' in localStorage.
Verified end-to-end via headless Playwright: 4-option sort dropdown live,
50/50 cards draggable, drop simulation flips hero img src as expected.
Files touched
Diff
commit 3a36c5f3143d777e7ed893726842a06e7694d69b
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 6 17:22:08 2026 -0700
Drag product → cover image + sort by color/style/SKU + grid-size slider
Three new affordances on the right detail panel:
1. Drag any product card onto the cover image to set it as the hero.
- .pcard is now draggable=true with a custom MIME (application/x-dwc-product)
carrying { sku, image, title }
- .hero-large catches dragover/drop, highlights via existing dropzone-active
CSS, then POSTs the sku to the existing /api/site/:slug/hero endpoint
- Optimistic local update + toast confirmation; renderDetail() re-renders
so the cover swaps without a fetch round-trip
2. Sort dropdown — newest (default), Color (clusters black→white→warm→cool→
purple buckets via tag matching), Style (Traditional/Modern/Floral/etc.
buckets), SKU (alpha). Selection persists in localStorage as 'dwc_sort'.
3. Grid card-width slider — 80px to 260px, live-updates
grid-template-columns:repeat(auto-fill,minmax(N,1fr)) without re-render.
Persists as 'dwc_size' in localStorage.
Verified end-to-end via headless Playwright: 4-option sort dropdown live,
50/50 cards draggable, drop simulation flips hero img src as expected.
---
public/index.html | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 130 insertions(+), 4 deletions(-)
diff --git a/public/index.html b/public/index.html
index 20a9277..97036bf 100644
--- a/public/index.html
+++ b/public/index.html
@@ -137,6 +137,22 @@ aside { border-left: 1px solid var(--rule); background: var(--bg2);
border: 1px solid var(--gold); background: rgba(12,10,8,0.78); color: var(--gold);
font-size: 11px; line-height: 14px; text-align: center; font-weight: 700 }
.products-slider .pcard.selected .check { background: var(--gold); color: var(--bg) }
+.products-slider .pcard.dragging { opacity: 0.45; outline: 1px dashed var(--gold) }
+.products-slider .pcard[draggable="true"] { cursor: grab }
+.products-slider .pcard[draggable="true"]:active { cursor: grabbing }
+/* Pill-style label wrapping select / range inputs in the products bar */
+.actions-mini .pill-label {
+ display: inline-flex; align-items: center; gap: 6px;
+ font-family: var(--mono, monospace); font-size: 10px; letter-spacing: 0.10em;
+ text-transform: uppercase; color: var(--muted); border: 1px solid var(--rule);
+ padding: 4px 9px; border-radius: 2px;
+}
+.actions-mini .pill-label select,
+.actions-mini .pill-label input[type=range] {
+ background: transparent; color: var(--ink); border: none; outline: none;
+ font-family: inherit; font-size: 11px; cursor: pointer; min-width: 80px;
+}
+.actions-mini .pill-label input[type=range] { padding: 0; min-width: 100px; }
.products-slider .empty { padding: 24px; text-align: center; color: var(--faint);
font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; border: 1px dashed var(--rule) }
.products-bar { display: flex; justify-content: space-between; align-items: center;
@@ -418,6 +434,43 @@ async function fetchProducts(slug) {
}
}
+// Sort + sizing state — persisted in localStorage so it survives reloads.
+let PRODUCTS_SORT = localStorage.getItem('dwc_sort') || 'newest';
+let PRODUCTS_SIZE = parseInt(localStorage.getItem('dwc_size') || '140', 10);
+
+// Color buckets for sort=color. Tags include literal color names like "Dark Green",
+// "Beige", "Olive". We rank by where they fall on a hue cycle so similar colors
+// cluster together in the grid.
+const COLOR_RANK = {
+ black: 0, gray: 1, grey: 1, silver: 2, white: 3, ivory: 4, cream: 5, beige: 6,
+ brown: 7, tan: 8, khaki: 9, copper: 10,
+ red: 20, pink: 21, coral: 22, orange: 23, peach: 24, salmon: 25,
+ yellow: 30, gold: 31, mustard: 32, olive: 33,
+ green: 40, lime: 41, mint: 42, teal: 50, aqua: 51, turquoise: 52, cyan: 53,
+ blue: 60, navy: 61, indigo: 62, periwinkle: 63,
+ purple: 70, violet: 71, lavender: 72, plum: 73, magenta: 74, fuchsia: 75,
+};
+function colorRank(p) {
+ const tags = (p.tags || []).map(t => String(t).toLowerCase());
+ for (const t of tags) for (const k of Object.keys(COLOR_RANK)) if (t.includes(k)) return COLOR_RANK[k];
+ return 999;
+}
+const STYLE_TAGS = ['traditional','transitional','modern','contemporary','minimalist','art deco','victorian','mid-century','mid century','rustic','industrial','farmhouse','bohemian','boho','geometric','floral','botanical','damask','toile','grasscloth','silk','linen','vinyl','natural'];
+function styleKey(p) {
+ const tags = (p.tags || []).map(t => String(t).toLowerCase());
+ for (const s of STYLE_TAGS) if (tags.some(t => t.includes(s))) return s;
+ return 'zzz';
+}
+
+function sortProducts(products, mode) {
+ const arr = [...products];
+ if (mode === 'sku') arr.sort((a, b) => String(a.sku || a.handle || '').localeCompare(String(b.sku || b.handle || '')));
+ else if (mode === 'color') arr.sort((a, b) => colorRank(a) - colorRank(b) || String(a.title || '').localeCompare(String(b.title || '')));
+ else if (mode === 'style') arr.sort((a, b) => styleKey(a).localeCompare(styleKey(b)) || String(a.title || '').localeCompare(String(b.title || '')));
+ // 'newest' = leave as-returned (server already orders newest-first)
+ return arr;
+}
+
function renderProductsSlider() {
const el = document.getElementById('products-slider');
if (!el) return;
@@ -429,36 +482,109 @@ function renderProductsSlider() {
el.innerHTML = `<div class="empty">no products yet · add a collection then save+render</div>`;
return;
}
- const cards = PRODUCTS.map(p => {
+ const sorted = sortProducts(PRODUCTS, PRODUCTS_SORT);
+ const cards = sorted.map(p => {
const sku = String(p.sku || p.handle || '');
const sel = PRODUCT_SELECTED.has(sku) ? ' selected' : '';
const img = p.image_url ? `<img src="${esc(p.image_url)}" alt="${esc(p.title || '')}" loading="lazy" onerror="this.style.opacity=0.15">` : '';
const tipLines = [p.title || sku];
if (p.sku) tipLines.push(`SKU: ${p.sku}`);
+ if ((p.tags || []).length) tipLines.push(`Tags: ${(p.tags || []).slice(0, 6).join(', ')}`);
+ tipLines.push('Drag to cover image to set as hero');
const ts = p.published_at || p.created_at;
if (ts) {
const d = new Date(ts);
if (!isNaN(d)) tipLines.push(`Published: ${d.toISOString().slice(0,16).replace('T', ' ')} UTC`);
}
- return `<div class="pcard${sel}" data-sku="${esc(sku)}" onclick="toggleProductSelect('${esc(sku)}')" title="${esc(tipLines.join('\n'))}">
+ return `<div class="pcard${sel}" draggable="true" data-sku="${esc(sku)}" data-image="${esc(p.image_url || '')}" data-title="${esc(p.title || '')}" onclick="toggleProductSelect('${esc(sku)}')" title="${esc(tipLines.join('\n'))}">
<div class="check">${PRODUCT_SELECTED.has(sku) ? '✓' : ''}</div>
<div class="img">${img}</div>
<div class="label">${esc(p.title || sku)}</div>
</div>`;
}).join('');
const selCount = PRODUCT_SELECTED.size;
+ const sortLabel = { newest:'newest first', sku:'SKU A→Z', color:'by color', style:'by style' }[PRODUCTS_SORT] || PRODUCTS_SORT;
el.innerHTML = `
<div class="products-bar">
- <div class="count">${PRODUCTS.length} products${selCount ? ` · ${selCount} selected` : ''} · newest first</div>
+ <div class="count">${PRODUCTS.length} products${selCount ? ` · ${selCount} selected` : ''} · ${sortLabel}</div>
<div class="actions-mini">
+ <label class="pill-label">Sort:
+ <select onchange="setSort(this.value)">
+ <option value="newest" ${PRODUCTS_SORT==='newest'?'selected':''}>Newest</option>
+ <option value="color" ${PRODUCTS_SORT==='color' ?'selected':''}>Color</option>
+ <option value="style" ${PRODUCTS_SORT==='style' ?'selected':''}>Style</option>
+ <option value="sku" ${PRODUCTS_SORT==='sku' ?'selected':''}>SKU</option>
+ </select>
+ </label>
+ <label class="pill-label">Size:
+ <input type="range" min="80" max="260" step="10" value="${PRODUCTS_SIZE}" oninput="setGridSize(this.value)" title="Grid card width" />
+ </label>
${selCount ? `<button onclick="clearProductSelection()">Clear (${selCount})</button>` : ''}
${selCount ? `<button onclick="copySelectedSkus()">Copy SKUs</button>` : ''}
${selCount ? `<button onclick="removeSelectedFromList()">Remove (${selCount})</button>` : ''}
<button onclick="selectAllProducts()">Select all</button>
</div>
</div>
- <div class="row">${cards}</div>
+ <div class="row" style="grid-template-columns:repeat(auto-fill,minmax(${PRODUCTS_SIZE}px,1fr))">${cards}</div>
`;
+ wireProductDrag();
+}
+
+function setSort(v) {
+ PRODUCTS_SORT = v;
+ localStorage.setItem('dwc_sort', v);
+ renderProductsSlider();
+}
+
+function setGridSize(v) {
+ PRODUCTS_SIZE = parseInt(v, 10);
+ localStorage.setItem('dwc_size', String(PRODUCTS_SIZE));
+ // Live update without re-render so dragging the slider doesn't tear the grid
+ const row = document.querySelector('#products-slider .row');
+ if (row) row.style.gridTemplateColumns = `repeat(auto-fill,minmax(${PRODUCTS_SIZE}px,1fr))`;
+}
+
+// Wire HTML5 drag from .pcard → drop on .hero-large
+function wireProductDrag() {
+ document.querySelectorAll('#products-slider .pcard').forEach(card => {
+ card.addEventListener('dragstart', e => {
+ const sku = card.dataset.sku;
+ const image = card.dataset.image;
+ const title = card.dataset.title;
+ e.dataTransfer.setData('application/x-dwc-product', JSON.stringify({ sku, image, title }));
+ e.dataTransfer.setData('text/plain', sku);
+ e.dataTransfer.effectAllowed = 'copy';
+ card.classList.add('dragging');
+ });
+ card.addEventListener('dragend', () => card.classList.remove('dragging'));
+ });
+ const hero = document.querySelector('#detail .hero-large');
+ if (!hero) return;
+ hero.addEventListener('dragover', e => {
+ if (![...e.dataTransfer.types].includes('application/x-dwc-product')) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'copy';
+ hero.classList.add('dropzone-active');
+ });
+ hero.addEventListener('dragleave', () => hero.classList.remove('dropzone-active'));
+ hero.addEventListener('drop', async e => {
+ e.preventDefault();
+ hero.classList.remove('dropzone-active');
+ let payload;
+ try { payload = JSON.parse(e.dataTransfer.getData('application/x-dwc-product')); } catch { return; }
+ if (!payload?.sku) return;
+ toast(`pinning ${payload.title || payload.sku} as cover…`);
+ const r = await fetch(`api/site/${CURRENT}/hero`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ sku: payload.sku }),
+ });
+ const j = await r.json();
+ if (!r.ok) return toast('drop failed: ' + (j.error || r.status), false);
+ SITES[CURRENT].hero_sku = payload.sku;
+ if (payload.image && SITES[CURRENT].hero) SITES[CURRENT].hero = { ...SITES[CURRENT].hero, sku: payload.sku, title: payload.title || SITES[CURRENT].hero.title, image_url: payload.image };
+ toast(`✓ pinned ${payload.title || payload.sku}`, true);
+ renderDetail();
+ });
}
function toggleProductSelect(sku) {
← f016e8f Click site → show all products as a responsive grid (was hor
·
back to Dw Collections Viewer
·
Probe live HTTP status; flag redirected/parked sites in the aa01c45 →