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.
112 lines
4.2 KiB
JavaScript
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 };
|