Ryan 6b06e08c08 BidaAngMamayan: public supporter tribute news desk for Senator BAM Aquino
Forked from Forthena (PERSONAL/forthena) and retargeted:
- NO login, NO LLM: the edition is public and the ranking is deterministic
  (stories that name Sen. BAM lead; freshest first) — Daniel: no
  like/dislike or store profile for visitors.
- Crawler scoped to BAM: Bing news keyword queries + Inquirer/Rappler
  fallback wires, with a two-tier relevance gate (named-BAM always;
  Aquino+legislative-context only from fallback) so op-eds and non-Bam
  senators are dropped.
- Desks: BAM (lead), Gaming, Education, Politics, Energy, PH, Business.
- Community board /board: 'Thank you Senator BAM' — nickname-only posts,
  HTML-stripped, URL-only rejected, IP rate-limited (3/10min, 20/day).
- Yellow-gamer theme (Orbitron/Rajdhani/JetBrains Mono, dark + gold,
  HUD corners), supporter disclaimer in header and footer.
- Tests: category/date/relevance/text suites + Mongo board suite +
  full HTTP e2e (all passing).

Infra: 66.181.46.132 (bidaangmamamayan.com), local Mongo 7.0,
nginx + certbot TLS, PM2 bama (:3000) + 15-min crawl cron.
2026-09-15 08:37:03 +00:00

112 lines
4.2 KiB
JavaScript

// Shared UI atoms for the edition — ink icons only.
// The raw ♥ codepoint renders as a broken/arrow-ish glyph on some Android fonts,
// so the heart, cross and arrow are inline SVG paths filled with currentColor.
//
// Reading-pass atoms (2026-08-31): ReadStamp, ReadAtButton and Headline live here
// because the front page, the section pages and the reader pane all draw them and
// they must not drift apart.
export function HeartIcon({ size = 11 }) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true" style={{ display: "inline", verticalAlign: "-1px" }}>
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" fill="currentColor" />
</svg>
);
}
export function CrossIcon({ size = 9 }) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true" style={{ display: "inline", verticalAlign: "-1px" }}>
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" fill="currentColor" />
</svg>
);
}
// North-east arrow — the "leaves the paper" glyph. Also SVG for the same font reason.
export function ArrowIcon({ size = 10 }) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true" style={{ display: "inline", verticalAlign: "-1px" }}>
<path d="M6 18L18 6M18 6H9.2M18 6v8.8" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="square" />
</svg>
);
}
const imgSrc = (u) => (u ? `/api/img?u=${encodeURIComponent(u)}` : null);
// Photo sized by its context (.ft-sec img, .ft-brief-item img, .ft-story img).
// No image -> ink hatching placeholder of the same box.
export function Thumb({ story, eager = false }) {
if (!story.image) return <span className="ft-noimg" aria-hidden="true" />;
return (
<img
src={imgSrc(story.image)}
alt=""
loading={eager ? "eager" : "lazy"}
referrerPolicy="no-referrer"
onError={(e) => { e.currentTarget.style.display = "none"; }}
/>
);
}
// Proof the story was read, drawn as a rubber stamp rather than a badge: rotated,
// letter-spaced, double-ruled. `opened` gets a dot — weaker evidence, weaker mark.
export function ReadStamp({ state }) {
if (!state) return null;
if (state.read) {
return (
<span
className="ft-readstamp"
title={state.readAt ? `Read ${new Date(state.readAt).toLocaleDateString()}` : "Read"}
>
READ
</span>
);
}
if (state.opened) return <span className="ft-opendot" title="Opened — not finished" aria-label="Opened, not finished" />;
return null;
}
// "Read at <source>" — the only way out of the paper, so it is a button, not a
// whispered underlined link (Daniel, 2026-08-31). Outlined red + arrow; the one
// control allowed to open a new tab.
export function ReadAtButton({ story, size = "sm", as = "div", label }) {
const Tag = as === "span" ? "span" : "div";
return (
<Tag className={as === "span" ? "ft-readat ft-readat-inline" : "ft-readat"}>
<a
className={`ft-btn ft-btn--outred${size === "lg" ? " big" : ""}`}
href={story.url}
target="_blank"
rel="noreferrer"
title={`Open this story on ${story.source || "the publisher's site"}`}
>
Read at {story.source || "source"} <ArrowIcon />
</a>
{label && <span className="ft-readat-note">{label}</span>}
</Tag>
);
}
// Headline as a real link that stays inside the paper: the pane, not a new tab.
// The browser never follows it (we have no <base href>) — href keeps middle-click,
// copy-link-address and right-click alive for people who want the URL.
export function Headline({ story, onOpen, as = "h3", className, style, children }) {
const Tag = as;
return (
<Tag className={className} style={style}>
<a
href={story.url}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0 || e.altKey) return;
e.preventDefault();
onOpen(story);
}}
>
{children || story.title}
</a>
</Tag>
);
}
export { imgSrc };