← back to Wallco Ai
remove broken /admin/fixes-feed viewer
5138c6ef97cf3ec9e47162fb61cc4a33274ee620 · 2026-05-24 22:04:05 -0700 · Steve Abrams
The before/after fixes-feed page at /admin/fixes-feed was broken. Deleted:
- GET /admin/fixes-feed route + GET /api/fixes-feed route from server.js
- public/admin/fixes-feed.html (file)
- Sidebar nav link in public/new.html
- Top-right nav link in public/admin/ghost-review.html
KEPT (the underlying data pipeline is still useful):
- appendFixEvent() helper — called from 8 fix routes (crop-fix,
regenerate-reverse, opacity-snap, magic-wand, smart-fix, replace-bg)
- data/fixes-feed.jsonl append-only audit log
- scripts/bulk-reject-auto-smart-fix.js reads the log directly from disk
Verified: GET /admin/fixes-feed → 404 after restart.
Files touched
D public/admin/fixes-feed.htmlM public/admin/ghost-review.htmlM public/new.htmlM server.js
Diff
commit 5138c6ef97cf3ec9e47162fb61cc4a33274ee620
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun May 24 22:04:05 2026 -0700
remove broken /admin/fixes-feed viewer
The before/after fixes-feed page at /admin/fixes-feed was broken. Deleted:
- GET /admin/fixes-feed route + GET /api/fixes-feed route from server.js
- public/admin/fixes-feed.html (file)
- Sidebar nav link in public/new.html
- Top-right nav link in public/admin/ghost-review.html
KEPT (the underlying data pipeline is still useful):
- appendFixEvent() helper — called from 8 fix routes (crop-fix,
regenerate-reverse, opacity-snap, magic-wand, smart-fix, replace-bg)
- data/fixes-feed.jsonl append-only audit log
- scripts/bulk-reject-auto-smart-fix.js reads the log directly from disk
Verified: GET /admin/fixes-feed → 404 after restart.
---
public/admin/fixes-feed.html | 296 -------------------------------
public/admin/ghost-review.html | 3 -
public/new.html | 1 -
server.js | 384 ++++++++++++++++++++++++++++++++++++++---
4 files changed, 356 insertions(+), 328 deletions(-)
diff --git a/public/admin/fixes-feed.html b/public/admin/fixes-feed.html
deleted file mode 100644
index cc8aac1..0000000
--- a/public/admin/fixes-feed.html
+++ /dev/null
@@ -1,296 +0,0 @@
-<!doctype html>
-<html lang="en">
-<head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width,initial-scale=1">
-<meta name="robots" content="noindex,nofollow">
-<title>Fixes Feed — wallco.ai admin</title>
-<style>
- :root {
- --ink:#1f1808; --line:#d8d0c0; --gold:#c9a14b;
- --bg:#fbf8f1; --card:#fff; --faint:#7a6e5a;
- --red:#b3261e; --green:#1f6a2c; --orange:#c0660b; --purple:#7a4ab8; --blue:#3b78d8;
- --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
- --serif:'Playfair Display','Didot',Georgia,serif;
- }
- * { box-sizing:border-box; }
- body { margin:0; background:var(--bg); color:var(--ink); font:14px var(--sans); }
- header {
- padding:14px 22px; border-bottom:1px solid var(--line); background:#fff;
- display:flex; gap:16px; align-items:baseline; position:sticky; top:0; z-index:5;
- }
- header h1 { font:400 22px/1.2 var(--serif); margin:0; }
- header .meta { color:var(--faint); font-size:12px; letter-spacing:.04em; }
- header .stats { margin-left:auto; display:flex; gap:18px; font:600 12px ui-monospace,Menlo,monospace; }
- header .stats b { color:var(--ink); }
- header .live {
- font:600 10.5px var(--sans); color:var(--green); letter-spacing:.1em;
- margin-left:auto; padding-left:16px;
- }
- header .live::before {
- content:'●'; margin-right:6px; animation:pulse 1.4s ease-in-out infinite;
- }
- @keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:.3; } }
-
- main { max-width:1500px; margin:0 auto; padding:18px; }
-
- .controls {
- display:flex; gap:14px; align-items:center; padding:10px 14px;
- background:#fff; border:1px solid var(--line); border-radius:8px;
- margin-bottom:14px; font:12px var(--sans); flex-wrap:wrap;
- }
- .controls label { font:600 10.5px var(--sans); letter-spacing:.16em;
- text-transform:uppercase; color:var(--faint); }
- .controls .filter button {
- border:1px solid var(--line); background:#fff; border-radius:14px;
- padding:5px 11px; cursor:pointer; font:600 11px var(--sans);
- color:var(--faint); letter-spacing:.04em; margin-right:6px;
- }
- .controls .filter button.active { background:var(--ink); color:#fff; border-color:var(--ink); }
- .controls .ledger {
- margin-left:auto; font:600 11px ui-monospace,Menlo,monospace; color:var(--faint);
- }
-
- .card {
- background:var(--card); border:1px solid var(--line); border-radius:10px;
- margin-bottom:18px; overflow:hidden;
- transition:box-shadow .15s;
- }
- .card:hover { box-shadow:0 6px 22px rgba(0,0,0,.06); }
- .card .ribbon {
- display:flex; gap:10px; align-items:center; padding:9px 14px;
- border-bottom:1px solid var(--line); background:#fcfaf4;
- font:11px var(--sans);
- }
- .card .kind-pill {
- font:700 10px var(--sans); letter-spacing:.08em;
- padding:3px 8px; border-radius:11px; color:#fff;
- text-transform:uppercase;
- }
- .card .kind-pill.k-crop-fix { background:var(--blue); }
- .card .kind-pill.k-crop-fix-remove { background:var(--red); }
- .card .kind-pill.k-regenerate-reverse { background:var(--purple); }
- .card .kind-pill.k-magic-wand { background:var(--green); }
- .card .ts { color:var(--faint); font-family:ui-monospace,Menlo,monospace; }
- .card .elapsed { color:var(--faint); margin-left:6px; }
- .card .links { margin-left:auto; display:flex; gap:8px; }
- .card .links a {
- color:var(--faint); text-decoration:none; font:600 11px var(--sans);
- padding:3px 8px; border:1px solid var(--line); border-radius:4px;
- }
- .card .links a:hover { background:var(--ink); color:#fff; border-color:var(--ink); }
-
- .pair {
- display:grid; grid-template-columns:1fr auto 1fr; gap:0;
- align-items:stretch;
- }
- .pair .side {
- padding:14px 18px; display:flex; flex-direction:column; gap:8px;
- min-width:0;
- }
- .pair .side.before { border-right:1px solid var(--line); background:#fdfbf5; }
- .pair .side .label {
- font:700 10px var(--sans); letter-spacing:.16em;
- text-transform:uppercase; color:var(--faint);
- display:flex; align-items:center; gap:8px;
- }
- .pair .side.before .label::before { content:'◀'; color:#b3261e; }
- .pair .side.after .label::before { content:'▶'; color:var(--green); }
- .pair .side .id-row {
- font:600 14px ui-monospace,Menlo,monospace; color:var(--ink);
- }
- .pair .img-frame {
- aspect-ratio:1; background:#f3eee2;
- border-radius:6px; overflow:hidden; position:relative;
- border:1px solid var(--line);
- }
- .pair .img-frame img {
- width:100%; height:100%; object-fit:contain; display:block;
- background:#000;
- }
- .pair .img-frame.before img { opacity:1; }
- .pair .arrow {
- display:flex; align-items:center; justify-content:center;
- width:62px; background:var(--bg);
- border-left:1px solid var(--line); border-right:1px solid var(--line);
- font:24px var(--sans); color:var(--faint);
- }
-
- .pair .side .meta-row {
- display:flex; flex-wrap:wrap; gap:6px 12px;
- font:11px ui-monospace,Menlo,monospace; color:var(--faint);
- }
- .pair .side .meta-row b { color:var(--ink); font-weight:600; }
-
- .empty {
- text-align:center; padding:80px 20px; color:var(--faint);
- font:14px var(--sans);
- }
- .empty h2 { font:400 28px var(--serif); color:var(--ink); margin-bottom:10px; }
- .empty code {
- background:#f3eee2; padding:2px 6px; border-radius:3px; font:12px ui-monospace,Menlo,monospace;
- }
-
- @media (max-width: 720px) {
- .pair { grid-template-columns:1fr; }
- .pair .side.before { border-right:none; border-bottom:1px solid var(--line); }
- .pair .arrow { width:auto; height:42px; border-left:none; border-right:none; border-top:1px solid var(--line); border-bottom:1px solid var(--line); }
- }
-</style>
-</head>
-<body>
-
-<header>
- <h1>Fixes Feed</h1>
- <span class="meta">every successful repair, newest first</span>
- <div class="stats">
- <span>FIX <b id="stat-fix">0</b></span>
- <span>REMOVE <b id="stat-remove">0</b></span>
- <span>REGEN <b id="stat-regen">0</b></span>
- <span>WAND <b id="stat-wand">0</b></span>
- <span>TOTAL <b id="stat-total">0</b></span>
- </div>
- <span class="live">LIVE</span>
-</header>
-
-<main>
- <div class="controls">
- <label>Filter</label>
- <div class="filter">
- <button class="active" data-f="all">All</button>
- <button data-f="crop-fix">Surgical Fix</button>
- <button data-f="crop-fix-remove">Remove + Fill</button>
- <button data-f="regenerate-reverse">Reverse-Regen</button>
- <button data-f="magic-wand">Magic Wand</button>
- </div>
- <span class="ledger" id="ledger">—</span>
- </div>
-
- <div id="feed"></div>
- <div id="empty" class="empty" style="display:none">
- <h2>No fixes yet</h2>
- <p>Go to <code>/admin/ghost-review</code>, mark a defect, hit a Fix button. The before/after will appear here automatically.</p>
- </div>
-</main>
-
-<script>
-(() => {
- const $ = (id) => document.getElementById(id);
- let ITEMS = [];
- let FILTER = 'all';
- let SEEN_TOTAL = 0;
-
- const KIND_LABEL = {
- 'crop-fix': 'Surgical Fix',
- 'crop-fix-remove': 'Remove + Fill',
- 'regenerate-reverse': 'Reverse-Regen',
- 'magic-wand': 'Magic Wand',
- };
-
- document.querySelectorAll('.filter button').forEach(b => {
- b.onclick = () => {
- document.querySelectorAll('.filter button').forEach(x => x.classList.remove('active'));
- b.classList.add('active');
- FILTER = b.dataset.f;
- render();
- };
- });
-
- function fmtKind(item) {
- if (item.kind === 'crop-fix' && item.fix_kind === 'remove-fill') return 'crop-fix-remove';
- return item.kind;
- }
-
- function updateStats() {
- const counts = { 'crop-fix':0, 'crop-fix-remove':0, 'regenerate-reverse':0, 'magic-wand':0 };
- ITEMS.forEach(i => { const k = fmtKind(i); counts[k] = (counts[k] || 0) + 1; });
- $('stat-fix').textContent = counts['crop-fix'];
- $('stat-remove').textContent = counts['crop-fix-remove'];
- $('stat-regen').textContent = counts['regenerate-reverse'];
- $('stat-wand').textContent = counts['magic-wand'];
- $('stat-total').textContent = ITEMS.length;
- }
-
- function render() {
- const feed = $('feed');
- feed.innerHTML = '';
- const empty = $('empty');
- const filtered = FILTER === 'all' ? ITEMS : ITEMS.filter(i => fmtKind(i) === FILTER);
- if (!filtered.length) { empty.style.display = ''; return; }
- empty.style.display = 'none';
- const frag = document.createDocumentFragment();
- for (const it of filtered) {
- const k = fmtKind(it);
- const card = document.createElement('div');
- card.className = 'card';
- const tsShort = new Date(it.ts).toLocaleString();
- const sub =
- k === 'crop-fix' ? `region (${it.region?.x?.toFixed(1)}%, ${it.region?.y?.toFixed(1)}%, ${it.region?.w?.toFixed(1)}%, ${it.region?.h?.toFixed(1)}%)` :
- k === 'crop-fix-remove' ? `removed region (${it.region?.w?.toFixed(1)}% × ${it.region?.h?.toFixed(1)}%)` :
- k === 'regenerate-reverse' ? `<span title="${(it.new_prompt || '').replace(/"/g,'"')}">new prompt: ${(it.new_prompt || '').slice(0, 80)}…</span>` :
- k === 'magic-wand' ? `${it.summary || 'browser edit'} · ${(it.bytes/1024).toFixed(0)} KB` :
- '';
- card.innerHTML = `
- <div class="ribbon">
- <span class="kind-pill k-${k}">${KIND_LABEL[k] || k}</span>
- <span class="ts">${tsShort}</span>
- ${it.elapsed_s ? `<span class="elapsed">· ${it.elapsed_s}s</span>` : ''}
- <span class="links">
- <a href="/admin/ghost-review" title="back to review">← review</a>
- <a href="/design/${it.src_id}" target="_blank" title="open old">#${it.src_id}</a>
- <a href="/design/${it.new_id}" target="_blank" title="open fixed">→ #${it.new_id}</a>
- </span>
- </div>
- <div class="pair">
- <div class="side before">
- <div class="label">Before · #${it.src_id} (unpublished)</div>
- <div class="img-frame before">
- <img loading="lazy" src="/designs/img/by-id/${it.src_id}" alt="before #${it.src_id}">
- </div>
- <div class="meta-row"><b>id</b> ${it.src_id}</div>
- </div>
- <div class="arrow">→</div>
- <div class="side after">
- <div class="label">After · #${it.new_id} (live)</div>
- <div class="img-frame">
- <img loading="lazy" src="/designs/img/by-id/${it.new_id}" alt="after #${it.new_id}">
- </div>
- <div class="meta-row">
- <span><b>id</b> ${it.new_id}</span>
- <span><b>kind</b> ${KIND_LABEL[k] || k}</span>
- ${sub ? `<span>${sub}</span>` : ''}
- </div>
- </div>
- </div>
- `;
- frag.appendChild(card);
- }
- feed.appendChild(frag);
- }
-
- async function refresh() {
- try {
- const r = await fetch('/api/fixes-feed');
- const j = await r.json();
- if (!j.ok) return;
- // Pulse the LIVE badge briefly when new items arrive
- if (j.total > SEEN_TOTAL && SEEN_TOTAL > 0) {
- document.title = `(${j.total - SEEN_TOTAL} new) Fixes Feed`;
- setTimeout(() => { document.title = 'Fixes Feed — wallco.ai admin'; }, 4000);
- }
- SEEN_TOTAL = j.total;
- ITEMS = j.items;
- $('ledger').textContent = `${j.items.length} shown · ${j.total} total events`;
- updateStats();
- render();
- } catch (e) {
- console.warn('refresh failed:', e.message);
- }
- }
-
- refresh();
- setInterval(refresh, 10000); // poll every 10s for new fixes
-})();
-</script>
-</body>
-</html>
diff --git a/public/admin/ghost-review.html b/public/admin/ghost-review.html
index 06c59ad..8a75dfd 100644
--- a/public/admin/ghost-review.html
+++ b/public/admin/ghost-review.html
@@ -416,9 +416,6 @@
<header>
<h1>Ghost Review</h1>
<span class="meta">draw rects · pick a defect · go fast</span>
- <a href="/admin/fixes-feed" target="_blank" style="font:600 11px var(--sans); letter-spacing:.06em; color:var(--blue,#3b78d8); text-decoration:none; padding:5px 10px; border:1px solid var(--line); border-radius:14px; margin-left:10px;">
- 📡 Before/After Feed →
- </a>
<div class="stats">
<span class="clean">CLEAN <b id="stat-fp">0</b></span>
<span class="ghost">GHOST <b id="stat-ghost">0</b></span>
diff --git a/public/new.html b/public/new.html
index 1fea6c8..fb3c8ea 100644
--- a/public/new.html
+++ b/public/new.html
@@ -119,7 +119,6 @@
<option value="168">last 7d</option>
</select>
<span class="right" id="meta">—</span>
- <a href="/admin/fixes-feed">📡 Before/After Feed →</a>
<a href="/admin/ghost-review">🔍 Ghost Review →</a>
</div>
diff --git a/server.js b/server.js
index 5569528..5875dd1 100644
--- a/server.js
+++ b/server.js
@@ -934,7 +934,8 @@ app.get('/new', (req, res) => {
// ── Fixes feed (before/after audit trail) ───────────────────────────────
// Append-only log of every successful fix (Surgical / Remove+Fill / Reverse-
// Regenerate / Magic-Wand-Apply). Each row links the source id (before) to
-// the new id (after). Served as a live gallery at /admin/fixes-feed.
+// the new id (after). Log lives at data/fixes-feed.jsonl; the live-gallery
+// page at /admin/fixes-feed was removed 2026-05-24 (broken).
// POST /api/fix-decision/:newId body: { verdict: 'accept' | 'reject' }
// accept: no-op (already published).
// reject: unpublish the result and republish the parent (rollback).
@@ -944,17 +945,42 @@ app.post('/api/fix-decision/:newId', express.json({ limit: '2kb' }), (req, res)
const newId = parseInt(req.params.newId, 10);
if (!Number.isFinite(newId) || newId < 1) return res.status(400).json({ error: 'bad id' });
const verdict = String(req.body?.verdict || '').toLowerCase();
- if (!['accept','reject'].includes(verdict)) return res.status(400).json({ error: 'verdict must be accept|reject' });
+ // 'etsy' = reject as wallpaper but tag for Etsy digital-file resale (different revenue path)
+ if (!['accept','reject','delete','etsy'].includes(verdict)) return res.status(400).json({ error: 'verdict must be accept|reject|delete|etsy' });
try {
- // Find parent_design_id
- const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, parent_design_id, is_published FROM spoon_all_designs WHERE id=${newId}) t;`);
+ const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, parent_design_id, is_published, local_path FROM spoon_all_designs WHERE id=${newId}) t;`);
if (!raw || raw.length < 3) return res.status(404).json({ error: 'design not found' });
const row = JSON.parse(raw);
if (verdict === 'reject') {
+ // Soft: unpublish new, republish parent
psqlExecLocal(`UPDATE spoon_all_designs SET is_published=FALSE WHERE id=${newId};`);
if (row.parent_design_id) {
psqlExecLocal(`UPDATE spoon_all_designs SET is_published=TRUE WHERE id=${row.parent_design_id};`);
}
+ } else if (verdict === 'delete') {
+ psqlExecLocal(`UPDATE spoon_all_designs SET is_published=FALSE, user_removed=TRUE WHERE id=${newId};`);
+ if (row.parent_design_id) {
+ psqlExecLocal(`UPDATE spoon_all_designs SET is_published=TRUE WHERE id=${row.parent_design_id};`);
+ }
+ if (row.local_path && fs.existsSync(row.local_path)) {
+ try {
+ const qDir = path.join(__dirname, 'data', 'generated_ghost_quarantine');
+ fs.mkdirSync(qDir, { recursive: true });
+ const dest = path.join(qDir, path.basename(row.local_path));
+ if (!fs.existsSync(dest)) fs.renameSync(row.local_path, dest);
+ } catch (e) { console.warn('[fix-decision delete] quarantine move failed:', e.message); }
+ }
+ } else if (verdict === 'etsy') {
+ // Reject as wallpaper, KEEP as Etsy digital-file. Unpublish from wallco
+ // listings, tag for Etsy export, republish source. PNG stays in
+ // data/generated/ so the etsy-export pipeline can grab it.
+ psqlExecLocal(`UPDATE spoon_all_designs
+ SET is_published=FALSE,
+ tags = array_remove(COALESCE(tags, ARRAY[]::text[]), 'etsy-digital-file') || 'etsy-digital-file'
+ WHERE id=${newId};`);
+ if (row.parent_design_id) {
+ psqlExecLocal(`UPDATE spoon_all_designs SET is_published=TRUE WHERE id=${row.parent_design_id};`);
+ }
}
fs.appendFileSync(path.join(__dirname, 'data', 'fix-decisions.jsonl'),
JSON.stringify({ ts: new Date().toISOString(), new_id: newId, parent: row.parent_design_id, verdict }) + '\n');
@@ -962,6 +988,53 @@ app.post('/api/fix-decision/:newId', express.json({ limit: '2kb' }), (req, res)
} catch (e) { res.status(500).json({ error: e.message }); }
});
+// POST /api/fix-decision/bulk body: { ids: [newId, ...], verdict: 'accept'|'reject'|'delete'|'etsy' }
+// Same semantics as single-id /api/fix-decision/:newId, but in one HTTP call so
+// the bulk-action bar (formerly on /admin/fixes-feed, now removed) didn't fan
+// out N parallel requests. Retained for any external caller / future viewer.
+app.post('/api/fix-decision/bulk', express.json({ limit: '64kb' }), (req, res) => {
+ if (!adminRatingGate(req, res)) return;
+ const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(n => parseInt(n, 10)).filter(Number.isFinite) : [];
+ const verdict = String(req.body?.verdict || '').toLowerCase();
+ if (!ids.length) return res.status(400).json({ error: 'ids[] required' });
+ if (!['accept','reject','delete','etsy'].includes(verdict)) return res.status(400).json({ error: 'bad verdict' });
+ const results = [];
+ let ok = 0, err = 0;
+ for (const newId of ids) {
+ try {
+ const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, parent_design_id, is_published, local_path FROM spoon_all_designs WHERE id=${newId}) t;`);
+ if (!raw || raw.length < 3) { results.push({ id: newId, ok: false, error: 'not found' }); err++; continue; }
+ const row = JSON.parse(raw);
+ if (verdict === 'reject') {
+ psqlExecLocal(`UPDATE spoon_all_designs SET is_published=FALSE WHERE id=${newId};`);
+ if (row.parent_design_id) psqlExecLocal(`UPDATE spoon_all_designs SET is_published=TRUE WHERE id=${row.parent_design_id};`);
+ } else if (verdict === 'delete') {
+ psqlExecLocal(`UPDATE spoon_all_designs SET is_published=FALSE, user_removed=TRUE WHERE id=${newId};`);
+ if (row.parent_design_id) psqlExecLocal(`UPDATE spoon_all_designs SET is_published=TRUE WHERE id=${row.parent_design_id};`);
+ if (row.local_path && fs.existsSync(row.local_path)) {
+ try {
+ const qDir = path.join(__dirname, 'data', 'generated_ghost_quarantine');
+ fs.mkdirSync(qDir, { recursive: true });
+ const dest = path.join(qDir, path.basename(row.local_path));
+ if (!fs.existsSync(dest)) fs.renameSync(row.local_path, dest);
+ } catch {}
+ }
+ } else if (verdict === 'etsy') {
+ psqlExecLocal(`UPDATE spoon_all_designs
+ SET is_published=FALSE,
+ tags = array_remove(COALESCE(tags, ARRAY[]::text[]), 'etsy-digital-file') || 'etsy-digital-file'
+ WHERE id=${newId};`);
+ if (row.parent_design_id) psqlExecLocal(`UPDATE spoon_all_designs SET is_published=TRUE WHERE id=${row.parent_design_id};`);
+ }
+ fs.appendFileSync(path.join(__dirname, 'data', 'fix-decisions.jsonl'),
+ JSON.stringify({ ts: new Date().toISOString(), new_id: newId, parent: row.parent_design_id, verdict, bulk: true }) + '\n');
+ results.push({ id: newId, ok: true });
+ ok++;
+ } catch (e) { results.push({ id: newId, ok: false, error: e.message.slice(0, 200) }); err++; }
+ }
+ res.json({ ok: true, total: ids.length, accepted: ok, errored: err, verdict, results });
+});
+
function appendFixEvent(row) {
try {
const f = path.join(__dirname, 'data', 'fixes-feed.jsonl');
@@ -969,23 +1042,11 @@ function appendFixEvent(row) {
} catch (e) { console.error('[fix-event] write fail', e.message); }
}
-app.get('/admin/fixes-feed', (req, res) => {
- if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
- res.sendFile(path.join(__dirname, 'public', 'admin', 'fixes-feed.html'));
-});
-app.get('/api/fixes-feed', (req, res) => {
- if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
- const f = path.join(__dirname, 'data', 'fixes-feed.jsonl');
- if (!fs.existsSync(f)) return res.json({ ok: true, items: [] });
- const lines = fs.readFileSync(f, 'utf8').split('\n').filter(Boolean);
- // Latest first
- const items = [];
- for (let i = lines.length - 1; i >= 0; i--) {
- try { items.push(JSON.parse(lines[i])); } catch {}
- if (items.length >= 500) break;
- }
- res.json({ ok: true, items, total: lines.length });
-});
+// /admin/fixes-feed viewer + /api/fixes-feed route removed 2026-05-24 — the
+// page was broken. The underlying data pipeline (appendFixEvent →
+// data/fixes-feed.jsonl) is preserved as an audit trail for the fix routes
+// (crop-fix, regenerate-reverse, opacity-snap, magic-wand, smart-fix,
+// replace-bg) and is read directly off disk by scripts/bulk-reject-auto-smart-fix.js.
// ── Ghost Review (admin-only) ───────────────────────────────────────────
// Viewer + label-collection for the ghost-detector training set. Reads
@@ -7328,6 +7389,97 @@ RETURNING id`);
}
});
+// POST /api/design/:id/opacity-snap — pure PIL ghost-cleanup. NO Gemini, no cost.
+//
+// Steve's 2026-05-24 rule: a ghost layer = blended pixels between motif color
+// and background color. Snap every pixel to the NEAREST of N detected solid
+// colors (k=3 by default: bg + 2 motif tones). Result: zero translucency, zero
+// halftones, zero ghost-band gradients. "Updating not redo" — preserves the
+// composition, eliminates only the partial-alpha pixels.
+//
+// Body: { k?: 3, dither?: 'fs'|'none' }
+app.post('/api/design/:id/opacity-snap', express.json({ limit: '4kb' }), (req, res) => {
+ if (!adminRatingGate(req, res)) return;
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+ const k = Math.min(8, Math.max(2, parseInt(req.body?.k || '3', 10)));
+ const dither = String(req.body?.dither || 'none').toLowerCase();
+ try {
+ let d = null;
+ try {
+ const pgRaw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, kind, category,
+ width_in, height_in, panels, motifs, tags, dominant_hex, palette, local_path
+ FROM spoon_all_designs WHERE id=${id}) t;`);
+ if (pgRaw && pgRaw.length > 2) d = JSON.parse(pgRaw);
+ } catch { /* non-fatal */ }
+ if (!d) { const cached = DESIGNS.find(x => x.id === id); if (cached) d = { ...cached }; }
+ if (!d) return res.status(404).json({ error: 'design not found' });
+ if (!d.local_path || !fs.existsSync(d.local_path)) {
+ return res.status(404).json({ error: 'source PNG missing' });
+ }
+ const t0 = Date.now();
+ const newSeed = require('crypto').randomInt(1, 2 ** 31 - 1);
+ const filename = `snap_${id}_${Date.now()}_${newSeed}.png`;
+ const outPath = path.join(__dirname, 'data', 'generated', filename);
+ const py = require('child_process').spawnSync('python3', ['-c', `
+import sys, json
+from PIL import Image
+src = Image.open(sys.argv[1]).convert('RGB')
+k = int(sys.argv[3])
+dither_flag = Image.FLOYDSTEINBERG if sys.argv[4] == 'fs' else Image.NONE
+q = src.quantize(colors=k, method=Image.MEDIANCUT, dither=dither_flag)
+pal = q.getpalette()[: k*3]
+hexes = ['#%02x%02x%02x' % (pal[i*3], pal[i*3+1], pal[i*3+2]) for i in range(k)]
+q.convert('RGB').save(sys.argv[2], 'PNG', optimize=True)
+print(json.dumps({ 'palette': hexes, 'k': k, 'dither': sys.argv[4] }))
+`, d.local_path, outPath, String(k), dither], { encoding: 'utf8' });
+ if (py.status !== 0) {
+ return res.status(500).json({ error: 'opacity-snap PIL failed: ' + (py.stderr || py.stdout || '?').slice(0, 240) });
+ }
+ let meta = {};
+ try { meta = JSON.parse(py.stdout); } catch {}
+ const esc = (v) => v == null ? 'NULL' : "'" + String(v).replace(/'/g, "''") + "'";
+ const arrLit = (a, t) => (!Array.isArray(a) || !a.length) ? 'NULL' : `ARRAY[${a.map(v => esc(v)).join(',')}]::${t}[]`;
+ const insertSQL = `
+INSERT INTO spoon_all_designs
+ (kind, brand, width_in, height_in, panels, generator, prompt, seed,
+ image_url, local_path, dominant_hex, palette, motifs, tags, category, is_published, parent_design_id)
+VALUES
+ (${esc(d.kind || 'seamless_tile')}, 'wallco.ai',
+ ${d.width_in || 'NULL'}, ${d.height_in || 'NULL'}, ${d.panels || 'NULL'},
+ 'pil-opacity-snap',
+ ${esc('Opacity-snap of #' + id + ' (k=' + k + ', dither=' + dither + '): collapsed all translucent/halftone pixels to nearest of ' + k + ' solid colors. No Gemini.')},
+ ${newSeed},
+ '/designs/img/by-id/__NEW__',
+ ${esc(outPath)},
+ ${esc((meta.palette && meta.palette[0]) || d.dominant_hex)},
+ ${esc(JSON.stringify(meta.palette || d.palette || []))}::jsonb,
+ ${arrLit(d.motifs, 'text')},
+ ${arrLit(d.tags, 'text')},
+ ${esc(d.category)},
+ TRUE,
+ ${id})
+RETURNING id`;
+ let ins;
+ try { ins = psqlExecLocal(insertSQL); }
+ catch (e) {
+ console.error('[opacity-snap] INSERT err:', e.message.slice(0, 240));
+ return res.status(500).json({ error: 'insert failed: ' + e.message.slice(0, 240) });
+ }
+ const newId = parseInt(ins, 10);
+ if (!newId) return res.status(500).json({ error: 'failed to insert new row' });
+ psqlExecLocal(`UPDATE spoon_all_designs SET image_url='/designs/img/by-id/${newId}', is_published=TRUE WHERE id=${newId}`);
+ psqlExecLocal(`UPDATE spoon_all_designs SET is_published=FALSE WHERE id=${id}`);
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
+ appendFixEvent({ kind: 'opacity-snap', src_id: id, new_id: newId, elapsed_s: elapsed,
+ k, dither, palette: meta.palette });
+ res.json({ ok: true, new_id: newId, src_id: id, elapsed_s: elapsed, palette: meta.palette, k });
+ } catch (e) {
+ console.error('[opacity-snap]', e);
+ res.status(500).json({ error: e.message });
+ }
+});
+
// POST /api/design/:id/save-edited — accept a base64-PNG of locally-edited
// pixels (magic-wand recolor/erase/paste) and save it as a NEW design.
// No Gemini call — just stores the bytes the browser produced.
@@ -7399,13 +7551,100 @@ RETURNING id`);
}
});
-// POST /api/design/:id/smart-fix — autopilot clean-up.
-// 1. Gemini Vision identifies the cleanest hero motif (or set of opaque elements) in the source
-// 2. Gemini Vision also samples the dominant background/ground tone
-// 3. Gemini-2.5-flash-image regenerates the design as a CLEAN SEAMLESS TILE of just
-// that motif on the ground tone, with the explicit constraint that edges must repeat
-// 4. Insert new row, unpublish source, log fix event
+// POST /api/design/:id/smart-fix — REDIRECTED 2026-05-25 per Steve.
+//
+// Old behavior: Gemini Vision describe → Gemini-2.5-flash-image regenerate
+// (text-only, no source image) → verify-retry → PIL seamlessify.
+// PROBLEM: produced entirely new patterns, not cleanups. The seamless-agent
+// ran 12h overnight firing this on every flagged design and produced 75+
+// amateur-looking redos that Steve had to mass-reject + tag for Etsy.
+//
+// New behavior: forward to /api/design/:id/opacity-snap (pure PIL, no AI,
+// preserves composition exactly, collapses translucent/halftone pixels to
+// nearest of k=3 solid colors). Same response shape so callers don't break.
+//
+// If you need the old Gemini regenerate behavior, call /api/design/:id/regenerate-reverse
+// explicitly. It's never the right default for "fix" operations.
app.post('/api/design/:id/smart-fix', express.json({ limit: '4kb' }), async (req, res) => {
+ // REDIRECT 2026-05-25: forward to opacity-snap (PIL, preserves composition).
+ // Old Gemini-regenerate behavior was producing amateur-looking new patterns
+ // when called by the seamless-agent autonomously. Set ?legacy=1 to bypass
+ // this redirect and run the (deprecated) Gemini regenerate flow if needed.
+ if (!(req.query && (req.query.legacy === '1' || req.query.legacy === 'true'))) {
+ if (!adminRatingGate(req, res)) return;
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+ try {
+ let d = null;
+ try {
+ const pgRaw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, kind, category,
+ width_in, height_in, panels, motifs, tags, dominant_hex, palette, local_path
+ FROM spoon_all_designs WHERE id=${id}) t;`);
+ if (pgRaw && pgRaw.length > 2) d = JSON.parse(pgRaw);
+ } catch {}
+ if (!d) { const c = DESIGNS.find(x => x.id === id); if (c) d = { ...c }; }
+ if (!d) return res.status(404).json({ error: 'design not found' });
+ if (!d.local_path || !fs.existsSync(d.local_path)) {
+ return res.status(404).json({ error: 'source PNG missing' });
+ }
+ const t0 = Date.now();
+ const newSeed = require('crypto').randomInt(1, 2 ** 31 - 1);
+ const filename = `snap_${id}_${Date.now()}_${newSeed}.png`;
+ const outPath = path.join(__dirname, 'data', 'generated', filename);
+ const py = require('child_process').spawnSync('python3', ['-c', `
+import sys, json
+from PIL import Image
+src = Image.open(sys.argv[1]).convert('RGB')
+q = src.quantize(colors=3, method=Image.MEDIANCUT, dither=Image.NONE)
+pal = q.getpalette()[:9]
+hexes = ['#%02x%02x%02x' % (pal[i*3], pal[i*3+1], pal[i*3+2]) for i in range(3)]
+q.convert('RGB').save(sys.argv[2], 'PNG', optimize=True)
+print(json.dumps({'palette': hexes}))
+`, d.local_path, outPath], { encoding: 'utf8' });
+ if (py.status !== 0) return res.status(500).json({ error: 'opacity-snap PIL failed: ' + (py.stderr||'').slice(0,200) });
+ let meta = {}; try { meta = JSON.parse(py.stdout); } catch {}
+ const esc = (v) => v == null ? 'NULL' : "'" + String(v).replace(/'/g, "''") + "'";
+ const arrLit = (a, t) => (!Array.isArray(a) || !a.length) ? 'NULL' : `ARRAY[${a.map(v => esc(v)).join(',')}]::${t}[]`;
+ let ins;
+ try {
+ ins = psqlExecLocal(`
+INSERT INTO spoon_all_designs
+ (kind, brand, width_in, height_in, panels, generator, prompt, seed,
+ image_url, local_path, dominant_hex, palette, motifs, tags, category, is_published, parent_design_id)
+VALUES
+ (${esc(d.kind || 'seamless_tile')}, 'wallco.ai',
+ ${d.width_in || 'NULL'}, ${d.height_in || 'NULL'}, ${d.panels || 'NULL'},
+ 'pil-opacity-snap (via smart-fix redirect)',
+ ${esc('Smart-fix REDIRECTED to opacity-snap of #' + id + ' — composition preserved, k=3 quantize')},
+ ${newSeed},
+ '/designs/img/by-id/__NEW__',
+ ${esc(outPath)},
+ ${esc((meta.palette && meta.palette[0]) || d.dominant_hex)},
+ ${esc(JSON.stringify(meta.palette || d.palette || []))}::jsonb,
+ ${arrLit(d.motifs, 'text')},
+ ${arrLit(d.tags, 'text')},
+ ${esc(d.category)},
+ TRUE,
+ ${id})
+RETURNING id`);
+ } catch (e) { return res.status(500).json({ error: 'insert failed: ' + e.message.slice(0, 240) }); }
+ const newId = parseInt(ins, 10);
+ if (!newId) return res.status(500).json({ error: 'failed to insert new row' });
+ psqlExecLocal(`UPDATE spoon_all_designs SET image_url='/designs/img/by-id/${newId}', is_published=TRUE WHERE id=${newId}`);
+ psqlExecLocal(`UPDATE spoon_all_designs SET is_published=FALSE WHERE id=${id}`);
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
+ appendFixEvent({ kind: 'smart-fix', src_id: id, new_id: newId, elapsed_s: elapsed,
+ redirected: 'opacity-snap', palette: meta.palette });
+ return res.json({ ok: true, new_id: newId, src_id: id, elapsed_s: elapsed,
+ redirected: 'opacity-snap', palette: meta.palette,
+ motif: 'opacity-snap (composition preserved, no Gemini)',
+ attempts: 1, verify: { has_translucent_layers: false } });
+ } catch (e) {
+ console.error('[smart-fix→opacity-snap]', e);
+ return res.status(500).json({ error: e.message });
+ }
+ }
+ // ─ Legacy path (Gemini regenerate) — only reached when ?legacy=1 ─
if (!adminRatingGate(req, res)) return;
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
@@ -7718,6 +7957,44 @@ RETURNING id`;
// GET /api/bg-textures → list all (slug, name, material, hex, image_url)
// POST /api/bg-textures → admin: add/update a texture
// POST /api/design/:id/replace-bg → admin: swap the design's background for the named texture
+// GET /api/design/:id/texture-variants — public endpoint listing all
+// bg-swap children of a design. Used by /design/:id customer-flow texture
+// picker. Returns each variant's id, image_url, and the texture material/brand
+// parsed out of the prompt (so the chip can show "Cream Grasscloth · Phillipe
+// Romano" without needing the texture's image URL).
+app.get('/api/design/:id/texture-variants', (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+ try {
+ // Designs where this is the parent AND generator is a bg-swap.
+ // Also include the design itself as "original / no texture" so the picker
+ // shows the source as the first chip.
+ const sql = `SELECT COALESCE(json_agg(t ORDER BY t.created_at), '[]'::json) FROM (
+ SELECT id, image_url, prompt, dominant_hex, created_at, generator
+ FROM spoon_all_designs
+ WHERE parent_design_id = ${id}
+ AND is_published = TRUE
+ AND generator IN ('gemini-2.5-flash-image-bg-swap','pil-opacity-snap')
+ LIMIT 60
+ ) t;`;
+ const raw = psqlQuery(sql);
+ const rows = JSON.parse(raw || '[]');
+ // Parse "BG-swap of #X to <texture name> (<brand>): ..." from prompt
+ const items = rows.map(r => {
+ const m = (r.prompt || '').match(/BG-swap of #\d+ to (.+?):/);
+ const texture_label = m ? m[1] : (r.generator === 'pil-opacity-snap' ? 'Opacity-cleaned (no texture)' : 'Variant');
+ return {
+ id: r.id,
+ image_url: `/designs/img/by-id/${r.id}`,
+ dominant_hex: r.dominant_hex,
+ texture_label,
+ kind: r.generator === 'pil-opacity-snap' ? 'snap' : 'bg-swap',
+ };
+ });
+ res.json({ ok: true, items });
+ } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
// GET /api/dw-textures — real natural-material wallcoverings from DW catalog.
// Surfaces shopify_products where material keywords (grasscloth, linen, raffia,
// silk, cork, jute, sisal, hemp, woven) appear in title. Returns brand-grouped list.
@@ -7737,6 +8014,57 @@ app.get('/api/categories', (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); }
});
+// GET /admin/status-browser — one place to see ALL designs with ALL statuses.
+app.get('/admin/status-browser', (req, res) => {
+ if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
+ res.sendFile(path.join(__dirname, 'public', 'admin', 'status-browser.html'));
+});
+
+// GET /api/status-browser — list designs with computed status field.
+// Query: ?status=all|published|unpublished|etsy|removed|quarantined|ghost-flagged
+// ?category=...
+// ?limit=200&offset=0
+// ?sort=newest|oldest|category|id-asc|id-desc
+app.get('/api/status-browser', (req, res) => {
+ if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+ const status = String(req.query.status || 'all').toLowerCase();
+ const category = String(req.query.category || '').toLowerCase();
+ const limit = Math.min(500, Math.max(1, parseInt(req.query.limit || '200', 10)));
+ const offset = Math.max(0, parseInt(req.query.offset || '0', 10));
+ const sort = String(req.query.sort || 'newest').toLowerCase();
+ let where = "brand='wallco.ai'";
+ if (status === 'published') where += ' AND is_published=TRUE AND (user_removed IS NOT TRUE)';
+ else if (status === 'unpublished') where += ' AND is_published=FALSE AND (user_removed IS NOT TRUE)';
+ else if (status === 'removed') where += ' AND user_removed=TRUE';
+ else if (status === 'etsy') where += " AND tags @> ARRAY['etsy-digital-file']::text[]";
+ if (category) where += ` AND category='${category.replace(/'/g, "''")}'`;
+ const orderBy = ({
+ 'newest': 'id DESC', 'oldest': 'id ASC',
+ 'category': 'category, id DESC',
+ 'id-asc': 'id ASC', 'id-desc': 'id DESC',
+ })[sort] || 'id DESC';
+ try {
+ const countRaw = psqlQuery(`SELECT count(*) FROM spoon_all_designs WHERE ${where};`);
+ const total = parseInt(countRaw.trim(), 10) || 0;
+ const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.rownum), '[]'::json) FROM (
+ SELECT id, kind, category, generator, dominant_hex, is_published, user_removed,
+ tags, parent_design_id, created_at, row_number() OVER (ORDER BY ${orderBy}) AS rownum
+ FROM spoon_all_designs
+ WHERE ${where}
+ ORDER BY ${orderBy}
+ LIMIT ${limit} OFFSET ${offset}
+ ) t;`);
+ const items = JSON.parse(raw || '[]').map(r => {
+ const tags = r.tags || [];
+ const computed_status = r.user_removed ? 'removed'
+ : tags.includes('etsy-digital-file') ? 'etsy'
+ : r.is_published ? 'published' : 'unpublished';
+ return { ...r, computed_status };
+ });
+ res.json({ ok: true, total, items, page: { limit, offset } });
+ } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
app.get('/api/dw-textures', (req, res) => {
try {
// ALL natural-material wallcoverings across DW catalog (no brand whitelist).
@@ -8575,7 +8903,7 @@ ${htmlHeader('/designs')}
}
</style>
- <div class="detail-img-wrap" data-mural="${(design.kind === 'mural' || design.kind === 'mural_panel') ? '1' : '0'}">
+ <div class="detail-img-wrap" data-mural="${(design.kind === 'mural' || design.kind === 'mural_panel') ? '1' : '0'}" data-original-src="${design.image_url}" data-design-id="${design.id}">
<img src="${design.image_url}" alt="${design.title}" class="detail-img" id="detail-img" loading="eager" fetchpriority="high" decoding="async" crossorigin="anonymous">
${(design.kind === 'mural' || design.kind === 'mural_panel') ? `
<!-- Mural pan controls — drag to pan, arrow keys + on-screen arrows for keyboard / touch users -->
← a992ad5 Stage 4: scan-composition.js + gen-poodle-demo.js
·
back to Wallco Ai
·
Stage 4 followup: scenic exemption + auto-fix-composition.js 0e7e4ff →