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