← back to Norma
agents/instagram-agent/public/posts-viewer.html
283 lines
<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Norma IG — Auto-Posts</title>
<style>
:root{
--bg:#0c0f1a;--s:#131829;--e:#1c2237;--t:#f0f0f5;--m:#a1a1b5;--mut:#6b7280;
--p:#10b981;--ph:#059669;--r:#f43f5e;--amber:#f59e0b;--bd:#2a3048;--card:220px;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--t);font:15px/1.45 -apple-system,system-ui,Segoe UI,sans-serif}
a{color:var(--p);text-decoration:none}
header{position:sticky;top:0;z-index:20;background:var(--s);border-bottom:1px solid var(--bd);padding:12px 16px;display:flex;flex-direction:column;gap:10px}
.row1{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
h1{font-size:16px;margin:0;font-weight:650;display:flex;gap:8px;align-items:center}
.count{color:var(--m);font-size:13px}
.grow{flex:1}
input,select{background:var(--e);color:var(--t);border:1px solid var(--bd);border-radius:8px;padding:7px 10px;font-size:13px}
input:focus,select:focus{outline:none;border-color:var(--p)}
.range{display:flex;align-items:center;gap:6px;color:var(--m);font-size:12px}
input[type=range]{accent-color:var(--p)}
.modewrap{display:flex;align-items:center;gap:8px;font-size:13px}
.seg{display:inline-flex;border:1px solid var(--bd);border-radius:8px;overflow:hidden}
.seg button{background:var(--e);color:var(--m);border:0;padding:7px 11px;font-size:12.5px;cursor:pointer}
.seg button.on{background:var(--p);color:#04120c;font-weight:650}
.seg button.on.live{background:var(--r);color:#fff}
.warn{background:#3a1620;border:1px solid var(--r);color:#ffd7de;border-radius:8px;padding:8px 12px;font-size:12.5px;display:none}
.warn.show{display:block}
.warn b{color:#fff}
.oc{font-size:12px;color:var(--m)}
.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{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}
.acct{font-size:11px;color:var(--p);font-weight:600;text-transform:lowercase;letter-spacing:.2px}
.ttl{font-size:12.5px;line-height:1.35;max-height:52px;overflow:hidden}
.when{font-size:11px;color:var(--m);display:flex;align-items:center;gap:4px}
.acts{display:flex;gap:6px;padding:0 10px 10px}
.acts a,.acts button{flex:1;text-align:center;font-size:12px;padding:7px 6px;border-radius:8px;border:1px solid var(--bd);cursor:pointer}
.open{background:var(--e);color:var(--t)}
.del{background:transparent;color:var(--r);border-color:var(--r);font-weight:600}
.del:hover{background:var(--r);color:#fff}
.card.busy{opacity:.6;pointer-events:none}
.err{padding:0 10px 10px;font-size:11.5px;color:#ffd7de}
.err .fix{display:flex;gap:6px;margin-top:6px}
.err .fix a,.err .fix button{flex:1;font-size:11px;padding:5px;border-radius:6px;border:1px solid var(--bd);background:var(--e);color:var(--t);cursor:pointer}
.empty{color:var(--m);padding:40px;text-align:center}
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--e);border:1px solid var(--bd);border-radius:10px;padding:10px 16px;font-size:13px;z-index:50;opacity:0;transition:opacity .2s}
.toast.show{opacity:1}.toast.good{border-color:var(--p)}.toast.bad{border-color:var(--r)}
</style></head><body>
<header>
<div class="row1">
<h1>📸 Norma Auto-Posts <span class="count" id="count">loading…</span></h1>
<span class="grow"></span>
<input id="q" placeholder="search title / account…" style="min-width:190px">
<select id="acct"><option value="">All accounts</option></select>
<select id="sort">
<option value="new">Newest</option>
<option value="old">Oldest</option>
<option value="acct">Account A→Z</option>
<option value="title">Title A→Z</option>
</select>
<span class="range">density <input type="range" id="dens" min="150" max="340" step="10"></span>
</div>
<div class="row1">
<span class="modewrap">Delete mode:
<span class="seg">
<button id="mList" data-mode="tombstone">Remove from list</button>
<button id="mLive" data-mode="live" class="live">Delete on Instagram</button>
</span>
</span>
<span class="oc" id="oc"></span>
<span class="grow"></span>
</div>
<div class="warn" id="warn">
<b>Live mode:</b> the trash button will actually delete the post <b>on Instagram</b> (irreversible) by
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>
<script>
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'}); }
function toast(msg,cls){ const el=$('#toast'); el.textContent=msg; el.className='toast show '+(cls||''); setTimeout(()=>el.className='toast',2600); }
function applyMode(){
if(!LIVE_ENABLED){ // live delete gated off → tombstone-only
MODE='tombstone';
$('#mLive').style.display='none';
$('#oc').innerHTML='Live delete on Instagram is <b>disabled</b> (tombstone-only). Enable with <code>IG_LIVE_DELETE=1</code>.';
$('#warn').classList.remove('show');
}else{
$('#mLive').style.display='';
}
$('#mList').classList.toggle('on',MODE==='tombstone');
$('#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…';
try{ const r=await fetch('api/posts/oc-status').then(r=>r.json());
$('#oc').innerHTML=r.loggedIn?'Chrome: <span class="ok">logged into Instagram ✓</span> (deletes the currently-active account’s posts)':'Chrome: <span class="bad">not logged into Instagram</span> — log in via openclaw first';
}catch{ $('#oc').innerHTML='Chrome: <span class="bad">openclaw unreachable</span>'; }
}
function view(){
const q=$('#q').value.trim().toLowerCase();
const acct=$('#acct').value, sort=$('#sort').value;
let rows=ALL.filter(p=>(!acct||p.handle===acct)&&(!q||(p.title+' '+p.handle+' '+p.page_name).toLowerCase().includes(q)));
rows.sort((a,b)=>{
if(sort==='new') return (b.ts||'').localeCompare(a.ts||'');
if(sort==='old') return (a.ts||'').localeCompare(b.ts||'');
if(sort==='acct') return a.handle.localeCompare(b.handle)||(b.ts||'').localeCompare(a.ts||'');
if(sort==='title') return a.title.localeCompare(b.title);
return 0;
});
const g=$('#grid');
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>`;
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>
<div class="ttl">${esc(p.title)}</div>
<div class="when">🕓 ${fmtWhen(p.ts)}</div>
</div>
<div class="acts">${open}<button class="del" onclick="del(this)">🗑 Delete</button></div>
</div>`;
}).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;
const live=MODE==='live';
const ok=confirm(live
? `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{ 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;
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(){
const d=await fetch('api/posts').then(r=>r.json());
ALL=d.posts||[];
LIVE_ENABLED=!!d.liveEnabled; applyMode();
const sel=$('#acct');
sel.innerHTML='<option value="">All accounts</option>'+(d.accounts||[]).map(a=>`<option value="${esc(a.handle)}">@${esc(a.handle)} (${a.count})</option>`).join('');
view();
}
// wire controls + restore prefs
['q','acct','sort'].forEach(id=>$('#'+id).addEventListener('input',view));
$('#sort').value=LS.get('sort','new'); $('#sort').addEventListener('change',()=>LS.set('sort',$('#sort').value));
const dens=$('#dens'); dens.value=LS.get('dens','220'); document.documentElement.style.setProperty('--card',dens.value+'px');
dens.addEventListener('input',()=>{document.documentElement.style.setProperty('--card',dens.value+'px');LS.set('dens',dens.value);});
$('#mList').addEventListener('click',()=>{MODE='tombstone';LS.set('mode',MODE);applyMode();});
$('#mLive').addEventListener('click',()=>{MODE='live';LS.set('mode',MODE);applyMode();});
applyMode(); load().catch(e=>toast('Failed to load posts: '+e.message,'bad'));
</script></body></html>