Files
socialmag/dashboard.html
T

749 lines
24 KiB
HTML

<title>socialmag console</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;450;500;600&display=swap">
<style>
/* socialmag console — dark by commitment (the brief asks for dark), so every
surface and every color is painted explicitly and no theme block flips it. */
:root {
--bg: #0F1218;
--surface: #161A22;
--surface-2: #1C212B;
--line: #272D3A;
--line-soft: #1F2531;
--text: #E5E9F1;
--text-dim: #99A1B3;
--text-faint:#6B7488;
--accent: #D8A657; /* brass — belongs to no platform */
--accent-dim:#8A6B37;
--ok: #46BE84;
--bad: #F0555F;
--idle: #566072;
--threads: #FFFFFF; /* brief says #000000; invisible on this ground —
drawn as a black chip with a white ring instead */
--fb: #1877F2;
--ig: #E4405F;
--yt: #FF0000;
--tiktok: #00F2EA;
--twitter: #1DA1F2;
--r-sm: 4px;
--r-md: 8px;
--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--sans: "IBM Plex Sans", system-ui, -apple-system, Segoe UI, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: 1180px;
margin: 0 auto;
padding: 0 16px 56px;
}
/* ── header ────────────────────────────────────────────────────────── */
header.top {
position: sticky;
top: 0;
z-index: 10;
background: linear-gradient(var(--bg) 72%, rgba(15, 18, 24, 0));
padding: 20px 0 14px;
}
.brandline {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
}
.wordmark {
font-family: var(--mono);
font-size: 20px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text);
}
.wordmark::before {
content: "$ ";
color: var(--accent-dim);
font-weight: 400;
}
.badge {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
border: 1px solid var(--accent-dim);
border-radius: 999px;
padding: 2px 9px;
}
.freshness {
margin-left: auto;
display: flex;
align-items: center;
gap: 7px;
font-family: var(--mono);
font-size: 11px;
color: var(--text-faint);
}
.pulse {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 0 rgba(70, 190, 132, 0.55);
}
.pulse.live { animation: ping 2.6s ease-out infinite; }
.pulse.cold { background: var(--idle); animation: none; }
@keyframes ping {
0% { box-shadow: 0 0 0 0 rgba(70, 190, 132, 0.5); }
70% { box-shadow: 0 0 0 7px rgba(70, 190, 132, 0); }
100% { box-shadow: 0 0 0 0 rgba(70, 190, 132, 0); }
}
@media (prefers-reduced-motion: reduce) {
.pulse.live { animation: none; }
}
/* ── platform strip ────────────────────────────────────────────────── */
.strip {
display: flex;
gap: 8px;
overflow-x: auto;
padding: 14px 0 4px;
scrollbar-width: thin;
}
.pchip {
flex: 1 0 auto;
min-width: 116px;
display: flex;
align-items: center;
gap: 9px;
padding: 9px 11px;
background: var(--surface);
border: 1px solid var(--line-soft);
border-radius: var(--r-md);
}
.glyph {
width: 22px; height: 22px;
flex: 0 0 22px;
display: grid;
place-items: center;
border-radius: var(--r-sm);
font-family: var(--mono);
font-size: 10px;
font-weight: 600;
color: #0B0E13;
letter-spacing: -0.03em;
}
.glyph.threads { background: #000; color: #fff; box-shadow: inset 0 0 0 1px var(--threads); }
.glyph.fb { background: var(--fb); color: #fff; }
.glyph.ig { background: var(--ig); color: #fff; }
.glyph.yt { background: var(--yt); color: #fff; }
.glyph.tiktok { background: var(--tiktok); color: #04201F; }
.glyph.twitter { background: var(--twitter); color: #06222F; }
.pchip .pname {
font-size: 12.5px;
font-weight: 500;
line-height: 1.2;
}
.pchip .pstate {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-faint);
}
.dot {
margin-left: auto;
width: 8px; height: 8px;
border-radius: 50%;
background: var(--idle);
flex: 0 0 8px;
}
.dot.in { background: var(--ok); }
.dot.out { background: var(--bad); }
/* ── stats ─────────────────────────────────────────────────────────── */
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
background: var(--line-soft);
border: 1px solid var(--line-soft);
border-radius: var(--r-md);
overflow: hidden;
margin-top: 18px;
}
.stat {
background: var(--surface);
padding: 14px 16px;
}
.stat .k {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
}
.stat .v {
font-family: var(--mono);
font-size: 26px;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--text);
line-height: 1.25;
margin-top: 4px;
}
.stat .v.zero { color: var(--text-faint); }
/* ── layout ────────────────────────────────────────────────────────── */
main {
display: grid;
grid-template-columns: 1fr;
gap: 26px;
margin-top: 26px;
align-items: start;
}
@media (min-width: 900px) {
main { grid-template-columns: minmax(0, 1fr) 320px; gap: 30px; }
}
.shead {
display: flex;
align-items: baseline;
gap: 10px;
padding-bottom: 9px;
border-bottom: 1px solid var(--line);
margin-bottom: 14px;
}
.shead h2 {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
margin: 0;
text-wrap: balance;
}
.shead .tool {
font-family: var(--mono);
font-size: 10.5px;
color: var(--accent-dim);
}
.shead .count {
margin-left: auto;
font-family: var(--mono);
font-size: 11px;
color: var(--text-faint);
font-variant-numeric: tabular-nums;
}
/* ── timeline ──────────────────────────────────────────────────────── */
.timeline { display: flex; flex-direction: column; gap: 10px; }
.card {
display: grid;
grid-template-columns: 22px minmax(0, 1fr);
gap: 11px;
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--line-soft);
border-left: 2px solid var(--stripe, var(--line));
border-radius: 0 var(--r-md) var(--r-md) 0;
}
.card .body { min-width: 0; }
.card .meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-family: var(--mono);
font-size: 10.5px;
color: var(--text-faint);
}
.card .who {
color: var(--text-dim);
font-weight: 500;
}
.card .text {
margin-top: 5px;
font-size: 13.5px;
color: var(--text);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.card .text.quiet { color: var(--text-dim); }
.kind {
text-transform: uppercase;
letter-spacing: 0.07em;
font-size: 9.5px;
padding: 1px 6px;
border-radius: var(--r-sm);
border: 1px solid var(--line);
color: var(--text-dim);
}
.kind.sent { border-color: var(--accent-dim); color: var(--accent); }
.kind.error { border-color: #6C2B31; color: var(--bad); }
.engagement {
display: flex;
gap: 14px;
margin-top: 8px;
font-family: var(--mono);
font-size: 11px;
color: var(--text-faint);
font-variant-numeric: tabular-nums;
}
.engagement span b {
color: var(--text-dim);
font-weight: 500;
}
.ts { margin-left: auto; white-space: nowrap; }
/* ── rail ──────────────────────────────────────────────────────────── */
.rail { display: flex; flex-direction: column; gap: 26px; }
.tweet {
padding: 11px 0;
border-bottom: 1px solid var(--line-soft);
}
.tweet:last-child { border-bottom: 0; }
.tweet .who {
font-size: 12.5px;
font-weight: 500;
color: var(--text);
overflow-wrap: anywhere;
}
.tweet .text {
font-size: 12.5px;
color: var(--text-dim);
margin-top: 3px;
overflow-wrap: anywhere;
}
.tweet .engagement { margin-top: 6px; }
/* ── empty / notice ────────────────────────────────────────────────── */
.empty {
padding: 22px 16px;
border: 1px dashed var(--line);
border-radius: var(--r-md);
color: var(--text-faint);
font-size: 12.5px;
}
.empty code {
font-family: var(--mono);
font-size: 11.5px;
color: var(--accent-dim);
}
.notice {
margin-top: 16px;
padding: 11px 14px;
border: 1px solid #5C3A1C;
background: #1E1810;
border-radius: var(--r-md);
color: #E3B679;
font-size: 12.5px;
}
.notice code { font-family: var(--mono); font-size: 11.5px; }
footer.foot {
margin-top: 34px;
padding-top: 14px;
border-top: 1px solid var(--line-soft);
font-family: var(--mono);
font-size: 10.5px;
color: var(--text-faint);
display: flex;
gap: 8px 18px;
flex-wrap: wrap;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
</style>
<div class="wrap">
<header class="top">
<div class="brandline">
<span class="wordmark">socialmag</span>
<span class="badge">6 platforms</span>
<span class="freshness">
<span class="pulse cold" id="pulse"></span>
<span id="fresh">connecting to store…</span>
</span>
</div>
<div class="strip" id="strip"></div>
</header>
<div class="stats">
<div class="stat">
<div class="k">Posts sent</div>
<div class="v zero" id="stat-posts">0</div>
</div>
<div class="stat">
<div class="k">Total views</div>
<div class="v zero" id="stat-views">—</div>
</div>
<div class="stat">
<div class="k">Platforms active</div>
<div class="v zero" id="stat-active">0<span style="color:var(--text-faint);font-size:15px">/6</span></div>
</div>
</div>
<div id="notice"></div>
<main>
<section>
<div class="shead">
<h2>Timeline</h2>
<span class="tool">create_post · get_notifications</span>
<span class="count" id="tl-count"></span>
</div>
<div class="timeline" id="timeline"></div>
</section>
<aside class="rail">
<section>
<div class="shead">
<h2>Twitter intel</h2>
<span class="tool">get_feed</span>
<span class="count" id="feed-count"></span>
</div>
<div id="feed"></div>
</section>
</aside>
</main>
<footer class="foot">
<span>display only — the posting loop writes, this page reads</span>
<span>collections: platforms · posts · notifications · feed</span>
</footer>
</div>
<script>
(() => {
"use strict";
// Platform registry — id, label, glyph mark, stripe color. Order is the
// order socialmag's server.py registers its modules.
const PLATFORMS = [
{ id: "threads", name: "Threads", mark: "T", color: "#FFFFFF" },
{ id: "fb", name: "Facebook", mark: "f", color: "#1877F2" },
{ id: "ig", name: "Instagram", mark: "IG", color: "#E4405F" },
{ id: "yt", name: "YouTube", mark: "YT", color: "#FF0000" },
{ id: "tiktok", name: "TikTok", mark: "TT", color: "#00F2EA" },
{ id: "twitter", name: "Twitter", mark: "X", color: "#1DA1F2" }
];
const BY_ID = Object.fromEntries(PLATFORMS.map(p => [p.id, p]));
// Aliases the writer loop might use instead of the canonical ids.
const ALIAS = {
facebook: "fb", instagram: "ig", youtube: "yt",
x: "twitter", tiktok: "tiktok", threads: "threads"
};
const norm = id => BY_ID[id] ? id : (ALIAS[String(id || "").toLowerCase()] || null);
const state = { platforms: {}, posts: [], notifications: [], feed: [], lastData: null };
const $ = id => document.getElementById(id);
const esc = s => String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// ── time ────────────────────────────────────────────────────────────
// Rows carry ts / time / createdAt / checkedAt depending on which tool
// produced them; anything unparseable sorts last and renders as "—".
const parseTime = row => {
const raw = row.ts ?? row.time ?? row.createdAt ?? row.timestamp ?? null;
if (raw == null) return null;
if (typeof raw === "number") return raw > 1e12 ? raw : raw * 1000;
const t = Date.parse(raw);
return Number.isNaN(t) ? null : t;
};
const ago = ms => {
if (ms == null) return "—";
const s = Math.max(0, (Date.now() - ms) / 1000);
if (s < 45) return "just now";
if (s < 3600) return Math.round(s / 60) + "m ago";
if (s < 86400) return Math.round(s / 3600) + "h ago";
if (s < 604800) return Math.round(s / 86400) + "d ago";
return new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric" });
};
// Engagement counts arrive as scraped strings ("1.2K") or numbers.
const countNum = v => {
if (typeof v === "number") return v;
const s = String(v || "").trim().replace(/,/g, "");
const m = s.match(/^([\d.]+)\s*([KMB])?$/i);
if (!m) return 0;
const mult = { k: 1e3, m: 1e6, b: 1e9 }[(m[2] || "").toLowerCase()] || 1;
return parseFloat(m[1]) * mult;
};
const showCount = v => (v === 0 || v === "0" || v == null) ? "0" : String(v);
const compact = n =>
n >= 1e6 ? (n / 1e6).toFixed(n >= 1e7 ? 0 : 1) + "M" :
n >= 1e3 ? (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + "K" : String(n);
// ── platform strip ──────────────────────────────────────────────────
function loginState(doc) {
if (!doc) return "unknown";
if (typeof doc.loggedIn === "boolean") return doc.loggedIn ? "in" : "out";
if (doc.error) return "unknown";
const s = String(doc.status ?? "").toLowerCase();
if (["true", "in", "ok", "logged_in", "logged in", "yes"].includes(s)) return "in";
if (["false", "out", "logged_out", "logged out", "no"].includes(s)) return "out";
return "unknown";
}
const STATE_LABEL = { in: "logged in", out: "signed out", unknown: "unknown" };
function renderStrip() {
$("strip").innerHTML = PLATFORMS.map(p => {
const st = loginState(state.platforms[p.id]);
return `<div class="pchip" title="${esc(p.name)} — ${STATE_LABEL[st]}">
<span class="glyph ${p.id}">${esc(p.mark)}</span>
<span>
<span class="pname">${esc(p.name)}</span><br>
<span class="pstate">${STATE_LABEL[st]}</span>
</span>
<span class="dot ${st === "unknown" ? "" : st}"></span>
</div>`;
}).join("");
}
// ── timeline ────────────────────────────────────────────────────────
function timelineRows() {
const rows = [];
for (const d of state.posts) {
const pid = norm(d.platform) || norm(d.id);
rows.push({
kind: (d.status && String(d.status).toLowerCase() !== "ok") ? "error" : "sent",
pid,
who: pid ? BY_ID[pid].name : (d.platform || "unknown platform"),
text: d.text || d.detail || "",
detail: d.detail && d.status && String(d.status).toLowerCase() !== "ok" ? d.detail : "",
t: parseTime(d),
stats: { views: d.views, likes: d.likes, replies: d.replies, reposts: d.reposts ?? d.retweets }
});
}
for (const d of state.notifications) {
const pid = norm(d.platform) || "threads";
rows.push({
kind: "activity",
pid,
who: BY_ID[pid] ? BY_ID[pid].name : String(d.platform),
text: d.text || "",
t: parseTime(d),
stats: {}
});
}
rows.sort((a, b) => (b.t ?? -Infinity) - (a.t ?? -Infinity));
return rows;
}
function engagementHTML(s) {
const parts = [];
if (s.views != null) parts.push(`<span><b>${esc(showCount(s.views))}</b> views</span>`);
if (s.likes != null) parts.push(`<span><b>${esc(showCount(s.likes))}</b> likes</span>`);
if (s.replies != null) parts.push(`<span><b>${esc(showCount(s.replies))}</b> replies</span>`);
if (s.reposts != null) parts.push(`<span><b>${esc(showCount(s.reposts))}</b> reposts</span>`);
return parts.length ? `<div class="engagement">${parts.join("")}</div>` : "";
}
const KIND_LABEL = { sent: "posted", error: "failed", activity: "activity" };
function renderTimeline() {
const rows = timelineRows();
$("tl-count").textContent = rows.length ? rows.length + " entries" : "";
if (!rows.length) {
$("timeline").innerHTML = `<div class="empty">
Nothing here yet. <code>create_post</code> writes to <code>posts</code>
and <code>get_notifications</code> writes to <code>notifications</code> —
both land in this timeline, newest first.
</div>`;
return;
}
$("timeline").innerHTML = rows.map(r => {
const p = r.pid ? BY_ID[r.pid] : null;
const stripe = p ? p.color : "var(--line)";
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}">
${glyph}
<div class="body">
<div class="meta">
<span class="who">${esc(r.who)}</span>
<span class="kind ${r.kind === "activity" ? "" : r.kind}">${KIND_LABEL[r.kind]}</span>
<span class="ts">${esc(ago(r.t))}</span>
</div>
<div class="text${r.kind === "activity" ? " quiet" : ""}">${esc(r.text) || "<span style='color:var(--text-faint)'>(no text)</span>"}</div>
${r.detail ? `<div class="text quiet" style="font-size:12px">${esc(r.detail)}</div>` : ""}
${engagementHTML(r.stats)}
</div>
</article>`;
}).join("");
}
// ── twitter intel ───────────────────────────────────────────────────
function renderFeed() {
const rows = state.feed.slice().sort((a, b) => (parseTime(b) ?? -Infinity) - (parseTime(a) ?? -Infinity));
$("feed-count").textContent = rows.length ? rows.length : "";
if (!rows.length) {
$("feed").innerHTML = `<div class="empty">
No tweets pulled yet. <code>get_feed</code> writes scraped tweets
(author, text, time, likes, retweets, replies) to <code>feed</code>.
</div>`;
return;
}
$("feed").innerHTML = rows.map(t => `<div class="tweet">
<div class="who">${esc(t.author || "unknown")}</div>
<div class="text">${esc(t.text || "")}</div>
<div class="meta engagement">
<span><b>${esc(showCount(t.likes))}</b> likes</span>
<span><b>${esc(showCount(t.retweets ?? t.reposts))}</b> RT</span>
<span><b>${esc(showCount(t.replies))}</b> replies</span>
<span class="ts">${esc(ago(parseTime(t)))}</span>
</div>
</div>`).join("");
}
// ── stats ───────────────────────────────────────────────────────────
function renderStats() {
const sent = state.posts.filter(d => !d.status || String(d.status).toLowerCase() === "ok").length;
const views = state.posts.reduce((n, d) => n + countNum(d.views), 0);
const active = PLATFORMS.filter(p => loginState(state.platforms[p.id]) === "in").length;
const set = (id, txt, zero) => {
const el = $(id);
el.innerHTML = txt;
el.classList.toggle("zero", zero);
};
set("stat-posts", String(sent), sent === 0);
set("stat-views", views > 0 ? compact(Math.round(views)) : "—", views === 0);
set("stat-active",
`${active}<span style="color:var(--text-faint);font-size:15px">/6</span>`, active === 0);
}
function renderAll() {
renderStrip();
renderStats();
renderTimeline();
renderFeed();
}
function setFreshness(text, live) {
$("fresh").textContent = text;
$("pulse").className = "pulse " + (live ? "live" : "cold");
}
function notice(html) {
$("notice").innerHTML = html ? `<div class="notice">${html}</div>` : "";
}
// ── first paint: the layout, with empty states, before any store call ─
renderAll();
// ── store ───────────────────────────────────────────────────────────
const docsOf = snap => snap.docs.map(d => Object.assign({ id: d.id }, d.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.`);
return;
}
setFreshness("live", true);
const onErr = where => e => {
setFreshness("store error", false);
notice(`Lost the <code>${where}</code> subscription: <code>${esc(e.code)}</code>.
Reload the page to resubscribe.`);
};
const touch = () => { state.lastData = Date.now(); };
// One live subscription per collection. Snapshots deliver other writers'
// rows as they land, and fall back to a ~30s refresh on their own.
db.collection("platforms").onSnapshot(snap => {
state.platforms = {};
for (const d of snap.docs) {
const id = norm(d.id);
if (id) state.platforms[id] = d.data();
}
touch(); renderStrip(); renderStats();
}, onErr("platforms"));
db.collection("posts").limit(200).onSnapshot(snap => {
state.posts = docsOf(snap);
touch(); renderTimeline(); renderStats();
}, onErr("posts"));
db.collection("notifications").limit(200).onSnapshot(snap => {
state.notifications = docsOf(snap);
touch(); renderTimeline();
}, onErr("notifications"));
db.collection("feed").limit(60).onSnapshot(snap => {
state.feed = docsOf(snap);
touch(); renderFeed();
}, onErr("feed"));
})();
// Relative timestamps and the freshness line go stale on their own; retick
// them every 30s. The subscriptions above deliver the data itself.
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);
})();
</script>