[object Object]

← back to Wallco Ai

Save-this-colorway: per-user EXCLUSIVE versioned colorways. New src/colorways.js (wallco_user_colorways table + /api/colorways save/list/delete, trade-auth gated); color-dots.js gains Save button + version rail (jump to any saved version) + reset. Verified authed round-trip v1/v2 + unauthed 401

58b694bff8d648baba41c416dc43d0749c8079dc · 2026-05-29 13:35:20 -0700 · Steve

Files touched

Diff

commit 58b694bff8d648baba41c416dc43d0749c8079dc
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri May 29 13:35:20 2026 -0700

    Save-this-colorway: per-user EXCLUSIVE versioned colorways. New src/colorways.js (wallco_user_colorways table + /api/colorways save/list/delete, trade-auth gated); color-dots.js gains Save button + version rail (jump to any saved version) + reset. Verified authed round-trip v1/v2 + unauthed 401
---
 public/js/color-dots.js |  89 ++++++++++++++++++++++++++++++++++++++++-
 server.js               |   4 ++
 src/colorways.js        | 103 ++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 195 insertions(+), 1 deletion(-)

diff --git a/public/js/color-dots.js b/public/js/color-dots.js
index 3bf5ad4..a2fc40c 100644
--- a/public/js/color-dots.js
+++ b/public/js/color-dots.js
@@ -152,8 +152,95 @@
       d.addEventListener('click', function (e) { e.stopPropagation(); openWheel(d, img, k); });
       col.appendChild(d);
     });
-    // tiny "drag a color" hint dot at bottom
     host.appendChild(col);
+    // attach the save/version toolbar once, to the main pattern (needs a design id)
+    if (!window._cdBarDone && pageDesignId()) { window._cdBarDone = true; buildToolbar(img, host); }
+  }
+
+  // ── save / version colorways (per-user exclusive) ────────────────────────
+  function pageDesignId() {
+    var m = location.pathname.match(/\/design\/(\d+)/); if (m) return parseInt(m[1], 10);
+    var q = new URLSearchParams(location.search).get('id'); if (q && /^\d+$/.test(q)) return parseInt(q, 10);
+    return null;
+  }
+  function currentInkMap(img) {
+    var st = state.get(img); if (!st) return [];
+    return st.inks.map(function (ink, k) {
+      return { from: hex(ink[0], ink[1], ink[2]), to: hex(st.cur[k][0], st.cur[k][1], st.cur[k][2]) };
+    });
+  }
+  function applyColorway(img, inkMap) {
+    var st = state.get(img); if (!st || !Array.isArray(inkMap)) return;
+    inkMap.forEach(function (m) {
+      var fr = hex2rgb(m.from), to = hex2rgb(m.to), best = 0, bd = 1e9;
+      for (var k = 0; k < st.inks.length; k++) {
+        var dr = st.inks[k][0] - fr[0], dg = st.inks[k][1] - fr[1], db = st.inks[k][2] - fr[2], d = dr * dr + dg * dg + db * db;
+        if (d < bd) { bd = d; best = k; }
+      }
+      st.cur[best] = [to[0], to[1], to[2]];
+    });
+    var strip = img.parentElement && img.parentElement.querySelector('.cd-strip');
+    if (strip) [].forEach.call(strip.children, function (dot, k) { if (st.cur[k]) dot.style.background = hex(st.cur[k][0], st.cur[k][1], st.cur[k][2]); });
+    recolor(img);
+  }
+  function toast(host, msg) {
+    var t = document.createElement('div'); t.textContent = msg;
+    t.style.cssText = 'position:absolute;left:10px;bottom:54px;z-index:8;background:#16203c;color:#fff;font:600 12px sans-serif;padding:6px 12px;border-radius:8px;box-shadow:0 2px 10px rgba(0,0,0,.3)';
+    host.appendChild(t); setTimeout(function () { t.remove(); }, 1700);
+  }
+  function buildToolbar(img, host) {
+    var did = pageDesignId(); if (!did) return;
+    var bar = document.createElement('div'); bar.className = 'cd-bar';
+    bar.style.cssText = 'position:absolute;left:10px;bottom:10px;z-index:7;display:flex;gap:8px;align-items:center;flex-wrap:wrap;background:rgba(255,255,255,.94);border-radius:999px;padding:6px 8px;box-shadow:0 2px 10px rgba(0,0,0,.18);max-width:calc(100% - 20px)';
+    var save = document.createElement('button'); save.type = 'button'; save.textContent = '💾 Save colorway';
+    save.style.cssText = 'border:0;border-radius:999px;background:#16203c;color:#fff;font:600 12px sans-serif;padding:7px 12px;cursor:pointer';
+    var vers = document.createElement('span'); vers.className = 'cd-versions'; vers.style.cssText = 'display:flex;gap:6px;align-items:center';
+    var reset = document.createElement('button'); reset.type = 'button'; reset.textContent = '↺'; reset.title = 'Reset to original';
+    reset.style.cssText = 'border:0;border-radius:50%;width:28px;height:28px;background:#eee;cursor:pointer;font-size:13px';
+    bar.appendChild(save); bar.appendChild(vers); bar.appendChild(reset);
+    host.appendChild(bar);
+
+    function chip(cw) {
+      var b = document.createElement('button'); b.type = 'button';
+      b.title = (cw.name || ('Version ' + cw.version)) + ' · ' + new Date(cw.created_at).toLocaleString();
+      var stops = (cw.ink_map || []).map(function (m) { return m.to; });
+      b.style.cssText = 'border:1px solid #ccc;border-radius:6px;width:32px;height:22px;cursor:pointer;' +
+        (stops.length ? 'background:linear-gradient(90deg,' + stops.join(',') + ')' : 'background:#eee');
+      var lab = document.createElement('span'); lab.textContent = 'v' + cw.version;
+      b.appendChild(lab); lab.style.cssText = 'display:block;font:700 8px sans-serif;color:#fff;text-shadow:0 0 2px #000';
+      b.addEventListener('click', function () { applyColorway(img, cw.ink_map); });
+      return b;
+    }
+    function load() {
+      fetch('/api/colorways?design_id=' + did, { credentials: 'same-origin' })
+        .then(function (r) { return r.json(); })
+        .then(function (j) { vers.innerHTML = ''; (j.colorways || []).forEach(function (cw) { vers.appendChild(chip(cw)); }); })
+        .catch(function () {});
+    }
+    save.addEventListener('click', function () {
+      var st = state.get(img); if (!st) return;
+      var changed = currentInkMap(img).filter(function (m) { return m.from !== m.to; });
+      if (!changed.length) { toast(host, 'Recolor a dot first'); return; }
+      save.disabled = true; save.textContent = 'Saving…';
+      fetch('/api/colorways/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin',
+        body: JSON.stringify({ design_id: did, ink_map: currentInkMap(img) }) })
+        .then(function (r) { return r.json().then(function (j) { return { s: r.status, j: j }; }); })
+        .then(function (o) {
+          save.disabled = false; save.textContent = '💾 Save colorway';
+          if (o.s === 401) { toast(host, 'Sign in to save your colorway'); return; }
+          if (o.j && o.j.ok) { toast(host, 'Saved as v' + o.j.version); load(); }
+          else { toast(host, (o.j && o.j.error) || 'Save failed'); }
+        })
+        .catch(function () { save.disabled = false; save.textContent = '💾 Save colorway'; toast(host, 'Save failed'); });
+    });
+    reset.addEventListener('click', function () {
+      var st = state.get(img); if (!st) return;
+      st.cur = st.inks.map(function (c) { return c.slice(); });
+      var strip = host.querySelector('.cd-strip');
+      if (strip) [].forEach.call(strip.children, function (dot, k) { dot.style.background = hex(st.inks[k][0], st.inks[k][1], st.inks[k][2]); });
+      recolor(img);
+    });
+    load();
   }
 
   function scan() {
diff --git a/server.js b/server.js
index 173f7fc..f7f2866 100644
--- a/server.js
+++ b/server.js
@@ -3674,6 +3674,10 @@ function ensureTradeUserTables() {
 }
 ensureTradeUserTables();
 
+// User-exclusive saved colorways (color-dots wheel → save/version). Mounted as
+// a module so server.js stays untouched beyond this line; needs getTradeUser.
+try { require('./src/colorways').mount(app, { getTradeUser }); } catch (e) { console.error('Colorways mount failed:', e.message); }
+
 // ── Ensure user_removed column on all_designs ────────────────────────
 // The /api/design/:id/remove endpoint writes user_removed=TRUE through
 // the spoon_all_designs view (which enumerates this column). Target the
diff --git a/src/colorways.js b/src/colorways.js
new file mode 100644
index 0000000..690bdfd
--- /dev/null
+++ b/src/colorways.js
@@ -0,0 +1,103 @@
+/* src/colorways.js — user-exclusive saved colorways.
+ *
+ * When a trade user recolors a pattern (via the color-dots wheel) they can save
+ * the result as an EXCLUSIVE colorway under their account. Each save is a new
+ * version; the PDP lists them and can jump to any version. Mounted as a module
+ * so the 863KB drifted server.js stays untouched beyond one mount line.
+ *
+ *   POST /api/colorways/save  { design_id, ink_map:[{from,to}...], name? }  (auth)
+ *   GET  /api/colorways?design_id=N                                          (auth)
+ *   DELETE /api/colorways/:id                                                (auth)
+ *
+ * ink_map is the per-ink remap [{from:'#rrggbb', to:'#rrggbb'}]. Stored JSONB.
+ * Rows are private to user_id (exclusive). mount(app, { getTradeUser, psql, pgEsc }).
+ */
+'use strict';
+const { psqlQuery: psql, pgEsc } = require('../lib/db');
+const expressJson = require('express').json({ limit: '64kb' });
+
+function mount(app, deps) {
+  const { getTradeUser } = deps || {};
+  if (!getTradeUser) { console.error('[colorways] missing getTradeUser dep — not mounted'); return; }
+
+  // ── migration (idempotent) ──────────────────────────────────────────────
+  try {
+    psql(`CREATE TABLE IF NOT EXISTS wallco_user_colorways (
+      id         SERIAL PRIMARY KEY,
+      user_id    INTEGER NOT NULL REFERENCES wallco_trade_users(id) ON DELETE CASCADE,
+      design_id  INTEGER NOT NULL,
+      name       TEXT,
+      ink_map    JSONB NOT NULL,
+      exclusive  BOOLEAN DEFAULT TRUE,
+      created_at TIMESTAMPTZ DEFAULT NOW()
+    );`);
+    psql(`CREATE INDEX IF NOT EXISTS wallco_user_colorways_ud_idx
+             ON wallco_user_colorways(user_id, design_id, created_at ASC);`);
+    console.log('  Colorways table ready (wallco_user_colorways)');
+  } catch (e) { console.error('[colorways] migration failed:', e.message); }
+
+  // POST /api/colorways/save
+  app.post('/api/colorways/save', expressJson, (req, res) => {
+    const u = getTradeUser(req);
+    if (!u) return res.status(401).json({ ok: false, error: 'Sign in to save your exclusive colorway.' });
+    const b = req.body || {};
+    const designId = parseInt(b.design_id, 10);
+    if (!Number.isFinite(designId) || designId <= 0) return res.status(400).json({ ok: false, error: 'design_id required.' });
+    let inkMap = b.ink_map;
+    if (!Array.isArray(inkMap) || !inkMap.length) return res.status(400).json({ ok: false, error: 'ink_map required.' });
+    // sanitize: keep only {from,to} hex pairs
+    inkMap = inkMap
+      .filter(m => m && /^#[0-9a-fA-F]{6}$/.test(m.from) && /^#[0-9a-fA-F]{6}$/.test(m.to))
+      .map(m => ({ from: m.from.toLowerCase(), to: m.to.toLowerCase() }));
+    if (!inkMap.length) return res.status(400).json({ ok: false, error: 'ink_map had no valid hex pairs.' });
+    const name = (typeof b.name === 'string' ? b.name : '').slice(0, 80) || null;
+    try {
+      const jsonStr = JSON.stringify(inkMap);
+      const id = psql(`INSERT INTO wallco_user_colorways (user_id, design_id, name, ink_map)
+        VALUES (${parseInt(u.id, 10)}, ${designId}, ${name ? pgEsc(name) : 'NULL'}, ${pgEsc(jsonStr)}::jsonb)
+        RETURNING id;`).trim();
+      const version = parseInt(psql(`SELECT count(*) FROM wallco_user_colorways
+        WHERE user_id=${parseInt(u.id, 10)} AND design_id=${designId};`).trim(), 10) || 1;
+      res.json({ ok: true, id: parseInt(id, 10), version, design_id: designId });
+    } catch (e) {
+      console.error('[colorways/save] failed:', e.message);
+      res.status(500).json({ ok: false, error: 'Save failed.' });
+    }
+  });
+
+  // GET /api/colorways?design_id=N — current user's exclusive colorways for a design.
+  app.get('/api/colorways', (req, res) => {
+    const u = getTradeUser(req);
+    if (!u) return res.json({ ok: true, authenticated: false, colorways: [] });
+    const designId = parseInt(req.query.design_id, 10);
+    if (!Number.isFinite(designId) || designId <= 0) return res.status(400).json({ ok: false, error: 'design_id required.' });
+    try {
+      const raw = psql(`SELECT COALESCE(json_agg(t ORDER BY t.created_at ASC), '[]'::json) FROM (
+        SELECT id, name, ink_map, created_at FROM wallco_user_colorways
+        WHERE user_id=${parseInt(u.id, 10)} AND design_id=${designId}) t;`);
+      let rows = [];
+      try { rows = JSON.parse(raw || '[]'); } catch (e) { rows = []; }
+      const colorways = rows.map((r, i) => ({ id: r.id, name: r.name, ink_map: r.ink_map, created_at: r.created_at, version: i + 1 }));
+      res.json({ ok: true, authenticated: true, colorways });
+    } catch (e) {
+      console.error('[colorways/list] failed:', e.message);
+      res.status(500).json({ ok: false, error: 'Lookup failed.' });
+    }
+  });
+
+  // DELETE /api/colorways/:id — remove one of the user's own colorways.
+  app.delete('/api/colorways/:id', (req, res) => {
+    const u = getTradeUser(req);
+    if (!u) return res.status(401).json({ ok: false, error: 'Sign in.' });
+    const id = parseInt(req.params.id, 10);
+    if (!Number.isFinite(id)) return res.status(400).json({ ok: false, error: 'bad id' });
+    try {
+      psql(`DELETE FROM wallco_user_colorways WHERE id=${id} AND user_id=${parseInt(u.id, 10)};`);
+      res.json({ ok: true });
+    } catch (e) { res.status(500).json({ ok: false, error: 'Delete failed.' }); }
+  });
+
+  console.log('  Colorways layer mounted');
+}
+
+module.exports = { mount };

← 69f306b color-dots: interactive recolor — click a dot to open an HSV  ·  back to Wallco Ai  ·  feat(theme-gallery): width + height sliders, dynamic resize 4e1f5e0 →