← back to Pitch Guard
Pitch Guard: resizable / collapsible / reorderable / pop-out panels
5c43a5ee779f539cf4ad67df5e55c06f8fe2cf9c · 2026-05-19 08:59:45 -0700 · Steve
Files touched
Diff
commit 5c43a5ee779f539cf4ad67df5e55c06f8fe2cf9c
Author: Steve <steve@designerwallcoverings.com>
Date: Tue May 19 08:59:45 2026 -0700
Pitch Guard: resizable / collapsible / reorderable / pop-out panels
---
public/index.html | 287 ++++++++++++++++++++++++++++++++++++++++++++----------
1 file changed, 235 insertions(+), 52 deletions(-)
diff --git a/public/index.html b/public/index.html
index a1f5ab3..9dcbcde 100644
--- a/public/index.html
+++ b/public/index.html
@@ -15,23 +15,45 @@
body{font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:var(--cream);color:var(--ink);height:100vh;overflow:hidden}
h1,h2,h3,.serif{font-family:Georgia,"Times New Roman",serif}
header{height:54px;background:var(--ink);color:var(--cream);display:flex;align-items:center;
- padding:0 18px;gap:14px;border-bottom:3px solid var(--gold)}
+ padding:0 18px;gap:14px;border-bottom:3px solid var(--gold);flex-shrink:0}
header h1{font-size:18px;font-weight:600;letter-spacing:.3px}
header .sub{font-size:12px;color:#cdc6b3;letter-spacing:.4px}
header .spacer{flex:1}
header .acct{font-size:12px;background:#3a352b;padding:5px 11px;border-radius:20px;color:#e8e1cd}
- .layout{display:flex;height:calc(100vh - 54px)}
+ header .reset{font-size:11px;background:none;border:1px solid #4a4438;color:#cdc6b3;
+ padding:4px 10px;border-radius:5px;cursor:pointer}
+ header .reset:hover{border-color:var(--gold);color:#fff}
+ .layout{display:flex;height:calc(100vh - 54px);overflow:hidden}
/* ── panels ── */
- .panel{display:flex;flex-direction:column;height:100%;overflow:hidden}
- .pane-left{width:336px;border-right:1px solid var(--line);background:var(--paper)}
- .pane-mid{flex:1;min-width:340px;background:var(--cream)}
- .pane-right{width:438px;border-left:1px solid var(--line);background:var(--paper)}
- .pane-hd{padding:11px 14px;border-bottom:1px solid var(--line);display:flex;
- align-items:center;gap:8px;background:var(--paper);flex-shrink:0}
- .pane-hd h2{font-size:13px;text-transform:uppercase;letter-spacing:.9px;color:var(--muted);font-family:inherit;font-weight:700}
+ .panel{display:flex;flex-direction:column;height:100%;overflow:hidden;background:var(--paper);
+ flex:0 0 auto;min-width:46px}
+ .panel.flexed{flex:1 1 0;min-width:300px}
+ .panel.collapsed{flex:0 0 42px !important;min-width:42px;background:var(--cream)}
+ .panel.collapsed .pane-body,.panel.collapsed .pane-hd>*:not(.collapse-strip){display:none}
+ .panel.dragging{opacity:.4}
+ .panel.drop-target{outline:2px dashed var(--gold);outline-offset:-2px}
+ .pane-hd{padding:9px 12px;border-bottom:1px solid var(--line);display:flex;
+ align-items:center;gap:7px;background:var(--paper);flex-shrink:0;min-height:44px;cursor:grab}
+ .pane-hd:active{cursor:grabbing}
+ .pane-hd h2{font-size:12px;text-transform:uppercase;letter-spacing:.9px;color:var(--muted);
+ font-family:inherit;font-weight:700;white-space:nowrap}
+ .pane-hd .grip{color:var(--line);font-size:13px;cursor:grab;flex-shrink:0;letter-spacing:-2px}
.pane-hd .count{font-size:11px;background:var(--ink);color:var(--cream);border-radius:10px;padding:1px 8px}
- .pane-body{flex:1;overflow-y:auto}
.pane-hd .spacer{flex:1}
+ .hd-btn{font-size:12px;line-height:1;background:none;border:1px solid var(--line);color:var(--muted);
+ width:24px;height:24px;border-radius:5px;cursor:pointer;flex-shrink:0;padding:0}
+ .hd-btn:hover{border-color:var(--gold);color:var(--gold);background:var(--cream)}
+ .collapse-strip{display:none;flex-direction:column;align-items:center;gap:10px;padding-top:4px;cursor:pointer}
+ .panel.collapsed .pane-hd{flex-direction:column;cursor:pointer;padding:9px 4px;min-height:0}
+ .panel.collapsed .collapse-strip{display:flex}
+ .collapse-strip .vlabel{writing-mode:vertical-rl;font-size:11px;text-transform:uppercase;
+ letter-spacing:1.5px;color:var(--muted);font-weight:700}
+ .pane-body{flex:1;overflow-y:auto}
+ /* ── resize handle ── */
+ .rz-handle{flex:0 0 7px;cursor:col-resize;background:var(--line);position:relative}
+ .rz-handle:hover,.rz-handle.active{background:var(--gold)}
+ .rz-handle::after{content:"";position:absolute;left:2px;top:50%;width:3px;height:34px;
+ transform:translateY(-50%);background:var(--paper);border-radius:2px;opacity:.6}
/* ── buttons ── */
button{font:inherit;cursor:pointer;border:1px solid var(--line);background:var(--paper);
color:var(--ink);border-radius:6px;padding:6px 12px;transition:.12s}
@@ -49,7 +71,6 @@
.draft .subj{font-size:12px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:1px}
.draft .meta{font-size:11px;color:var(--muted);margin-top:3px}
.draft .badge{position:absolute;top:9px;right:12px}
- /* ── risk badges ── */
.badge{font-size:10px;font-weight:700;letter-spacing:.5px;border-radius:4px;padding:2px 6px;text-transform:uppercase}
.r-high{background:var(--redbg);color:var(--red)}
.r-elevated{background:var(--amberbg);color:var(--amber)}
@@ -119,6 +140,7 @@
.toast.show{opacity:1}
.toast.err{background:var(--red)}
.sent-stamp{font-size:11px;color:var(--green);font-weight:700}
+ body.solo .rz-handle,body.solo .grip,body.solo [data-act="popout"]{display:none}
</style>
</head>
<body>
@@ -126,37 +148,53 @@
<h1>Pitch Guard</h1>
<span class="sub">review before you follow up</span>
<span class="spacer"></span>
+ <button class="reset" id="resetLayout" title="Reset panels to default layout">⤢ reset layout</button>
<span class="acct" id="acct">info@designerwallcoverings.com</span>
</header>
-<div class="layout">
- <!-- LEFT: drafts -->
- <div class="panel pane-left">
+<div class="layout" id="layout">
+ <!-- DRAFTS -->
+ <div class="panel" id="panel-drafts" data-panel="drafts">
<div class="pane-hd">
+ <span class="collapse-strip"><button class="hd-btn" data-act="collapse">▸</button><span class="vlabel">Drafts</span></span>
+ <span class="grip">⠿</span>
<h2>Drafts</h2><span class="count" id="draftCount">·</span>
<span class="spacer"></span>
<button class="btn-sm" id="scanAll" title="Scan every contact for prior orders">Scan risk</button>
+ <button class="hd-btn" data-act="popout" title="Pop out into its own window">↗</button>
+ <button class="hd-btn" data-act="collapse" title="Collapse panel">–</button>
</div>
<div class="pane-body" id="draftList">
<div class="empty"><div class="spin"></div> loading drafts…</div>
</div>
</div>
- <!-- MIDDLE: correspondence -->
- <div class="panel pane-mid">
+ <!-- CORRESPONDENCE -->
+ <div class="panel flexed" id="panel-corr" data-panel="corr">
<div class="pane-hd">
+ <span class="collapse-strip"><button class="hd-btn" data-act="collapse">▸</button><span class="vlabel">Correspondence</span></span>
+ <span class="grip">⠿</span>
<h2>Correspondence</h2>
- <span class="spacer"></span>
<span class="count" id="threadCount" style="display:none"></span>
+ <span class="spacer"></span>
+ <button class="hd-btn" data-act="popout" title="Pop out into its own window">↗</button>
+ <button class="hd-btn" data-act="collapse" title="Collapse panel">–</button>
</div>
<div class="pane-body" id="corr">
- <div class="empty"><div class="big">✉</div>Pick a draft on the left to see every thread<br>with that contact before you decide.</div>
+ <div class="empty"><div class="big">✉</div>Pick a draft to see every thread<br>with that contact before you decide.</div>
</div>
</div>
- <!-- RIGHT: the pitch -->
- <div class="panel pane-right">
- <div class="pane-hd"><h2>The Pitch</h2></div>
+ <!-- PITCH -->
+ <div class="panel" id="panel-pitch" data-panel="pitch">
+ <div class="pane-hd">
+ <span class="collapse-strip"><button class="hd-btn" data-act="collapse">▸</button><span class="vlabel">The Pitch</span></span>
+ <span class="grip">⠿</span>
+ <h2>The Pitch</h2>
+ <span class="spacer"></span>
+ <button class="hd-btn" data-act="popout" title="Pop out into its own window">↗</button>
+ <button class="hd-btn" data-act="collapse" title="Collapse panel">–</button>
+ </div>
<div class="pane-body" id="pitch">
<div class="pitch-empty">No draft selected.<br>The followup waiting for this client will appear here.</div>
</div>
@@ -169,7 +207,7 @@
const $ = s => document.querySelector(s);
let DRAFTS = [], CURRENT = null, MORE = false;
const riskCache = {}; // email -> full /api/contact result
-const liteCache = {}; // email -> light /api/risk result {risk,signals}
+const liteCache = {}; // email -> light /api/risk result
const verdictOf = email => riskCache[email] || liteCache[email] || null;
function toast(msg, err){ const t=$('#toast'); t.textContent=msg; t.className='toast show'+(err?' err':''); setTimeout(()=>t.className='toast',2600); }
@@ -178,26 +216,171 @@ function fmtDate(d){ if(!d) return ''; const t=Date.parse(d); if(!t) return d; c
return dt.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'2-digit'}); }
const riskWord = {high:'High risk',elevated:'Elevated',caution:'Caution',clear:'Clear'};
-// ── load drafts ──
+/* ════════ LAYOUT MANAGER — resize · collapse · reorder · pop-out ════════ */
+const PANELS = ['drafts','corr','pitch'];
+const LS = 'pg.layout.v1';
+const DEFAULT_LAYOUT = { order:['drafts','corr','pitch'], width:{drafts:336,corr:0,pitch:438}, collapsed:{} };
+let layout = loadLayout();
+
+function loadLayout(){
+ try{ const v=JSON.parse(localStorage.getItem(LS)); if(v&&v.order) return Object.assign({},DEFAULT_LAYOUT,v,{width:Object.assign({},DEFAULT_LAYOUT.width,v.width),collapsed:v.collapsed||{}}); }catch(e){}
+ return JSON.parse(JSON.stringify(DEFAULT_LAYOUT));
+}
+function saveLayout(){ try{ localStorage.setItem(LS,JSON.stringify(layout)); }catch(e){} }
+
+// apply order + widths + collapsed to the DOM
+function applyLayout(){
+ const lay = $('#layout');
+ // strip existing handles
+ lay.querySelectorAll('.rz-handle').forEach(h=>h.remove());
+ // order the panels
+ layout.order.forEach(key=>{
+ const p = document.getElementById('panel-'+key);
+ if(p) lay.appendChild(p);
+ });
+ // the middle slot flexes; edges are fixed-width
+ layout.order.forEach((key,i)=>{
+ const p = document.getElementById('panel-'+key);
+ if(!p) return;
+ p.classList.toggle('collapsed', !!layout.collapsed[key]);
+ const mid = (i===1);
+ p.classList.toggle('flexed', mid && !layout.collapsed[key]);
+ if(!mid && !layout.collapsed[key]){
+ p.style.flex = '0 0 ' + (layout.width[key]||340) + 'px';
+ } else {
+ p.style.flex = '';
+ }
+ });
+ // insert resize handles between panels
+ for(let i=0;i<layout.order.length-1;i++){
+ const after = document.getElementById('panel-'+layout.order[i]);
+ const h = document.createElement('div');
+ h.className='rz-handle'; h.dataset.idx=i;
+ after.after(h);
+ wireHandle(h, i);
+ }
+ saveLayout();
+}
+
+// resize: handle i sits between slot i and slot i+1; drag resizes the non-middle one
+function wireHandle(h, i){
+ h.addEventListener('pointerdown', e=>{
+ e.preventDefault();
+ const leftKey = layout.order[i], rightKey = layout.order[i+1];
+ // pick the edge (non-middle) panel this handle should resize
+ const targetKey = (i===0) ? leftKey : rightKey;
+ const grow = (i===0) ? 1 : -1; // dragging right widens slot0; widens slot2 when dragging left
+ const startX = e.clientX, startW = layout.width[targetKey] || document.getElementById('panel-'+targetKey).offsetWidth;
+ h.classList.add('active');
+ h.setPointerCapture(e.pointerId);
+ const move = ev=>{
+ let w = startW + grow*(ev.clientX-startX);
+ w = Math.max(180, Math.min(900, w));
+ layout.width[targetKey] = w;
+ const p = document.getElementById('panel-'+targetKey);
+ if(p) p.style.flex = '0 0 '+w+'px';
+ };
+ const up = ()=>{ h.classList.remove('active'); h.removeEventListener('pointermove',move);
+ h.removeEventListener('pointerup',up); saveLayout(); };
+ h.addEventListener('pointermove',move);
+ h.addEventListener('pointerup',up);
+ });
+}
+
+// reorder: drag a panel by its header onto another panel
+let dragKey = null;
+function wireDragReorder(){
+ PANELS.forEach(key=>{
+ const p = document.getElementById('panel-'+key);
+ const hd = p.querySelector('.pane-hd');
+ hd.setAttribute('draggable','true');
+ hd.addEventListener('dragstart', e=>{
+ dragKey = key; p.classList.add('dragging');
+ e.dataTransfer.effectAllowed='move';
+ try{ e.dataTransfer.setData('text/plain',key); }catch(_){}
+ });
+ hd.addEventListener('dragend', ()=>{ p.classList.remove('dragging');
+ PANELS.forEach(k=>document.getElementById('panel-'+k).classList.remove('drop-target')); dragKey=null; });
+ p.addEventListener('dragover', e=>{ if(dragKey&&dragKey!==key){ e.preventDefault(); p.classList.add('drop-target'); } });
+ p.addEventListener('dragleave', ()=>p.classList.remove('drop-target'));
+ p.addEventListener('drop', e=>{
+ e.preventDefault(); p.classList.remove('drop-target');
+ if(!dragKey||dragKey===key) return;
+ const ord = layout.order.slice();
+ ord.splice(ord.indexOf(dragKey),1);
+ ord.splice(ord.indexOf(key),0,dragKey);
+ layout.order = ord; applyLayout();
+ });
+ });
+}
+
+function toggleCollapse(key){
+ layout.collapsed[key] = !layout.collapsed[key];
+ applyLayout();
+}
+function popout(key){
+ const u = new URL(location.href);
+ u.searchParams.set('only', key);
+ if(CURRENT) u.searchParams.set('draft', CURRENT);
+ window.open(u.toString(), 'pg-'+key, 'width=560,height=900');
+}
+function resetLayout(){ localStorage.removeItem(LS); layout = JSON.parse(JSON.stringify(DEFAULT_LAYOUT)); applyLayout(); toast('Layout reset'); }
+
+// header buttons (collapse / popout) + collapsed-strip click
+function wirePanelControls(){
+ PANELS.forEach(key=>{
+ const p = document.getElementById('panel-'+key);
+ if(!p) return; // removed in solo mode
+ p.querySelectorAll('[data-act]').forEach(btn=>{
+ btn.addEventListener('click', e=>{
+ e.stopPropagation();
+ if(btn.dataset.act==='collapse') toggleCollapse(key);
+ else if(btn.dataset.act==='popout') popout(key);
+ });
+ });
+ // clicking anywhere on a collapsed panel re-expands it
+ p.querySelector('.pane-hd').addEventListener('click', e=>{
+ if(p.classList.contains('collapsed') && !e.target.closest('[data-act]')) toggleCollapse(key);
+ });
+ });
+}
+
+// solo mode — a popped-out single panel
+function applySoloMode(){
+ const params = new URLSearchParams(location.search);
+ const only = params.get('only');
+ if(!only || !PANELS.includes(only)) return null;
+ document.body.classList.add('solo');
+ PANELS.forEach(k=>{
+ const p = document.getElementById('panel-'+k);
+ if(k===only){ p.classList.add('flexed'); p.classList.remove('collapsed'); p.style.flex='1 1 0'; }
+ else p.remove();
+ });
+ $('#resetLayout').style.display='none';
+ document.querySelector('header .sub').textContent = only==='drafts'?'drafts':only==='corr'?'correspondence':'the pitch';
+ return params.get('draft');
+}
+
+/* ════════ DATA ════════ */
async function loadDrafts(){
try{
const r = await fetch('/api/drafts'); const d = await r.json();
if(d.error) throw new Error(d.error);
- DRAFTS = d.drafts; MORE = d.more; $('#acct').textContent = d.account;
- $('#draftCount').textContent = d.count + (d.more ? '+' : '');
- $('#draftCount').title = d.more ? d.count + ' newest shown — the followup-bot backlog is larger' : d.count + ' drafts';
+ DRAFTS = d.drafts; MORE = d.more;
+ const acct = $('#acct'); if(acct) acct.textContent = d.account;
+ const dc = $('#draftCount'); if(dc) dc.textContent = d.count + (d.more ? '+' : '');
renderDrafts();
- }catch(e){ $('#draftList').innerHTML = '<div class="empty">⚠ '+esc(e.message)+'</div>'; }
+ }catch(e){ const el = $('#draftList'); if(el) el.innerHTML = '<div class="empty">⚠ '+esc(e.message)+'</div>'; }
}
function renderDrafts(){
const el = $('#draftList');
+ if(!el) return; // drafts panel popped out / not in this window
if(!DRAFTS.length){ el.innerHTML='<div class="empty">No drafts waiting.</div>'; return; }
el.innerHTML = (MORE?'<div class="meta" style="padding:7px 14px;color:var(--muted);border-bottom:1px solid var(--line)">Showing the '+DRAFTS.length+' most recent followup drafts — the bot backlog is larger.</div>':'') +
DRAFTS.map(d=>{
const c = verdictOf(d.toEmail);
let badge = '<span class="badge r-pending">·</span>';
- if(d._scanning) badge = '<span class="badge r-pending"><span class="spin"></span></span>';
- else if(c) badge = '<span class="badge r-'+c.risk+'">'+c.risk+'</span>';
+ if(c) badge = '<span class="badge r-'+c.risk+'">'+c.risk+'</span>';
return '<div class="draft'+(CURRENT===d.id?' active':'')+'" data-id="'+d.id+'">'+
badge+
'<div class="who">'+esc(d.toName)+'</div>'+
@@ -208,20 +391,17 @@ function renderDrafts(){
el.querySelectorAll('.draft').forEach(n=>n.onclick=()=>selectDraft(n.dataset.id));
}
-// ── select a draft ──
async function selectDraft(id){
CURRENT = id; renderDrafts();
const d = DRAFTS.find(x=>x.id===id); if(!d) return;
- if(!d.toEmail){
- $('#corr').innerHTML = '<div class="empty">This draft has no recipient address — nothing to cross-check.</div>';
- } else {
- $('#corr').innerHTML = '<div class="empty"><div class="spin"></div> pulling every thread with '+esc(d.toEmail)+'…</div>';
- loadContact(d.toEmail);
- }
+ const corr = $('#corr');
+ if(corr){
+ if(!d.toEmail) corr.innerHTML = '<div class="empty">This draft has no recipient address — nothing to cross-check.</div>';
+ else { corr.innerHTML = '<div class="empty"><div class="spin"></div> pulling every thread with '+esc(d.toEmail)+'…</div>'; loadContact(d.toEmail); }
+ } else if(d.toEmail){ loadContact(d.toEmail); }
loadPitch(d);
}
-// ── middle: correspondence ──
async function loadContact(email){
try{
let c = riskCache[email];
@@ -231,6 +411,7 @@ async function loadContact(email){
}catch(e){ $('#corr').innerHTML='<div class="empty">⚠ '+esc(e.message)+'</div>'; }
}
function renderCorr(c){
+ if(!$('#corr')) return; // correspondence panel not in this window
$('#threadCount').style.display='inline-block';
$('#threadCount').textContent = c.threadCount+' thread'+(c.threadCount===1?'':'s');
const sigChips = c.signals.map(s=>'<span class="badge r-high">'+esc(s)+'</span>').join(' ');
@@ -265,24 +446,22 @@ function renderCorr(c){
$('#corr').innerHTML = banner +
'<div class="contact-hd"><b>'+esc(c.email)+'</b> — '+c.totalMessages+' messages across '+c.threadCount+' thread'+(c.threadCount===1?'':'s')+
(c.customerReplied?' · customer has replied':'')+'</div>' + threads;
-
$('#corr').querySelectorAll('.msg').forEach(n=>n.onclick=()=>toggleMsg(n));
}
async function toggleMsg(node){
node.classList.toggle('open');
const body = node.querySelector('.m-body');
if(node.classList.contains('open') && body.dataset.loaded==='0'){
- body.dataset.loaded='1';
- body.textContent='loading…';
+ body.dataset.loaded='1'; body.textContent='loading…';
try{ const r=await fetch('/api/message/'+node.dataset.mid); const m=await r.json();
body.textContent = m.body || m.snippet || '(no text body)';
}catch(e){ body.textContent='⚠ '+e.message; }
}
}
-// ── right: the pitch ──
async function loadPitch(d){
const el = $('#pitch');
+ if(!el) return; // pitch panel not in this window
el.innerHTML = '<div class="pitch-empty"><div class="spin"></div> loading draft…</div>';
let body='';
try{ const r=await fetch('/api/message/'+d.id); const m=await r.json(); body=m.body||''; }
@@ -304,7 +483,6 @@ async function loadPitch(d){
'</div>';
$('#skipBtn').onclick=()=>{ toast('Skipped — draft left untouched in Gmail'); };
$('#sendBtn').onclick=()=>sendPitch(d);
- // verdict — show any cached read instantly; the full /api/contact refines it
const c = verdictOf(d.toEmail);
if(c) renderVerdict(c);
else if(d.toEmail) $('#verdictSlot').innerHTML='<div class="verdict caution"><h3><span class="spin"></span> Checking every thread with this contact…</h3></div>';
@@ -314,11 +492,11 @@ function renderVerdict(c){
const slot = $('#verdictSlot'); if(!slot) return;
let html='', gated = (c.risk==='high'||c.risk==='elevated');
if(c.risk==='high') html='<h3>⛔ '+riskWord.high+'</h3><p>Order / payment activity found. This client should likely <b>not</b> get a followup pitch. Confirm with Samantha first.</p>';
- else if(c.risk==='elevated') html='<h3>⚠ '+riskWord.elevated+'</h3><p>Invoice / order signals found in other threads — review the middle panel before sending.</p>';
+ else if(c.risk==='elevated') html='<h3>⚠ '+riskWord.elevated+'</h3><p>Invoice / order signals found in other threads — review the correspondence before sending.</p>';
else if(c.risk==='caution') html='<h3>↔ '+riskWord.caution+'</h3><p>Multiple threads exist. Make sure this followup is not a duplicate.</p>';
else html='<h3>✓ '+riskWord.clear+'</h3><p>No prior-order signals. Safe to follow up.</p>';
if(gated) html+='<label class="override"><input type="checkbox" id="ovr">'+
- '<span>I reviewed every thread above and confirm this followup is still appropriate.</span></label>';
+ '<span>I reviewed every thread and confirm this followup is still appropriate.</span></label>';
slot.className=''; slot.innerHTML='<div class="verdict '+c.risk+'">'+html+'</div>';
const sb=$('#sendBtn');
if(sb){
@@ -328,7 +506,6 @@ function renderVerdict(c){
}
}
-// ── send ──
async function sendPitch(d){
const subj=$('#pSubj').value.trim(), body=$('#pBody').value.trim();
if(!d.toEmail){ toast('No recipient on this draft',1); return; }
@@ -346,11 +523,9 @@ async function sendPitch(d){
if(out.error) throw new Error(out.error);
$('#sendState').innerHTML='<span class="sent-stamp">✓ sent in-thread</span>';
toast('Followup sent to '+d.toEmail);
- d._sent=true;
}catch(e){ $('#sendState').textContent=''; sb.disabled=false; toast('Send failed: '+e.message,1); }
}
-// ── scan all contacts for risk ──
async function scanAll(){
const btn=$('#scanAll'); btn.disabled=true;
const targets=[...new Set(DRAFTS.filter(d=>d.toEmail&&!verdictOf(d.toEmail)).map(d=>d.toEmail))];
@@ -362,20 +537,28 @@ async function scanAll(){
while(i<targets.length){
const email=targets[i++];
try{ const r=await fetch('/api/risk?email='+encodeURIComponent(email)); const v=await r.json();
- if(!v.error) liteCache[email]=v; }catch{}
+ if(!v.error) liteCache[email]=v; }catch(e){}
done++; tick(); renderDrafts();
- await new Promise(r=>setTimeout(r,250)); // gentle on Gmail quota
+ await new Promise(r=>setTimeout(r,250));
}
}
- await Promise.all([worker(),worker()]); // concurrency 2
+ await Promise.all([worker(),worker()]);
btn.disabled=false; btn.textContent='Scan risk';
const high=DRAFTS.filter(d=>{const v=verdictOf(d.toEmail);return v&&v.risk==='high';}).length;
const elev=DRAFTS.filter(d=>{const v=verdictOf(d.toEmail);return v&&v.risk==='elevated';}).length;
toast('Scan done — '+high+' high-risk, '+elev+' elevated');
}
-$('#scanAll').onclick=scanAll;
-loadDrafts();
+/* ════════ BOOT ════════ */
+$('#scanAll').onclick=scanAll;
+$('#resetLayout').onclick=resetLayout;
+const soloDraft = applySoloMode();
+if(!document.body.classList.contains('solo')){
+ applyLayout();
+ wireDragReorder();
+}
+wirePanelControls();
+loadDrafts().then(()=>{ if(soloDraft) selectDraft(soloDraft); });
</script>
</body>
</html>
← dd9f2df move Pitch Guard to port 8123
·
back to Pitch Guard
·
Split drafts into Clients + collapsible Vendors section (ven e6f61db →