← back to Degournay Review Viewer
Add marquee drag-select + Confirm Settlement Violation (archive+unpublish, never online)
f3f459ff33befe7b044216d175c576f49c0ada35 · 2026-07-22 08:06:07 -0700 · Steve Abrams
Files touched
Diff
commit f3f459ff33befe7b044216d175c576f49c0ada35
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 22 08:06:07 2026 -0700
Add marquee drag-select + Confirm Settlement Violation (archive+unpublish, never online)
---
server.js | 156 +++++++++++++++++++++++++++++++++++++++++---------------------
1 file changed, 103 insertions(+), 53 deletions(-)
diff --git a/server.js b/server.js
index af04192..5ce2ad3 100644
--- a/server.js
+++ b/server.js
@@ -1,10 +1,12 @@
#!/usr/bin/env node
/**
* De Gournay settlement-review viewer.
- * Shows the 179 needs_review De Gournay SKUs for Steve to select 1→all and
- * ACTIVATE (Google-feed excluded first → status=ACTIVE → Online Store).
- * Basic auth admin/DW2024!. Reads live from dw_unified; writes activated_at back.
- * Live Shopify writes happen ONLY on Steve's explicit "Activate Selected" click.
+ * Grid of the needs_review De Gournay SKUs. Steve selects (click, drag-marquee,
+ * or select-all) then either:
+ * - ACTIVATE SELECTED → Google-channel EXCLUDED → status ACTIVE → Online Store (goes live)
+ * - CONFIRM SETTLEMENT VIOLATION → status ARCHIVED + unpublished from every channel
+ * → never shows online; recorded to violations log + dw_unified.
+ * Basic auth admin/DW2024!. Live Shopify writes happen ONLY on Steve's explicit click.
*/
const http = require('http');
const { execSync } = require('child_process');
@@ -18,6 +20,7 @@ const GOOGLE = 'gid://shopify/Publication/29646651457';
const ONLINE = 'gid://shopify/Publication/22208643184';
const AUTH = 'Basic ' + Buffer.from('admin:DW2024!').toString('base64');
const PG = 'host=/tmp dbname=dw_unified';
+const VIOLOG = process.env.HOME + '/.claude/yolo-queue/degournay-settlement-violations.jsonl';
function q(sql) { return execSync(`psql "${PG}" -tAc "${sql.replace(/"/g, '\\"')}"`).toString(); }
async function gql(query, variables) {
@@ -29,7 +32,7 @@ async function gql(query, variables) {
const sleep = ms => new Promise(r => setTimeout(r, ms));
function products() {
- const sql = `select coalesce(json_agg(t order by t.pattern_name, t.color_name),'[]') from (select shopify_product_id pid, pattern_name, color_name, thumb_url, image_url, product_url, settlement_notes, ai_colors, crawled_at, activated_at from degournay_catalog where settlement_status='needs_review' and shopify_product_id is not null) t`;
+ const sql = `select coalesce(json_agg(t order by t.pattern_name, t.color_name),'[]') from (select shopify_product_id pid, pattern_name, color_name, thumb_url, image_url, product_url, settlement_notes, ai_colors, crawled_at, activated_at, settlement_status from degournay_catalog where settlement_status in ('needs_review','violation') and shopify_product_id is not null) t`;
return JSON.parse(q(sql) || '[]');
}
@@ -40,68 +43,85 @@ async function activate(pid) {
if (u.productUpdate.userErrors.length) throw new Error(JSON.stringify(u.productUpdate.userErrors));
await gql(`mutation($id:ID!,$p:ID!){publishablePublish(id:$id,input:[{publicationId:$p}]){userErrors{message}}}`, { id: gid, p: ONLINE });
const v = await gql(`query($id:ID!,$g:ID!,$o:ID!){product(id:$id){status onG:publishedOnPublication(publicationId:$g) onO:publishedOnPublication(publicationId:$o)}}`, { id: gid, g: GOOGLE, o: ONLINE });
- const p = v.product;
- const ok = p.status === 'ACTIVE' && !p.onG && p.onO;
+ const p = v.product; const ok = p.status === 'ACTIVE' && !p.onG && p.onO;
if (ok) execSync(`psql "${PG}" -q -c "UPDATE degournay_catalog SET activated_at=now(), settlement_status='ok' WHERE shopify_product_id='${pid}';"`);
- return { pid, ok, status: p.status, onGoogle: p.onG, onOnlineStore: p.onO };
+ return { pid, ok, status: p.status };
+}
+
+async function violate(pid) {
+ const gid = `gid://shopify/Product/${pid}`;
+ // unpublish from every channel, then ARCHIVE so it can never show online
+ await gql(`mutation($id:ID!,$g:ID!,$o:ID!){publishableUnpublish(id:$id,input:[{publicationId:$g},{publicationId:$o}]){userErrors{message}}}`, { id: gid, g: GOOGLE, o: ONLINE });
+ const u = await gql(`mutation($p:ProductInput!){productUpdate(input:$p){product{status onO:publishedOnPublication(publicationId:"${ONLINE}")} userErrors{message}}}`, { p: { id: gid, status: 'ARCHIVED' } });
+ if (u.productUpdate.userErrors.length) throw new Error(JSON.stringify(u.productUpdate.userErrors));
+ const p = u.productUpdate.product; const ok = p.status === 'ARCHIVED' && !p.onO;
+ if (ok) {
+ execSync(`psql "${PG}" -q -c "UPDATE degournay_catalog SET settlement_status='violation', activated_at=NULL, updated_at=now() WHERE shopify_product_id='${pid}';"`);
+ const row = q(`select json_build_object('pid','${pid}','pattern',pattern_name,'color',color_name,'notes',settlement_notes,'sku',degournay_sku) from degournay_catalog where shopify_product_id='${pid}'`).trim();
+ try { fs.appendFileSync(VIOLOG, JSON.stringify({ ...JSON.parse(row), confirmed_at: new Date().toISOString() }) + '\n'); } catch {}
+ }
+ return { pid, ok, status: p.status };
}
const PAGE = () => `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
-<title>De Gournay — Settlement Review (179)</title>
+<title>De Gournay — Settlement Review</title>
<style>
:root{--min:230px}
-*{box-sizing:border-box}body{margin:0;font:14px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;background:#0e0e10;color:#e8e6e1}
-header{position:sticky;top:0;z-index:10;background:#16161a;border-bottom:1px solid #2a2a30;padding:10px 16px;display:flex;gap:14px;align-items:center;flex-wrap:wrap}
-h1{font-size:15px;margin:0;font-weight:600;letter-spacing:.02em}
+*{box-sizing:border-box}body{margin:0;font:14px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;background:#0e0e10;color:#e8e6e1;-webkit-user-select:none;user-select:none}
+header{position:sticky;top:0;z-index:20;background:#16161a;border-bottom:1px solid #2a2a30;padding:10px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap}
+h1{font-size:15px;margin:0;font-weight:600}
.muted{color:#8a8a92}
button,select{background:#22222a;color:#e8e6e1;border:1px solid #34343c;border-radius:7px;padding:7px 11px;font-size:13px;cursor:pointer}
-button.primary{background:#3a6df0;border-color:#3a6df0;color:#fff;font-weight:600}
-button.primary:disabled{opacity:.4;cursor:default}
-input[type=range]{vertical-align:middle}
-#grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--min),1fr));gap:12px;padding:16px}
-.card{background:#17171c;border:1px solid #2a2a30;border-radius:10px;overflow:hidden;position:relative;transition:border-color .12s}
-.card.sel{border-color:#3a6df0;box-shadow:0 0 0 1px #3a6df0}
-.card.done{opacity:.55}
-.card .imgwrap{aspect-ratio:3/4;background:#0b0b0d;cursor:pointer;position:relative}
-.card img{width:100%;height:100%;object-fit:cover;display:block}
-.card .chk{position:absolute;top:8px;left:8px;width:22px;height:22px;accent-color:#3a6df0;cursor:pointer}
-.card .badge{position:absolute;top:8px;right:8px;background:#000a;padding:2px 7px;border-radius:20px;font-size:11px}
+button.go{background:#2f9e5a;border-color:#2f9e5a;color:#fff;font-weight:600}
+button.bad{background:#c23b3b;border-color:#c23b3b;color:#fff;font-weight:700}
+button:disabled{opacity:.4;cursor:default}
+#grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--min),1fr));gap:12px;padding:16px;position:relative}
+.card{background:#17171c;border:1px solid #2a2a30;border-radius:10px;overflow:hidden;position:relative;transition:border-color .1s}
+.card.sel{border-color:#e0b23a;box-shadow:0 0 0 2px #e0b23a}
+.card.done{opacity:.5}.card.viol{opacity:.5}
+.card .imgwrap{aspect-ratio:3/4;background:#0b0b0d;position:relative;cursor:zoom-in}
+.card img{width:100%;height:100%;object-fit:cover;display:block;pointer-events:none}
+.card .chk{position:absolute;top:8px;left:8px;width:22px;height:22px;accent-color:#e0b23a;cursor:pointer;z-index:2}
+.card .badge{position:absolute;top:8px;right:8px;background:#000b;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:600}
+.badge.ok{color:#57c07a}.badge.viol{color:#ff8a8a}
.card .body{padding:9px 10px}
-.card .ttl{font-weight:600;font-size:13px;margin-bottom:2px}
-.card .cw{color:#a9a7a0;font-size:12px;margin-bottom:5px}
+.card .ttl{font-weight:600;font-size:13px}.card .cw{color:#a9a7a0;font-size:12px;margin-bottom:5px}
.card .note{font-size:11px;color:#c9a86a;margin-bottom:6px;min-height:28px}
.dots{display:flex;gap:3px;margin-bottom:6px}.dot{width:14px;height:14px;border-radius:50%;border:1px solid #0006}
.when{font-size:10.5px;color:#6f6f77}
-a{color:#7fa0ff;text-decoration:none}
-.ok{color:#57c07a}.err{color:#e06060}
+#marquee{position:fixed;border:1.5px solid #e0b23a;background:#e0b23a22;pointer-events:none;z-index:15;display:none}
+.hint{font-size:12px;color:#7f7f88}
</style></head><body>
<header>
<h1>De Gournay · Settlement Review <span class="muted" id="cnt"></span></h1>
<button id="all">Select all</button><button id="none">Clear</button>
<label class="muted">Sort <select id="sort"><option value="design">Design A→Z</option><option value="color">Colourway A→Z</option><option value="bird">Birds/Butterflies first</option></select></label>
- <label class="muted">Density <input type="range" id="dens" min="150" max="360" value="230"></label>
+ <label class="muted">Grid <input type="range" id="dens" min="140" max="380" value="230"></label>
+ <span class="hint">drag on the grid to marquee-select</span>
<span style="flex:1"></span>
<span id="selcnt" class="muted">0 selected</span>
- <button id="act" class="primary" disabled>Activate Selected</button>
+ <button id="act" class="go" disabled>Activate Selected</button>
+ <button id="viol" class="bad" disabled>Confirm Settlement Violation</button>
</header>
<div id="grid"></div>
+<div id="marquee"></div>
<script>
let items=[], sel=new Set();
const $=s=>document.querySelector(s);
function palette(ai){try{const a=typeof ai==='string'?JSON.parse(ai):ai;return (a||[]).slice(0,6).map(c=>'<span class="dot" title="'+c.name+' '+c.pct+'%" style="background:'+c.hex+'"></span>').join('')}catch{return''}}
function fmt(t){try{return new Date(t).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})}catch{return t}}
+function pending(p){return !p.activated_at && p.settlement_status!=='violation';}
function render(){
- const s=$('#sort').value;
- let list=[...items];
+ const s=$('#sort').value; let list=[...items];
if(s==='color')list.sort((a,b)=>(a.color_name||'').localeCompare(b.color_name||''));
else if(s==='bird')list.sort((a,b)=>(/B=1/.test(b.settlement_notes)?1:0)-(/B=1/.test(a.settlement_notes)?1:0));
else list.sort((a,b)=>(a.pattern_name||'').localeCompare(b.pattern_name||'')||(a.color_name||'').localeCompare(b.color_name||''));
$('#grid').innerHTML=list.map(p=>{
- const done=!!p.activated_at; const on=sel.has(p.pid);
- return '<div class="card'+(on?' sel':'')+(done?' done':'')+'" data-id="'+p.pid+'">'+
- '<div class="imgwrap" onclick="window.open(\\''+p.product_url+'\\')">'+
- (done?'':'<input type="checkbox" class="chk" '+(on?'checked':'')+' onclick="event.stopPropagation();tog(\\''+p.pid+'\\')">')+
- (done?'<span class="badge ok">✓ live</span>':'')+
+ const done=!!p.activated_at, viol=p.settlement_status==='viol'||p.settlement_status==='violation', on=sel.has(p.pid), locked=done||viol;
+ return '<div class="card'+(on?' sel':'')+(done?' done':'')+(viol?' viol':'')+'" data-id="'+p.pid+'">'+
+ '<div class="imgwrap" data-open="'+p.product_url+'">'+
+ (locked?'':'<input type="checkbox" class="chk" '+(on?'checked':'')+' data-id="'+p.pid+'">')+
+ (done?'<span class="badge ok">✓ live</span>':'')+(viol?'<span class="badge viol">⛔ violation</span>':'')+
'<img loading="lazy" src="'+(p.thumb_url||p.image_url||'')+'"></div>'+
'<div class="body"><div class="ttl">'+p.pattern_name+'</div><div class="cw">'+(p.color_name||'')+'</div>'+
'<div class="note" title="'+(p.settlement_notes||'')+'">'+(p.settlement_notes||'').split('|')[0]+'</div>'+
@@ -109,41 +129,71 @@ function render(){
'<div class="when" title="crawled '+p.crawled_at+'">🕓 '+fmt(p.crawled_at)+'</div></div></div>';
}).join('');
}
-function tog(id){if(sel.has(id))sel.delete(id);else sel.add(id);syncSel();}
-function syncSel(){$('#selcnt').textContent=sel.size+' selected';$('#act').disabled=sel.size===0;$('#act').textContent='Activate Selected ('+sel.size+')';render();}
-$('#all').onclick=()=>{items.filter(p=>!p.activated_at).forEach(p=>sel.add(p.pid));syncSel();};
+function syncSel(){$('#selcnt').textContent=sel.size+' selected';$('#act').disabled=$('#viol').disabled=sel.size===0;$('#act').textContent='Activate Selected ('+sel.size+')';$('#viol').textContent='Confirm Settlement Violation ('+sel.size+')';render();}
+$('#all').onclick=()=>{items.filter(pending).forEach(p=>sel.add(p.pid));syncSel();};
$('#none').onclick=()=>{sel.clear();syncSel();};
$('#sort').onchange=render;
$('#dens').oninput=e=>{document.documentElement.style.setProperty('--min',e.target.value+'px');localStorage.dgDens=e.target.value;};
if(localStorage.dgDens){$('#dens').value=localStorage.dgDens;document.documentElement.style.setProperty('--min',localStorage.dgDens+'px');}
-$('#act').onclick=async()=>{
- const ids=[...sel];
- if(!confirm('Activate '+ids.length+' De Gournay product(s)?\\nEach goes: Google-channel EXCLUDED → status ACTIVE → Online Store. This is customer-facing live.'))return;
- $('#act').disabled=true;$('#act').textContent='Activating…';
- const r=await fetch('/api/activate',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids})});
- const j=await r.json();
+// click: checkbox toggle, or open image
+$('#grid').addEventListener('click',e=>{
+ if(dragMoved){dragMoved=false;return;}
+ const chk=e.target.closest('.chk'); if(chk){const id=chk.dataset.id;if(sel.has(id))sel.delete(id);else sel.add(id);syncSel();return;}
+ const img=e.target.closest('.imgwrap'); if(img&&img.dataset.open)window.open(img.dataset.open,'_blank');
+});
+// marquee drag-select
+let sx,sy,dragging=false,dragMoved=false;
+const mq=$('#marquee');
+document.addEventListener('mousedown',e=>{
+ if(e.button!==0)return; if(e.target.closest('header'))return; if(e.target.closest('.chk'))return;
+ sx=e.clientX;sy=e.clientY;dragging=true;dragMoved=false;
+});
+document.addEventListener('mousemove',e=>{
+ if(!dragging)return;
+ if(Math.abs(e.clientX-sx)+Math.abs(e.clientY-sy)>6)dragMoved=true;
+ if(!dragMoved)return;
+ const x=Math.min(sx,e.clientX),y=Math.min(sy,e.clientY),w=Math.abs(e.clientX-sx),h=Math.abs(e.clientY-sy);
+ mq.style.display='block';mq.style.left=x+'px';mq.style.top=y+'px';mq.style.width=w+'px';mq.style.height=h+'px';
+ const r={left:x,top:y,right:x+w,bottom:y+h};
+ document.querySelectorAll('.card').forEach(c=>{
+ const id=c.dataset.id, b=c.getBoundingClientRect();
+ const hit=!(b.right<r.left||b.left>r.right||b.bottom<r.top||b.top>r.bottom);
+ if(hit && !c.classList.contains('done') && !c.classList.contains('viol')) sel.add(id);
+ });
+ syncSel();
+});
+document.addEventListener('mouseup',()=>{dragging=false;mq.style.display='none';});
+async function run(url,verb){
+ const ids=[...sel]; const label=verb==='violation'?'CONFIRM SETTLEMENT VIOLATION on':'Activate';
+ const warn=verb==='violation'?'These will be ARCHIVED + unpublished from every channel and will NEVER show online.':'These go live: Google-channel EXCLUDED → ACTIVE → Online Store.';
+ if(!confirm(label+' '+ids.length+' product(s)?\\n'+warn))return;
+ $('#act').disabled=$('#viol').disabled=true;$(verb==='violation'?'#viol':'#act').textContent='Working…';
+ const j=await (await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids})})).json();
const ok=j.results.filter(x=>x.ok).length,bad=j.results.filter(x=>!x.ok);
- alert('Activated '+ok+'/'+ids.length+(bad.length?'\\nFailed: '+bad.map(b=>b.pid).join(', '):'\\nAll on Online Store, none on Google.'));
+ alert((verb==='violation'?'Marked violation ':'Activated ')+ok+'/'+ids.length+(bad.length?'\\nFailed: '+bad.map(b=>b.pid).join(', '):''));
sel.clear();await load();syncSel();
-};
-async function load(){items=await (await fetch('/api/products')).json();$('#cnt').textContent='('+items.filter(p=>!p.activated_at).length+' pending · '+items.length+' total)';render();}
+}
+$('#act').onclick=()=>run('/api/activate','activate');
+$('#viol').onclick=()=>run('/api/violation','violation');
+async function load(){items=await (await fetch('/api/products')).json();const pend=items.filter(pending).length;$('#cnt').textContent='('+pend+' pending · '+items.length+' total)';render();}
load();
</script></body></html>`;
const server = http.createServer(async (req, res) => {
if (req.headers.authorization !== AUTH) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="dg"' }); return res.end('auth'); }
try {
- if (req.url === '/' ) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE()); }
+ if (req.url === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE()); }
if (req.url === '/api/products') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(products())); }
- if (req.url === '/api/activate' && req.method === 'POST') {
+ if ((req.url === '/api/activate' || req.url === '/api/violation') && req.method === 'POST') {
let b = ''; req.on('data', c => b += c); await new Promise(r => req.on('end', r));
const ids = (JSON.parse(b || '{}').ids || []).slice(0, 200);
+ const fn = req.url === '/api/violation' ? violate : activate;
const results = [];
- for (const id of ids) { try { results.push(await activate(id)); } catch (e) { results.push({ pid: id, ok: false, err: String(e).slice(0, 120) }); } await sleep(400); }
- console.log(`[activate] ${results.filter(r => r.ok).length}/${ids.length} ok`);
+ for (const id of ids) { try { results.push(await fn(id)); } catch (e) { results.push({ pid: id, ok: false, err: String(e).slice(0, 120) }); } await sleep(400); }
+ console.log(`[${req.url}] ${results.filter(r => r.ok).length}/${ids.length} ok`);
res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ results }));
}
res.writeHead(404); res.end('nf');
} catch (e) { res.writeHead(500); res.end(String(e).slice(0, 200)); }
});
-server.listen(0, '127.0.0.1', () => console.log('DG_REVIEW_VIEWER_PORT=' + server.address().port));
+server.listen(49479, '127.0.0.1', () => console.log('DG_REVIEW_VIEWER on 49479'));
← a9e408a De Gournay settlement-review viewer (select 1-all, activate
·
back to Degournay Review Viewer
·
chore: lint (sanitize incoming ids) + refactor (drop dead vi df1320d →