← back to Wallco Ai
colorways: admin Publish badge on chip + new_design_published in /api/colorways
3bcf7db1af481faa982d937fe528401728be10e7 · 2026-05-29 17:52:16 -0700 · Steve
Round 4 UI work. /dtd verdicts (Codex+Claude; qwen err on both panels):
- badge placement: A (inline overlay, top-right corner of chip)
- state source: A (LEFT JOIN spoon_all_designs in GET /api/colorways)
Backend:
- GET /api/colorways now LEFT JOINs spoon_all_designs on new_design_id and
returns new_design_published per colorway, plus top-level isAdmin so the
frontend can gate the badge without a second auth probe.
- No change to POST publish/unpublish endpoints (Round 3, a730f34b).
Frontend (public/js/color-dots.js):
- chip(cw) now wraps the <a> in position:relative <span>.
- When window.__COLORWAYS_IS_ADMIN AND cw.new_design_id, render a 16px eye
badge at top-right (green=published, gray=unpublished). Click flips state
via POST /api/colorways/<id>/(un)publish; toast on success/failure;
preventDefault+stopPropagation so it does not trigger the chip's
click-to-preview or dblclick-to-navigate handlers.
- load() reads j.isAdmin and stashes on window.__COLORWAYS_IS_ADMIN.
- Non-admins never see the badge (default false).
E2E verified on loopback: GET shows new_design_published toggles correctly
through publish/unpublish round-trip.
Files touched
M public/js/color-dots.jsM src/colorways.js
Diff
commit 3bcf7db1af481faa982d937fe528401728be10e7
Author: Steve <steve@designerwallcoverings.com>
Date: Fri May 29 17:52:16 2026 -0700
colorways: admin Publish badge on chip + new_design_published in /api/colorways
Round 4 UI work. /dtd verdicts (Codex+Claude; qwen err on both panels):
- badge placement: A (inline overlay, top-right corner of chip)
- state source: A (LEFT JOIN spoon_all_designs in GET /api/colorways)
Backend:
- GET /api/colorways now LEFT JOINs spoon_all_designs on new_design_id and
returns new_design_published per colorway, plus top-level isAdmin so the
frontend can gate the badge without a second auth probe.
- No change to POST publish/unpublish endpoints (Round 3, a730f34b).
Frontend (public/js/color-dots.js):
- chip(cw) now wraps the <a> in position:relative <span>.
- When window.__COLORWAYS_IS_ADMIN AND cw.new_design_id, render a 16px eye
badge at top-right (green=published, gray=unpublished). Click flips state
via POST /api/colorways/<id>/(un)publish; toast on success/failure;
preventDefault+stopPropagation so it does not trigger the chip's
click-to-preview or dblclick-to-navigate handlers.
- load() reads j.isAdmin and stashes on window.__COLORWAYS_IS_ADMIN.
- Non-admins never see the badge (default false).
E2E verified on loopback: GET shows new_design_published toggles correctly
through publish/unpublish round-trip.
---
public/js/color-dots.js | 69 ++++++++++++++++++++++++++++++++++++++++++++++++-
src/colorways.js | 21 +++++++++++----
2 files changed, 84 insertions(+), 6 deletions(-)
diff --git a/public/js/color-dots.js b/public/js/color-dots.js
index 7e60d59..3739c5b 100644
--- a/public/js/color-dots.js
+++ b/public/js/color-dots.js
@@ -206,6 +206,11 @@
// href on hover). Steve 2026-05-29: "click into it as a new product".
// Plain click stays in-page (instant preview + URL replace, no reload).
// Modifier/middle clicks fall through to browser-native open-in-tab.
+ // Wrapped in a position:relative <span> so the admin Publish badge can
+ // absolutely-position itself at the chip's top-right corner without
+ // breaking the existing 32x22 link layout.
+ var wrap = document.createElement('span');
+ wrap.style.cssText = 'position:relative;display:inline-block;line-height:0';
var b = document.createElement('a');
// PREFERRED — promoted colorway has its own spoon_all_designs row
// (server baked the PNG at save time). Clean product URL.
@@ -249,7 +254,64 @@
ev.stopPropagation();
location.href = b.href;
});
- return b;
+ wrap.appendChild(b);
+ // Admin Publish badge — only renders when (1) the caller is an admin
+ // (window.__COLORWAYS_IS_ADMIN, set by load() from /api/colorways.isAdmin)
+ // AND (2) the colorway has a promoted spoon_all_designs row
+ // (cw.new_design_id is set — ink_map-only saves have no row to publish).
+ // Eye icon (visible: green = published, gray = unpublished). Click flips
+ // is_published via POST /api/colorways/<id>/(un)publish (admin-gated
+ // backend, /dtd verdict 2026-05-29 — only explicit admin click publishes
+ // a colorway to the public catalog).
+ if (window.__COLORWAYS_IS_ADMIN && cw.new_design_id) {
+ var badge = document.createElement('button');
+ badge.type = 'button';
+ var pubState = !!cw.new_design_published;
+ function paint() {
+ badge.textContent = '👁';
+ badge.title = pubState
+ ? ('Published — click to unpublish #' + cw.new_design_id)
+ : ('Not published — click to publish #' + cw.new_design_id);
+ badge.style.cssText = 'position:absolute;top:-6px;right:-6px;z-index:2;' +
+ 'width:16px;height:16px;border-radius:50%;border:1.5px solid #fff;' +
+ 'padding:0;margin:0;cursor:pointer;font-size:9px;line-height:1;' +
+ 'display:flex;align-items:center;justify-content:center;' +
+ 'box-shadow:0 1px 3px rgba(0,0,0,.4);' +
+ (pubState ? 'background:#22a06b;color:#fff;' : 'background:#9aa0a6;color:#fff;');
+ }
+ paint();
+ badge.addEventListener('click', function (ev) {
+ // Prevent the click from bubbling to the chip (which would apply the
+ // colorway) and from triggering native navigation on the anchor.
+ ev.preventDefault();
+ ev.stopPropagation();
+ if (badge.disabled) return;
+ badge.disabled = true;
+ var action = pubState ? 'unpublish' : 'publish';
+ fetch('/api/colorways/' + cw.id + '/' + action, {
+ method: 'POST', credentials: 'same-origin'
+ })
+ .then(function (r) { return r.json(); })
+ .then(function (j) {
+ if (j && j.ok) {
+ pubState = !!j.is_published;
+ cw.new_design_published = pubState;
+ paint();
+ toast(host, pubState ? 'Published v' + cw.version : 'Unpublished v' + cw.version);
+ } else {
+ toast(host, (j && j.error) || (action + ' failed'));
+ }
+ })
+ .catch(function () { toast(host, action + ' failed'); })
+ .then(function () { badge.disabled = false; });
+ });
+ // Stop dblclick from bubbling so it doesn't navigate.
+ badge.addEventListener('dblclick', function (ev) {
+ ev.preventDefault(); ev.stopPropagation();
+ });
+ wrap.appendChild(badge);
+ }
+ return wrap;
}
// Auto-apply ?cw=<id> from URL on load — makes /design/<id>?cw=<cwid>
// behave like a distinct "new product" page that opens in that colorway.
@@ -265,6 +327,11 @@
fetch('/api/colorways?design_id=' + did, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (j) {
+ // /api/colorways now returns isAdmin (loopback OR dw_auth JWT OR
+ // ?admin=<token>). Stash on window so chip() can gate the Publish
+ // badge without re-probing auth. Defaults to false on non-admins so
+ // customer sessions never see the badge.
+ window.__COLORWAYS_IS_ADMIN = !!(j && j.isAdmin);
vers.innerHTML = '';
(j.colorways || []).forEach(function (cw) { vers.appendChild(chip(cw)); });
applyFromUrl(j.colorways);
diff --git a/src/colorways.js b/src/colorways.js
index 6602c55..b438c5c 100644
--- a/src/colorways.js
+++ b/src/colorways.js
@@ -235,22 +235,33 @@ RETURNING id;`).trim();
}
// GET /api/colorways?design_id=N — current user's exclusive colorways for a design.
+ // 2026-05-29: LEFT JOIN spoon_all_designs on new_design_id so the response
+ // carries new_design_published per row. Lets the admin Publish badge render
+ // its state in one round-trip instead of n+1 lazy fetches. /dtd verdict A.
+ // The JOIN is harmless for non-promoted rows (new_design_id IS NULL → field
+ // is null on the wire). Also exposes is_admin so the frontend can gate the
+ // badge without a second auth probe (admins always render isAdmin=true).
app.get('/api/colorways', (req, res) => {
const u = getEffectiveUser(req);
- if (!u) return res.json({ ok: true, authenticated: false, colorways: [] });
+ if (!u) return res.json({ ok: true, authenticated: false, isAdmin: 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, new_design_id FROM wallco_user_colorways
- WHERE user_id=${parseInt(u.id, 10)} AND design_id=${designId}) t;`);
+ SELECT c.id, c.name, c.ink_map, c.created_at, c.new_design_id,
+ s.is_published AS new_design_published
+ FROM wallco_user_colorways c
+ LEFT JOIN spoon_all_designs s ON s.id = c.new_design_id
+ WHERE c.user_id=${parseInt(u.id, 10)} AND c.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,
- new_design_id: r.new_design_id, version: i + 1
+ new_design_id: r.new_design_id,
+ new_design_published: r.new_design_published === true || r.new_design_published === 't',
+ version: i + 1
}));
- res.json({ ok: true, authenticated: true, colorways });
+ res.json({ ok: true, authenticated: true, isAdmin: isAdmin(req), colorways });
} catch (e) {
console.error('[colorways/list] failed:', e.message);
res.status(500).json({ ok: false, error: 'Lookup failed.' });
← a730f34 colorways: add admin Publish/Unpublish endpoints for promote
·
back to Wallco Ai
·
PDP: fall back to wallco_rooms table for room_mockups when d a049441 →