← back to Norma
Bulk-select delete on IG auto-post viewer
ffc9303c0505b797ba3deac695e55df1ae65c784 · 2026-08-17 07:58:04 -0700 · steve
- Checkbox per card + bulk bar (select all shown / clear / delete selected)
- Sequential deletion; LIVE mode paced 60s between deletes (Meta ban-safe)
with live countdown + Stop button; tombstone mode runs back-to-back
- Per-item failure surfaces inline (open in IG / remove anyway); button
label + confirm text adapt to tombstone vs live mode
- Verified in real Chrome: render, select, select-all, clear, mode label
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M agents/instagram-agent/public/posts-viewer.html
Diff
commit ffc9303c0505b797ba3deac695e55df1ae65c784
Author: steve <steve@designerwallcoverings.com>
Date: Mon Aug 17 07:58:04 2026 -0700
Bulk-select delete on IG auto-post viewer
- Checkbox per card + bulk bar (select all shown / clear / delete selected)
- Sequential deletion; LIVE mode paced 60s between deletes (Meta ban-safe)
with live countdown + Stop button; tombstone mode runs back-to-back
- Per-item failure surfaces inline (open in IG / remove anyway); button
label + confirm text adapt to tombstone vs live mode
- Verified in real Chrome: render, select, select-all, clear, mode label
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
agents/instagram-agent/public/posts-viewer.html | 131 +++++++++++++++++++-----
1 file changed, 107 insertions(+), 24 deletions(-)
diff --git a/agents/instagram-agent/public/posts-viewer.html b/agents/instagram-agent/public/posts-viewer.html
index 02a014a..8bc3d35 100644
--- a/agents/instagram-agent/public/posts-viewer.html
+++ b/agents/instagram-agent/public/posts-viewer.html
@@ -30,8 +30,17 @@ input[type=range]{accent-color:var(--p)}
.oc .ok{color:var(--p)}.oc .bad{color:var(--r)}
main{padding:14px 16px 60px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--card),1fr));gap:12px}
-.card{background:var(--s);border:1px solid var(--bd);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;transition:opacity .25s,transform .25s}
+.card{position:relative;background:var(--s);border:1px solid var(--bd);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;transition:opacity .25s,transform .25s}
.card.gone{opacity:0;transform:scale(.94);pointer-events:none}
+.card.sel{outline:2px solid var(--p);outline-offset:-2px}
+.pick{position:absolute;top:8px;left:8px;width:22px;height:22px;z-index:3;cursor:pointer;accent-color:var(--p);background:#0009;border-radius:5px}
+.bulkbar{display:none;align-items:center;gap:10px;background:var(--e);border:1px solid var(--p);border-radius:9px;padding:8px 12px;font-size:13px;flex-wrap:wrap}
+.bulkbar.show{display:flex}
+.bulkbar .n{font-weight:650;color:var(--p)}
+.bulkbar button{background:var(--s);color:var(--t);border:1px solid var(--bd);border-radius:7px;padding:6px 11px;font-size:12.5px;cursor:pointer}
+.bulkbar button.go{background:var(--r);border-color:var(--r);color:#fff;font-weight:600}
+.bulkbar button.stop{background:var(--amber);border-color:var(--amber);color:#1a1205;font-weight:600}
+.bulkbar .prog{color:var(--m)}
.thumb{width:100%;aspect-ratio:1;object-fit:cover;background:var(--e);display:block}
.thumb.ph{display:flex;align-items:center;justify-content:center;color:var(--mut);font-size:12px}
.body{padding:9px 10px;display:flex;flex-direction:column;gap:6px;flex:1}
@@ -80,6 +89,14 @@ main{padding:14px 16px 60px}
driving your logged-in Chrome via openclaw. Deletes run one at a time and only work on the account
Chrome is logged into. If the Delete option isn't reachable, nothing is removed.
</div>
+ <div class="bulkbar" id="bulkbar">
+ <span><span class="n" id="selN">0</span> selected</span>
+ <button onclick="selectAllShown()">Select all shown</button>
+ <button onclick="clearSel()">Clear</button>
+ <button class="go" id="bulkGo" onclick="runBulk()">🗑 Delete selected</button>
+ <span class="prog" id="bulkProg"></span>
+ <button class="stop" id="bulkStop" style="display:none" onclick="stopBulk()">■ Stop</button>
+ </div>
</header>
<main><div class="grid" id="grid"></div><div class="empty" id="empty" style="display:none">No posts.</div></main>
<div class="toast" id="toast"></div>
@@ -89,6 +106,9 @@ const $=s=>document.querySelector(s);
const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const LS={get:(k,d)=>{try{return localStorage.getItem('igposts.'+k)??d}catch{return d}},set:(k,v)=>{try{localStorage.setItem('igposts.'+k,v)}catch{}}};
let ALL=[], MODE=LS.get('mode','tombstone'), LIVE_ENABLED=false;
+const SELECTED=new Set();
+const LIVE_PACE_MS=60000; // 60s between LIVE deletes — Meta's automation-ban threshold
+let BULK_RUNNING=false, BULK_ABORT=false;
function fmtWhen(t){ if(!t) return '—'; const d=new Date(t); return isNaN(d)?esc(t):d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }
@@ -107,6 +127,7 @@ function applyMode(){
$('#mLive').classList.toggle('on',MODE==='live');
$('#warn').classList.toggle('show',MODE==='live');
if(LIVE_ENABLED){ if(MODE==='live') probeOc(); else $('#oc').textContent=''; }
+ updateBulkBar();
}
async function probeOc(){
$('#oc').innerHTML='checking Chrome login…';
@@ -130,7 +151,9 @@ function view(){
g.innerHTML=rows.map(p=>{
const img=p.image?`<img class="thumb" loading="lazy" src="${esc(p.image)}" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'thumb ph',textContent:'no image'}))">`:`<div class="thumb ph">no image</div>`;
const open=p.permalink?`<a class="open" href="${esc(p.permalink)}" target="_blank" rel="noopener noreferrer">Open ↗</a>`:`<span class="open" style="opacity:.4">no link</span>`;
- return `<div class="card" data-id="${esc(p.id)}">
+ const on=SELECTED.has(p.id);
+ return `<div class="card${on?' sel':''}" data-id="${esc(p.id)}">
+ <input type="checkbox" class="pick" ${on?'checked':''} title="select" onchange="togglePick('${esc(p.id)}',this.checked)">
${img}
<div class="body">
<div class="acct">@${esc(p.handle)}</div>
@@ -142,8 +165,51 @@ function view(){
}).join('');
$('#empty').style.display=rows.length?'none':'block';
$('#count').textContent=`${rows.length} shown · ${ALL.length} total · ${new Set(ALL.map(p=>p.handle)).size} accounts`;
+ updateBulkBar();
+}
+
+// ── selection ────────────────────────────────────────────────────────────────
+function togglePick(id,on){
+ if(on) SELECTED.add(id); else SELECTED.delete(id);
+ const card=document.querySelector(`.card[data-id="${cssq(id)}"]`); if(card) card.classList.toggle('sel',on);
+ updateBulkBar();
+}
+function cssq(s){ return String(s).replace(/["\\]/g,'\\$&'); }
+function shownIds(){ return [...document.querySelectorAll('.grid .card')].map(c=>c.dataset.id); }
+function selectAllShown(){ shownIds().forEach(id=>SELECTED.add(id)); view(); }
+function clearSel(){ SELECTED.clear(); view(); }
+function updateBulkBar(){
+ // prune ids no longer present (filtered/removed)
+ const present=new Set(ALL.map(p=>p.id)); [...SELECTED].forEach(id=>{ if(!present.has(id)) SELECTED.delete(id); });
+ $('#selN').textContent=SELECTED.size;
+ $('#bulkbar').classList.toggle('show',SELECTED.size>0||BULK_RUNNING);
+ $('#bulkGo').textContent=(MODE==='live'?'🗑 Delete selected on Instagram':'🗑 Remove selected from list');
+}
+
+// ── core delete (shared by single + bulk) ─────────────────────────────────────
+async function deletePost(p,live){
+ const r=await fetch('api/posts/delete',{method:'POST',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({permalink:p.permalink,media_id:p.media_id,handle:p.handle,live})}).then(r=>r.json());
+ if(!r.ok) throw new Error(r.error||'delete failed');
+ return r;
+}
+function fadeOut(id){
+ const card=document.querySelector(`.card[data-id="${cssq(id)}"]`);
+ if(card) card.classList.add('gone');
+ setTimeout(()=>{ ALL=ALL.filter(x=>x.id!==id); SELECTED.delete(id); view(); },260);
+}
+function showCardErr(id,msg){
+ const card=document.querySelector(`.card[data-id="${cssq(id)}"]`); if(!card) return;
+ card.classList.remove('busy'); card.querySelectorAll('.err').forEach(e=>e.remove());
+ const p=ALL.find(x=>x.id===id)||{};
+ const div=document.createElement('div'); div.className='err';
+ div.innerHTML=`⚠ ${esc(msg)}<div class="fix">`+
+ (p.permalink?`<a href="${esc(p.permalink)}" target="_blank" rel="noopener noreferrer">Open in IG to delete</a>`:``)+
+ `<button onclick="forceRemove('${esc(id)}')">Remove from list anyway</button></div>`;
+ card.querySelector('.acts').after(div);
}
+// single-card delete
async function del(btn){
const card=btn.closest('.card'); const id=card.dataset.id;
const p=ALL.find(x=>x.id===id); if(!p) return;
@@ -152,31 +218,48 @@ async function del(btn){
? `DELETE ON INSTAGRAM (permanent):\n\n@${p.handle}\n${p.title}\n\nThis drives Chrome to delete the live post. Continue?`
: `Remove from this viewer & Norma's ledger?\n\n@${p.handle}\n${p.title}\n\n(Does NOT delete it on Instagram.)`);
if(!ok) return;
- card.classList.add('busy');
- card.querySelectorAll('.err').forEach(e=>e.remove());
- try{
- const r=await fetch('api/posts/delete',{method:'POST',headers:{'Content-Type':'application/json'},
- body:JSON.stringify({permalink:p.permalink,media_id:p.media_id,handle:p.handle,live})}).then(r=>r.json());
- if(r.ok){
- card.classList.add('gone');
- setTimeout(()=>{ ALL=ALL.filter(x=>x.id!==id); view(); },260);
- toast(live?'Deleted on Instagram ✓':'Removed from list ✓','good');
- }else{ throw new Error(r.error||'delete failed'); }
- }catch(e){
- card.classList.remove('busy');
- const div=document.createElement('div'); div.className='err';
- div.innerHTML=`⚠ ${esc(e.message)}<div class="fix">`+
- (p.permalink?`<a href="${esc(p.permalink)}" target="_blank" rel="noopener noreferrer">Open in IG to delete</a>`:``)+
- `<button onclick="forceRemove('${esc(id)}')">Remove from list anyway</button></div>`;
- card.querySelector('.acts').after(div);
- toast('Delete failed','bad');
- }
+ card.classList.add('busy'); card.querySelectorAll('.err').forEach(e=>e.remove());
+ try{ await deletePost(p,live); fadeOut(id); toast(live?'Deleted on Instagram ✓':'Removed from list ✓','good'); }
+ catch(e){ showCardErr(id,e.message); toast('Delete failed','bad'); }
}
async function forceRemove(id){
const p=ALL.find(x=>x.id===id); if(!p) return;
- await fetch('api/posts/delete',{method:'POST',headers:{'Content-Type':'application/json'},
- body:JSON.stringify({permalink:p.permalink,media_id:p.media_id,handle:p.handle,live:false})});
- ALL=ALL.filter(x=>x.id!==id); view(); toast('Removed from list ✓','good');
+ try{ await deletePost(p,false); }catch{}
+ fadeOut(id); toast('Removed from list ✓','good');
+}
+
+// ── bulk delete (sequential, paced for LIVE) ──────────────────────────────────
+const wait=ms=>new Promise(r=>setTimeout(r,ms));
+function stopBulk(){ BULK_ABORT=true; $('#bulkProg').textContent='stopping after current…'; }
+async function runBulk(){
+ if(BULK_RUNNING) return;
+ const ids=[...SELECTED]; if(!ids.length) return;
+ const live=MODE==='live';
+ const ok=confirm(live
+ ? `DELETE ${ids.length} POST(S) ON INSTAGRAM (permanent).\n\nRuns one at a time, ${LIVE_PACE_MS/1000}s apart (ban-safe pacing) — this takes ~${Math.ceil(ids.length*LIVE_PACE_MS/60000)} min. Only posts owned by the account Chrome is logged into will delete. Continue?`
+ : `Remove ${ids.length} post(s) from this viewer & Norma's ledger?\n\n(Does NOT delete them on Instagram.)`);
+ if(!ok) return;
+ BULK_RUNNING=true; BULK_ABORT=false;
+ $('#bulkStop').style.display=live?'':'none'; $('#bulkGo').disabled=true;
+ let done=0, fail=0;
+ for(let i=0;i<ids.length;i++){
+ if(BULK_ABORT){ break; }
+ const id=ids[i]; const p=ALL.find(x=>x.id===id); if(!p) continue;
+ const card=document.querySelector(`.card[data-id="${cssq(id)}"]`); if(card) card.classList.add('busy');
+ $('#bulkProg').textContent=`deleting ${i+1}/${ids.length} — @${p.handle}…`;
+ try{ await deletePost(p,live); fadeOut(id); done++; }
+ catch(e){ showCardErr(id,e.message); fail++; }
+ // pace only LIVE deletes, and only if more remain and not aborting
+ if(live && i<ids.length-1 && !BULK_ABORT){
+ for(let s=LIVE_PACE_MS/1000; s>0 && !BULK_ABORT; s--){ $('#bulkProg').textContent=`deleted ${done}/${ids.length} · next in ${s}s`; await wait(1000); }
+ }
+ }
+ BULK_RUNNING=false;
+ $('#bulkStop').style.display='none'; $('#bulkGo').disabled=false;
+ $('#bulkProg').textContent=`done — ${done} deleted${fail?`, ${fail} failed`:''}${BULK_ABORT?' (stopped)':''}`;
+ toast(`Bulk ${live?'delete':'remove'}: ${done} done${fail?`, ${fail} failed`:''}`,fail?'bad':'good');
+ updateBulkBar();
+ setTimeout(()=>{ if(!BULK_RUNNING) $('#bulkProg').textContent=''; },6000);
}
async function load(){
← 1b1e7d4 ig: wrong-image delete tooling — 4 posts (metallic/suede), p
·
back to Norma
·
IG: add serialized, per-account-aware Spoonflower delete scr c90044b →