Ryan 49e5df5af0 BidaAngMamayan: no-image lead hero watermark + Playwright UI suite
- .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.
2026-09-15 08:57:20 +00:00

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>
);
}