← 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
M public/js/color-dots.jsM server.jsA src/colorways.js
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 →