[object Object]

← 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 →