← back to Marketing Command Center
public/ig-activity.html
181 lines
<!doctype html>
<html lang="en">
<head>
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
var _fetch = window.fetch.bind(window);
var cleanBase = function () { return location.origin + location.pathname; };
window.fetch = function (input, init) {
try {
if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
input = new URL(input, cleanBase()).href;
} else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
input = new Request(new URL(input.url, cleanBase()).href, input);
}
} catch (_) { /* fall through to native */ }
return _fetch(input, init);
};
})();
</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>IG Network Activity · Designer Wallcoverings</title>
<style>
:root{ --bg:#0f0e0c; --card:#1a1815; --line:#2a2620; --ink:#f4f1ea; --mut:#a7a094; --accent:#c9a86a; --cols:4; }
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif}
header{padding:28px 28px 10px;border-bottom:1px solid var(--line)}
h1{margin:0;font-weight:300;letter-spacing:.14em;text-transform:uppercase;font-size:20px}
h1 b{color:var(--accent);font-weight:600}
.sub{color:var(--mut);font-size:13px;margin-top:6px}
.stats{display:flex;gap:22px;margin-top:14px;flex-wrap:wrap}
.stat{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px 16px}
.stat b{display:block;font-size:22px;color:var(--accent);font-weight:600}
.stat span{font-size:11px;color:var(--mut);text-transform:uppercase;letter-spacing:.08em}
.toolbar{display:flex;gap:18px;align-items:center;padding:16px 28px;flex-wrap:wrap;position:sticky;top:0;background:var(--bg);z-index:5;border-bottom:1px solid var(--line)}
.toolbar label{color:var(--mut);font-size:12px;text-transform:uppercase;letter-spacing:.08em;margin-right:8px}
select,input[type=range]{accent-color:var(--accent)}
select{background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px}
.seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
.seg button{background:var(--card);color:var(--mut);border:0;padding:7px 14px;font-size:13px;cursor:pointer}
.seg button.on{background:var(--accent);color:#000;font-weight:600}
#densityWrap.hide{display:none}
/* GRID */
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:16px;padding:22px 28px 60px}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;transition:border-color .15s,transform .15s}
.card:hover{border-color:var(--accent);transform:translateY(-2px)}
.thumb{aspect-ratio:1/1;background:#111 center/cover no-repeat;display:block}
.body{padding:12px 13px 13px}
.acct{color:var(--accent);font-weight:600;font-size:13px;text-decoration:none}
.acct:hover{text-decoration:underline}
.title{font-size:13px;color:var(--ink);margin:5px 0 8px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:2.6em}
.when{color:var(--mut);font-size:11px;display:flex;align-items:center;gap:5px}
.foot{display:flex;justify-content:space-between;align-items:center;margin-top:9px;padding-top:9px;border-top:1px solid var(--line)}
.view{font-size:12px;color:var(--accent);text-decoration:none;border:1px solid var(--line);border-radius:6px;padding:4px 9px}
.view:hover{background:var(--accent);color:#000;border-color:var(--accent)}
/* LIST / TABLE */
table{width:calc(100% - 56px);margin:22px 28px 60px;border-collapse:collapse;font-size:13px}
th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle}
th{color:var(--mut);text-transform:uppercase;letter-spacing:.06em;font-size:11px;cursor:pointer;user-select:none;white-space:nowrap}
th:hover{color:var(--accent)}
th .arw{color:var(--accent);font-size:10px}
td img{width:44px;height:44px;object-fit:cover;border-radius:6px;display:block;background:#111}
td a.acct{color:var(--accent)}
tr:hover td{background:#161410}
.empty{color:var(--mut);text-align:center;padding:80px 20px}
</style>
<link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</head>
<body>
<header>
<h1>Designer <b>Wallcoverings</b> · IG Network Activity</h1>
<div class="sub">What's being posted, and to which account — across the live Instagram fleet.</div>
<div class="stats">
<div class="stat"><b id="s-posts">–</b><span>Posts</span></div>
<div class="stat"><b id="s-accts">–</b><span>Accounts</span></div>
<div class="stat"><b id="s-last">–</b><span>Last post</span></div>
</div>
</header>
<div class="toolbar">
<div><label>View</label>
<span class="seg"><button id="v-grid" class="on">Grid</button><button id="v-list">List</button></span>
</div>
<div><label>Sort</label>
<select id="sort">
<option value="ts|desc">Newest</option>
<option value="ts|asc">Oldest</option>
<option value="handle|asc">Account A→Z</option>
<option value="handle|desc">Account Z→A</option>
<option value="product_title|asc">Product A→Z</option>
<option value="product_title|desc">Product Z→A</option>
<option value="kind|asc">Type A→Z</option>
</select>
</div>
<div id="densityWrap"><label>Density</label>
<input type="range" id="density" min="2" max="7" step="1">
</div>
<div class="sub" id="gen" style="margin-left:auto"></div>
</div>
<div id="grid" class="grid"></div>
<table id="list" style="display:none"><thead><tr>
<th data-k="image_url">Image</th>
<th data-k="handle">Account <span class="arw"></span></th>
<th data-k="product_title">Product <span class="arw"></span></th>
<th data-k="ts">Posted <span class="arw"></span></th>
<th data-k="kind">Type <span class="arw"></span></th>
<th>Link</th>
</tr></thead><tbody id="rows"></tbody></table>
<script>
const $=s=>document.querySelector(s);
const fmt=iso=>{try{return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})}catch{return iso||''}};
let DATA=[], SORT=localStorage.getItem('ig_sort')||'ts|desc', MODE=localStorage.getItem('ig_mode')||'grid';
function sorted(){
const [k,dir]=SORT.split('|'); const s=[...DATA];
s.sort((a,b)=>{ let x=a[k],y=b[k];
if(k==='ts'){x=new Date(x);y=new Date(y);}
else {x=(x||'').toString().toLowerCase();y=(y||'').toString().toLowerCase();}
return (x<y?-1:x>y?1:0)*(dir==='desc'?-1:1); });
return s;
}
function render(){
const p=sorted();
const grid=$('#grid'), list=$('#list');
if(MODE==='grid'){ grid.style.display='grid'; list.style.display='none'; $('#densityWrap').classList.remove('hide');
grid.innerHTML = p.length? p.map(x=>`
<div class="card">
<a href="${x.permalink||'#'}" target="_blank" rel="noopener noreferrer"><span class="thumb" style="background-image:url('${(x.image_url||'').replace(/'/g,'')}')"></span></a>
<div class="body">
<a class="acct" href="https://www.instagram.com/${x.handle}/" target="_blank" rel="noopener noreferrer">@${x.handle}</a>
<div class="title" title="${(x.product_title||'').replace(/"/g,'"')}">${x.product_title||'—'}</div>
<div class="when" title="${x.ts||''}">🕓 ${fmt(x.ts)}</div>
<div class="foot"><span class="when">${x.kind||'IMAGE'}</span>
<a class="view" href="${x.permalink||'#'}" target="_blank" rel="noopener noreferrer">View post ↗</a></div>
</div>
</div>`).join('') : '<div class="empty">No posts logged yet.</div>';
} else { grid.style.display='none'; list.style.display='table'; $('#densityWrap').classList.add('hide');
$('#rows').innerHTML = p.length? p.map(x=>`
<tr>
<td><a href="${x.permalink||'#'}" target="_blank" rel="noopener noreferrer"><img src="${x.image_url||''}" alt=""></a></td>
<td><a class="acct" href="https://www.instagram.com/${x.handle}/" target="_blank" rel="noopener noreferrer">@${x.handle}</a></td>
<td>${x.product_title||'—'}</td>
<td title="${x.ts||''}">🕓 ${fmt(x.ts)}</td>
<td>${x.kind||'IMAGE'}</td>
<td><a class="view" href="${x.permalink||'#'}" target="_blank" rel="noopener noreferrer">View ↗</a></td>
</tr>`).join('') : '<tr><td colspan="6" class="empty">No posts logged yet.</td></tr>';
}
// header arrows
const [ck,cd]=SORT.split('|');
document.querySelectorAll('th[data-k]').forEach(th=>{const a=th.querySelector('.arw'); if(a) a.textContent = th.dataset.k===ck?(cd==='desc'?'▼':'▲'):'';});
$('#sort').value=SORT;
}
function setMode(m){MODE=m;localStorage.setItem('ig_mode',m);$('#v-grid').classList.toggle('on',m==='grid');$('#v-list').classList.toggle('on',m==='list');render();}
function setSort(s){SORT=s;localStorage.setItem('ig_sort',s);render();}
$('#v-grid').onclick=()=>setMode('grid'); $('#v-list').onclick=()=>setMode('list');
$('#sort').onchange=()=>setSort($('#sort').value);
document.querySelectorAll('th[data-k]').forEach(th=>th.onclick=()=>{const k=th.dataset.k;const [ck,cd]=SORT.split('|');setSort(k+'|'+(ck===k&&cd==='asc'?'desc':'asc'));});
const sDen=localStorage.getItem('ig_density')||'4'; $('#density').value=sDen; document.documentElement.style.setProperty('--cols',sDen);
$('#density').oninput=()=>{const v=$('#density').value;localStorage.setItem('ig_density',v);document.documentElement.style.setProperty('--cols',v);};
async function load(){
try{ const r=await fetch('ig-activity.json?t='+Date.now()); const j=await r.json();
DATA=j.posts||[]; $('#s-posts').textContent=j.total_posts??DATA.length;
$('#s-accts').textContent=j.accounts_touched??new Set(DATA.map(x=>x.handle)).size;
$('#s-last').textContent=j.last_post_at?fmt(j.last_post_at):'–';
$('#gen').textContent='Updated '+(j.generated_at?fmt(j.generated_at):'');
}catch(e){DATA=[];}
setMode(MODE); // applies mode + renders
}
load(); setInterval(load,60000);
</script>
<script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body>
</html>