v0.3: dashboard local mode, openbridge tab reuse, tab activation fix

- dashboard.html: local dashboard.json mode (10s poll) alongside artifact db mode;
  clickable cards behind an http(s)-only safeUrl filter; meta charset; mode-aware
  freshness line
- openbridge.py: localhost shim exposing open_link so dashboard clicks reuse the
  platform's warm tab instead of spawning one per click
- cdp.py: Tab.activate(); get_or_open_tab activates reused tabs — a backgrounded
  tab never finishes rendering, which made YouTube's login check read a signed-in
  account as signed out
- README/MANIFEST: corrected CDP port (9336, was documented as 9222), removed the
  false claim that the server writes dashboard.json, documented the reboot/VPS
  requirements
- .gitignore: __pycache__/, *.pyc, state.json, Archived/; removed stale tracked
  pycache and state.json from index
This commit is contained in:
2026-09-11 10:18:38 +08:00
parent eaedf4f409
commit 1ac06c1bbe
20 changed files with 719 additions and 72 deletions
+198 -17
View File
@@ -1,3 +1,4 @@
<meta charset="utf-8">
<title>socialmag console</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -374,6 +375,40 @@
}
.notice code { font-family: var(--mono); font-size: 11.5px; }
/* Local-JSON mode is information, not failure — the orange above stays for
real errors. */
.notice.info {
border-color: #2C3B4E;
background: #141A22;
color: var(--text-dim);
}
.notice.info code { color: var(--accent-dim); }
/* ── clickable cards ───────────────────────────────────────────────── */
a.cardlink {
display: block;
text-decoration: none;
color: inherit;
border-radius: 0 var(--r-md) var(--r-md) 0;
}
a.cardlink:hover .card,
a.cardlink:focus-visible .card {
background: var(--surface-2);
border-color: var(--line);
}
a.cardlink:hover .tweet,
a.cardlink:focus-visible .tweet {
background: var(--surface-2);
}
a.cardlink .tweet {
padding-left: 8px;
padding-right: 8px;
margin-left: -8px;
margin-right: -8px;
border-radius: var(--r-sm);
}
footer.foot {
margin-top: 34px;
padding-top: 14px;
@@ -472,11 +507,77 @@
};
const norm = id => BY_ID[id] ? id : (ALIAS[String(id || "").toLowerCase()] || null);
const state = { platforms: {}, posts: [], notifications: [], feed: [], lastData: null };
const state = { platforms: {}, posts: [], notifications: [], feed: [], lastData: null, mode: null };
const $ = id => document.getElementById(id);
// Escapes " as well — these strings go into href/title attributes now.
const esc = s => String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
// Scraped URLs are untrusted input. Anything that isn't http(s) — a
// javascript: href, a relative path that never got absolutised — renders as
// a plain unclickable card rather than a live link.
const safeUrl = u => {
if (!u) return null;
try {
const p = new URL(String(u), location.href);
return (p.protocol === "http:" || p.protocol === "https:") ? p.href : null;
} catch { return null; }
};
const linkWrap = (url, inner, platform) => {
const href = safeUrl(url);
if (!href) return inner;
const pd = platform ? ` data-platform="${esc(platform)}"` : "";
return `<a class="cardlink" href="${esc(href)}"${pd} target="_blank" rel="noopener noreferrer">${inner}</a>`;
};
// ── tab reuse ───────────────────────────────────────────────────────
// target="_blank" spawns a tab per click. socialmag already knows how to
// reuse a warm tab per platform (open_link), but a static page can't speak
// MCP — openbridge.py exposes that one call over localhost.
//
// Progressive enhancement, deliberately: if the bridge isn't running, the
// click is left alone and the anchor opens a new tab as before. The page is
// never worse than it was without this.
const BRIDGE = "http://127.0.0.1:8322";
let bridgeUp = null; // null = unknown, true/false = probed
fetch(BRIDGE + "/health", { cache: "no-store" })
.then(r => { bridgeUp = r.ok; })
.catch(() => { bridgeUp = false; });
document.addEventListener("click", ev => {
const a = ev.target.closest && ev.target.closest("a.cardlink[data-platform]");
if (!a) return;
// Respect the browser's own gestures — ctrl/cmd/middle-click means the
// person explicitly asked for a new tab.
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey || ev.button !== 0) return;
if (bridgeUp === false) return;
ev.preventDefault();
const url = a.getAttribute("href");
const platform = a.getAttribute("data-platform");
const q = `?platform=${encodeURIComponent(platform)}&url=${encodeURIComponent(url)}`;
fetch(BRIDGE + "/open" + q, { cache: "no-store" })
.then(r => r.json().then(body => ({ ok: r.ok, body })))
.then(({ ok, body }) => {
bridgeUp = true;
if (!ok) {
// The bridge answered and refused. Don't silently open a tab instead —
// say why, since that's a real error the person should see.
notice(`Couldn't reuse the ${esc(platform)} tab:
<code>${esc(body && body.error || "unknown error")}</code>`);
}
})
.catch(() => {
// Bridge went away mid-session: fall back for this click and every
// click after it.
bridgeUp = false;
window.open(url, "_blank", "noopener");
});
});
// ── time ────────────────────────────────────────────────────────────
// Rows carry ts / time / createdAt / checkedAt depending on which tool
@@ -555,6 +656,7 @@
text: d.text || d.detail || "",
detail: d.detail && d.status && String(d.status).toLowerCase() !== "ok" ? d.detail : "",
t: parseTime(d),
url: d.url || d.link || null,
stats: { views: d.views, likes: d.likes, replies: d.replies, reposts: d.reposts ?? d.retweets }
});
}
@@ -567,6 +669,7 @@
who: BY_ID[pid] ? BY_ID[pid].name : String(d.platform),
text: d.text || "",
t: parseTime(d),
url: d.url || d.link || null,
stats: {}
});
}
@@ -605,7 +708,7 @@
const glyph = p
? `<span class="glyph ${p.id}">${esc(p.mark)}</span>`
: `<span class="glyph" style="background:var(--surface-2);color:var(--text-faint)">?</span>`;
return `<article class="card" style="--stripe:${stripe}">
return linkWrap(r.url, `<article class="card" style="--stripe:${stripe}">
${glyph}
<div class="body">
<div class="meta">
@@ -617,7 +720,7 @@
${r.detail ? `<div class="text quiet" style="font-size:12px">${esc(r.detail)}</div>` : ""}
${engagementHTML(r.stats)}
</div>
</article>`;
</article>`, r.pid);
}).join("");
}
@@ -635,7 +738,7 @@
return;
}
$("feed").innerHTML = rows.map(t => `<div class="tweet">
$("feed").innerHTML = rows.map(t => linkWrap(t.url, `<div class="tweet">
<div class="who">${esc(t.author || "unknown")}</div>
<div class="text">${esc(t.text || "")}</div>
<div class="meta engagement">
@@ -644,7 +747,7 @@
<span><b>${esc(showCount(t.replies))}</b> replies</span>
<span class="ts">${esc(ago(parseTime(t)))}</span>
</div>
</div>`).join("");
</div>`, "twitter")).join("");
}
// ── stats ───────────────────────────────────────────────────────────
@@ -677,8 +780,19 @@
$("pulse").className = "pulse " + (live ? "live" : "cold");
}
function notice(html) {
$("notice").innerHTML = html ? `<div class="notice">${html}</div>` : "";
function notice(html, cls) {
$("notice").innerHTML = html
? `<div class="notice${cls ? " " + cls : ""}">${html}</div>` : "";
}
// Mode-aware freshness. This used to be inlined in the ticker and rewrote the
// line to "live · …" unconditionally, so local mode claimed "live" one tick
// after load.
function tickFreshness() {
if (!state.lastData || !state.mode) return;
const s = Math.round((Date.now() - state.lastData) / 1000);
const when = s < 60 ? (s < 3 ? "just now" : s + "s ago") : ago(state.lastData);
setFreshness(state.mode + " · updated " + when, state.mode === "live");
}
// ── first paint: the layout, with empty states, before any store call ─
@@ -688,15 +802,84 @@
const docsOf = snap => snap.docs.map(d => Object.assign({ id: d.id }, d.data()));
// ── local JSON mode ─────────────────────────────────────────────────
// When the artifact store isn't there (served from a plain file server, or
// opened as a local file), the page reads dashboard.json beside itself and
// re-polls it. Same schema as the db collections.
const LOCAL_POLL_MS = 10000;
function applyLocal(data) {
state.platforms = {};
const plats = data.platforms || {};
if (Array.isArray(plats)) {
for (const row of plats) {
const id = norm(row.id || row.platform);
if (id) state.platforms[id] = row;
}
} else {
for (const [k, v] of Object.entries(plats)) {
const id = norm(k);
if (id) state.platforms[id] = (v && typeof v === "object") ? v : { loggedIn: v };
}
}
state.posts = data.posts || [];
state.notifications = data.notifications || [];
state.feed = data.feed || [];
state.lastData = Date.now();
renderAll();
tickFreshness();
}
async function pollLocal(first) {
let res;
try {
res = await fetch("dashboard.json", { cache: "no-store" });
} catch (e) {
if (first) {
setFreshness("local · can't read dashboard.json", false);
notice(`Couldn't fetch <code>dashboard.json</code>. A page opened as
<code>file://</code> can't fetch its neighbours — serve the folder over
HTTP instead (<code>python3 -m http.server</code>).`);
}
return;
}
if (!res.ok) {
if (first) {
setFreshness(`local · no dashboard.json (HTTP ${res.status})`, false);
notice(`No <code>dashboard.json</code> next to this page
(HTTP <code>${res.status}</code>). Write one with the same shape as the
<code>platforms · posts · notifications · feed</code> collections.`);
}
return;
}
let data;
try { data = await res.json(); }
catch (e) {
if (first) {
setFreshness("local · dashboard.json is not valid JSON", false);
notice(`<code>dashboard.json</code> loaded but didn't parse:
<code>${esc(e.message)}</code>.`);
}
return;
}
if (first) {
notice(`Local mode — reading <code>dashboard.json</code> from disk, refreshed
every ${LOCAL_POLL_MS / 1000}s. The artifact store isn't available here.`, "info");
}
applyLocal(data);
}
(async () => {
const db = window.claude && await window.claude.use("db");
if (!db) {
setFreshness("store unavailable", false);
notice(`Not reading from the artifact store — this view can't run the
<code>db</code> capability. The layout below is the empty state.`);
state.mode = "local";
setFreshness("local · loading…", false);
await pollLocal(true);
setInterval(() => pollLocal(false), LOCAL_POLL_MS);
return;
}
state.mode = "live";
setFreshness("live", true);
const onErr = where => e => {
@@ -735,14 +918,12 @@
})();
// Relative timestamps and the freshness line go stale on their own; retick
// them every 30s. The subscriptions above deliver the data itself.
// them on the same cadence as the local poll. The data itself arrives from
// the subscriptions (live) or pollLocal (local).
setInterval(() => {
renderTimeline();
renderFeed();
if (state.lastData) {
const s = Math.round((Date.now() - state.lastData) / 1000);
setFreshness(s < 60 ? "live · updated " + s + "s ago" : "live · updated " + ago(state.lastData), true);
}
}, 30000);
tickFreshness();
}, LOCAL_POLL_MS);
})();
</script>