← back to Sdcc Journalists Viewer
public/index.html
389 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SDCC Media Contacts — Journalist Finder</title>
<style>
:root { --cols: 3; --scale: 1; --bg:#0C0F1A; --surface:#131829; --el:#1C2237; --pri:#10b981; --amber:#f59e0b; --text:#f0f0f5; --sub:#a1a1b5; --muted:#6B7280; --border:#2A3048; }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--text); font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
a { color:var(--pri); text-decoration:none; } a:hover { text-decoration:underline; }
header { display:flex; align-items:center; gap:14px; padding:14px 20px; border-bottom:1px solid var(--border); position:sticky; top:0; background:var(--bg); z-index:5; flex-wrap:wrap; }
header h1 { font-size:17px; margin:0; letter-spacing:.02em; }
header .sync { font-size:12px; color:var(--sub); margin-left:auto; display:flex; gap:10px; align-items:center; }
button { background:var(--el); color:var(--text); border:1px solid var(--border); border-radius:8px; padding:6px 12px; cursor:pointer; font-size:12.5px; }
button:hover { border-color:var(--pri); }
.layout { display:flex; min-height:calc(100vh - 60px); }
aside { width:250px; flex:0 0 250px; border-right:1px solid var(--border); padding:14px; position:sticky; top:60px; align-self:flex-start; max-height:calc(100vh - 60px); overflow-y:auto; }
aside h3 { font-size:11px; text-transform:uppercase; letter-spacing:.1em; color:var(--muted); margin:16px 0 8px; }
aside h3:first-child { margin-top:0; }
.field-toggle { display:flex; align-items:center; gap:8px; padding:4px 2px; font-size:13px; color:var(--sub); cursor:pointer; user-select:none; }
.field-toggle input { accent-color:var(--pri); }
.field-toggle:hover { color:var(--text); }
main { flex:1; padding:16px 20px; }
.controls { display:flex; gap:12px; align-items:center; flex-wrap:wrap; margin-bottom:14px; }
.controls input[type=search] { background:var(--el); border:1px solid var(--border); border-radius:8px; color:var(--text); padding:7px 12px; width:260px; font-size:13px; }
.controls select { background:var(--el); border:1px solid var(--border); border-radius:8px; color:var(--text); padding:7px 10px; font-size:13px; }
.controls .density { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--sub); }
.controls input[type=range] { accent-color:var(--pri); width:120px; }
.count { font-size:12px; color:var(--muted); }
.grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:12px; }
.card { background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:calc(14px*var(--scale)); font-size:calc(13px*var(--scale)); display:flex; flex-direction:column; gap:6px; }
.card .name { font-weight:700; font-size:calc(15.5px*var(--scale)); }
.card .outlet { color:var(--amber); font-size:calc(12.5px*var(--scale)); }
.badge { display:inline-block; background:var(--el); border:1px solid var(--border); border-radius:99px; padding:1px 9px; font-size:calc(11px*var(--scale)); color:var(--sub); margin-right:4px; }
.badge.contact { border-color:var(--pri); color:var(--pri); }
.badge.stale { border-color:#f43f5e; color:#f43f5e; }
.kv { color:var(--sub); }
.kv b { color:var(--text); font-weight:600; }
.linkrow { display:flex; flex-wrap:wrap; gap:6px; margin-top:4px; }
.linkrow a { background:var(--el); border:1px solid var(--border); border-radius:8px; padding:3px 9px; font-size:calc(11.5px*var(--scale)); display:inline-flex; gap:5px; align-items:center; }
.linkrow a:hover { border-color:var(--pri); text-decoration:none; }
.article { border-left:3px solid var(--pri); padding-left:9px; margin-top:2px; color:var(--sub); }
.article a { color:var(--text); font-weight:600; }
.notes { color:var(--muted); font-size:calc(11.5px*var(--scale)); font-style:italic; }
.empty { color:var(--muted); padding:40px; text-align:center; }
/* Cards ⇄ List view toggle */
.viewtoggle { display:inline-flex; border:1px solid var(--border); border-radius:8px; overflow:hidden; }
.viewtoggle button { border:0; border-radius:0; background:var(--el); padding:6px 12px; }
.viewtoggle button.active { background:var(--pri); color:#08110c; font-weight:600; }
.viewtoggle button+button { border-left:1px solid var(--border); }
/* List (table) view */
.table-wrap { overflow-x:auto; border:1px solid var(--border); border-radius:12px; }
table.list { border-collapse:collapse; width:100%; font-size:calc(13px*var(--scale)); }
table.list th, table.list td { text-align:left; padding:calc(7px*var(--scale)) 12px; border-bottom:1px solid var(--border); white-space:nowrap; vertical-align:top; }
table.list thead th { position:sticky; top:60px; background:var(--el); color:var(--sub); font-size:calc(11.5px*var(--scale)); text-transform:uppercase; letter-spacing:.04em; cursor:pointer; user-select:none; z-index:2; }
table.list thead th:hover { color:var(--text); }
table.list thead th.sorted { color:var(--pri); }
table.list thead th .arr { font-size:10px; margin-left:3px; }
table.list tbody tr:hover { background:var(--surface); }
table.list td.notes-cell { white-space:normal; max-width:340px; color:var(--muted); font-style:italic; }
table.list td.num { text-align:right; font-variant-numeric:tabular-nums; }
table.list tr.row-main td { border-bottom:0; }
table.list tr.notes-row td { padding-top:2px; }
/* Notes-below-user + editable note (shared by list + cards) */
.note-block { display:flex; flex-direction:column; gap:6px; padding:2px 0 4px; white-space:normal; max-width:920px; }
.sheet-note { color:var(--muted); font-style:italic; font-size:calc(12px*var(--scale)); }
.sheet-note b { color:var(--sub); font-style:normal; }
.user-note { display:flex; align-items:flex-start; gap:8px; flex-wrap:wrap; }
.user-note .un-text { color:var(--text); font-size:calc(12.5px*var(--scale)); white-space:pre-wrap; }
.user-note .un-text.empty { color:var(--muted); font-style:italic; }
.user-note .un-tag { font-size:10px; color:var(--pri); border:1px solid var(--pri); border-radius:99px; padding:0 6px; align-self:center; }
.user-note textarea { flex:1 1 260px; background:var(--el); border:1px solid var(--border); border-radius:8px; color:var(--text); padding:6px 9px; font:inherit; font-size:calc(12.5px*var(--scale)); resize:vertical; min-height:52px; }
.un-edit, .un-save, .un-cancel { padding:2px 9px; font-size:11.5px; }
.un-save { border-color:var(--pri); color:var(--pri); }
@media (max-width:900px){ .layout{flex-direction:column} aside{width:100%;position:static;max-height:none;flex:auto} :root{--cols:1!important} }
</style>
</head>
<body>
<header>
<h1>SDCC Media Contacts</h1>
<span class="count" id="count"></span>
<span class="sync">
<span id="syncinfo">loading…</span>
<button id="refreshBtn" title="Pull latest edits from the Google Drive spreadsheet">⟳ Refresh from Drive</button>
<a href="https://drive.google.com/file/d/1uTKInwC0BfaZlSNrTR821C_7aqVBI8Pn/view" target="_blank" rel="noopener noreferrer">Open spreadsheet ↗</a>
</span>
</header>
<div class="layout">
<aside>
<h3>Search</h3>
<input type="search" id="q" placeholder="name, outlet, beat…" style="width:100%;background:var(--el);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 10px;font-size:13px">
<h3>Show fields</h3>
<div id="toggles"></div>
<h3>Filter</h3>
<label class="field-toggle"><input type="checkbox" id="fEmail"> Has email</label>
<label class="field-toggle"><input type="checkbox" id="fContact"> Has Gmail history</label>
<label class="field-toggle"><input type="checkbox" id="fActive"> Active beat only</label>
</aside>
<main>
<div class="controls">
<span class="viewtoggle" id="viewtoggle">
<button type="button" data-view="cards">▦ Cards</button>
<button type="button" data-view="list">☰ List</button>
</span>
<select id="sort">
<option value="contact">Last contact (newest)</option>
<option value="articles">Articles tracked</option>
<option value="name">Name A→Z</option>
<option value="outlet">Outlet A→Z</option>
<option value="artdate">Latest article date</option>
</select>
<span class="density">Columns <input type="range" id="density" min="1" max="12" step="1" value="3"><b id="colcount" style="color:var(--pri);min-width:1.2em;text-align:center">3</b></span>
</div>
<div class="grid" id="grid"></div>
<div class="table-wrap" id="tablewrap" style="display:none"></div>
<div class="empty" id="empty" style="display:none">No journalists match.</div>
</main>
</div>
<script>
const FIELDS = [
["beat","Beat",true],["medium","Medium",true],["location","Location",true],
["email","Email",true],["phone","Phone",true],["social","Social links (X / LinkedIn)",true],
["article","Latest article",true],["tracked","Articles tracked by SDCC",true],
["contact","Gmail contact history",true],["outletchange","Outlet change flag",true],
["notes","Notes",false],
];
const LS = (k,v)=> v===undefined ? localStorage.getItem("sdccjv_"+k) : localStorage.setItem("sdccjv_"+k,v);
let DATA = [], META = {};
// List-view columns — [key, label, type]. Every one is a clickable sort header.
const COLUMNS = [
["name","Name","text"],
["current_outlet_verified","Outlet","text"],
["medium","Medium","text"],
["beat","Beat","text"],
["email","Email","text"],
["phone","Phone","text"],
["twitter","X","text"],
["location","Location","text"],
["article_date","Latest article","date"],
["articles_tracked_10yr","Tracked","num"],
["date_of_last_contact","Last contact","date"],
["date_of_first_contact","First contact","date"],
];
let VIEW = LS("view")||"cards";
let TSORT = { col: LS("tcol")||"name", dir: +(LS("tdir")||1) };
let UNOTES = {}; // Steve-added notes, name → text (from /api/notes)
const vis = {};
FIELDS.forEach(([k,,def])=>{ const s=LS("f_"+k); vis[k] = s===null?def:s==="1"; });
function el(html){ const t=document.createElement("template"); t.innerHTML=html.trim(); return t.content.firstChild; }
const esc = s => (s||"").replace(/[&<>"]/g, c=>({"&":"&","<":"<",">":">",'"':"""}[c]));
function buildToggles(){
const box = document.getElementById("toggles"); box.innerHTML="";
FIELDS.forEach(([k,label])=>{
const row = el(`<label class="field-toggle"><input type="checkbox" ${vis[k]?"checked":""}> ${label}</label>`);
row.querySelector("input").onchange = e => { vis[k]=e.target.checked; LS("f_"+k, e.target.checked?"1":"0"); render(); };
box.appendChild(row);
});
}
function linkX(handle,name,outlet){
if(handle) return `https://x.com/${encodeURIComponent(handle.replace(/^@/,""))}`;
return `https://x.com/search?q=${encodeURIComponent(name+" "+(outlet||""))}&f=user`;
}
function linkLI(name,outlet){
return `https://www.linkedin.com/search/results/people/?keywords=${encodeURIComponent(name+" "+(outlet||""))}`;
}
function outletChanged(r){
const a=(r.outlet_on_file||"").toLowerCase(), b=(r.current_outlet_verified||"").toLowerCase();
return a && b && !b.includes(a.split(" ")[0]) ? true : false;
}
function card(r){
const L = r._links||{};
const parts = [];
parts.push(`<div class="name">${esc(r.name)}</div>`);
const oc = outletChanged(r);
parts.push(`<div class="outlet">${esc(r.current_outlet_verified||r.outlet_on_file||"—")}${vis.outletchange&&oc?` <span class="badge stale">moved from ${esc(r.outlet_on_file)}</span>`:""}</div>`);
const badges=[];
if(vis.medium&&r.medium) badges.push(`<span class="badge">${esc(r.medium)}</span>`);
if(vis.location&&r.location) badges.push(`<span class="badge">${esc(r.location)}</span>`);
if(vis.tracked&&r.articles_tracked_10yr&&+r.articles_tracked_10yr>0) badges.push(`<span class="badge">${esc(r.articles_tracked_10yr)} tracked</span>`);
if(vis.contact&&r.date_of_last_contact) badges.push(`<span class="badge contact">last contact ${esc(r.date_of_last_contact)}</span>`);
if(badges.length) parts.push(`<div>${badges.join("")}</div>`);
if(vis.beat&&r.beat) parts.push(`<div class="kv"><b>Beat:</b> ${esc(r.beat)}</div>`);
const links=[];
if(vis.email&&r.email) links.push(`<a href="mailto:${esc(r.email)}" title="${esc(r.email_source||"")}">✉️ ${esc(r.email)}</a>`);
if(vis.phone&&r.phone) links.push(`<a href="tel:${esc(r.phone.replace(/[^0-9+]/g,""))}">📞 ${esc(r.phone)}</a>`);
if(vis.social){
links.push(`<a href="${linkX(r.twitter,r.name,r.current_outlet_verified||r.outlet_on_file)}" target="_blank" rel="noopener noreferrer">𝕏 ${esc(r.twitter||"find")}</a>`);
links.push(`<a href="${linkLI(r.name,r.current_outlet_verified||r.outlet_on_file)}" target="_blank" rel="noopener noreferrer">in LinkedIn</a>`);
}
if(vis.contact){
if(L.initial_email) links.push(`<a href="${esc(L.initial_email)}" target="_blank" rel="noopener noreferrer">📥 first email${r.date_of_first_contact?" · "+esc(r.date_of_first_contact):""}</a>`);
if(L.last_email) links.push(`<a href="${esc(L.last_email)}" target="_blank" rel="noopener noreferrer">📥 last email${r.date_of_last_contact?" · "+esc(r.date_of_last_contact):""}</a>`);
}
if(links.length) parts.push(`<div class="linkrow">${links.join("")}</div>`);
if(vis.article&&(r.latest_article||L.latest_article)){
const t = esc(r.latest_article||"latest article");
parts.push(`<div class="article">${L.latest_article?`<a href="${esc(L.latest_article)}" target="_blank" rel="noopener noreferrer">${t} ↗</a>`:t}${r.article_date?`<div>${esc(r.article_date)}${r.link_type==="author_page"?" · byline page":""}</div>`:""}</div>`);
}
parts.push(noteBlockHTML(r, vis.notes)); // sheet note (toggle-gated) + your editable note
return el(`<div class="card">${parts.join("")}</div>`);
}
function filteredRows(){
const q=(document.getElementById("q").value||"").toLowerCase();
const fE=document.getElementById("fEmail").checked, fC=document.getElementById("fContact").checked, fA=document.getElementById("fActive").checked;
return DATA.filter(r=>{
if(q && !JSON.stringify(r).toLowerCase().includes(q)) return false;
if(fE && !r.email) return false;
if(fC && !r.date_of_last_contact) return false;
if(fA && /stub|deprioritiz|not a (journalist|reporter)|inactive/i.test(r.notes||"")) return false;
return true;
});
}
function renderCards(rows){
const s=document.getElementById("sort").value;
const by=(f,dir=1)=>(a,b)=>((a[f]||"")<(b[f]||"")?-1:1)*dir;
if(s==="name") rows.sort(by("name"));
if(s==="outlet") rows.sort(by("current_outlet_verified"));
if(s==="artdate") rows.sort(by("article_date",-1));
if(s==="articles") rows.sort((a,b)=>(+b.articles_tracked_10yr||0)-(+a.articles_tracked_10yr||0));
if(s==="contact") rows.sort((a,b)=>((b.date_of_last_contact||"")+(+b.articles_tracked_10yr||0)).localeCompare((a.date_of_last_contact||"")+(+a.articles_tracked_10yr||0)));
const grid=document.getElementById("grid"); grid.innerHTML="";
rows.forEach(r=>grid.appendChild(card(r)));
wireNotes(grid);
}
// One cell of the list view — key cells become clickable (email, phone, X, article, gmail threads).
function cellHTML(r,col){
const L=r._links||{}, k=col[0], v=r[k];
if(k==="email") return v?`<a href="mailto:${esc(v)}" title="${esc(r.email_source||"")}">${esc(v)}</a>`:"";
if(k==="phone") return v?`<a href="tel:${esc(v.replace(/[^0-9+]/g,""))}">${esc(v)}</a>`:"";
if(k==="twitter"){ const url=linkX(v,r.name,r.current_outlet_verified||r.outlet_on_file);
return v?`<a href="${url}" target="_blank" rel="noopener noreferrer">${esc(v)}</a>`:`<a href="${url}" target="_blank" rel="noopener noreferrer" class="kv">find</a>`; }
if(k==="article_date"){ const url=L.latest_article||r.latest_article, t=esc(v||(url?"↗":""));
return url?`<a href="${esc(url)}" target="_blank" rel="noopener noreferrer">${t}</a>`:t; }
if(k==="date_of_last_contact") return v?(L.last_email?`<a href="${esc(L.last_email)}" target="_blank" rel="noopener noreferrer">${esc(v)}</a>`:esc(v)):"";
if(k==="date_of_first_contact") return v?(L.initial_email?`<a href="${esc(L.initial_email)}" target="_blank" rel="noopener noreferrer">${esc(v)}</a>`:esc(v)):"";
return esc(v||"");
}
// Notes-below-user block: the Drive/sheet note (read-only) + Steve's own editable note.
function noteBlockHTML(r, showSheet){
const sheet = (showSheet && r.notes) ? `<div class="sheet-note"><b>Sheet:</b> ${esc(r.notes)}</div>` : "";
const un = UNOTES[r.name] || "";
return `<div class="note-block" data-name="${esc(r.name)}">${sheet}`
+ `<div class="user-note">${userNoteInner(un)}</div></div>`;
}
function userNoteInner(un){
return `<span class="un-tag">my note</span>`
+ `<span class="${un?"un-text":"un-text empty"}">${un?esc(un):"add a note…"}</span>`
+ `<button type="button" class="un-edit" title="Add or edit your note">✎</button>`;
}
function wireNotes(root){
root.querySelectorAll(".note-block").forEach(nb=>{
const btn = nb.querySelector(".un-edit");
if(btn) btn.onclick = () => openNoteEditor(nb, nb.getAttribute("data-name"));
});
}
function openNoteEditor(nb, name){
const wrap = nb.querySelector(".user-note");
const cur = UNOTES[name] || "";
wrap.innerHTML = `<textarea class="un-ta" placeholder="Your note about ${esc(name)}…">${esc(cur)}</textarea>`
+ `<button type="button" class="un-save">Save</button><button type="button" class="un-cancel">Cancel</button>`;
const ta = wrap.querySelector(".un-ta"); ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length);
wrap.querySelector(".un-cancel").onclick = () => restoreNote(wrap, name);
wrap.querySelector(".un-save").onclick = async (e) => {
const val = ta.value; e.target.disabled = true; e.target.textContent = "Saving…";
const ok = await saveNote(name, val);
if(ok){ const t=val.trim(); if(t) UNOTES[name]=t; else delete UNOTES[name]; }
else { e.target.disabled=false; e.target.textContent="Save"; alert("Couldn't save the note — try again."); return; }
restoreNote(wrap, name);
};
}
function restoreNote(wrap, name){
wrap.innerHTML = userNoteInner(UNOTES[name] || "");
const nb = wrap.closest(".note-block");
wrap.querySelector(".un-edit").onclick = () => openNoteEditor(nb, name);
}
async function saveNote(name, note){
try { const r = await fetch("/api/notes", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({name, note}) }); return r.ok; }
catch { return false; }
}
function renderTable(rows){
const col = COLUMNS.find(c=>c[0]===TSORT.col) || COLUMNS[0];
const isEmpty = r => col[2]==="num" ? false : !((r[col[0]]||"").toString().trim());
rows.sort((a,b)=>{
const ae=isEmpty(a), be=isEmpty(b);
if(ae&&!be) return 1; if(be&&!ae) return -1; if(ae&&be) return 0; // blanks always sink
let d;
if(col[2]==="num") d=(+a[col[0]]||0)-(+b[col[0]]||0);
else { const av=(a[col[0]]||"").toString().toLowerCase(), bv=(b[col[0]]||"").toString().toLowerCase(); d=av<bv?-1:av>bv?1:0; }
return d*TSORT.dir;
});
const head = COLUMNS.map(c=>{
const active=c[0]===col[0];
return `<th data-col="${c[0]}" class="${active?"sorted":""}" title="Sort by ${esc(c[1])}">${esc(c[1])}${active?`<span class="arr">${TSORT.dir>0?"▲":"▼"}</span>`:""}</th>`;
}).join("");
const body = rows.map(r=>{
const cells = COLUMNS.map(c=>`<td class="${c[2]==="num"?"num":""}">${cellHTML(r,c)}</td>`).join("");
// notes moved to a full-width row right below the journalist's row
return `<tr class="row-main">${cells}</tr><tr class="notes-row"><td colspan="${COLUMNS.length}">${noteBlockHTML(r,true)}</td></tr>`;
}).join("");
const wrap=document.getElementById("tablewrap");
wrap.innerHTML=`<table class="list"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
wrap.querySelectorAll("thead th").forEach(h=>h.onclick=()=>{
const c=h.getAttribute("data-col");
if(TSORT.col===c){ TSORT.dir=-TSORT.dir; } // same col → flip direction
else { TSORT.col=c; const t=(COLUMNS.find(x=>x[0]===c)||[])[2]; TSORT.dir=(t==="num"||t==="date")?-1:1; } // num/date default desc
LS("tcol",TSORT.col); LS("tdir",TSORT.dir); render();
});
wireNotes(wrap);
}
function render(){
const rows = filteredRows();
const list = VIEW==="list";
if(list) renderTable(rows); else renderCards(rows);
document.getElementById("grid").style.display = list?"none":"grid";
document.getElementById("tablewrap").style.display = list?"block":"none";
document.getElementById("sort").style.display = list?"none":""; // headers drive sorting in list mode
document.getElementById("empty").style.display = rows.length?"none":"block";
document.getElementById("count").textContent = `${rows.length} of ${DATA.length} journalists`;
}
async function load(){
const r = await fetch("/api/data"); const j = await r.json();
// normalize header-derived keys from the sheet
DATA = (j.rows||[]).map(x=>({
name:x.name, outlet_on_file:x.outlet_on_file||x["outlet_on_file"], current_outlet_verified:x.current_outlet_verified,
medium:x.medium, beat:x.beat, email:x.email, email_source:x.email_source, phone:x.phone,
twitter:x.twitter, location:x.location, latest_article:x.latest_article, article_date:x.article_date,
link_type:x.link_type, articles_tracked_10yr:x.articles_tracked_10yr, date_of_first_contact:x.first_contact||x.date_of_first_contact,
date_of_last_contact:x.last_contact||x.date_of_last_contact, contact_source:x.contact_source, notes:x.notes, _links:x._links||{}
}));
META=j;
try { const nr = await fetch("/api/notes"); UNOTES = nr.ok ? await nr.json() : {}; } catch { UNOTES = {}; }
const t = j.generated_at ? new Date(j.generated_at) : null;
document.getElementById("syncinfo").textContent =
(j.drive_pull_ok?"synced from Drive ":"⚠ Drive unreachable, cached ") + (t?t.toLocaleString(undefined,{month:"short",day:"numeric",hour:"numeric",minute:"2-digit"}):"");
render();
}
document.getElementById("q").oninput=render;
["fEmail","fContact","fActive"].forEach(id=>document.getElementById(id).onchange=render);
document.getElementById("sort").onchange=e=>{ LS("sort",e.target.value); render(); };
document.getElementById("density").oninput=e=>{
const v=+e.target.value; LS("density",v);
document.documentElement.style.setProperty("--cols", v);
// shrink cards as columns grow, clamped so text stays readable at 6–8 cols
document.documentElement.style.setProperty("--scale", Math.max(0.7, 1.25 - v*0.08).toFixed(2));
document.getElementById("colcount").textContent = v;
};
document.getElementById("refreshBtn").onclick=async()=>{
const b=document.getElementById("refreshBtn"); b.disabled=true; b.textContent="⟳ pulling…";
try{ await fetch("/api/refresh",{method:"POST"}); await load(); } finally { b.disabled=false; b.textContent="⟳ Refresh from Drive"; }
};
// view toggle (Cards ⇄ List) — persists in localStorage
document.querySelectorAll("#viewtoggle button").forEach(b=>{
b.classList.toggle("active", b.dataset.view===VIEW);
b.onclick=()=>{
VIEW=b.dataset.view; LS("view",VIEW);
document.querySelectorAll("#viewtoggle button").forEach(x=>x.classList.toggle("active",x.dataset.view===VIEW));
render();
};
});
// restore persisted controls
const sv=LS("sort"); if(sv) document.getElementById("sort").value=sv;
const dv=LS("density")||3; document.getElementById("density").value=dv;
document.getElementById("density").dispatchEvent(new Event("input"));
buildToggles();
load();
setInterval(load, 60*1000); // pick up server's 5-min Drive pulls without a manual reload
</script>
</body>
</html>