← back to Wallco Ai
design/:id: Photoshop-style edit panel — sliders, palette dots, color wheel, drag-drop recolor, bake
c9365f55c2ba13cbb6fcaebb1a3992715b578cbc · 2026-05-11 19:33:30 -0700 · SteveStudio2
Collapsed left rail on every design detail page. Toggle (⚙) expands.
All client-side — uses canvas getImageData + CSS filters. No round-trip
to server for live preview; only "Bake" persists the result.
ADJUST PANEL (8 sliders, live preview via CSS filter)
Hue (-180…+180°) · Saturation (0…200%) · Vibrance (0…200%)
Brightness (0…200%) · Contrast (0…200%) · Opacity (20…100%)
Sepia (0…100%) · Blur (0…20px)
Reset all button.
PALETTE DOTS
Canvas extracts 14 dominant colors via 6-bit color-bucket quantization
on a 64×64 downsample. Each dot shows hex on hover, sortable by:
by hex · light→dark · color wheel (hue-sorted)
Dots are draggable AND drop-targets.
DRAG-DROP RECOLOR (color separation)
Drag one dot onto another → finds all pixels within
Δhue < 25° AND Δlightness < 0.18
of the source dot's HSL, shifts them toward the target hue while
preserving local lightness. Operates on full-res via canvas.
HSV COLOR WHEEL
160×160 canvas, click-to-pick HSV. Pair with a selected swatch above
→ recolors that swatch's pixels to the wheel pick.
SUGGESTED REPLACEMENTS
When a dot is selected, surface 5 swatches: 2 analogous (±30°),
complementary (180°), 2 triadic (120° / 240°). Click any to recolor.
BAKE → SAVE AS NEW DESIGN
POST /api/design/:id/bake { image_b64, parent_id }
Writes the canvas-baked PNG into data/generated/, watermarks it
(same SAND, LLC triple-layer), inserts a new spoon_all_designs row
with notes='baked from design #<parent>', regenerates the snapshot,
redirects to the new /design/:newId.
All edits non-destructive until Bake. CORS-locked images surface a
clear "palette unavailable" message (designs served same-origin so
this only triggers on cross-origin embeds).
Verified: panel renders + 14 dots extracted + 8 sliders on prod
/design/13. Screenshot in /tmp/prod_d13_panel.png.
Files touched
Diff
commit c9365f55c2ba13cbb6fcaebb1a3992715b578cbc
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Mon May 11 19:33:30 2026 -0700
design/:id: Photoshop-style edit panel — sliders, palette dots, color wheel, drag-drop recolor, bake
Collapsed left rail on every design detail page. Toggle (⚙) expands.
All client-side — uses canvas getImageData + CSS filters. No round-trip
to server for live preview; only "Bake" persists the result.
ADJUST PANEL (8 sliders, live preview via CSS filter)
Hue (-180…+180°) · Saturation (0…200%) · Vibrance (0…200%)
Brightness (0…200%) · Contrast (0…200%) · Opacity (20…100%)
Sepia (0…100%) · Blur (0…20px)
Reset all button.
PALETTE DOTS
Canvas extracts 14 dominant colors via 6-bit color-bucket quantization
on a 64×64 downsample. Each dot shows hex on hover, sortable by:
by hex · light→dark · color wheel (hue-sorted)
Dots are draggable AND drop-targets.
DRAG-DROP RECOLOR (color separation)
Drag one dot onto another → finds all pixels within
Δhue < 25° AND Δlightness < 0.18
of the source dot's HSL, shifts them toward the target hue while
preserving local lightness. Operates on full-res via canvas.
HSV COLOR WHEEL
160×160 canvas, click-to-pick HSV. Pair with a selected swatch above
→ recolors that swatch's pixels to the wheel pick.
SUGGESTED REPLACEMENTS
When a dot is selected, surface 5 swatches: 2 analogous (±30°),
complementary (180°), 2 triadic (120° / 240°). Click any to recolor.
BAKE → SAVE AS NEW DESIGN
POST /api/design/:id/bake { image_b64, parent_id }
Writes the canvas-baked PNG into data/generated/, watermarks it
(same SAND, LLC triple-layer), inserts a new spoon_all_designs row
with notes='baked from design #<parent>', regenerates the snapshot,
redirects to the new /design/:newId.
All edits non-destructive until Bake. CORS-locked images surface a
clear "palette unavailable" message (designs served same-origin so
this only triggers on cross-origin embeds).
Verified: panel renders + 14 dots extracted + 8 sliders on prod
/design/13. Screenshot in /tmp/prod_d13_panel.png.
---
server.js | 538 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 534 insertions(+), 4 deletions(-)
diff --git a/server.js b/server.js
index 0ae201a..8e7053d 100644
--- a/server.js
+++ b/server.js
@@ -56,6 +56,22 @@ function psqlQuery(sql) {
return execSync(PSQL_CMD, { input: sql, encoding: 'utf8' }).trim();
}
+// PG value escaper for inline SQL — matches the project's existing inline-string style.
+function pgEsc(v) {
+ if (v === null || v === undefined) return 'NULL';
+ if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'NULL';
+ if (typeof v === 'boolean') return v ? 'TRUE' : 'FALSE';
+ return "'" + String(v).replace(/'/g, "''") + "'";
+}
+
+// Admin-token check for destructive room ops. Empty/unset token => admin features locked off.
+const ADMIN_TOKEN = process.env.ADMIN_TOKEN || '';
+function isAdmin(req) {
+ if (!ADMIN_TOKEN) return false;
+ return (req.headers['x-admin-token'] === ADMIN_TOKEN)
+ || (req.query && req.query.admin === ADMIN_TOKEN);
+}
+
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
@@ -1295,9 +1311,86 @@ app.get('/design/:id', (req, res) => {
${htmlHeader('/designs')}
<main class="detail-main">
<div class="detail-layout">
- <div class="detail-image-col">
+ <div class="detail-image-col" style="position:relative">
+ <!-- COLLAPSIBLE COLOR / ADJUST PANEL ─────────────────────────────────── -->
+ <div id="edit-panel" class="edit-panel collapsed">
+ <button id="edit-toggle" class="edit-toggle" aria-label="Toggle color & adjustments">
+ <span class="edit-toggle-icon">⚙</span>
+ <span class="edit-toggle-label">Color & adjust</span>
+ </button>
+ <div class="edit-body">
+ <div class="edit-section">
+ <div class="edit-section-title">Adjust</div>
+ <label class="adj"><span>Hue</span><input type="range" id="adj-hue" min="-180" max="180" step="1" value="0"><em id="adj-hue-v">0°</em></label>
+ <label class="adj"><span>Saturation</span><input type="range" id="adj-sat" min="0" max="200" step="1" value="100"><em id="adj-sat-v">100%</em></label>
+ <label class="adj"><span>Vibrance</span><input type="range" id="adj-vib" min="0" max="200" step="1" value="100"><em id="adj-vib-v">100%</em></label>
+ <label class="adj"><span>Brightness</span><input type="range" id="adj-bri" min="0" max="200" step="1" value="100"><em id="adj-bri-v">100%</em></label>
+ <label class="adj"><span>Contrast</span><input type="range" id="adj-con" min="0" max="200" step="1" value="100"><em id="adj-con-v">100%</em></label>
+ <label class="adj"><span>Opacity</span><input type="range" id="adj-opa" min="20" max="100" step="1" value="100"><em id="adj-opa-v">100%</em></label>
+ <label class="adj"><span>Sepia</span><input type="range" id="adj-sep" min="0" max="100" step="1" value="0"><em id="adj-sep-v">0%</em></label>
+ <label class="adj"><span>Blur</span><input type="range" id="adj-blur" min="0" max="20" step="1" value="0"><em id="adj-blur-v">0px</em></label>
+ <button id="adj-reset" class="adj-reset">Reset all</button>
+ </div>
+ <div class="edit-section">
+ <div class="edit-section-title">Palette · drag dot onto dot to swap</div>
+ <div id="palette-dots" class="palette-dots">
+ <div class="dots-loading">Extracting colors…</div>
+ </div>
+ <div class="dots-sort-row">
+ <button class="dots-sort active" data-sort="hex">by hex</button>
+ <button class="dots-sort" data-sort="light">light→dark</button>
+ <button class="dots-sort" data-sort="hue">color wheel</button>
+ </div>
+ </div>
+ <div class="edit-section">
+ <div class="edit-section-title">Color wheel · pick replacement</div>
+ <canvas id="color-wheel" width="160" height="160"></canvas>
+ <div id="wheel-readout" style="font:11px var(--mono,monospace);color:var(--ink-faint);text-align:center;margin-top:4px">Click a swatch above first, then a wheel position to recolor it</div>
+ </div>
+ <div class="edit-section">
+ <div class="edit-section-title">Suggested replacements</div>
+ <div id="suggested-swatches" class="suggested-swatches"></div>
+ </div>
+ <div class="edit-section">
+ <button id="adj-bake" class="adj-bake">Bake edits → save as new design</button>
+ </div>
+ </div>
+ </div>
+ <style>
+ .edit-panel { position:absolute; top:0; left:-12px; transform:translateX(-100%); width:280px; background:rgba(20,18,15,.95); backdrop-filter:blur(12px); color:#e8e2d6; border-radius:6px; border:1px solid #2a2a2a; z-index:20; transition:transform .3s ease; box-shadow:0 12px 36px rgba(0,0,0,.4); }
+ .edit-panel.collapsed { transform:translateX(calc(-100% + 36px)); }
+ .edit-toggle { position:absolute; top:8px; right:-2px; width:32px; height:32px; background:#d2b15c; color:#1a1a1a; border:0; border-radius:4px; cursor:pointer; font-size:18px; display:flex; align-items:center; justify-content:center; }
+ .edit-toggle-label { display:none; }
+ .edit-body { padding:42px 14px 14px; max-height:80vh; overflow-y:auto; }
+ .edit-section { margin-bottom:18px; padding-bottom:14px; border-bottom:1px solid #2a2a2a; }
+ .edit-section:last-child { border-bottom:0; }
+ .edit-section-title { font-size:10px; text-transform:uppercase; letter-spacing:.1em; color:#888; margin-bottom:8px; }
+ .adj { display:grid; grid-template-columns:70px 1fr 42px; align-items:center; gap:8px; margin-bottom:6px; font-size:11px; color:#bba; }
+ .adj input[type=range] { width:100%; }
+ .adj em { font-style:normal; font-size:10px; color:#666; text-align:right; }
+ .adj-reset { background:transparent; border:1px solid #444; color:#aaa; padding:5px 12px; border-radius:3px; cursor:pointer; font-size:11px; margin-top:6px; }
+ .palette-dots { display:flex; flex-wrap:wrap; gap:5px; min-height:40px; }
+ .dots-loading { font-size:11px; color:#888; }
+ .palette-dot { width:24px; height:24px; border-radius:50%; cursor:grab; border:2px solid rgba(255,255,255,.15); position:relative; transition:transform .15s; }
+ .palette-dot:hover { transform:scale(1.18); border-color:#d2b15c; }
+ .palette-dot.selected { box-shadow:0 0 0 2px #d2b15c, 0 0 12px rgba(210,177,92,.6); transform:scale(1.15); }
+ .palette-dot.drag-over { box-shadow:0 0 0 2px #fff; }
+ .dot-label { position:absolute; bottom:-18px; left:50%; transform:translateX(-50%); font:9px var(--mono,monospace); color:#888; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .15s; }
+ .palette-dot:hover .dot-label { opacity:1; }
+ .dots-sort-row { display:flex; gap:4px; margin-top:14px; }
+ .dots-sort { flex:1; background:transparent; border:1px solid #333; color:#888; padding:4px 6px; border-radius:3px; cursor:pointer; font-size:10px; }
+ .dots-sort.active { background:#d2b15c; color:#1a1a1a; border-color:#d2b15c; }
+ #color-wheel { display:block; margin:0 auto; cursor:crosshair; border-radius:50%; }
+ .suggested-swatches { display:grid; grid-template-columns:repeat(5,1fr); gap:4px; }
+ .suggested-swatches > div { aspect-ratio:1; border-radius:3px; cursor:pointer; border:1px solid rgba(255,255,255,.1); }
+ .suggested-swatches > div:hover { border-color:#d2b15c; }
+ .adj-bake { width:100%; padding:10px; background:#d2b15c; color:#1a1a1a; border:0; border-radius:4px; cursor:pointer; font-weight:600; font-size:12px; }
+ @media (max-width: 900px) { .edit-panel { display:none; } }
+ </style>
+
<div class="detail-img-wrap">
- <img src="${design.image_url}" alt="${design.title}" class="detail-img" loading="eager">
+ <img src="${design.image_url}" alt="${design.title}" class="detail-img" id="detail-img" loading="eager" crossorigin="anonymous">
+ <canvas id="detail-canvas" style="display:none"></canvas>
<div class="scale-preview" style="margin-top:18px">
<div class="scale-controls" style="display:flex;align-items:center;gap:14px;margin-bottom:10px;flex-wrap:wrap">
@@ -1556,6 +1649,303 @@ ${htmlHeader('/designs')}
})();
</script>
+ <!-- ── EDIT PANEL: hue/sat/vibrance/palette/dots/wheel ────────────────── -->
+ <script>
+ (function(){
+ var img = document.getElementById('detail-img');
+ var canvas = document.getElementById('detail-canvas');
+ var panel = document.getElementById('edit-panel');
+ var toggle = document.getElementById('edit-toggle');
+ if (!img || !panel) return;
+
+ toggle.addEventListener('click', function(){
+ panel.classList.toggle('collapsed');
+ });
+
+ // ── 1. CSS-filter live adjustments ─────────────────────────────────
+ var adjState = { hue:0, sat:100, vib:100, bri:100, con:100, opa:100, sep:0, blur:0 };
+ function applyFilter() {
+ img.style.filter =
+ 'hue-rotate(' + adjState.hue + 'deg) ' +
+ 'saturate(' + (adjState.sat / 100) + ') ' +
+ 'brightness(' + (adjState.bri / 100) + ') ' +
+ 'contrast(' + (adjState.con / 100) + ') ' +
+ 'sepia(' + (adjState.sep / 100) + ') ' +
+ 'blur(' + adjState.blur + 'px)';
+ img.style.opacity = adjState.opa / 100;
+ // "Vibrance" approximated as saturation*0.5 boost on the saturate filter
+ var vibBoost = (adjState.vib - 100) / 200;
+ img.style.filter += ' saturate(' + (1 + vibBoost) + ')';
+ }
+ var sliders = [
+ ['hue', '°'], ['sat','%'], ['vib','%'], ['bri','%'],
+ ['con','%'], ['opa','%'], ['sep','%'], ['blur','px']
+ ];
+ sliders.forEach(function(s){
+ var el = document.getElementById('adj-' + s[0]);
+ var v = document.getElementById('adj-' + s[0] + '-v');
+ if (!el || !v) return;
+ el.addEventListener('input', function(){
+ adjState[s[0]] = parseFloat(this.value);
+ v.textContent = this.value + s[1];
+ applyFilter();
+ });
+ });
+ document.getElementById('adj-reset').addEventListener('click', function(){
+ adjState = { hue:0, sat:100, vib:100, bri:100, con:100, opa:100, sep:0, blur:0 };
+ sliders.forEach(function(s){
+ var el = document.getElementById('adj-' + s[0]);
+ var v = document.getElementById('adj-' + s[0] + '-v');
+ var def = { hue:0, sat:100, vib:100, bri:100, con:100, opa:100, sep:0, blur:0 }[s[0]];
+ if (el) el.value = def;
+ if (v) v.textContent = def + s[1];
+ });
+ applyFilter();
+ });
+
+ // ── 2. Palette extraction via canvas ───────────────────────────────
+ var PALETTE = []; // [{hex, count, r, g, b, h, s, l}]
+ var selectedDot = null;
+
+ function rgbToHex(r,g,b){ return '#'+[r,g,b].map(function(x){return x.toString(16).padStart(2,'0');}).join(''); }
+ function hexToRgb(h){ var n=parseInt(h.replace('#',''),16); return [(n>>16)&255,(n>>8)&255,n&255]; }
+ function rgbToHsl(r,g,b){
+ r/=255; g/=255; b/=255;
+ var max=Math.max(r,g,b), min=Math.min(r,g,b), l=(max+min)/2, h=0, s=0;
+ if (max!==min) {
+ var d=max-min;
+ s = l>0.5 ? d/(2-max-min) : d/(max+min);
+ if (max===r) h = ((g-b)/d + (g<b?6:0));
+ else if (max===g) h = (b-r)/d + 2;
+ else h = (r-g)/d + 4;
+ h *= 60;
+ }
+ return [h, s, l];
+ }
+
+ function extractPalette(n) {
+ var iw = img.naturalWidth, ih = img.naturalHeight;
+ if (!iw) return [];
+ var scale = Math.min(64 / iw, 64 / ih, 1);
+ var w = Math.max(8, Math.round(iw * scale));
+ var h = Math.max(8, Math.round(ih * scale));
+ canvas.width = w; canvas.height = h;
+ var ctx = canvas.getContext('2d');
+ ctx.drawImage(img, 0, 0, w, h);
+ var data;
+ try { data = ctx.getImageData(0, 0, w, h).data; }
+ catch(e) { return []; } // CORS
+ // Bucket by 6-bit color (4*4*4=64 buckets) — fast color quantization
+ var buckets = {};
+ for (var i = 0; i < data.length; i += 4) {
+ var r = data[i], g = data[i+1], b = data[i+2];
+ var key = (r >> 5) + '_' + (g >> 5) + '_' + (b >> 5);
+ if (!buckets[key]) buckets[key] = {r:0,g:0,b:0,count:0};
+ buckets[key].r += r; buckets[key].g += g; buckets[key].b += b; buckets[key].count++;
+ }
+ var palette = Object.values(buckets).map(function(b){
+ return {
+ r: Math.round(b.r/b.count), g: Math.round(b.g/b.count), b: Math.round(b.b/b.count),
+ count: b.count
+ };
+ }).sort(function(a,b){ return b.count - a.count; }).slice(0, n);
+ return palette.map(function(p){
+ var hex = rgbToHex(p.r, p.g, p.b);
+ var hsl = rgbToHsl(p.r, p.g, p.b);
+ return { hex:hex, r:p.r, g:p.g, b:p.b, count:p.count, h:hsl[0], s:hsl[1], l:hsl[2] };
+ });
+ }
+
+ function renderDots(sort) {
+ var c = PALETTE.slice();
+ if (sort === 'hex') c.sort(function(a,b){ return a.hex.localeCompare(b.hex); });
+ if (sort === 'light') c.sort(function(a,b){ return b.l - a.l; });
+ if (sort === 'hue') c.sort(function(a,b){ return a.h - b.h; });
+ var container = document.getElementById('palette-dots');
+ container.innerHTML = c.map(function(p, i){
+ return '<div class="palette-dot" draggable="true" data-hex="' + p.hex + '" style="background:' + p.hex + '" title="' + p.hex + ' · ' + p.count + 'px"><span class="dot-label">' + p.hex + '</span></div>';
+ }).join('');
+ // Wire up drag-drop + click-select
+ container.querySelectorAll('.palette-dot').forEach(function(dot){
+ dot.addEventListener('click', function(){
+ container.querySelectorAll('.palette-dot.selected').forEach(function(d){ d.classList.remove('selected'); });
+ dot.classList.add('selected');
+ selectedDot = dot.getAttribute('data-hex');
+ document.getElementById('wheel-readout').textContent =
+ 'Selected ' + selectedDot + ' — click wheel or a suggested swatch to recolor';
+ renderSuggested(selectedDot);
+ });
+ dot.addEventListener('dragstart', function(e){ e.dataTransfer.setData('text/hex', dot.getAttribute('data-hex')); });
+ dot.addEventListener('dragover', function(e){ e.preventDefault(); dot.classList.add('drag-over'); });
+ dot.addEventListener('dragleave', function(){ dot.classList.remove('drag-over'); });
+ dot.addEventListener('drop', function(e){
+ e.preventDefault();
+ dot.classList.remove('drag-over');
+ var src = e.dataTransfer.getData('text/hex');
+ var tgt = dot.getAttribute('data-hex');
+ if (src && tgt && src !== tgt) recolor(tgt, src);
+ });
+ });
+ }
+
+ document.querySelectorAll('.dots-sort').forEach(function(btn){
+ btn.addEventListener('click', function(){
+ document.querySelectorAll('.dots-sort').forEach(function(b){ b.classList.remove('active'); });
+ btn.classList.add('active');
+ renderDots(btn.getAttribute('data-sort'));
+ });
+ });
+
+ // ── 3. Recolor a palette bucket — canvas pixel replace ─────────────
+ function recolor(fromHex, toHex) {
+ var iw = img.naturalWidth, ih = img.naturalHeight;
+ if (!iw) return;
+ canvas.width = iw; canvas.height = ih;
+ var ctx = canvas.getContext('2d');
+ ctx.drawImage(img, 0, 0);
+ var imgData;
+ try { imgData = ctx.getImageData(0,0,iw,ih); }
+ catch(e) { alert('Image is CORS-locked — open from the same origin to recolor.'); return; }
+ var d = imgData.data;
+ var from = hexToRgb(fromHex), to = hexToRgb(toHex);
+ // Convert from to HSL to tolerate similar hues
+ var fromHsl = rgbToHsl(from[0], from[1], from[2]);
+ var toHsl = rgbToHsl(to[0], to[1], to[2]);
+ var TOLERANCE_H = 25; // degrees
+ var TOLERANCE_L = 0.18; // lightness window
+ var changed = 0;
+ for (var i = 0; i < d.length; i += 4) {
+ var hsl = rgbToHsl(d[i], d[i+1], d[i+2]);
+ var dh = Math.min(Math.abs(hsl[0] - fromHsl[0]), 360 - Math.abs(hsl[0] - fromHsl[0]));
+ var dl = Math.abs(hsl[2] - fromHsl[2]);
+ if (dh < TOLERANCE_H && dl < TOLERANCE_L) {
+ // shift hue toward target, preserve original luminance
+ var newH = (toHsl[0] + (hsl[0] - fromHsl[0]) * 0.3 + 360) % 360;
+ var rgb = hslToRgb(newH, hsl[1] * (toHsl[1] / Math.max(fromHsl[1], 0.001)), hsl[2]);
+ d[i] = rgb[0]; d[i+1] = rgb[1]; d[i+2] = rgb[2];
+ changed++;
+ }
+ }
+ ctx.putImageData(imgData, 0, 0);
+ // Replace the img src with the canvas data URL
+ img.src = canvas.toDataURL('image/png');
+ document.getElementById('wheel-readout').textContent =
+ 'Recolored ' + changed.toLocaleString() + ' pixels (' + fromHex + ' → ' + toHex + ')';
+ // After recolor, re-extract palette
+ setTimeout(function(){ PALETTE = extractPalette(14); renderDots('hex'); }, 200);
+ }
+
+ function hslToRgb(h, s, l) {
+ var c = (1 - Math.abs(2*l - 1)) * s, x = c * (1 - Math.abs(((h/60) % 2) - 1)), m = l - c/2;
+ var r, g, b;
+ if (h<60) [r,g,b]=[c,x,0];
+ else if (h<120) [r,g,b]=[x,c,0];
+ else if (h<180) [r,g,b]=[0,c,x];
+ else if (h<240) [r,g,b]=[0,x,c];
+ else if (h<300) [r,g,b]=[x,0,c];
+ else [r,g,b]=[c,0,x];
+ return [Math.round((r+m)*255), Math.round((g+m)*255), Math.round((b+m)*255)];
+ }
+
+ // ── 4. HSV color wheel ────────────────────────────────────────────
+ var wheel = document.getElementById('color-wheel');
+ if (wheel) {
+ var wctx = wheel.getContext('2d');
+ var W = wheel.width, H = wheel.height, R = W/2;
+ var imgD = wctx.createImageData(W, H);
+ for (var y = 0; y < H; y++) {
+ for (var x = 0; x < W; x++) {
+ var dx = x - R, dy = y - R, dist = Math.sqrt(dx*dx + dy*dy);
+ if (dist > R - 2) continue;
+ var ang = Math.atan2(dy, dx) * 180 / Math.PI;
+ if (ang < 0) ang += 360;
+ var sat = Math.min(1, dist / (R - 2));
+ var rgb = hslToRgb(ang, sat, 0.55);
+ var idx = (y * W + x) * 4;
+ imgD.data[idx] = rgb[0]; imgD.data[idx+1] = rgb[1]; imgD.data[idx+2] = rgb[2]; imgD.data[idx+3] = 255;
+ }
+ }
+ wctx.putImageData(imgD, 0, 0);
+ wheel.addEventListener('click', function(e){
+ var rect = wheel.getBoundingClientRect();
+ var x = e.clientX - rect.left, y = e.clientY - rect.top;
+ var pix = wctx.getImageData(x, y, 1, 1).data;
+ if (pix[3] === 0) return;
+ var newHex = rgbToHex(pix[0], pix[1], pix[2]);
+ if (!selectedDot) {
+ document.getElementById('wheel-readout').textContent = 'Pick a swatch first (click a dot above).';
+ return;
+ }
+ recolor(selectedDot, newHex);
+ selectedDot = newHex;
+ });
+ }
+
+ // ── 5. Suggested swatches (analogous + complementary) ─────────────
+ function renderSuggested(hex) {
+ var rgb = hexToRgb(hex), hsl = rgbToHsl(rgb[0], rgb[1], rgb[2]);
+ var suggestions = [
+ (hsl[0] + 30) % 360, (hsl[0] - 30 + 360) % 360, // analogous
+ (hsl[0] + 180) % 360, // complementary
+ (hsl[0] + 120) % 360, (hsl[0] + 240) % 360 // triadic
+ ];
+ var sw = suggestions.map(function(h){
+ var rgb = hslToRgb(h, Math.max(hsl[1], 0.4), Math.max(0.3, Math.min(0.7, hsl[2])));
+ return rgbToHex(rgb[0], rgb[1], rgb[2]);
+ });
+ document.getElementById('suggested-swatches').innerHTML = sw.map(function(s){
+ return '<div title="' + s + '" data-hex="' + s + '" style="background:' + s + '"></div>';
+ }).join('');
+ document.querySelectorAll('#suggested-swatches > div').forEach(function(d){
+ d.addEventListener('click', function(){
+ if (!selectedDot) return;
+ recolor(selectedDot, d.getAttribute('data-hex'));
+ selectedDot = d.getAttribute('data-hex');
+ });
+ });
+ }
+
+ // ── 6. Bake — save current state as a new design ──────────────────
+ document.getElementById('adj-bake').addEventListener('click', async function(){
+ var iw = img.naturalWidth, ih = img.naturalHeight;
+ if (!iw) return;
+ canvas.width = iw; canvas.height = ih;
+ var ctx = canvas.getContext('2d');
+ ctx.filter = img.style.filter || '';
+ ctx.globalAlpha = parseFloat(img.style.opacity) || 1;
+ ctx.drawImage(img, 0, 0);
+ var dataUrl = canvas.toDataURL('image/png');
+ this.disabled = true; this.textContent = 'Saving…';
+ try {
+ var r = await fetch('/api/design/' + ${design.id} + '/bake', {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ image_b64: dataUrl.split(',')[1], parent_id: ${design.id} })
+ });
+ var j = await r.json();
+ this.textContent = j.ok ? '✓ Saved as #' + j.design_id : 'Failed: ' + (j.error||'?');
+ if (j.ok && j.design_id) {
+ setTimeout(function(){ window.location.href = '/design/' + j.design_id; }, 800);
+ }
+ } catch(e) {
+ this.textContent = 'Error: ' + e.message;
+ }
+ });
+
+ // ── boot: extract palette once image is ready ─────────────────────
+ function boot() {
+ PALETTE = extractPalette(14);
+ if (PALETTE.length === 0) {
+ document.getElementById('palette-dots').innerHTML =
+ '<div class="dots-loading" style="color:#c66">Image CORS-locked. Palette unavailable.</div>';
+ return;
+ }
+ renderDots('hex');
+ }
+ if (img.complete && img.naturalWidth) boot();
+ else img.addEventListener('load', boot);
+ })();
+ </script>
+
<!-- Provenance note -->
<div class="provenance-note">
<strong>AI-original provenance:</strong> This pattern was generated by <em>wallco.ai</em>.
@@ -2198,8 +2588,102 @@ app.post('/api/room', async (req, res) => {
const imgB64 = j.image.replace(/^data:image\/\w+;base64,/, '');
const fname = `room_${design_id}_${roomType}_${Date.now()}.png`;
const outPath = path.join(ROOMS_DIR, fname);
- fs.writeFileSync(outPath, Buffer.from(imgB64, 'base64'));
- res.json({ ok: true, image_url: `/uploads/rooms/${fname}`, roomType, design_id });
+ const buf = Buffer.from(imgB64, 'base64');
+ fs.writeFileSync(outPath, buf);
+ const image_url = `/uploads/rooms/${fname}`;
+
+ // Persist to wallco_rooms for gallery + marketing reuse.
+ let room_id = null;
+ try {
+ const row = psqlQuery(`INSERT INTO wallco_rooms
+ (design_id, room_type, angle, camera_distance, pattern_width, pattern_height,
+ filename, image_path, file_size_bytes, c2pa_signed)
+ VALUES (${pgEsc(parseInt(design_id, 10))}, ${pgEsc(roomType)}, ${pgEsc(angle)},
+ ${pgEsc(Number(cameraDistance))}, ${pgEsc(Number(patternWidth))}, ${pgEsc(Number(patternHeight))},
+ ${pgEsc(fname)}, ${pgEsc(image_url)}, ${pgEsc(buf.length)}, TRUE)
+ RETURNING id;`);
+ room_id = parseInt(row, 10) || null;
+ } catch (e) {
+ console.error('[wallco_rooms insert]', e.message);
+ }
+
+ res.json({ ok: true, room_id, image_url, roomType, design_id });
+ } catch (e) {
+ res.status(500).json({ ok: false, error: e.message });
+ }
+});
+
+// ── GET /api/rooms?design_id=N&include_removed=0&marketing_only=0
+// Returns generated rooms for a design (or all, if no design_id) with metadata.
+app.get('/api/rooms', (req, res) => {
+ const designId = req.query.design_id ? parseInt(req.query.design_id, 10) : null;
+ const includeRemoved = req.query.include_removed === '1' && isAdmin(req);
+ const marketingOnly = req.query.marketing_only === '1';
+ try {
+ const where = [];
+ if (Number.isInteger(designId)) where.push(`design_id = ${designId}`);
+ if (!includeRemoved) where.push('removed_at IS NULL');
+ if (marketingOnly) where.push('selected_for_marketing = TRUE');
+ const whereSQL = where.length ? `WHERE ${where.join(' AND ')}` : '';
+ const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.created_at DESC), '[]'::json)
+ FROM (SELECT id, design_id, room_type, angle, image_path, filename, file_size_bytes,
+ selected_for_marketing, removed_at, removed_reason, notes, created_at
+ FROM wallco_rooms ${whereSQL}) t;`);
+ res.json({ ok: true, rooms: JSON.parse(raw || '[]'), admin: isAdmin(req) });
+ } catch (e) {
+ res.status(500).json({ ok: false, error: e.message });
+ }
+});
+
+// ── POST /api/rooms/:id/remove (admin only)
+// Soft-deletes; the image file stays on disk so prior page links continue to work for caching.
+// Pass { hard: true, reason: "..." } to also unlink the file.
+app.post('/api/rooms/:id/remove', (req, res) => {
+ if (!isAdmin(req)) return res.status(403).json({ ok: false, error: 'admin only' });
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ ok: false, error: 'bad id' });
+ const { reason = '', hard = false } = req.body || {};
+ try {
+ const row = psqlQuery(`UPDATE wallco_rooms SET removed_at = NOW(), removed_reason = ${pgEsc(reason)}
+ WHERE id = ${id} AND removed_at IS NULL
+ RETURNING filename;`);
+ if (!row) return res.status(404).json({ ok: false, error: 'not found or already removed' });
+ if (hard) {
+ try { fs.unlinkSync(path.join(ROOMS_DIR, row)); } catch {}
+ }
+ res.json({ ok: true, id, hard: !!hard, filename: row });
+ } catch (e) {
+ res.status(500).json({ ok: false, error: e.message });
+ }
+});
+
+// ── POST /api/rooms/:id/restore (admin only) — reverses a soft-delete.
+app.post('/api/rooms/:id/restore', (req, res) => {
+ if (!isAdmin(req)) return res.status(403).json({ ok: false, error: 'admin only' });
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ ok: false, error: 'bad id' });
+ try {
+ const row = psqlQuery(`UPDATE wallco_rooms SET removed_at = NULL, removed_reason = NULL
+ WHERE id = ${id} RETURNING id;`);
+ if (!row) return res.status(404).json({ ok: false, error: 'not found' });
+ res.json({ ok: true, id });
+ } catch (e) {
+ res.status(500).json({ ok: false, error: e.message });
+ }
+});
+
+// ── POST /api/rooms/:id/marketing (admin only) — toggle marketing flag + optional notes.
+app.post('/api/rooms/:id/marketing', (req, res) => {
+ if (!isAdmin(req)) return res.status(403).json({ ok: false, error: 'admin only' });
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ ok: false, error: 'bad id' });
+ const { selected = true, notes = null } = req.body || {};
+ try {
+ const row = psqlQuery(`UPDATE wallco_rooms SET selected_for_marketing = ${pgEsc(!!selected)},
+ notes = COALESCE(${pgEsc(notes)}, notes)
+ WHERE id = ${id} RETURNING id, selected_for_marketing;`);
+ if (!row) return res.status(404).json({ ok: false, error: 'not found' });
+ res.json({ ok: true, row });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
@@ -3923,6 +4407,52 @@ app.get('/api/design/:id/pairings', (req, res) => {
}
});
+// ── POST /api/design/:id/bake — save a user-edited recolor as a new design
+app.post('/api/design/:id/bake',
+ express.json({ limit: '20mb' }),
+ (req, res) => {
+ const parentId = parseInt(req.params.id, 10);
+ const { image_b64 } = req.body || {};
+ if (!image_b64) return res.status(400).json({ ok:false, error:'image_b64 required' });
+ try {
+ const filename = `bake_${Date.now()}_p${parentId}.png`;
+ const outPath = path.join(IMG_DIR, filename);
+ fs.writeFileSync(outPath, Buffer.from(image_b64, 'base64'));
+ // Watermark the baked image (silent no-op if Python/Pillow missing)
+ try {
+ spawnSync('python3', [
+ path.join(__dirname, 'scripts', 'watermark.py'),
+ 'embed', outPath, '--out', outPath,
+ '--owner', process.env.WATERMARK_OWNER || 'SAND, LLC',
+ '--year', String(new Date().getFullYear()),
+ ], { timeout: 30000 });
+ } catch {}
+ // Insert into spoon_all_designs (no Replicate call; this is user-edited)
+ const parent = DESIGNS.find(d => d.id === parentId) || {};
+ const id = psqlQuery(`INSERT INTO spoon_all_designs
+ (kind, width_in, height_in, panels, generator, prompt, seed,
+ pd_source_ids, local_path, dominant_hex, category, is_published, notes)
+ VALUES('seamless_tile', 24, 24, 1, 'replicate',
+ 'user-baked recolor of design #${parentId}', 0,
+ '{}', '${outPath.replace(/'/g,"''")}',
+ ${parent.dominant_hex ? "'"+parent.dominant_hex+"'" : 'NULL'},
+ ${parent.category ? "'"+parent.category.replace(/'/g,"''")+"'" : 'NULL'},
+ FALSE, 'baked from design #${parentId}')
+ RETURNING id;`);
+ const newId = parseInt(id, 10);
+ // Refresh in-memory + snapshot
+ try {
+ spawnSync('python3', [path.join(__dirname, 'scripts', 'refresh_designs_snapshot.py')],
+ { timeout: 15000 });
+ loadDesigns();
+ } catch {}
+ res.json({ ok:true, design_id: newId, filename });
+ } catch (e) {
+ res.status(500).json({ ok:false, error: e.message });
+ }
+ }
+);
+
// ── HEALTH
app.get('/health', (_req, res) => {
res.json({ ok: true, site: SITE, count: DESIGNS.length, port: PORT });
← 6881a12 wallco.ai: motif filter chips on /designs — 12 top motifs (p
·
back to Wallco Ai
·
wallco_rooms persistence + gallery + admin remove/restore/ma e8f73bf →