← back to Norma
agents/instagram-agent/public/notes-viewer.html
152 lines
<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Instagram — What to Post Next (per account)</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:340px;
}
*{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;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,textarea,select{font-family:inherit}
input{background:var(--e);color:var(--t);border:1px solid var(--bd);border-radius:8px;padding:7px 10px;font-size:13px;min-width:200px}
input: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)}
.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}
main{padding:14px 16px 80px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--card),1fr));gap:14px}
.card{background:var(--s);border:1px solid var(--bd);border-radius:12px;padding:12px;display:flex;flex-direction:column;gap:9px}
.card.hide{display:none}
.chead{display:flex;align-items:center;gap:8px}
.dot{width:12px;height:12px;border-radius:50%;flex:none}
.h{font-weight:650;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pn{color:var(--mut);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.card textarea{width:100%;min-height:104px;resize:vertical;background:var(--e);color:var(--t);border:1px solid var(--bd);border-radius:9px;padding:9px 10px;font-size:13px;line-height:1.5}
.card textarea:focus{outline:none;border-color:var(--p)}
.card textarea.dirty{border-color:var(--amber)}
.foot{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--mut);min-height:16px}
.when{display:flex;align-items:center;gap:5px}
.status{margin-left:auto}
.status.saving{color:var(--amber)}
.status.saved{color:var(--p)}
.status.err{color:var(--r)}
.card .lv{color:var(--m);font-size:11.5px}
.filled .h::after{content:"📝";font-size:11px;margin-left:6px}
.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>
<h1>📝 What to Post Next <span class="sub">— per account, from DW Shopify</span></h1>
<input id="q" placeholder="Filter accounts…">
<div class="seg" id="filt">
<button data-f="all" class="on">All</button>
<button data-f="with">With notes</button>
<button data-f="without">Empty</button>
</div>
<span class="grow"></span>
<span class="sub" id="summary">loading…</span>
<a class="btn" href="/calendar">📅 Calendar</a>
<a class="btn" href="/live">📸 List</a>
</header>
<main><div class="grid" id="grid"></div></main>
<div class="toast" id="toast"></div>
<script>
const $=(s)=>document.querySelector(s);
let ACCOUNTS=[];
function color(i){ return `hsl(${Math.round((i*137.508)%360)} 66% 60%)`; }
function toast(m,err){ const t=$('#toast'); t.textContent=m; t.className='toast show'+(err?' err':''); clearTimeout(t._h); t._h=setTimeout(()=>t.className='toast',2400); }
function fmtWhen(iso){
if(!iso) return 'never edited';
return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
}
async function load(){
let d;
try{ d=await (await fetch('/api/live/account-notes')).json(); if(d.error) throw new Error(d.error); }
catch(e){ toast('Load failed: '+e.message,true); return; }
ACCOUNTS=d.accounts.map((a,i)=>({...a,color:color(i)}));
render();
updateSummary();
}
function updateSummary(){
const withN=ACCOUNTS.filter(a=>a.note && a.note.trim()).length;
$('#summary').textContent=`${withN}/${ACCOUNTS.length} accounts have a post plan`;
}
function render(){
const grid=$('#grid'); grid.innerHTML='';
for(const a of ACCOUNTS){
const card=document.createElement('div');
card.className='card'+((a.note&&a.note.trim())?' filled':'');
card.dataset.handle=a.handle.toLowerCase();
card.dataset.has=(a.note&&a.note.trim())?'1':'0';
card.innerHTML=`
<div class="chead">
<span class="dot" style="background:${a.color}"></span>
<span class="h">@${a.handle}</span>
<a class="lv" href="/live?account=${a.ig_user_id}" title="Open this account's posts" style="margin-left:auto">posts ↗</a>
</div>
${a.page_name?`<div class="pn">${a.page_name}</div>`:''}
<textarea placeholder="What should @${a.handle} post next from the DW Shopify store? e.g. product handles, a collection, a theme, timing…">${(a.note||'').replace(/</g,'<')}</textarea>
<div class="foot">
<span class="when">🕓 <span class="wt">${fmtWhen(a.updated_at)}</span></span>
<span class="status"></span>
</div>`;
const ta=card.querySelector('textarea');
const status=card.querySelector('.status');
const wt=card.querySelector('.wt');
let saved=a.note||'';
let timer=null;
const save=async()=>{
const val=ta.value;
if(val===saved){ ta.classList.remove('dirty'); status.className='status'; status.textContent=''; return; }
status.className='status saving'; status.textContent='saving…';
try{
const r=await fetch('/api/live/account-notes',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account:a.ig_user_id,note:val})});
const j=await r.json(); if(!r.ok||j.error) throw new Error(j.error||('HTTP '+r.status));
saved=val; a.note=val; a.updated_at=j.updated_at;
ta.classList.remove('dirty');
status.className='status saved'; status.textContent='✓ saved';
wt.textContent=fmtWhen(j.updated_at);
card.classList.toggle('filled', !!(val&&val.trim()));
card.dataset.has=(val&&val.trim())?'1':'0';
updateSummary(); applyFilter();
setTimeout(()=>{ if(status.textContent==='✓ saved'){ status.className='status'; status.textContent=''; } },1500);
}catch(e){ status.className='status err'; status.textContent='save failed'; toast('Save failed: '+e.message,true); }
};
ta.addEventListener('input',()=>{ ta.classList.toggle('dirty', ta.value!==saved); clearTimeout(timer); timer=setTimeout(save,900); });
ta.addEventListener('blur',()=>{ clearTimeout(timer); save(); });
grid.appendChild(card);
}
applyFilter();
}
let curFilter='all';
function applyFilter(){
const q=($('#q').value||'').toLowerCase().trim();
document.querySelectorAll('.card').forEach(c=>{
const matchQ=!q||c.dataset.handle.includes(q);
const has=c.dataset.has==='1';
const matchF=curFilter==='all'||(curFilter==='with'&&has)||(curFilter==='without'&&!has);
c.classList.toggle('hide',!(matchQ&&matchF));
});
}
$('#q').addEventListener('input',applyFilter);
$('#filt').addEventListener('click',(e)=>{ const b=e.target.closest('button'); if(!b)return;
document.querySelectorAll('#filt button').forEach(x=>x.classList.remove('on')); b.classList.add('on'); curFilter=b.dataset.f; applyFilter(); });
load();
</script>
</body></html>