749 lines
24 KiB
HTML
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
|
|
// ── 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>
|