[object Object]

← back to Wallco Ai

wallco: add full sort options (oldest/title-desc/vivid/muted/has-room/random) + mural 1×1 pan-and-crop

829515f72fb491ab98cac9e595b63c4d741bf797 · 2026-05-13 14:04:39 -0700 · SteveStudio2

Files touched

Diff

commit 829515f72fb491ab98cac9e595b63c4d741bf797
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 14:04:39 2026 -0700

    wallco: add full sort options (oldest/title-desc/vivid/muted/has-room/random) + mural 1×1 pan-and-crop
---
 server.js | 203 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
 1 file changed, 179 insertions(+), 24 deletions(-)

diff --git a/server.js b/server.js
index 4cc8ddc..06627fb 100644
--- a/server.js
+++ b/server.js
@@ -643,11 +643,29 @@ function sortDesigns(designs, sort) {
     const r = hasRoom(a) - hasRoom(b);
     return r !== 0 ? r : cmp(a,b);
   };
+  // Saturation helper — HSL S channel; neutrals fall to the end.
+  const sat = x => { try { return (hexToHSL(x.dominant_hex)||[0,0,0])[1] || 0; } catch { return 0; } };
   switch (sort) {
-    case 'title':       return d.sort(tie((a,b) => a.title.localeCompare(b.title)));
-    case 'category':    return d.sort(tie((a,b) => a.category.localeCompare(b.category)));
-    case 'light-dark':  return d.sort(tie((a,b) => hexToHSL(b.dominant_hex)[2] - hexToHSL(a.dominant_hex)[2]));
-    case 'dark-light':  return d.sort(tie((a,b) => hexToHSL(a.dominant_hex)[2] - hexToHSL(b.dominant_hex)[2]));
+    case 'oldest':       return d.sort((a,b) => (a.id||0) - (b.id||0));
+    case 'title':        return d.sort(tie((a,b) => a.title.localeCompare(b.title)));
+    case 'title-desc':   return d.sort(tie((a,b) => b.title.localeCompare(a.title)));
+    case 'category':     return d.sort(tie((a,b) => a.category.localeCompare(b.category)));
+    case 'category-desc':return d.sort(tie((a,b) => b.category.localeCompare(a.category)));
+    case 'light-dark':   return d.sort(tie((a,b) => hexToHSL(b.dominant_hex)[2] - hexToHSL(a.dominant_hex)[2]));
+    case 'dark-light':   return d.sort(tie((a,b) => hexToHSL(a.dominant_hex)[2] - hexToHSL(b.dominant_hex)[2]));
+    case 'vivid':        return d.sort(tie((a,b) => sat(b) - sat(a)));
+    case 'muted':        return d.sort(tie((a,b) => sat(a) - sat(b)));
+    case 'has-room':     return d.sort((a,b) => {
+      const r = hasRoom(a) - hasRoom(b);
+      return r !== 0 ? r : (b.id||0) - (a.id||0);
+    });
+    case 'random': {
+      // Deterministic-per-request shuffle — keeps pagination consistent within
+      // a session by seeding off the floor of the current minute.
+      const seed = Math.floor(Date.now() / 60000);
+      const rand = (n) => { const x = Math.sin(seed * 9301 + n * 49297) * 233280; return x - Math.floor(x); };
+      return d.map((x,i) => ({ x, k: rand(i) })).sort((a,b) => a.k - b.k).map(o => o.x);
+    }
     case 'color-wheel': return d.sort(tie((a,b) => {
       const [ha,,la] = hexToHSL(a.dominant_hex);
       const [hb,,lb] = hexToHSL(b.dominant_hex);
@@ -3370,15 +3388,40 @@ ${cat === 'mural-scenic' ? `
             ['newest',      'Newest'],
             ['title',       'Title A→Z'],
             ['category',    'Category'],
-            ['light-dark',  'Light → Dark'],
-            ['dark-light',  'Dark → Light'],
             ['color-wheel', 'Color Wheel'],
+            ['random',      'Random'],
           ].map(([k, label]) => {
             const on = sort === k;
             return `<button type="button" class="sort-btn${on?' is-on':''}" data-sort="${k}" role="radio" aria-checked="${on}"
               onclick="document.getElementById('sort-hidden').value='${k}';this.form.submit()"
               style="padding:5px 11px;border:1px solid ${on?'var(--accent,#1a1a1a)':'var(--line,#d8d0c0)'};background:${on?'var(--accent,#1a1a1a)':'transparent'};color:${on?'var(--bg,#fff)':'var(--ink,#3a2818)'};border-radius:999px;font:11px var(--sans,system-ui);letter-spacing:.04em;cursor:pointer;white-space:nowrap">${label}</button>`;
           }).join('')}
+          <!-- Full "More sorts" dropdown — every typical sort, available to all users (not admin-gated). -->
+          <select aria-label="More sort options" onchange="if(this.value){document.getElementById('sort-hidden').value=this.value;this.form.submit()}"
+            style="padding:5px 11px;border:1px solid var(--line,#d8d0c0);background:transparent;color:var(--ink,#3a2818);border-radius:999px;font:11px var(--sans,system-ui);letter-spacing:.04em;cursor:pointer;margin-left:2px">
+            <option value="">More sorts…</option>
+            <optgroup label="Date">
+              <option value="newest"     ${sort==='newest'?'selected':''}>Newest first</option>
+              <option value="oldest"     ${sort==='oldest'?'selected':''}>Oldest first</option>
+            </optgroup>
+            <optgroup label="Alphabetical">
+              <option value="title"        ${sort==='title'?'selected':''}>Title A → Z</option>
+              <option value="title-desc"   ${sort==='title-desc'?'selected':''}>Title Z → A</option>
+              <option value="category"     ${sort==='category'?'selected':''}>Category A → Z</option>
+              <option value="category-desc"${sort==='category-desc'?'selected':''}>Category Z → A</option>
+            </optgroup>
+            <optgroup label="Color">
+              <option value="light-dark"  ${sort==='light-dark'?'selected':''}>Light → Dark</option>
+              <option value="dark-light"  ${sort==='dark-light'?'selected':''}>Dark → Light</option>
+              <option value="color-wheel" ${sort==='color-wheel'?'selected':''}>Color Wheel (warm → cool)</option>
+              <option value="vivid"       ${sort==='vivid'?'selected':''}>Most Vivid</option>
+              <option value="muted"       ${sort==='muted'?'selected':''}>Most Muted</option>
+            </optgroup>
+            <optgroup label="Other">
+              <option value="has-room"    ${sort==='has-room'?'selected':''}>In-Room previews first</option>
+              <option value="random"      ${sort==='random'?'selected':''}>Random shuffle</option>
+            </optgroup>
+          </select>
         </div>
         <label class="density-label" style="display:inline-flex;align-items:center;gap:8px;font:11px var(--sans);text-transform:uppercase;letter-spacing:.1em;color:var(--ink-faint)">Density
           <input type="range" id="density-slider" min="1" max="12" step="1" value="6" class="density-range" style="flex:0 0 140px">
@@ -5975,35 +6018,52 @@ ${htmlHeader('/designs')}
             <span id="scale-readout" style="font-family:var(--serif);font-size:18px;color:var(--ink);min-width:90px">24" repeat</span>
           </div>
           <!-- Mural-size repeat stepper. Click Next → to walk through 1× (full
-               tile), 2×, 4×, 8× (room-scale), 16× (mural-scale) repeat counts.
-               Synchronizes the #scale-viewport background-size so a single
-               tap previews how the design feels at mural scale. -->
+               tile · MURAL MODE with pan + zoom), 2×, 4×, 8× (room-scale),
+               16× (mural-scale) repeat counts.
+               At level 0 (1×1) the viewport switches to MURAL MODE: drag to
+               pan the image, scroll to zoom, double-click to reset.
+               Patterned after grahambrown.com and rebelwalls.com mural tools. -->
           <div class="mural-stepper" style="display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap">
             <span style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint)">Mural repeat</span>
             <div id="mural-dots" style="display:flex;gap:6px">
-              <span class="mural-dot active" data-level="0" title="full tile"></span>
+              <span class="mural-dot active" data-level="0" title="Mural — full tile · 1×1 · drag to position"></span>
               <span class="mural-dot" data-level="1" title="2×2"></span>
               <span class="mural-dot" data-level="2" title="4×4"></span>
               <span class="mural-dot" data-level="3" title="8×8 — room"></span>
               <span class="mural-dot" data-level="4" title="16×16 — mural"></span>
             </div>
-            <span id="mural-readout" style="font-family:var(--serif);font-size:14px;color:var(--ink-soft,#555);min-width:130px">full tile · 1×1</span>
+            <span id="mural-readout" style="font-family:var(--serif);font-size:14px;color:var(--ink-soft,#555);min-width:200px">Mural · 1×1 — drag to position</span>
+            <button type="button" id="mural-reset" style="padding:6px 10px;border:1px solid var(--line);background:transparent;color:var(--ink-soft);font:10.5px var(--sans);letter-spacing:.06em;text-transform:uppercase;border-radius:4px;cursor:pointer;display:none">Reset</button>
             <button type="button" id="mural-next" style="padding:6px 12px;border:1px solid var(--line);background:transparent;color:var(--ink);font:11px var(--sans);font-weight:600;letter-spacing:.06em;text-transform:uppercase;border-radius:4px;cursor:pointer;margin-left:auto">Next →</button>
           </div>
           <style>
             .mural-dot { width:8px; height:8px; border-radius:50%; background:#d8d2c4; border:1px solid #b9b3a3; transition: background .15s, transform .15s; cursor:pointer; }
             .mural-dot.active { background: var(--gold,#c9a14b); border-color: var(--gold,#c9a14b); transform: scale(1.25); }
+            #scale-viewport.mural-mode { cursor: grab; }
+            #scale-viewport.mural-mode.dragging { cursor: grabbing; }
+            #scale-viewport .mural-hint { position:absolute; bottom:8px; left:10px; right:10px; display:flex; justify-content:space-between; align-items:center; font:10px ui-monospace,Menlo,monospace; color:#fff; text-shadow:0 1px 2px rgba(0,0,0,.7); letter-spacing:.04em; pointer-events:none; opacity:.85; }
+            #scale-viewport .mural-hint .zoom-pill { padding:2px 7px; background:rgba(0,0,0,.42); border-radius:999px; pointer-events:auto; }
           </style>
-          <div id="scale-viewport" style="width:100%;aspect-ratio:3/2;border:1px solid var(--line);border-radius:8px;overflow:hidden;background-image:url('${design.image_url}');background-repeat:repeat;background-size:160px 160px"></div>
+          <div id="scale-viewport" style="width:100%;aspect-ratio:3/2;border:1px solid var(--line);border-radius:8px;overflow:hidden;background-image:url('${design.image_url}');background-repeat:repeat;background-size:160px 160px;position:relative;user-select:none;-webkit-user-select:none">
+            <div class="mural-hint" id="mural-hint" style="display:none">
+              <span>Drag to position · scroll to zoom · double-click to reset</span>
+              <span class="zoom-pill" id="mural-zoom-pill">100%</span>
+            </div>
+          </div>
           <script>
           (function(){
             // Mural-size stepper. Cycles 1×→2×→4×→8×→16× repeat counts on the
-            // existing #scale-viewport. Labels: "full tile · 1×1", "2×2",
-            // "4×4", "8×8 · room scale", "16×16 · mural". Persists last level
-            // in localStorage under "wallco.detail.mural-level" so the
-            // preference survives a refresh.
+            // existing #scale-viewport. Labels: "Mural · 1×1 — drag to position",
+            // "2×2", "4×4", "8×8 · room scale", "16×16 · mural". Persists last
+            // level in localStorage under "wallco.detail.mural-level".
+            //
+            // At LEVEL 0 the viewport enters MURAL MODE — the design fills the
+            // whole wall as ONE panel and the user can drag/zoom to pick the
+            // exact crop. This matches the UX on grahambrown.com murals and
+            // rebelwalls.com — the user, not the algorithm, decides what part
+            // of the image lands on their wall.
             const LEVELS = [
-              { count: 1,  label: 'full tile · 1×1' },
+              { count: 1,  label: 'Mural · 1×1 — drag to position' },
               { count: 2,  label: '2×2' },
               { count: 4,  label: '4×4' },
               { count: 8,  label: '8×8 · room scale' },
@@ -6013,22 +6073,117 @@ ${htmlHeader('/designs')}
             const dots   = document.querySelectorAll('#mural-dots .mural-dot');
             const read   = document.getElementById('mural-readout');
             const btn    = document.getElementById('mural-next');
+            const resetBtn = document.getElementById('mural-reset');
+            const hint   = document.getElementById('mural-hint');
+            const zoomPill = document.getElementById('mural-zoom-pill');
             const SLIDER = document.getElementById('scale-slider');
+            const DESIGN_ID = ${JSON.stringify(design.id || '')};
+            const LS_LEVEL = 'wallco.detail.mural-level';
+            const LS_CROP  = 'wallco.detail.mural-crop.' + DESIGN_ID;
             let level = 0;
-            try { const stored = localStorage.getItem('wallco.detail.mural-level'); if (stored!=null) level = Math.min(Math.max(parseInt(stored,10)||0, 0), LEVELS.length-1); } catch {}
+            // mural crop state — per-design
+            let crop = { zoom: 1, px: 50, py: 50 }; // zoom 1 = "cover the viewport once"; px/py % position
+            try { const stored = localStorage.getItem(LS_LEVEL); if (stored!=null) level = Math.min(Math.max(parseInt(stored,10)||0, 0), LEVELS.length-1); } catch {}
+            try { const saved = JSON.parse(localStorage.getItem(LS_CROP) || 'null'); if (saved && typeof saved.zoom === 'number') crop = Object.assign(crop, saved); } catch {}
+            function persistCrop() { try { localStorage.setItem(LS_CROP, JSON.stringify(crop)); } catch {} }
+            function applyCrop() {
+              const sizePct = (100 * crop.zoom).toFixed(1) + '%';
+              vp.style.backgroundSize = sizePct + ' ' + sizePct;
+              vp.style.backgroundRepeat = 'no-repeat';
+              vp.style.backgroundPosition = crop.px.toFixed(2) + '% ' + crop.py.toFixed(2) + '%';
+              if (zoomPill) zoomPill.textContent = Math.round(crop.zoom * 100) + '%';
+            }
             function apply() {
               const { count, label } = LEVELS[level];
-              if (vp) vp.style.backgroundSize = (100/count).toFixed(2) + '% ' + (100/count).toFixed(2) + '%';
               dots.forEach((d,i) => d.classList.toggle('active', i===level));
               if (read) read.textContent = label;
-              try { localStorage.setItem('wallco.detail.mural-level', String(level)); } catch {}
+              try { localStorage.setItem(LS_LEVEL, String(level)); } catch {}
+              if (level === 0) {
+                vp.classList.add('mural-mode');
+                if (hint) hint.style.display = 'flex';
+                if (resetBtn) resetBtn.style.display = '';
+                applyCrop();
+              } else {
+                vp.classList.remove('mural-mode');
+                if (hint) hint.style.display = 'none';
+                if (resetBtn) resetBtn.style.display = 'none';
+                vp.style.backgroundRepeat = 'repeat';
+                vp.style.backgroundSize = (100/count).toFixed(2) + '% ' + (100/count).toFixed(2) + '%';
+                vp.style.backgroundPosition = '0% 0%';
+              }
             }
             if (btn) btn.addEventListener('click', () => { level = (level + 1) % LEVELS.length; apply(); });
             dots.forEach(d => d.addEventListener('click', (e) => { level = parseInt(e.currentTarget.dataset.level,10) || 0; apply(); }));
-            // The legacy "Pattern scale" slider keeps working — its background-size is
-            // overridden the moment the user clicks Next. If they touch the slider
-            // again, we hand control back to it (reset level → 0 read-only).
-            if (SLIDER) SLIDER.addEventListener('input', () => { level = 0; dots.forEach(d => d.classList.remove('active')); dots[0] && dots[0].classList.add('active'); if (read) read.textContent = 'full tile · 1×1'; });
+            // The legacy "Pattern scale" slider keeps working at levels 1-4.
+            // At level 0 (mural mode) the slider hands off to drag/zoom.
+            if (SLIDER) SLIDER.addEventListener('input', () => {
+              if (level === 0) return; // mural mode owns the viewport
+              level = 0;
+              dots.forEach(d => d.classList.remove('active'));
+              dots[0] && dots[0].classList.add('active');
+              if (read) read.textContent = LEVELS[0].label;
+              apply();
+            });
+
+            // ── Mural mode interactions: drag-to-pan, wheel-to-zoom, dblclick reset.
+            let drag = null;
+            vp.addEventListener('mousedown', (e) => {
+              if (level !== 0) return;
+              drag = { x: e.clientX, y: e.clientY, px0: crop.px, py0: crop.py };
+              vp.classList.add('dragging');
+              e.preventDefault();
+            });
+            window.addEventListener('mousemove', (e) => {
+              if (!drag || level !== 0) return;
+              const rect = vp.getBoundingClientRect();
+              const dx = (e.clientX - drag.x) / rect.width * 100;
+              const dy = (e.clientY - drag.y) / rect.height * 100;
+              // Drag right → reveal left side of image (background-position % goes down)
+              crop.px = Math.max(0, Math.min(100, drag.px0 - dx));
+              crop.py = Math.max(0, Math.min(100, drag.py0 - dy));
+              applyCrop();
+            });
+            window.addEventListener('mouseup', () => {
+              if (drag) { drag = null; vp.classList.remove('dragging'); persistCrop(); }
+            });
+            // touch support
+            vp.addEventListener('touchstart', (e) => {
+              if (level !== 0 || e.touches.length !== 1) return;
+              drag = { x: e.touches[0].clientX, y: e.touches[0].clientY, px0: crop.px, py0: crop.py };
+              vp.classList.add('dragging');
+            }, { passive: true });
+            vp.addEventListener('touchmove', (e) => {
+              if (!drag || level !== 0 || e.touches.length !== 1) return;
+              const rect = vp.getBoundingClientRect();
+              const dx = (e.touches[0].clientX - drag.x) / rect.width * 100;
+              const dy = (e.touches[0].clientY - drag.y) / rect.height * 100;
+              crop.px = Math.max(0, Math.min(100, drag.px0 - dx));
+              crop.py = Math.max(0, Math.min(100, drag.py0 - dy));
+              applyCrop();
+              e.preventDefault();
+            }, { passive: false });
+            vp.addEventListener('touchend', () => { if (drag) { drag = null; vp.classList.remove('dragging'); persistCrop(); } });
+            // wheel-to-zoom (only in mural mode, only when modifier or hover — avoid stealing page scroll)
+            vp.addEventListener('wheel', (e) => {
+              if (level !== 0) return;
+              e.preventDefault();
+              const dz = e.deltaY < 0 ? 1.08 : 1/1.08;
+              crop.zoom = Math.max(0.5, Math.min(6, crop.zoom * dz));
+              applyCrop();
+              persistCrop();
+            }, { passive: false });
+            // double-click → reset
+            vp.addEventListener('dblclick', (e) => {
+              if (level !== 0) return;
+              crop = { zoom: 1, px: 50, py: 50 };
+              applyCrop();
+              persistCrop();
+            });
+            if (resetBtn) resetBtn.addEventListener('click', () => {
+              crop = { zoom: 1, px: 50, py: 50 };
+              applyCrop();
+              persistCrop();
+            });
             apply();
           })();
           </script>

← 0a9b0d4 cards: pattern-only resting state, full meta on hover (admin  ·  back to Wallco Ai  ·  design page: add Share — Pinterest (Pin It) + Instagram (dow 0f93256 →