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:
+198
-17
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
|
||||
// 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>
|
||||
|
||||
Reference in New Issue
Block a user