← back to Pattern Vault
wpb-uploaders: control panel (:9813) — click-to-launch per-platform login/dry-run/verify, live status
7dbc8b463062e993c74e720e0d58b92ce9402868 · 2026-07-09 22:23:32 -0700 · Steve
Files touched
A wpb-uploaders/control-panel.jsA wpb-uploaders/public/control-panel.html
Diff
commit 7dbc8b463062e993c74e720e0d58b92ce9402868
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Jul 9 22:23:32 2026 -0700
wpb-uploaders: control panel (:9813) — click-to-launch per-platform login/dry-run/verify, live status
---
wpb-uploaders/control-panel.js | 115 ++++++++++++++++++++++++++++++++
wpb-uploaders/public/control-panel.html | 93 ++++++++++++++++++++++++++
2 files changed, 208 insertions(+)
diff --git a/wpb-uploaders/control-panel.js b/wpb-uploaders/control-panel.js
new file mode 100644
index 0000000..9249523
--- /dev/null
+++ b/wpb-uploaders/control-panel.js
@@ -0,0 +1,115 @@
+#!/usr/bin/env node
+/*
+ * WPB Uploader Control Panel — a local dashboard YOU drive.
+ * Buttons launch each platform's login-capture / dry-run / status; live status per platform.
+ * Zero-dependency Node. Basic auth admin:DW2024!. → http://localhost:9813
+ *
+ * node control-panel.js
+ */
+'use strict';
+const http = require('http'), fs = require('fs'), path = require('path'), os = require('os');
+const { spawn, execFile } = require('child_process');
+
+const PORT = process.env.PORT || 9813;
+const ROOT = __dirname;
+const PLATFORMS = ['spoonflower', 'etsy', 'creativemarket', 'redbubble', 'society6', 'patternbank'];
+const AUTH_USER = 'admin', AUTH_PASS = 'DW2024!';
+
+// track running capture children so the UI can show "login window open…"
+const running = {}; // platform -> {pid, startedAt}
+
+function loadPlatform(key) { try { return require(path.join(ROOT, 'platforms', `${key}.js`)); } catch { return null; } }
+
+// verify a captured session is REAL (load account page in a probe, check auth markers)
+function verifySession(key) {
+ return new Promise((resolve) => {
+ const cfg = loadPlatform(key);
+ if (!cfg || cfg.apiBased || !cfg.sessionFile || !fs.existsSync(cfg.sessionFile)) return resolve({ captured: false });
+ const code = `
+ const { chromium } = require('/Users/macstudio3/.npm-global/lib/node_modules/playwright');
+ (async () => { try {
+ const b = await chromium.launch({ headless: true });
+ const ctx = await b.newContext({ storageState: ${JSON.stringify(cfg.sessionFile)} });
+ const pg = await ctx.newPage();
+ await pg.goto(${JSON.stringify(cfg.healthUrl)}, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(()=>{});
+ await pg.waitForTimeout(1500);
+ const u = pg.url();
+ const bad = /login|signin/i.test(u) || !!(await pg.$('input[type=password]').catch(()=>null));
+ const logout = !!(await pg.$('a[href*=logout i], a[href*=sign_out i], button:has-text("Sign out"), button:has-text("Log out")').catch(()=>null));
+ const txt = ((await pg.evaluate(()=>document.body.innerText).catch(()=>'')||'')).toLowerCase();
+ const words = /sign out|log out|my account|dashboard|purchases|downloads|my shop/.test(txt);
+ console.log(JSON.stringify({ captured: !bad && (logout||words) }));
+ await b.close();
+ } catch(e) { console.log(JSON.stringify({ captured:false, err:String(e).slice(0,80) })); } })();`;
+ execFile(process.execPath, ['-e', code], { timeout: 45000 }, (err, stdout) => {
+ try { resolve(JSON.parse(stdout.trim().split('\n').pop())); } catch { resolve({ captured: false }); }
+ });
+ });
+}
+
+function statusOf(key) {
+ const cfg = loadPlatform(key);
+ const armed = key === 'spoonflower'
+ ? fs.existsSync(path.join(ROOT, '..', 'whimsical-compare', 'daily', 'ARMED'))
+ : fs.existsSync(path.join(ROOT, 'platforms', `${key}.ARMED`));
+ const hasSession = key === 'spoonflower'
+ ? fs.existsSync(path.join(os.homedir(), '.config/pattern-vault/sf-state.json'))
+ : !!(cfg && cfg.sessionFile && fs.existsSync(cfg.sessionFile));
+ return {
+ key, name: cfg ? cfg.name : key, brand: cfg ? cfg.brand : '', apiBased: !!(cfg && cfg.apiBased),
+ armed, hasSession, running: !!running[key],
+ blocker: key === 'spoonflower' ? 'none — working + armed'
+ : cfg && cfg.apiBased ? (process.env.ETSY_OAUTH_TOKEN ? 'token present' : 'needs Etsy OAuth token')
+ : hasSession ? 'session captured — needs DOM calibration'
+ : ['society6', 'patternbank'].includes(key) ? 'account pending confirmation'
+ : 'needs login (click Log in)',
+ };
+}
+
+// ---- http ----
+function auth(req) { const h = req.headers.authorization || ''; if (!h.startsWith('Basic ')) return false; const [u, p] = Buffer.from(h.slice(6), 'base64').toString().split(':'); return u === AUTH_USER && p === AUTH_PASS; }
+function send(res, code, body, type) { res.writeHead(code, { 'Content-Type': type || 'application/json', 'Cache-Control': 'no-store' }); res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body)); }
+
+const server = http.createServer(async (req, res) => {
+ const url = new URL(req.url, `http://localhost:${PORT}`);
+ const p = url.pathname;
+ if (p === '/healthz') return send(res, 200, { ok: true });
+ if (!auth(req)) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="WPB"' }); return res.end('auth'); }
+
+ if (p === '/api/status') {
+ const rows = PLATFORMS.map(statusOf);
+ return send(res, 200, { rows, generatedAt: new Date().toISOString() });
+ }
+
+ // launch a login-capture window for a platform (opens a browser for Steve to log into)
+ const capM = p.match(/^\/api\/capture\/(\w+)$/);
+ if (capM && req.method === 'POST') {
+ const key = capM[1];
+ if (!['creativemarket', 'redbubble', 'society6', 'patternbank'].includes(key)) return send(res, 400, { ok: false, error: 'not a web-login platform' });
+ if (running[key]) return send(res, 200, { ok: true, already: true });
+ const engine = url.searchParams.get('engine') === 'chromium' ? 'chromium' : 'webkit';
+ const child = spawn(process.execPath, [path.join(ROOT, 'capture-session.js'), key],
+ { cwd: ROOT, detached: true, stdio: 'ignore', env: { ...process.env, WPB_ENGINE: engine } });
+ running[key] = { pid: child.pid, startedAt: Date.now() };
+ child.on('exit', () => { delete running[key]; });
+ child.unref();
+ return send(res, 200, { ok: true, launched: key, engine, note: 'a browser window is opening — log in there' });
+ }
+
+ // verify a platform's captured session is genuinely authenticated
+ const verM = p.match(/^\/api\/verify\/(\w+)$/);
+ if (verM && req.method === 'POST') { const r = await verifySession(verM[1]); return send(res, 200, r); }
+
+ // Etsy dry-run (proves the 38-payload mapping, no network)
+ if (p === '/api/etsy/dryrun') { const cfg = loadPlatform('etsy'); return send(res, 200, cfg && cfg.dryRun ? cfg.dryRun() : { error: 'no etsy' }); }
+
+ // static
+ let f = p === '/' ? '/control-panel.html' : p;
+ const abs = path.join(ROOT, 'public', path.normalize(f).replace(/^(\.\.[\/\\])+/, ''));
+ if (abs.startsWith(path.join(ROOT, 'public')) && fs.existsSync(abs) && fs.statSync(abs).isFile()) {
+ const ext = path.extname(abs);
+ return send(res, 200, fs.readFileSync(abs), { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css' }[ext] || 'text/plain');
+ }
+ return send(res, 404, { error: 'not found' });
+});
+server.listen(PORT, () => console.log(`WPB Uploader Control Panel → http://localhost:${PORT} (admin:DW2024!)`));
diff --git a/wpb-uploaders/public/control-panel.html b/wpb-uploaders/public/control-panel.html
new file mode 100644
index 0000000..9729681
--- /dev/null
+++ b/wpb-uploaders/public/control-panel.html
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>WPB Uploader Control Panel</title>
+<style>
+ :root{--bg:#0d0e12;--card:#15171e;--line:#262a35;--ink:#f2f2f5;--dim:#9aa0ad;--accent:#f4a261;--good:#2ec4b6;--warn:#e9c46a;--bad:#e76f51;--pending:#8a8f9c}
+ *{box-sizing:border-box}
+ body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
+ .wrap{max-width:900px;margin:0 auto;padding:26px 20px 80px}
+ h1{font:700 22px/1 Georgia,serif;margin:0 0 4px}
+ h1 b{color:var(--accent)}
+ .sub{color:var(--dim);font-size:13px;margin-bottom:22px}
+ .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px 18px;margin-bottom:12px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
+ .card .nm{font-weight:700;font-size:16px}
+ .card .meta{color:var(--dim);font-size:12.5px}
+ .pill{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:3px 9px;border-radius:999px;white-space:nowrap}
+ .pill.good{background:rgba(46,196,182,.16);color:var(--good)}
+ .pill.warn{background:rgba(233,196,106,.16);color:var(--warn)}
+ .pill.bad{background:rgba(231,111,81,.16);color:var(--bad)}
+ .pill.pending{background:rgba(138,143,156,.16);color:var(--pending)}
+ .grow{flex:1;min-width:180px}
+ .actions{display:flex;gap:8px;flex-wrap:wrap}
+ button{background:var(--accent);color:#141414;border:0;border-radius:9px;padding:9px 15px;font-weight:700;font-size:13px;cursor:pointer}
+ button.ghost{background:transparent;color:var(--ink);border:1px solid var(--line)}
+ button:disabled{opacity:.4;cursor:not-allowed}
+ button:hover:not(:disabled){filter:brightness(1.07)}
+ .msg{font-size:12.5px;color:var(--dim);margin-top:8px;width:100%}
+ input.tok{background:#1b1e27;border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:8px 10px;font-size:12.5px;min-width:220px;flex:1}
+ .foot{color:var(--dim);font-size:11.5px;margin-top:24px;text-align:center}
+ a{color:var(--accent)}
+</style></head><body>
+<div class="wrap">
+ <h1><b>WPB</b> Uploader Control Panel</h1>
+ <div class="sub">Launch each platform yourself. Spoonflower is live; the rest need one thing from you.</div>
+ <div id="cards"></div>
+ <div class="foot">Local · admin:•••• · :9813 · Spoonflower uploads daily on its own. $0 (local).</div>
+</div>
+<script>
+const $ = s => document.querySelector(s);
+const api = (u,m) => fetch(u,{method:m||'GET'}).then(r=>r.json());
+function pill(row){
+ if(row.key==='spoonflower') return '<span class="pill good">● LIVE</span>';
+ if(row.running) return '<span class="pill warn">◐ login window open…</span>';
+ if(row.apiBased) return row.blocker==='token present'?'<span class="pill warn">token — dry-run ready</span>':'<span class="pill bad">needs token</span>';
+ if(row.hasSession) return '<span class="pill warn">session ✓ — calibrating</span>';
+ if(row.blocker.includes('pending')) return '<span class="pill pending">account pending</span>';
+ return '<span class="pill bad">needs login</span>';
+}
+function actions(row){
+ if(row.key==='spoonflower') return '<span class="meta">Uploads daily via the armed cron.</span>';
+ if(row.apiBased) return `
+ <input class="tok" id="etsytok" placeholder="paste Etsy OAuth token (listings_w)…">
+ <button class="ghost" onclick="etsyDry()">Preview 38 drafts</button>
+ <button onclick="etsyPush()" title="gated — pushes drafts once token is set">Push drafts</button>`;
+ if(row.blocker.includes('pending')) return '<span class="meta">Confirm the account (check email), then it moves to “needs login”.</span>';
+ // web-login platforms
+ return `
+ <button onclick="cap('${row.key}')" ${row.running?'disabled':''}>${row.hasSession?'Re-login':'Log in'}</button>
+ <button class="ghost" onclick="verify('${row.key}')">Verify session</button>`;
+}
+async function render(){
+ const {rows} = await api('/api/status');
+ $('#cards').innerHTML = rows.map(r=>`
+ <div class="card" data-k="${r.key}">
+ <div class="grow">
+ <div class="nm">${r.name} ${pill(r)}</div>
+ <div class="meta">${r.brand} · ${r.blocker}</div>
+ </div>
+ <div class="actions">${actions(r)}</div>
+ <div class="msg" id="msg-${r.key}"></div>
+ </div>`).join('');
+}
+function msg(k,t){ const el=$('#msg-'+k); if(el) el.textContent=t; }
+async function cap(k){
+ msg(k,'launching a login window… log in there, then click “Verify session”.');
+ const r = await api('/api/capture/'+k,'POST');
+ msg(k, r.ok ? '✅ browser opening — log in, then click “Verify session”.' : ('✗ '+(r.error||'failed')));
+ setTimeout(render, 1200);
+}
+async function verify(k){
+ msg(k,'checking your session (loading the account page)…');
+ const r = await api('/api/verify/'+k,'POST');
+ msg(k, r.captured ? '✅ session is GENUINELY logged in — captured & verified.' : '✗ not logged in yet (finish the login, then Verify again).');
+ render();
+}
+async function etsyDry(){
+ const r = await api('/api/etsy/dryrun');
+ msg('etsy', r.error?('✗ '+r.error):`✅ ${r.count} drafts mapped @ $${r.price}. Blockers: ${(r.blockers||[]).join('; ')||'none'}. (Sample title: ${(r.sample&&r.sample[0]&&r.sample[0].title||'').slice(0,50)})`);
+}
+function etsyPush(){ msg('etsy','⛔ Pushing live drafts is gated — paste the token + say “push etsy” to Claude, who’ll fire the 38 drafts (Steve-approved).'); }
+render(); setInterval(render, 5000);
+</script>
+</body></html>
← a0ae52d capture-session: WebKit (Safari engine) default per Steve; c
·
back to Pattern Vault
·
auto-save: 2026-07-13T06:53:18 (2 files) — data/trend-board. 378dec5 →