- .lead .hero.noimg: faint giant 'BAM' watermark so a photoless lead (most Bing-wire stories) never reads as a dead dark void. - tests/ui-check.mjs: headless Chromium suite (front, reader, board post + render, desk view, mobile) with a seeded scratch Mongo. - Fixed board rate-limit to compare BSON Date against Date (a numeric epoch matched nothing, so the counter stayed 0) — caught by tests/board-test.mjs. - Fixed /api/health double-await: getDb() is async, so 'await getDb().command(...)' called .command on the Promise. Caught by tests/e2e-run.mjs.
503 lines
21 KiB
JavaScript
503 lines
21 KiB
JavaScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { CATEGORIES, sectionName } from "../lib/categories.js";
|
|
import { clipText, dek } from "../lib/text.js";
|
|
|
|
// BidaAngMamamayan — a PUBLIC tribute news desk for Senator BAM Aquino.
|
|
// No login, no accounts, no "I like this" — the site's whole identity is the
|
|
// subject. Views: front, each desk, and the community board. The reader is a
|
|
// side drawer over the current view.
|
|
|
|
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
|
|
|
function fmtDate(d) {
|
|
return `${DAY_NAMES[d.getDay()]}, ${MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
|
|
}
|
|
function ago(iso) {
|
|
if (!iso) return "";
|
|
const h = (Date.now() - new Date(iso).getTime()) / 3600e3;
|
|
if (!Number.isFinite(h) || h < 0) return "";
|
|
if (h < 1) return `${Math.max(1, Math.round(h * 60))}m`;
|
|
if (h < 24) return `${Math.round(h)}h`;
|
|
return `${Math.round(h / 24)}d`;
|
|
}
|
|
function imgSrc(u) {
|
|
if (!u) return null;
|
|
return `/api/img?u=${encodeURIComponent(u)}`;
|
|
}
|
|
|
|
// The masthead wordmark: BIDA ANG MAMAMAYAN, with the B-A-M letters carried in
|
|
// gold (bold) as the tribute's highlight.
|
|
function Brand() {
|
|
return (
|
|
<div className="brand">
|
|
<span className="bam">B</span>ida<span className="bam">A</span>ng<span className="bam">M</span>amamayan
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function Home() {
|
|
const [view, setView] = useState("front"); // front | <catKey> | board
|
|
const [edition, setEdition] = useState(null);
|
|
const [desks, setDesks] = useState([]);
|
|
const [stats, setStats] = useState(null);
|
|
const [boardPosts, setBoardPosts] = useState(null);
|
|
const [boardCount, setBoardCount] = useState(0);
|
|
const [reading, setReading] = useState(null); // story in the drawer
|
|
const [toast, setToast] = useState("");
|
|
const toastTimer = useRef(null);
|
|
const [loadErr, setLoadErr] = useState("");
|
|
|
|
const say = useCallback((msg) => {
|
|
setToast(msg);
|
|
clearTimeout(toastTimer.current);
|
|
toastTimer.current = setTimeout(() => setToast(""), 3200);
|
|
}, []);
|
|
|
|
async function loadFeed() {
|
|
try {
|
|
const r = await fetch("/api/feed", { cache: "no-store", signal: AbortSignal.timeout(20000) });
|
|
const j = await r.json();
|
|
if (j.ok) {
|
|
setEdition(j.feed || null);
|
|
setDesks(j.desks || []);
|
|
setStats(j.stats || null);
|
|
setLoadErr("");
|
|
} else setLoadErr("The desk is still warming up — check back in a minute.");
|
|
} catch {
|
|
setLoadErr("Could not reach the desk. The site may be mid-deploy — try again shortly.");
|
|
}
|
|
}
|
|
|
|
async function loadBoard() {
|
|
try {
|
|
const r = await fetch("/api/board", { cache: "no-store", signal: AbortSignal.timeout(15000) });
|
|
const j = await r.json();
|
|
if (j.ok) {
|
|
setBoardPosts(j.posts || []);
|
|
setBoardCount(j.count || 0);
|
|
}
|
|
} catch {
|
|
/* board is optional chrome on the front view */
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadFeed();
|
|
loadBoard();
|
|
return () => clearTimeout(toastTimer.current);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Refresh the edition every 5 min so a long-open tab keeps ticking.
|
|
useEffect(() => {
|
|
const t = setInterval(loadFeed, 5 * 60 * 1000);
|
|
return () => clearInterval(t);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const items = useMemo(() => (edition ? edition.items || [] : []), [edition]);
|
|
|
|
const namedItems = useMemo(() => items.filter((i) => i.named).sort((a, b) => b.score - a.score), [items]);
|
|
|
|
const lead = useMemo(() => {
|
|
if (!items.length) return null;
|
|
const n = namedItems[0];
|
|
if (n) return n;
|
|
return items[0];
|
|
}, [items, namedItems]);
|
|
|
|
const deskByKey = useMemo(() => new Map(desks.map((d) => [d.key, d])), [desks]);
|
|
|
|
// The view's story list: the whole edition on front; a desk's stories on a
|
|
// desk view. The desk grid itself feeds from the pool (deskHeadlines), which
|
|
// is a superset of the ranked edition — use whichever has more.
|
|
const viewItems = useMemo(() => {
|
|
if (view === "front") return items;
|
|
if (view === "board") return [];
|
|
const d = deskByKey.get(view);
|
|
const fromPool = d ? d.stories || [] : [];
|
|
const fromEdition = items.filter((i) => i.category === view);
|
|
const map = new Map();
|
|
for (const s of [...fromEdition, ...fromPool]) map.set(s.url, s);
|
|
return [...map.values()].sort((a, b) => b.score - a.score || new Date(b.published || 0) - new Date(a.published || 0));
|
|
}, [view, items, deskByKey]);
|
|
|
|
function openStory(s) {
|
|
if (!s) return;
|
|
setReading({ story: s, state: "loading", article: null, realUrl: s.url });
|
|
}
|
|
function closeReading() {
|
|
setReading(null);
|
|
}
|
|
|
|
// ── ticker: named-BAM headlines scroll, pause on hover ─────────────────
|
|
const tickerItems = useMemo(() => namedItems.slice(0, 12), [namedItems]);
|
|
|
|
// ── board posting ──────────────────────────────────────────────────────
|
|
const [nick, setNick] = useState("");
|
|
const [msg, setMsg] = useState("");
|
|
const [posting, setPosting] = useState(false);
|
|
const [postErr, setPostErr] = useState("");
|
|
const [freshId, setFreshId] = useState(null);
|
|
|
|
async function doPost(e) {
|
|
e.preventDefault();
|
|
setPostErr("");
|
|
setPosting(true);
|
|
try {
|
|
const r = await fetch("/api/board", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ nick, message: msg }),
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
const j = await r.json();
|
|
if (j.ok && j.post) {
|
|
setBoardPosts((p) => (p ? [j.post, ...p] : [j.post]));
|
|
setBoardCount((c) => c + 1);
|
|
setMsg("");
|
|
setFreshId(j.post.id);
|
|
say("Posted. Thank you — the board is full of you.");
|
|
} else {
|
|
setPostErr(j.error || "Could not post right now.");
|
|
}
|
|
} catch {
|
|
setPostErr("Network hiccup — try again.");
|
|
} finally {
|
|
setPosting(false);
|
|
}
|
|
}
|
|
|
|
const deskLabel = view === "front" ? "Front Desk" : view === "board" ? "The Board" : sectionName(view);
|
|
|
|
return (
|
|
<>
|
|
<header className="masthead">
|
|
<Brand />
|
|
<div className="slogan">Bida ang Mamamayan</div>
|
|
<div className="who">
|
|
The supporter tribute desk for <b>Senator Bam Aquino</b> — the gamer of the Philippine
|
|
Senate, champion of college students, scholarships, and the people's power bill. Every
|
|
story that names him, crawled live from the wire.
|
|
</div>
|
|
<div className="disclaimer">
|
|
<b>DISCLAIMER</b> — BidaAngMamamayan is built and run by <b>supporters</b> of Senator Bam
|
|
Aquino. It is <b>not affiliated with, endorsed by, or operated by</b> the senator or his
|
|
office. News items belong to their original publishers; this site aggregates them for
|
|
tribute purposes only.
|
|
</div>
|
|
</header>
|
|
|
|
<nav className="navstrip">
|
|
<a className={`navbtn ${view === "front" ? "active" : ""}`} href="#front" onClick={(e) => { e.preventDefault(); setView("front"); window.scrollTo({ top: 0 }); }}>
|
|
<span className="dot" />Front
|
|
</a>
|
|
{CATEGORIES.filter((c) => c.key !== "bam").map((c) => (
|
|
<a key={c.key} className={`navbtn ${view === c.key ? "active" : ""}`} href={`#${c.key}`} onClick={(e) => { e.preventDefault(); setView(c.key); window.scrollTo({ top: 0 }); }}>
|
|
{c.label}
|
|
</a>
|
|
))}
|
|
<a className={`navbtn ${view === "board" ? "active" : ""}`} href="#board" onClick={(e) => { e.preventDefault(); setView("board"); window.scrollTo({ top: 0 }); }}>
|
|
{boardCount > 0 && <span className="dot" />}Thank You Board
|
|
</a>
|
|
</nav>
|
|
|
|
{stats && (
|
|
<div className="hud">
|
|
<div className="chip"><span className="live" />LIVE DESK</div>
|
|
<div className="chip">STORIES <b>{edition ? edition.total : "—"}</b></div>
|
|
<div className="chip">LAST 24H <b>{stats.today ?? "—"}</b></div>
|
|
<div className="chip">NAMED BAM <b>{edition ? edition.named : "—"}</b></div>
|
|
<div className="chip">BOARD <b>{boardCount}</b></div>
|
|
<div className="chip">EDITION <b>{fmtDate(new Date())}</b></div>
|
|
</div>
|
|
)}
|
|
{edition && (
|
|
<div className="xpbar">
|
|
<span>EDITION STRENGTH — {edition.total} WIRE STORIES LOADED</span>
|
|
<div className="track"><div className="fill" style={{ width: `${Math.min(100, Math.round((edition.total / 60) * 100))}%` }} /></div>
|
|
</div>
|
|
)}
|
|
|
|
{tickerItems.length > 1 && (
|
|
<div className="ticker">
|
|
<span className="label">BAM WIRE</span>
|
|
<div className="rail">
|
|
{[...tickerItems, ...tickerItems].map((s, i) => (
|
|
<span className="item" key={i}>
|
|
<b>{ago(s.published) || s.source}</b>
|
|
<a href={s.url} target="_blank" rel="noopener noreferrer" onClick={() => openStory(s)}>{s.title}</a>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<main className="wrap">
|
|
{loadErr && !edition && (
|
|
<div className="panel" style={{ padding: 40, textAlign: "center" }}>
|
|
<div className="empty">{loadErr}</div>
|
|
<button className="srcbtn" onClick={loadFeed}>RETRY</button>
|
|
</div>
|
|
)}
|
|
|
|
{view === "front" && (
|
|
<>
|
|
<div className="cols">
|
|
<div>
|
|
{!edition && (
|
|
<>
|
|
<div className="sk" style={{ aspectRatio: "16/8.5", marginBottom: 12 }} />
|
|
<div className="sk" style={{ height: 72, marginBottom: 8 }} />
|
|
<div className="sk" style={{ height: 72, marginBottom: 8 }} />
|
|
<div className="sk" style={{ height: 72 }} />
|
|
</>
|
|
)}
|
|
{lead && (
|
|
<article className="panel lead" onClick={() => openStory(lead)}>
|
|
<div className={`hero ${lead.image ? "" : "noimg"}`} style={lead.image ? { backgroundImage: `url(${imgSrc(lead.image)})` } : null}>
|
|
{!lead.image && <span className="ghost">BAM</span>}
|
|
<div className="ph-tag">
|
|
{lead.named && <span className="tag named">★ NAMED BAM</span>}
|
|
<span className="tag desk">{sectionName(lead.category)}</span>
|
|
<span className="tag src">{lead.source}</span>
|
|
</div>
|
|
</div>
|
|
<h1>{lead.title}</h1>
|
|
{lead.summary && <div className="dek">{dek(lead.summary, 220)}</div>}
|
|
<div className="meta"><span className="ts">{ago(lead.published)}</span> · {lead.desk || sectionName(lead.category)} · READ IN THE DESK →</div>
|
|
</article>
|
|
)}
|
|
|
|
<div className="panel" style={{ marginTop: 16 }}>
|
|
<div className="ph">On the wire <span className="count">{viewItems.length} stories</span></div>
|
|
{viewItems.slice(0, 14).map((s) => (
|
|
<StoryRow key={s.url} s={s} onOpen={() => openStory(s)} />
|
|
))}
|
|
{viewItems.length === 0 && <div className="empty">The wire is quiet — the next crawl tick is on the way.</div>}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="panel railcard">
|
|
<div className="ph">In this edition <span className="count">{desks.length} desks</span></div>
|
|
<div className="deskgrid" style={{ padding: 12 }}>
|
|
{desks.map((d) => (
|
|
<div key={d.key} className="deskcard" onClick={() => { setView(d.key); window.scrollTo({ top: 0 }); }}>
|
|
<div className="dh"><span className="pix" />{d.label}<span className="n">{d.count}</span></div>
|
|
<div className="dt">{clipText(d.stories[0].title, 90)}</div>
|
|
<div className="dm">{ago(d.stories[0].published)} · {d.stories[0].source}</div>
|
|
</div>
|
|
))}
|
|
{desks.length === 0 && <div className="empty" style={{ gridColumn: "1/-1" }}>Desks staff as the wire fills up.</div>}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="panel railcard board-teaser">
|
|
<div className="ph">Thank you, Senator BAM <span className="count">{boardCount} posted</span></div>
|
|
{(boardPosts || []).slice(0, 4).map((p) => (
|
|
<div className="post" key={p.id}>
|
|
<div className="nick">{p.nick}</div>
|
|
<div className="msg">{clipText(p.message, 120)}</div>
|
|
<div className="ts">{ago(p.createdAt)}</div>
|
|
</div>
|
|
))}
|
|
{(boardPosts || []).length === 0 && <div className="empty">No messages yet — be the first to say thank you.</div>}
|
|
<a className="more" href="#board" onClick={(e) => { e.preventDefault(); setView("board"); window.scrollTo({ top: 0 }); }}>
|
|
SIGN THE BOARD →
|
|
</a>
|
|
</div>
|
|
|
|
<div className="panel railcard">
|
|
<div className="ph">Who he is</div>
|
|
<div style={{ padding: 14, color: "var(--muted)", fontSize: 15, fontWeight: 500, lineHeight: 1.5 }}>
|
|
<b style={{ color: "var(--text)" }}>Francis "Bam" Tan Aquino IV</b> — Senator for
|
|
Pampanga. The first gamer to reach the Philippine Senate: a runner-up in the
|
|
2025 <b style={{ color: "var(--gold)" }}>PalaPinas</b> esports championship, and
|
|
a legislator who has made the fight for <b style={{ color: "var(--gold)" }}>college
|
|
students, scholarships, and fair electricity</b> his signature. This desk follows
|
|
the wire for every story that bears his name.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{view !== "front" && view !== "board" && (
|
|
<div className="panel">
|
|
<div className="ph">{deskLabel} <span className="count">{viewItems.length} stories</span></div>
|
|
{viewItems.map((s) => (
|
|
<StoryRow key={s.url} s={s} onOpen={() => openStory(s)} />
|
|
))}
|
|
{viewItems.length === 0 && (
|
|
<div className="empty">
|
|
Nothing on this desk right now — the wire is a slow burn for a single senator. The
|
|
next crawl tick (every 15 min) may staff it.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{view === "board" && (
|
|
<div>
|
|
<div className="boardhead">
|
|
<div className="h">Thank you, Senator BAM</div>
|
|
<div className="sub">
|
|
The community board. No account, no email — just your nickname and a word for the
|
|
senator. Plain words only: no links, no markup.
|
|
</div>
|
|
</div>
|
|
<form className="postform" onSubmit={doPost}>
|
|
<label htmlFor="nick">Your nickname</label>
|
|
<input id="nick" value={nick} onChange={(e) => setNick(e.target.value)} maxLength={24} placeholder="e.g. PampangaProud, GMAmateur, BamGang13" required />
|
|
<label htmlFor="msg">Your message</label>
|
|
<textarea id="msg" value={msg} onChange={(e) => setMsg(e.target.value)} maxLength={600} rows={4} placeholder="Thank you, Senator BAM — ..." required />
|
|
<button className="go" type="submit" disabled={posting || !nick.trim() || !msg.trim()}>
|
|
{posting ? "Sending..." : "Post to the board"}
|
|
</button>
|
|
{postErr && <div className="err">{postErr}</div>}
|
|
</form>
|
|
<div className="postlist">
|
|
{(boardPosts || []).map((p) => (
|
|
<div key={p.id} className={`post ${p.id === freshId ? "fresh" : ""}`}>
|
|
<div className="toprow">
|
|
<span className="nick">{p.nick}</span>
|
|
<span className="ts">{ago(p.createdAt)}</span>
|
|
</div>
|
|
<div className="msg">{p.message}</div>
|
|
</div>
|
|
))}
|
|
{boardPosts && boardPosts.length === 0 && (
|
|
<div className="empty">The board is empty. Say the first thank you.</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
<footer className="site">
|
|
<div className="fb">
|
|
<span className="bam">B</span>ida<span className="bam">A</span>ng<span className="bam">M</span>amamayan
|
|
</div>
|
|
<div className="disc">
|
|
A fan-made tribute run by supporters of Senator Bam Aquino. <b>Not affiliated with,
|
|
endorsed by, or operated by</b> the senator or his office. All news content belongs to its
|
|
original publishers.
|
|
</div>
|
|
<div>
|
|
Bida ang Mamamayan · built with love (and one very loud speaker) · {fmtDate(new Date()).split(",")[1].trim()}
|
|
</div>
|
|
</footer>
|
|
|
|
{reading && (
|
|
<Reader
|
|
state={reading}
|
|
onClose={closeReading}
|
|
onLoaded={(article, realUrl) => setReading((r) => (r ? { ...r, article, realUrl, state: "ready" } : r))}
|
|
onToast={say}
|
|
/>
|
|
)}
|
|
{toast && <div className="toast">{toast}</div>}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function StoryRow({ s, onOpen }) {
|
|
return (
|
|
<div className="story" onClick={onOpen}>
|
|
<div className="thumb" style={s.image ? { backgroundImage: `url(${imgSrc(s.image)})` } : null}>
|
|
{s.named && <span className="tag named">★</span>}
|
|
</div>
|
|
<div>
|
|
<div className="t">{clipText(s.title, 120)}</div>
|
|
{s.summary && <div className="s">{clipText(s.summary, 110)}</div>}
|
|
<div className="m"><b>{s.source}</b> · {ago(s.published)} · {sectionName(s.category)}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// The reader drawer: pulls the article body from /api/article (Mongo cache
|
|
// first, publisher fetch on a miss — warmed by the crawl tick).
|
|
function Reader({ state, onClose, onLoaded, onToast }) {
|
|
const { story, article, realUrl } = state;
|
|
const [failed, setFailed] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
(async () => {
|
|
try {
|
|
const r = await fetch(`/api/article?url=${encodeURIComponent(story.url)}`, {
|
|
cache: "no-store",
|
|
signal: AbortSignal.timeout(60000),
|
|
});
|
|
const j = await r.json();
|
|
if (!alive) return;
|
|
if (j.ok) onLoaded(j.article, j.realUrl);
|
|
else { setFailed(true); onToast("Article unavailable — read at the source instead."); }
|
|
} catch {
|
|
if (alive) { setFailed(true); onToast("Could not load the article."); }
|
|
}
|
|
})();
|
|
return () => { alive = false; };
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [story.url]);
|
|
|
|
// Close on ESC
|
|
useEffect(() => {
|
|
function k(e) { if (e.key === "Escape") onClose(); }
|
|
window.addEventListener("keydown", k);
|
|
return () => window.removeEventListener("keydown", k);
|
|
}, [onClose]);
|
|
|
|
return (
|
|
<div className="drawer" onClick={(e) => { if (e.target.classList.contains("drawer")) onClose(); }}>
|
|
<div className="sheet">
|
|
<div className="top">
|
|
<span className="k">READING — {story.source?.toUpperCase()}</span>
|
|
<button className="close" onClick={onClose}>ESC ✕</button>
|
|
</div>
|
|
{story.image && !failed && <img className="img" src={imgSrc(story.image)} alt="" />}
|
|
<div className="body">
|
|
<h2>{story.title}</h2>
|
|
<div className="byline">
|
|
<b>{story.source}</b> · {ago(story.published)} ago · {sectionName(story.category)}
|
|
{story.named ? " · ★ NAMED BAM" : ""}
|
|
</div>
|
|
{state.state === "loading" && <div className="loading">EXTRACTING ARTICLE...</div>}
|
|
{failed && (
|
|
<div className="blocked">
|
|
The publisher is blocking the extractor (paywall or bot wall). The story is still real —
|
|
read it at the source below.
|
|
{story.summary && <div style={{ marginTop: 12 }}>{story.summary}</div>}
|
|
</div>
|
|
)}
|
|
{!failed && state.state === "ready" && article && (
|
|
<>
|
|
{(article.paras || []).map((p, i) =>
|
|
p.text ? (
|
|
<p key={i} className={`para ${p.tag && p.tag !== "p" ? "head" : ""}`}>{p.text}</p>
|
|
) : (
|
|
<div key={i} className="blocked" style={{ marginTop: 16 }}>
|
|
Continue at the source — the full story lives on {story.source}.
|
|
</div>
|
|
)
|
|
)}
|
|
{(article.paras || []).length === 0 && article.metaDesc && (
|
|
<p className="para">{article.metaDesc}</p>
|
|
)}
|
|
</>
|
|
)}
|
|
<a className="srcbtn" href={realUrl || story.url} target="_blank" rel="noopener noreferrer">
|
|
READ AT {story.source?.toUpperCase()} ↗
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|