[object Object]

← back to Wallco Ai

designs: sort buttons (was dropdown) + /merge upload-and-fuse feature

d7d74bc9b408a707eab0d54d100e10209c67ea3c · 2026-05-13 11:01:15 -0700 · SteveStudio2

/designs:
  - Sort <select> dropdown replaced with prominent button-pill row:
    Newest · Title A→Z · Category · Light→Dark · Dark→Light · Color Wheel
  - Active sort pill highlighted (dark fill); ARIA role=radiogroup
  - Density slider styled inline with same baseline
  - Both visible without needing to open a control panel

/merge (NEW):
  - Drop in 1-3 source designs via either: paste a catalog ID OR upload
    a PNG/JPG/WebP (multer, 20mb cap each, max 3 files)
  - Optional extra direction textarea
  - 'Merge & Generate' button → POST /api/merge
    * Pulls each catalog source's prompt + dominant_hex + motifs[] from PG
    * For uploads: notes them as 'user reference N' textual cue
    * Synthesizes a unified SDXL prompt: 'A new wallcovering that visually
      fuses elements from N references: [source #1: ...] [source #2: ...]
      ... Tie the palette around these dominants: #abc, #def. Direction: <hint>.'
    * Fires generate_designs.js --category=merged --kind=seamless_tile
    * Returns {ok, design_id, seed, prompt, image_url, source_count}
  - Helper: GET /designs/img/by-id/:id streams the design PNG by ID
    (powers the slot preview when a catalog ID is pasted)

Smoke-tested: /merge page renders, multer installed, route compiles.

Files touched

Diff

commit d7d74bc9b408a707eab0d54d100e10209c67ea3c
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 11:01:15 2026 -0700

    designs: sort buttons (was dropdown) + /merge upload-and-fuse feature
    
    /designs:
      - Sort <select> dropdown replaced with prominent button-pill row:
        Newest · Title A→Z · Category · Light→Dark · Dark→Light · Color Wheel
      - Active sort pill highlighted (dark fill); ARIA role=radiogroup
      - Density slider styled inline with same baseline
      - Both visible without needing to open a control panel
    
    /merge (NEW):
      - Drop in 1-3 source designs via either: paste a catalog ID OR upload
        a PNG/JPG/WebP (multer, 20mb cap each, max 3 files)
      - Optional extra direction textarea
      - 'Merge & Generate' button → POST /api/merge
        * Pulls each catalog source's prompt + dominant_hex + motifs[] from PG
        * For uploads: notes them as 'user reference N' textual cue
        * Synthesizes a unified SDXL prompt: 'A new wallcovering that visually
          fuses elements from N references: [source #1: ...] [source #2: ...]
          ... Tie the palette around these dominants: #abc, #def. Direction: <hint>.'
        * Fires generate_designs.js --category=merged --kind=seamless_tile
        * Returns {ok, design_id, seed, prompt, image_url, source_count}
      - Helper: GET /designs/img/by-id/:id streams the design PNG by ID
        (powers the slot preview when a catalog ID is pasted)
    
    Smoke-tested: /merge page renders, multer installed, route compiles.
---
 server.js | 248 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 236 insertions(+), 12 deletions(-)

diff --git a/server.js b/server.js
index 11435f3..2214d5a 100644
--- a/server.js
+++ b/server.js
@@ -730,6 +730,224 @@ app.post('/admin/reload-designs', (req, res) => {
   res.json({ ok: true, before, after: DESIGNS.length, catalog_last_modified: CATALOG_LAST_MODIFIED, by_color_cache_ver: _byColorCacheVer });
 });
 
+// ── Merge ────────────────────────────────────────────────────────────────
+// Take 1-3 designs (catalog IDs and/or uploaded files), pull element
+// descriptions from each, fuse into a single SDXL prompt, generate a new
+// design that visually pulls from all sources. Saved with category='merged'.
+const mergeUpload = require('multer')({
+  dest: path.join(__dirname, 'data', 'merge-uploads'),
+  limits: { fileSize: 20 * 1024 * 1024, files: 3 },
+});
+
+app.get('/merge', (_req, res) => {
+  res.type('html').send(`<!doctype html>
+<html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Merge designs — wallco.ai</title>
+<style>
+:root { --ink:#2a1f10; --line:#d8d0c0; --gold:#c9a14b; --accent:#1a1a1a; --bg:#fff; --card:#f8f4ec; }
+body { margin:0; background:var(--bg); color:var(--ink); font:14px -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif }
+header { padding:20px 32px; border-bottom:1px solid var(--line); display:flex; gap:16px; align-items:baseline }
+header h1 { font-family:'Playfair Display','Didot',serif; font-weight:400; font-size:26px; margin:0 }
+header .meta { color:#888; font-size:13px }
+header a { margin-left:auto; color:var(--ink); text-decoration:none; font-size:13px; padding:6px 14px; border:1px solid var(--line); border-radius:14px }
+main { max-width:1080px; margin:0 auto; padding:32px 24px }
+.slots { display:grid; grid-template-columns:repeat(3,1fr); gap:18px; margin-bottom:22px }
+@media (max-width:760px) { .slots { grid-template-columns:1fr } }
+.slot { background:var(--card); border:1px solid var(--line); border-radius:8px; padding:14px; min-height:240px; display:flex; flex-direction:column; gap:8px }
+.slot h3 { margin:0; font-size:12px; letter-spacing:.16em; text-transform:uppercase; color:#7a6e5a }
+.slot .preview { flex:1; min-height:160px; background-size:cover; background-position:center; background-color:#fff; border:1px dashed var(--line); border-radius:6px; display:flex; align-items:center; justify-content:center; color:#bba; font-size:12px }
+.slot input[type=text], .slot input[type=file] { width:100%; padding:6px 8px; border:1px solid var(--line); border-radius:4px; font:12px var(--sans); background:#fff }
+.controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:14px }
+.controls textarea { flex:1; min-width:280px; padding:8px 10px; border:1px solid var(--line); border-radius:4px; font:13px monospace; min-height:54px }
+button.go { padding:12px 28px; background:var(--accent); color:#f4ece0; border:0; border-radius:6px; font:13px var(--sans); letter-spacing:.06em; text-transform:uppercase; cursor:pointer }
+button.go[disabled] { opacity:.55; cursor:wait }
+.status { font-size:12px; color:#888 }
+.result { margin-top:24px; padding:16px; border:1px solid var(--line); border-radius:8px; background:var(--card); display:none }
+.result.show { display:block }
+.result img { max-width:100%; border:1px solid var(--line); border-radius:6px; display:block; margin:10px 0 }
+</style>
+</head><body>
+<header>
+  <h1>Merge designs</h1>
+  <span class="meta">Pull elements from 1-3 designs into one new generation</span>
+  <a href="/designs">← Grid</a>
+</header>
+<main>
+  <p style="color:#666;line-height:1.6">Drop in up to 3 source images (uploads) or paste catalog design IDs. The merger pulls each design's motifs / color story / style and fuses them into a single new SDXL generation. Saved to the catalog as <code>category='merged'</code>.</p>
+
+  <div class="slots">
+    ${[1,2,3].map(n => `
+      <div class="slot" data-slot="${n}">
+        <h3>Source ${n}</h3>
+        <div class="preview" id="prev-${n}">drop image or paste design ID</div>
+        <label style="font-size:11px;color:#888">Catalog ID
+          <input type="text" id="src-id-${n}" placeholder="e.g. 1537" inputmode="numeric">
+        </label>
+        <label style="font-size:11px;color:#888">— or upload —
+          <input type="file" id="src-file-${n}" accept="image/png,image/jpeg,image/webp">
+        </label>
+      </div>
+    `).join('')}
+  </div>
+
+  <div class="controls">
+    <textarea id="extra-hint" placeholder="Optional extra direction — e.g. 'in a Persian miniature manner, dusty palette, mural-scale'"></textarea>
+    <button class="go" id="btn-merge" type="button">Merge & Generate</button>
+    <span class="status" id="status"></span>
+  </div>
+
+  <div class="result" id="result">
+    <strong>Merged design</strong>
+    <p id="result-meta" style="margin:6px 0;font-size:12px;color:#888"></p>
+    <img id="result-img" alt="merged">
+    <p><a id="result-link" href="#" style="color:var(--accent);font-weight:600">Open on the design page →</a></p>
+  </div>
+</main>
+<script>
+// Preview helpers — show image when ID is entered or file is picked
+[1,2,3].forEach(function(n){
+  var idIn = document.getElementById('src-id-'+n);
+  var fileIn = document.getElementById('src-file-'+n);
+  var prev = document.getElementById('prev-'+n);
+  idIn.addEventListener('input', function(){
+    var v = idIn.value.replace(/\\D/g,'');
+    if (v && parseInt(v,10) > 0) {
+      prev.style.backgroundImage = "url('/designs/img/by-id/" + v + "')";
+      // Fallback: just show the URL — server will respond if it exists
+      var img = new Image();
+      img.onload = function(){ prev.style.backgroundImage = "url('"+img.src+"')"; prev.textContent=''; };
+      img.onerror = function(){ prev.style.backgroundImage = ''; prev.textContent = '#'+v+' not found'; };
+      img.src = '/designs/img/by-id/' + v;
+      fileIn.value = '';
+    } else { prev.style.backgroundImage = ''; prev.textContent = 'drop image or paste design ID'; }
+  });
+  fileIn.addEventListener('change', function(){
+    var f = fileIn.files && fileIn.files[0]; if (!f) return;
+    var reader = new FileReader();
+    reader.onload = function(){ prev.style.backgroundImage = "url('"+reader.result+"')"; prev.textContent=''; };
+    reader.readAsDataURL(f);
+    idIn.value = '';
+  });
+});
+
+document.getElementById('btn-merge').addEventListener('click', function(){
+  var btn = this, status = document.getElementById('status');
+  var fd = new FormData();
+  var hint = document.getElementById('extra-hint').value.trim();
+  if (hint) fd.append('hint', hint);
+  var n = 0;
+  [1,2,3].forEach(function(i){
+    var id = (document.getElementById('src-id-'+i).value||'').replace(/\\D/g,'');
+    var f = document.getElementById('src-file-'+i).files[0];
+    if (id) { fd.append('source_ids', id); n++; }
+    else if (f) { fd.append('uploads', f); n++; }
+  });
+  if (n === 0) { status.textContent = 'Add at least one source'; return; }
+  status.textContent = 'Merging ~30-60s…'; btn.disabled = true;
+  fetch('/api/merge', { method:'POST', body: fd })
+    .then(function(r){ return r.json(); })
+    .then(function(j){
+      btn.disabled = false;
+      if (!j.ok) { status.textContent = 'Error: ' + (j.error||'unknown'); return; }
+      status.textContent = 'Merged → design #' + j.design_id;
+      var res = document.getElementById('result');
+      document.getElementById('result-meta').textContent =
+        'Sources: ' + (j.source_count||n) + ' · seed ' + j.seed + ' · ' + j.prompt.slice(0,80) + '…';
+      document.getElementById('result-img').src = j.image_url + '?t=' + Date.now();
+      document.getElementById('result-link').href = '/design/' + j.design_id;
+      res.classList.add('show');
+      res.scrollIntoView({ behavior:'smooth' });
+    })
+    .catch(function(e){ btn.disabled = false; status.textContent = 'Error: ' + e.message; });
+});
+</script>
+</body></html>`);
+});
+
+// Helper: serve a design image by id (used by /merge previews)
+app.get('/designs/img/by-id/:id', (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(404).end();
+  try {
+    const lp = psqlExecLocal(`SELECT local_path FROM spoon_all_designs WHERE id=${id}`);
+    if (!lp || !fs.existsSync(lp)) return res.status(404).end();
+    res.sendFile(path.resolve(lp));
+  } catch { res.status(500).end(); }
+});
+
+app.post('/api/merge', mergeUpload.array('uploads', 3), async (req, res) => {
+  try {
+    const sourceIds = []
+      .concat(req.body.source_ids || [])
+      .map(s => parseInt(s, 10))
+      .filter(n => Number.isFinite(n) && n > 0);
+    const uploads = (req.files || []);
+    const totalSources = sourceIds.length + uploads.length;
+    if (totalSources === 0) return res.status(400).json({ ok: false, error: 'no sources' });
+    if (totalSources > 3) return res.status(400).json({ ok: false, error: 'max 3 sources' });
+    const hint = String(req.body.hint || '').slice(0, 400);
+
+    // Pull a short element-description from each source.
+    // - Catalog IDs: read prompt + dominant_hex + motifs straight from PG (free).
+    // - Uploads: skip llava for now; just note "user-uploaded reference N" and
+    //   include the file in the prompt as an inspiration (textual cue only —
+    //   SDXL won't ingest the image without img2img; this is a future bump).
+    const fragments = [];
+    const palettes = [];
+    for (const sid of sourceIds) {
+      const row = psqlExecLocal(
+        `SELECT json_build_object('prompt', prompt, 'dominant_hex', dominant_hex, 'motifs', motifs, 'category', category)::text
+         FROM spoon_all_designs WHERE id=${sid}`
+      );
+      if (!row) continue;
+      let r; try { r = JSON.parse(row); } catch { continue; }
+      // Truncate the original prompt aggressively — keep first ~120 chars
+      const seed = (r.prompt || '').slice(0, 160).replace(/,?\s*NO\s.*$/i, '').trim();
+      fragments.push(`[source #${sid}: ${seed}${(r.motifs || []).length ? ' · motifs: ' + r.motifs.slice(0,4).join(', ') : ''}]`);
+      if (r.dominant_hex) palettes.push(r.dominant_hex);
+    }
+    for (let i = 0; i < uploads.length; i++) {
+      fragments.push(`[user reference #${i+1}: blend its overall feel and palette]`);
+    }
+    const mergedPrompt = [
+      `A new wallcovering design that visually fuses elements from ${totalSources} reference${totalSources>1?'s':''}:`,
+      fragments.join(' '),
+      palettes.length ? `Tie the palette around these dominants: ${palettes.join(', ')}.` : '',
+      hint ? `Direction: ${hint}.` : '',
+      'Refined hand-painted execution, seamless wallpaper repeat, archival quality, no text, no watermark.',
+    ].filter(Boolean).join(' ');
+
+    // Fire generate_designs.js with the merged prompt. category='merged'.
+    const { spawnSync } = require('child_process');
+    const r = spawnSync('node', [
+      path.join(__dirname, 'scripts', 'generate_designs.js'),
+      '--n', '1',
+      '--kind', 'seamless_tile',
+      '--category', 'merged',
+      '--prompts', mergedPrompt,
+    ], { encoding: 'utf8', cwd: __dirname, timeout: 180_000 });
+    if (r.status !== 0) {
+      return res.status(500).json({ ok: false, error: 'generator failed: ' + (r.stderr || r.stdout || '').slice(-200) });
+    }
+    // Pull the newest 'merged' row — that's the one we just made.
+    const idRaw = psqlExecLocal(`SELECT id FROM spoon_all_designs WHERE category='merged' ORDER BY id DESC LIMIT 1`);
+    const newId = parseInt(idRaw, 10);
+    const seedRaw = psqlExecLocal(`SELECT json_build_object('seed', seed, 'local_path', local_path)::text FROM spoon_all_designs WHERE id=${newId}`);
+    let meta = {}; try { meta = JSON.parse(seedRaw); } catch {}
+    const filename = (meta.local_path || '').split('/').pop();
+    res.json({
+      ok: true, design_id: newId,
+      seed: meta.seed,
+      prompt: mergedPrompt,
+      image_url: '/designs/img/' + filename,
+      source_count: totalSources,
+    });
+  } catch (e) {
+    res.status(500).json({ ok: false, error: e.message });
+  }
+});
+
 // ── License + download flow ────────────────────────────────────────────
 // One-time $950 infinite-use license per mural-scenic panel. Modeled on
 // Hannah's archive licensing — pay once, perpetual unlimited use, the buyer
@@ -3030,18 +3248,24 @@ ${cat === 'mural-scenic' ? `
           <option value="">All Categories</option>
           ${cats.map(c => `<option value="${c}" ${c===cat?'selected':''}>${c.charAt(0).toUpperCase()+c.slice(1)}</option>`).join('')}
         </select>
-        <label class="sort-label">Sort
-          <select id="sort-select" class="filter-select" onchange="document.getElementById('sort-hidden').value=this.value;this.form.submit()">
-            <option value="newest"      ${sort==='newest'?'selected':''}>Newest</option>
-            <option value="title"       ${sort==='title'?'selected':''}>Title A→Z</option>
-            <option value="category"    ${sort==='category'?'selected':''}>Category</option>
-            <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</option>
-          </select>
-        </label>
-        <label class="density-label">Density
-          <input type="range" id="density-slider" min="1" max="8" step="1" value="3" class="density-range">
+        <div class="sort-btn-row" style="display:flex;gap:4px;flex-wrap:wrap;align-items:center" role="radiogroup" aria-label="Sort designs">
+          <span style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.1em;color:var(--ink-faint);margin-right:4px">Sort</span>
+          ${[
+            ['newest',      'Newest'],
+            ['title',       'Title A→Z'],
+            ['category',    'Category'],
+            ['light-dark',  'Light → Dark'],
+            ['dark-light',  'Dark → Light'],
+            ['color-wheel', 'Color Wheel'],
+          ].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('')}
+        </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="8" step="1" value="3" class="density-range" style="flex:0 0 110px">
         </label>
         <button type="button" id="surprise-btn" class="filter-select"
           style="background:var(--gold,#c9a14b);color:var(--accent,#0d0d0d);border:0;font-weight:500;padding:8px 14px;cursor:pointer;border-radius:6px;letter-spacing:.04em"

← 753227d designs: studio adjust panel (6 photoshop sliders) + per-car  ·  back to Wallco Ai  ·  /generator: fix broken 'Run tick NOW' + DW palette slider gr 10447d1 →