← back to Norma
agents/instagram-agent/public/live-viewer.html
208 lines
<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Designer Wallcoverings — Instagram 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:210px;
}
*{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}
.sub{color:var(--m);font-size:12.5px}
.grow{flex:1}
button,input,select{font-family:inherit}
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)}
.btn{background:var(--e);color:var(--t);border:1px solid var(--bd);border-radius:8px;padding:7px 12px;font-size:12.5px;cursor:pointer}
.btn:hover{border-color:var(--p)}
.btn.primary{background:var(--p);color:#04120c;border-color:var(--p);font-weight:650}
.range{display:flex;align-items:center;gap:6px;color:var(--m);font-size:12px}
input[type=range]{accent-color:var(--p)}
.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}
main{padding:14px 16px 80px}
.day{margin:22px 0 8px;display:flex;align-items:baseline;gap:10px;border-bottom:1px solid var(--bd);padding-bottom:6px}
.day h2{font-size:14px;margin:0;font-weight:650}
.day .c{color:var(--m);font-size:12px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--card),1fr));gap:12px;margin-top:10px}
.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}
.thumb{aspect-ratio:1;background:#0a0d17 center/cover no-repeat;display:flex;align-items:center;justify-content:center;color:var(--mut);font-size:12px}
.badge{position:absolute;top:8px;left:8px;background:#000a;color:#fff;font-size:10.5px;padding:2px 7px;border-radius:20px;text-transform:capitalize}
.meta{padding:9px 10px 10px;display:flex;flex-direction:column;gap:6px;flex:1}
.cap{font-size:12px;color:var(--m);max-height:3.2em;overflow:hidden}
.stats{font-size:11.5px;color:var(--mut);display:flex;gap:10px}
.acts{display:flex;gap:6px;margin-top:auto}
.acts a{font-size:11.5px;color:var(--m)}
.del{margin-left:auto;background:transparent;color:var(--r);border:1px solid var(--bd);border-radius:7px;padding:4px 9px;font-size:11.5px;cursor:pointer}
.del:hover{background:var(--r);color:#fff;border-color:var(--r)}
.del[disabled]{opacity:.5;cursor:progress}
.empty{color:var(--m);text-align:center;padding:60px 0}
.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;display:none}
.toast.show{display:block}.toast.err{border-color:var(--r);color:#ffd7de}
</style></head>
<body>
<header>
<div class="row1">
<h1>📸 Instagram</h1>
<select id="account" title="Choose which Instagram account to load" style="min-width:190px;font-weight:600"></select>
<span class="sub" id="acct">loading…</span>
<span class="grow"></span>
<button class="btn" id="refresh" title="Fetch new posts since last cache">↻ Refresh</button>
<button class="btn" id="backfill" title="Paginate the entire post history into the cache">⤓ Backfill all</button>
<a class="btn" href="/calendar" title="See a month calendar of posts across all accounts">📅 Calendar</a>
<a class="btn" href="/notes" title="Per-account plan: what to post next from DW Shopify">📝 Notes</a>
</div>
<div class="row1">
<input id="q" placeholder="Search caption…" style="min-width:180px">
<div class="seg" id="range">
<button data-days="1" class="on">Today</button>
<button data-days="7">7 days</button>
<button data-days="30">30 days</button>
<button data-days="0">All</button>
</div>
<select id="sort">
<option value="new">Newest first</option>
<option value="old">Oldest first</option>
<option value="likes">Most liked</option>
<option value="comments">Most commented</option>
</select>
<label class="range">density
<input type="range" id="density" min="130" max="320" value="210">
</label>
<span class="grow"></span>
<span class="sub">Delete mode</span>
<div class="seg" id="mode">
<button data-live="0" class="on">Hide (safe)</button>
<button data-live="1">Delete on IG</button>
</div>
</div>
<div class="warn" id="warn"><b>Delete-on-IG is armed.</b> Clicking Delete drives the logged-in browser and <b>permanently removes the post from Instagram</b> (serialized, verified). This cannot be undone.</div>
<div class="warn" id="warnoff" style="background:#12241a;border-color:var(--p);color:#c7f5df"><b>Safe mode.</b> Delete only hides the post from this board — Instagram is not touched.</div>
</header>
<main><div id="out" class="empty">Loading…</div></main>
<div class="toast" id="toast"></div>
<script>
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
let STATE={days:1,sort:'new',q:'',live:0,account:'',handle:'',data:null,accounts:[]};
// persist controls
try{const p=JSON.parse(localStorage.getItem('iglive')||'{}');Object.assign(STATE,p);}catch{}
$('#density').value = localStorage.getItem('igdensity')||210;
document.documentElement.style.setProperty('--card', ($('#density').value)+'px');
function toast(msg,err){const t=$('#toast');t.textContent=msg;t.className='toast show'+(err?' err':'');clearTimeout(t._t);t._t=setTimeout(()=>t.className='toast',3200);}
function save(){localStorage.setItem('iglive',JSON.stringify({days:STATE.days,sort:STATE.sort,live:STATE.live,account:STATE.account}));}
async function loadAccounts(){
const r=await fetch('/api/live/accounts'); const j=await r.json();
STATE.accounts=j.accounts||[];
if(!STATE.account || !STATE.accounts.some(a=>a.ig_user_id===STATE.account)) STATE.account=j.default||(STATE.accounts[0]&&STATE.accounts[0].ig_user_id)||'';
const sel=$('#account');
sel.innerHTML=STATE.accounts.map(a=>`<option value="${a.ig_user_id}">@${a.handle}${a.cached_total?` (${a.cached_total.toLocaleString()})`:' — empty'}</option>`).join('');
sel.value=STATE.account;
const cur=STATE.accounts.find(a=>a.ig_user_id===STATE.account);
STATE.handle=cur?cur.handle:'';
}
function applyMode(){
$$('#mode button').forEach(b=>b.classList.toggle('on', +b.dataset.live===STATE.live));
const liveBtn=$('#mode button[data-live="1"]'); liveBtn.classList.toggle('live', STATE.live===1);
$('#warn').classList.toggle('show', STATE.live===1 && STATE.data && STATE.data.liveEnabled);
$('#warnoff').classList.toggle('show', STATE.live===0);
}
async function load(){
const r=await fetch('/api/live/posts?days='+STATE.days+'&account='+encodeURIComponent(STATE.account));
STATE.data=await r.json();
const d=STATE.data;
const cur=STATE.accounts.find(a=>a.ig_user_id===STATE.account); STATE.handle=cur?cur.handle:(d.account&&d.account.handle)||'';
if(d.account){$('#acct').innerHTML=`@${d.account.username||d.account.handle} · ${(d.account.media_count||0).toLocaleString()} posts · ${(d.account.followers_count||0).toLocaleString()} followers · <span class="sub">cache: ${(d.cached_total||0).toLocaleString()}${d.complete?'':' (partial — Backfill all)'}${d.cached_at?' · '+new Date(d.cached_at).toLocaleString():''}</span>`;}
if(STATE.live===1 && !d.liveEnabled){toast('Delete-on-IG is turned off server-side (IG_LIVE_DELETE). Falling back to safe mode.'); STATE.live=0;}
// auto-populate an account that's never been cached (once per account — no loop if it has 0 posts)
if((d.cached_total||0)===0 && !autoRefreshed.has(STATE.account)){
autoRefreshed.add(STATE.account);
$('#out').className='empty';$('#out').innerHTML='No posts cached for @'+STATE.handle+' yet — loading…';
await refresh(false);return;
}
applyMode(); render();
}
function render(){
const d=STATE.data; const out=$('#out'); if(!d){return;}
const q=STATE.q.trim().toLowerCase();
let days=(d.days||[]).map(g=>({...g,posts:g.posts.filter(p=>!q||(p.caption||'').toLowerCase().includes(q))})).filter(g=>g.posts.length);
const cmp={new:(a,b)=>(b.ts||'').localeCompare(a.ts||''),old:(a,b)=>(a.ts||'').localeCompare(b.ts||''),likes:(a,b)=>(b.likes||0)-(a.likes||0),comments:(a,b)=>(b.comments||0)-(a.comments||0)}[STATE.sort];
days.forEach(g=>g.posts.sort(cmp));
if(STATE.sort==='old') days=days.slice().reverse();
const total=days.reduce((n,g)=>n+g.posts.length,0);
if(!total){out.className='empty';out.innerHTML=d.cached_total?'No posts match.':'Cache is empty — click <b>↻ Refresh</b> to pull the latest posts.';return;}
out.className='';
out.innerHTML=days.map(g=>`
<div class="day"><h2>${g.label}</h2><span class="c">${g.posts.length} post${g.posts.length>1?'s':''}</span></div>
<div class="grid">${g.posts.map(card).join('')}</div>`).join('');
$$('.del').forEach(b=>b.onclick=()=>del(b));
}
function card(p){
const t=(p.type||'').toLowerCase();
const style=p.thumb?`style="background-image:url('${p.thumb.replace(/'/g,'')}')"`:'';
return `<div class="card" data-perma="${encodeURIComponent(p.permalink)}" data-id="${p.id}">
<div class="thumb" ${style}>${p.thumb?'':'no preview'}<span class="badge">${t.replace('_album','')}</span></div>
<div class="meta">
<div class="cap">${(p.caption||'').replace(/</g,'<').slice(0,140)||'<span style="color:var(--mut)">no caption</span>'}</div>
<div class="stats">♥ ${p.likes??'—'} · 💬 ${p.comments??'—'} · ${new Date(p.ts).toLocaleTimeString([], {hour:'numeric',minute:'2-digit'})}</div>
<div class="acts"><a href="${p.permalink}" target="_blank" rel="noopener noreferrer">open ↗</a>
<button class="del">Delete</button></div>
</div></div>`;
}
async function del(btn){
const cardEl=btn.closest('.card');
const permalink=decodeURIComponent(cardEl.dataset.perma);
const id=cardEl.dataset.id;
const live=STATE.live===1;
if(live && !confirm('Permanently DELETE this post from Instagram? This cannot be undone.')) return;
btn.disabled=true; btn.textContent=live?'deleting…':'hiding…';
try{
const r=await fetch('/api/posts/delete',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({permalink,media_id:id,live,handle:STATE.handle||'designerwallcoverings'})});
const j=await r.json();
if(j.ok){cardEl.classList.add('gone');setTimeout(()=>cardEl.remove(),260);toast(live?'Deleted on Instagram ✓':'Hidden from board ✓');}
else{btn.disabled=false;btn.textContent='Delete';toast(j.error||'delete failed',true);}
}catch(e){btn.disabled=false;btn.textContent='Delete';toast(e.message,true);}
}
// controls
$('#q').oninput=e=>{STATE.q=e.target.value;render();};
$('#sort').value=STATE.sort; $('#sort').onchange=e=>{STATE.sort=e.target.value;save();render();};
$('#density').oninput=e=>{document.documentElement.style.setProperty('--card',e.target.value+'px');localStorage.setItem('igdensity',e.target.value);};
$$('#range button').forEach(b=>{b.classList.toggle('on',+b.dataset.days===STATE.days);b.onclick=()=>{STATE.days=+b.dataset.days;$$('#range button').forEach(x=>x.classList.remove('on'));b.classList.add('on');save();load();};});
$$('#mode button').forEach(b=>b.onclick=()=>{STATE.live=+b.dataset.live;save();applyMode();});
let autoRefreshed=new Set();
async function refresh(full){const btn=full?$('#backfill'):$('#refresh');const o=btn.textContent;btn.textContent=full?'backfilling…':'refreshing…';btn.disabled=true;
try{const r=await fetch('/api/live/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({full,account:STATE.account})});const j=await r.json();
if(j.ok){toast(`@${j.account}: +${j.added} new · scanned ${j.scanned}${j.complete?' · full history':''}`);await load();}else{toast(j.error||'refresh failed',true);}}
catch(e){toast(e.message,true);}finally{btn.textContent=o;btn.disabled=false;}}
$('#refresh').onclick=()=>refresh(false);
$('#backfill').onclick=()=>{if(confirm('Paginate this account\'s ENTIRE post history into the cache? (one-time, up to a few hundred API calls)'))refresh(true);};
// account dropdown → switch feeds
$('#account').onchange=e=>{STATE.account=e.target.value;const cur=STATE.accounts.find(a=>a.ig_user_id===STATE.account);STATE.handle=cur?cur.handle:'';save();load();};
// init: day-seg from state, then accounts, then posts
$$('#range button').forEach(b=>b.classList.toggle('on',+b.dataset.days===STATE.days));
(async()=>{ await loadAccounts(); await load(); })();
</script>
</body></html>