commit 6b06e08c08e0f80b2eca59eef09ec262d37d7420 Author: Ryan Date: Tue Sep 15 08:37:03 2026 +0000 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f961508 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +.next +.env* +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..e173095 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# BidaAngMamayan + +**BIDA ANG MAMAMAYAN** — a public supporter tribute news desk for +**Senator Bam Aquino**, the gamer of the Philippine Senate and champion of +college students, scholarships, and fair electricity. + +> Forked from the Forthena architecture (Next.js + Mongo + an RSS crawler), +> retargeted to follow ONE subject and stripped of everything a private paper +> needs but a public tribute does not: **no login, no accounts, no +> like/dislike, no per-reader AI.** + +--- + +## What it is + +- **A live news crawl** (every 15 min, PM2 cron) that follows every story on + the Philippine wire that *names* Sen. Bam Aquino, plus a quiet-day fallback + to general PH wires. A **relevance gate** drops the senator's name from + unrelated copy — only genuine BAM stories print. +- **A yellow/gamer visual motif** (Orbitron + Rajdhani + JetBrains Mono, CRT + scanlines, an XP-bar edition meter, a scrolling BAM-wire ticker, a neon + "LIVE DESK" HUD) — because the senator is a gamer. +- **A public community board — "Thank you, Senator BAM"** — visitors post + with a **nickname only** (no account, no email). Light abuse controls: + per-IP rate limit, hard content caps, markup stripping, URL-only rejection. +- **An in-page reader drawer** that lifts the full article body server-side + (Readability) so a story can be read without leaving the site, with an + honest "read at source" fallback when a publisher blocks us. +- **A disclaimer, always visible** (masthead + footer): the site is run by + *supporters* and is **not affiliated with, endorsed by, or operated by** + the senator or his office. + +## The seven desks + +`BAM Front` (lead, named-BAM) · `Education` · `Energy & Electricity` · +`Gamer's Corner` · `The Senate Floor` · `The Philippines` · `World`. + +## Stack + +Next.js 15 (React 19, App Router, plain CSS — no Tailwind in the UI), +MongoDB 7.0, PM2, nginx + Let's Encrypt. **No LLM in the runtime path** — +ranking is deterministic (`named` → fresh → photo). The Forthena llmrouter +dependency was removed entirely. + +## Run locally + +```bash +npm install +# point at a Mongo (defaults to mongodb://127.0.0.1:27017/bama) +npm run build +npm start # :3000 +``` + +Tests: + +```bash +bash tests/run-all.sh # self-contained (no Mongo) +MONGODB_URI=mongodb://127.0.0.1:27099/bama node tests/board-test.mjs +node tests/e2e-run.mjs # boots the built app on :4003 +``` + +## Deploy (manual — no CI, by owner order) + +Target box: **66.181.46.132**, domain **bidaangmamamayan.com** (apex + www). + +1. On the box, `/var/www/bama` holds the source. `.env.production` lives + **only on the box** (never rsync it): `MONGODB_URI`, `CRON_SECRET`. +2. `rsync -az --delete --exclude node_modules --exclude .next --exclude .git --exclude '.env*' ./ root@66.181.46.132:/var/www/bama/` +3. On the box: `cd /var/www/bama && npm ci && npm run build && pm2 reload bama` +4. Crawl tick is PM2 app `bama-crawl` (`*/15 * * * *` → `POST /api/crawl` + with `x-bama-cron=$CRON_SECRET`). First edition appears within ~15 min; + fire one crawl by hand to seed it immediately. +5. TLS: `certbot --nginx -d bidaangmamamayan.com -d www.bidaangmamamayan.com` + (nginx is already configured for both; the 443 block is live once the + cert exists). Auto-renewal timer is armed by certbot. + +Mongo note: the box runs kernel 7.0 (Ubuntu 26.04) where Mongo 8.x is broken +(8.0 refuses the kernel, 8.2 SIGSEGVs). **MongoDB 7.0 from the jammy channel +is the working version** — installed at provision time. + +## Files that matter + +| path | role | +| --- | --- | +| `lib/sources.js` | the BAM query registry + Bing feed helpers | +| `lib/crawl.js` | crawler + **relevance gate** (`bamRelevance`) | +| `lib/categories.js` | the 7-desk registry (single source of truth) | +| `lib/feed.js` | public edition: deterministic rank, desk headlines, stats | +| `lib/article.js` | reader extraction (Readability + honest fallbacks) | +| `lib/board.js` | community board: caps, sanitise, rate limit | +| `app/page.js` | the whole front-end (client SPA: views + reader drawer) | +| `app/globals.css` | the yellow/gamer theme | +| `ecosystem.config.cjs` | PM2 apps (`bama`, `bama-crawl`) | + +--- + +*A fan project. Bida ang Mamamayan.* 🎮 diff --git a/app/api/article/route.js b/app/api/article/route.js new file mode 100644 index 0000000..0d76425 --- /dev/null +++ b/app/api/article/route.js @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; + +// The article body for the reader pane. Public (no auth), zero LLM. +// Mongo first (the crawler warms it on the tick), publisher fetch on a miss. + +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +export async function GET(req) { + const q = new URL(req.url).searchParams; + const url = q.get("url") || ""; + if (!/^https?:\/\//i.test(url)) return NextResponse.json({ ok: false, error: "url required" }, { status: 400 }); + + try { + const { getArticle } = await import("../../../lib/article"); + const a = await getArticle({ url, force: q.get("force") === "1" }); + const { story, realUrl, ...article } = a; + return NextResponse.json({ ok: true, url, realUrl: realUrl || url, article, story: story || null }); + } catch (e) { + console.warn("[bama] /api/article failed:", String((e && e.message) || e).slice(0, 160)); + return NextResponse.json({ ok: false, error: "article unavailable" }, { status: 500 }); + } +} diff --git a/app/api/board/route.js b/app/api/board/route.js new file mode 100644 index 0000000..3db6476 --- /dev/null +++ b/app/api/board/route.js @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; + +// The community board — "Thank you Senator BAM". +// GET -> the last 200 posts, newest first. +// POST -> { nick, message } (nickname only, no account — by design). +// +// Public by nature; the controls live in lib/board.js (rate limit per IP, +// content caps, markup stripping, URL-only rejection). + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const { getBoard, boardCount } = await import("../../../lib/board"); + const [posts, count] = await Promise.all([getBoard(), boardCount()]); + return NextResponse.json({ ok: true, posts, count }); + } catch (e) { + return NextResponse.json({ ok: false, error: "board unavailable" }, { status: 500 }); + } +} + +export async function POST(req) { + let body; + try { + body = await req.json(); + } catch { + return NextResponse.json({ ok: false, error: "expected json" }, { status: 400 }); + } + try { + const { postBoard } = await import("../../../lib/board"); + const r = await postBoard(req, body); + return NextResponse.json(r, { status: r.ok ? 200 : r.status }); + } catch (e) { + console.warn("[bama] board post failed:", String((e && e.message) || e).slice(0, 160)); + return NextResponse.json({ ok: false, error: "could not post right now" }, { status: 500 }); + } +} diff --git a/app/api/crawl/route.js b/app/api/crawl/route.js new file mode 100644 index 0000000..dbfdda3 --- /dev/null +++ b/app/api/crawl/route.js @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; + +// The crawl tick, fired by the PM2 cron (scripts/cron-digest.cjs) every 15 min. +// Protected by the CRON_SECRET header so a random visitor cannot trigger a +// crawl on demand (it costs feed fetches + og:image rescues + article +// prefetches). +export const maxDuration = 290; + +export async function POST(req) { + const secret = req.headers.get("x-bama-cron"); + if (secret !== (process.env.CRON_SECRET || "bama-cron")) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + try { + const { crawlOnce } = await import("../../../lib/crawl"); + const crawl = await crawlOnce(); + return NextResponse.json({ ok: true, crawl }); + } catch (e) { + return NextResponse.json({ ok: false, error: String((e && e.message) || e) }, { status: 500 }); + } +} diff --git a/app/api/feed/route.js b/app/api/feed/route.js new file mode 100644 index 0000000..172a8e8 --- /dev/null +++ b/app/api/feed/route.js @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; + +// The public edition. NO auth — this site has no login by design. +// No LLM on a page load either: the ranking is deterministic (lib/feed.js). + +export const dynamic = "force-dynamic"; + +export async function GET(req) { + try { + const { getFeed, deskHeadlines, editionStats } = await import("../../../lib/feed"); + const [feed, desks, stats] = await Promise.all([ + getFeed({ limit: 60 }), + deskHeadlines(), + editionStats(), + ]); + return NextResponse.json({ ok: true, feed, desks, stats }); + } catch (e) { + return NextResponse.json({ ok: false, error: "edition unavailable", detail: String((e && e.message) || e).slice(0, 200) }, { status: 500 }); + } +} diff --git a/app/api/health/route.js b/app/api/health/route.js new file mode 100644 index 0000000..7882a3e --- /dev/null +++ b/app/api/health/route.js @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; + +// Liveness probe (nginx/monitoring). Public. Reports the process is up and +// pings Mongo so a DB outage is visible here, not hidden behind a 200 feed. +// +// NOTE the double await is REQUIRED: getDb() is async (returns Promise). +// `await getDb().command(...)` would call .command on the PROMISE and throw +// "command is not a function" (caught in e2e 2026-09-15). + +export const dynamic = "force-dynamic"; + +export async function GET() { + let mongo = "unknown"; + try { + const { getDb } = await import("../../../lib/db"); + const db = await getDb(); + await db.command({ ping: 1 }); + mongo = "ok"; + } catch { + mongo = "down"; + } + return NextResponse.json({ ok: true, service: "bidaangmamamayan", mongo, time: new Date().toISOString() }); +} diff --git a/app/api/img/route.js b/app/api/img/route.js new file mode 100644 index 0000000..5e21346 --- /dev/null +++ b/app/api/img/route.js @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; + +// Image proxy. News CDN images hotlink-block on Referer and some are http-only +// (mixed content against our https site), so the paper loads them through here. +// No auth needed: images are public anyway, and it keeps the tags dumb. + +const MAX_BYTES = 4 * 1024 * 1024; + +export async function GET(req) { + const raw = new URL(req.url).searchParams.get("u") || ""; + let target; + try { + target = new URL(raw); + } catch { + return new NextResponse("bad url", { status: 400 }); + } + if (!/^https?:$/.test(target.protocol)) return new NextResponse("bad scheme", { status: 400 }); + + const cacheKey = raw; + const cached = globalThis.__ft_img?.get(cacheKey); + if (cached && Date.now() - cached.t < 1000 * 60 * 60 * 12) { + return new NextResponse(cached.body, { + headers: imgHeaders(cached.type, "public, max-age=43200"), + }); + } + + try { + const res = await fetch(target.href, { + headers: { + // A browser-looking Referer is what unblocks most PH/tech CDNs. + Referer: `${target.protocol}//${target.host}/`, + "User-Agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", + }, + redirect: "follow", + signal: AbortSignal.timeout(12000), + }); + if (!res.ok) return new NextResponse("upstream " + res.status, { status: 502 }); + + const len = Number(res.headers.get("content-length") || 0); + if (len > MAX_BYTES) return new NextResponse("too big", { status: 413 }); + + const type = res.headers.get("content-type") || "image/jpeg"; + if (!/^image\//i.test(type)) return new NextResponse("not an image", { status: 415 }); + + const buf = await res.arrayBuffer(); + if (buf.byteLength > MAX_BYTES) return new NextResponse("too big", { status: 413 }); + + if (!globalThis.__ft_img) globalThis.__ft_img = new Map(); + const map = globalThis.__ft_img; + if (map.size > 400) for (const k of map.keys()) { map.delete(k); if (map.size < 300) break; } + map.set(cacheKey, { t: Date.now(), type, body: buf }); + + return new NextResponse(buf, { headers: imgHeaders(type, "public, max-age=43200") }); + } catch (e) { + return new NextResponse("error", { status: 502 }); + } +} + +function imgHeaders(type, cache) { + return { + "Content-Type": type, + "Cache-Control": cache, + "X-Content-Type-Options": "nosniff", + }; +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..9824c11 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,647 @@ +/* BidaAngMamamayan — dark gamer theme with a gold/yellow motif. + Fonts (loaded in layout.js): Orbitron (display/masthead), Rajdhani (body), + JetBrains Mono (HUD labels, timestamps, counters). */ + +:root { + --bg: #0c0b09; + --bg-2: #12100c; + --panel: #171410; + --panel-2: #1d1913; + --line: #3a3226; + --line-soft: #2a241a; + --gold: #ffd426; + --gold-2: #ffbf00; + --gold-dim: #8a7422; + --neon: #2bff88; + --red: #ff4d5e; + --text: #efe9dc; + --muted: #a89e8a; + --faint: #6f6754; + --glow: 0 0 18px rgba(255, 212, 38, 0.35); + --glow-soft: 0 0 10px rgba(255, 212, 38, 0.18); + --font-display: "Orbitron", sans-serif; + --font-body: "Rajdhani", sans-serif; + --font-mono: "JetBrains Mono", monospace; +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + background: + radial-gradient(1200px 500px at 50% -100px, rgba(255, 212, 38, 0.07), transparent 60%), + var(--bg); + color: var(--text); + font-family: var(--font-body); + font-size: 17px; + line-height: 1.45; + -webkit-font-smoothing: antialiased; + min-height: 100vh; +} + +/* subtle CRT scanline + vignette, very low, pure CSS (no images) */ +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + background: repeating-linear-gradient( + to bottom, + rgba(255, 255, 255, 0.012) 0px, + rgba(255, 255, 255, 0.012) 1px, + transparent 1px, + transparent 3px + ); +} +body::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + background: radial-gradient(120% 90% at 50% 10%, transparent 60%, rgba(0, 0, 0, 0.5) 100%); +} +main, header, footer, .ticker { position: relative; z-index: 1; } + +a { color: var(--gold); text-decoration: none; } +a:hover { text-decoration: underline; } +button { font-family: inherit; cursor: pointer; } +input, textarea { font-family: inherit; } + +/* ── masthead ─────────────────────────────────────────────────────────── */ +.masthead { + padding: 26px 16px 14px; + text-align: center; + border-bottom: 2px solid var(--line); + background: linear-gradient(to bottom, rgba(255, 212, 38, 0.05), transparent); +} +.brand { + font-family: var(--font-display); + font-weight: 900; + letter-spacing: 0.02em; + font-size: clamp(30px, 7vw, 58px); + line-height: 1; + color: var(--text); + text-transform: uppercase; + user-select: none; +} +.brand .bam { + color: var(--gold); + font-weight: 900; + text-shadow: var(--glow); + -webkit-text-stroke: 1px rgba(255, 212, 38, 0.4); +} +.brand .bam-a { -webkit-text-stroke: 0; } +.masthead .slogan { + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.42em; + color: var(--gold); + margin-top: 10px; + text-transform: uppercase; +} +.masthead .who { + margin: 10px auto 0; + max-width: 720px; + color: var(--muted); + font-size: 16px; + font-weight: 500; +} +.masthead .who b { color: var(--text); font-weight: 700; } + +/* disclaimer bar — the site's legal heart, always visible on the front page */ +.disclaimer { + margin: 14px auto 0; + max-width: 860px; + border: 1px solid var(--line); + background: rgba(255, 77, 94, 0.05); + color: var(--muted); + font-size: 13.5px; + font-weight: 500; + padding: 8px 14px; + border-radius: 6px; + text-align: center; +} +.disclaimer b { color: var(--red); font-weight: 700; } + +/* nav / desk strip */ +.navstrip { + position: sticky; + top: 0; + z-index: 40; + display: flex; + gap: 6px; + overflow-x: auto; + padding: 10px 14px; + background: rgba(12, 11, 9, 0.92); + backdrop-filter: blur(6px); + border-bottom: 1px solid var(--line-soft); + scrollbar-width: none; +} +.navstrip::-webkit-scrollbar { display: none; } +.navbtn { + flex: 0 0 auto; + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + background: var(--panel); + border: 1px solid var(--line); + padding: 7px 12px; + border-radius: 4px; +} +.navbtn:hover { color: var(--gold); border-color: var(--gold-dim); text-decoration: none; } +.navbtn.active { + color: #12100c; + background: var(--gold); + border-color: var(--gold); + font-weight: 700; + box-shadow: var(--glow-soft); +} +.navbtn .dot { + display: inline-block; + width: 6px; height: 6px; + background: var(--neon); + border-radius: 50%; + margin-right: 6px; + box-shadow: 0 0 6px var(--neon); +} + +/* ── HUD stat bar (gamer XP/HP vibe) ──────────────────────────────────── */ +.hud { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: center; + padding: 14px 14px 4px; +} +.hud .chip { + font-family: var(--font-mono); + font-size: 11.5px; + letter-spacing: 0.06em; + color: var(--muted); + border: 1px solid var(--line); + background: var(--panel); + border-radius: 4px; + padding: 6px 10px; + display: flex; + gap: 8px; + align-items: center; +} +.hud .chip b { color: var(--gold); font-weight: 700; } +.hud .chip .live { + width: 7px; height: 7px; border-radius: 50%; + background: var(--neon); + box-shadow: 0 0 8px var(--neon); + animation: pulse 1.6s infinite; +} +@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } } + +/* XP bar: how full the edition is */ +.xpbar { + width: min(560px, 92vw); + margin: 10px auto 0; + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.14em; + color: var(--faint); +} +.xpbar .track { + height: 8px; + border: 1px solid var(--line); + border-radius: 4px; + background: var(--bg-2); + overflow: hidden; + margin-top: 4px; +} +.xpbar .fill { + height: 100%; + background: linear-gradient(90deg, var(--gold-2), var(--gold)); + box-shadow: var(--glow-soft); + transition: width 0.6s ease; +} + +/* ── news ticker ──────────────────────────────────────────────────────── */ +.ticker { + overflow: hidden; + border-top: 1px solid var(--line-soft); + border-bottom: 1px solid var(--line); + background: var(--bg-2); + white-space: nowrap; + padding: 7px 0; + position: relative; +} +.ticker .label { + position: absolute; + left: 0; top: 0; bottom: 0; + z-index: 2; + display: flex; + align-items: center; + background: var(--gold); + color: #12100c; + font-family: var(--font-mono); + font-weight: 700; + font-size: 11px; + letter-spacing: 0.1em; + padding: 0 10px; +} +.ticker .rail { + display: inline-block; + padding-left: 110px; + animation: ticker 60s linear infinite; +} +.ticker:hover .rail { animation-play-state: paused; } +.ticker .item { + font-family: var(--font-mono); + font-size: 12.5px; + color: var(--muted); + margin-right: 42px; +} +.ticker .item b { color: var(--gold); font-weight: 700; margin-right: 8px; } +.ticker .item a { color: var(--text); } +.ticker .item a:hover { color: var(--gold); } +@keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } } + +/* ── layout: lead + rail ─────────────────────────────────────────────── */ +.wrap { max-width: 1160px; margin: 0 auto; padding: 18px 14px 60px; } +.cols { + display: grid; + grid-template-columns: 1fr 330px; + gap: 20px; + align-items: start; +} +@media (max-width: 900px) { .cols { grid-template-columns: 1fr; } } + +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; +} +.panel .ph { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--line-soft); + font-family: var(--font-mono); + font-size: 11.5px; + letter-spacing: 0.18em; + color: var(--gold); + text-transform: uppercase; + background: linear-gradient(to bottom, rgba(255, 212, 38, 0.06), transparent); +} +.panel .ph .count { margin-left: auto; color: var(--faint); letter-spacing: 0.04em; } + +/* lead story */ +.lead { position: relative; cursor: pointer; } +.lead .hero { + position: relative; + aspect-ratio: 16/8.5; + background: var(--bg-2) center/cover no-repeat; + border-bottom: 1px solid var(--line); + overflow: hidden; +} +.lead .hero::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(to top, rgba(12, 11, 9, 0.95) 12%, rgba(12, 11, 9, 0.25) 55%, transparent); +} +.lead .hero .ph-tag { + position: absolute; + left: 12px; bottom: 12px; + z-index: 2; + display: flex; + flex-direction: column; + gap: 6px; +} +.tag { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.14em; + text-transform: uppercase; + padding: 4px 9px; + border-radius: 3px; + width: fit-content; + font-weight: 700; +} +.tag.named { background: var(--gold); color: #12100c; box-shadow: var(--glow-soft); } +.tag.desk { background: rgba(12, 11, 9, 0.8); color: var(--gold); border: 1px solid var(--gold-dim); } +.tag.src { background: rgba(12, 11, 9, 0.8); color: var(--muted); border: 1px solid var(--line); font-weight: 400; } +.lead h1 { + margin: 16px 18px 6px; + font-family: var(--font-display); + font-weight: 800; + font-size: clamp(21px, 3.4vw, 30px); + line-height: 1.18; + letter-spacing: 0.005em; + color: var(--text); + text-transform: none; +} +.lead .dek { margin: 0 18px 14px; color: var(--muted); font-size: 17.5px; font-weight: 500; } +.lead .meta { padding: 0 18px 16px; font-family: var(--font-mono); font-size: 11.5px; color: var(--faint); letter-spacing: 0.06em; } +.lead .meta .ts { color: var(--neon); } + +/* story rows */ +.story { + display: grid; + grid-template-columns: 96px 1fr; + gap: 12px; + padding: 12px 14px; + border-bottom: 1px solid var(--line-soft); + cursor: pointer; + transition: background 0.15s ease; +} +.story:last-child { border-bottom: none; } +.story:hover { background: var(--panel-2); } +.story .thumb { + width: 96px; height: 72px; + border-radius: 5px; + background: var(--bg-2) center/cover no-repeat; + border: 1px solid var(--line-soft); + position: relative; +} +.story .thumb .tag { position: absolute; left: 5px; top: 5px; font-size: 9px; padding: 2px 6px; } +.story .t { font-weight: 700; font-size: 16.5px; line-height: 1.25; color: var(--text); margin: 0 0 4px; } +.story .s { color: var(--muted); font-size: 14px; line-height: 1.35; margin: 0 0 6px; } +.story .m { font-family: var(--font-mono); font-size: 10.5px; color: var(--faint); letter-spacing: 0.05em; } +.story .m b { color: var(--gold); font-weight: 700; } +@media (max-width: 560px) { + .story { grid-template-columns: 1fr; } + .story .thumb { width: 100%; height: 150px; } +} + +/* desk grid */ +.deskgrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); + gap: 12px; +} +.deskcard { + border: 1px solid var(--line); + background: var(--panel); + border-radius: 8px; + padding: 12px 14px; + cursor: pointer; + position: relative; + overflow: hidden; + transition: border-color 0.15s ease, transform 0.15s ease; +} +.deskcard:hover { border-color: var(--gold-dim); transform: translateY(-2px); } +.deskcard .dh { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--gold); + font-weight: 700; + display: flex; + align-items: center; + gap: 7px; + margin-bottom: 8px; +} +.deskcard .dh .n { margin-left: auto; color: var(--faint); letter-spacing: 0; } +.deskcard .dh .pix { + width: 8px; height: 8px; + background: var(--neon); + box-shadow: 0 0 7px var(--neon); + clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); +} +.deskcard .dt { font-weight: 700; font-size: 15px; line-height: 1.3; color: var(--text); margin: 0; } +.deskcard .dm { font-family: var(--font-mono); font-size: 10.5px; color: var(--faint); margin-top: 8px; letter-spacing: 0.05em; } +.deskcard .db { color: var(--muted); font-size: 13px; margin-top: 2px; } + +/* rail cards */ +.railcard { margin-bottom: 16px; } +.board-teaser .post { padding: 10px 14px; border-bottom: 1px solid var(--line-soft); } +.board-teaser .post:last-of-type { border-bottom: none; } +.board-teaser .nick { font-family: var(--font-mono); font-size: 12px; color: var(--neon); font-weight: 700; letter-spacing: 0.05em; } +.board-teaser .msg { color: var(--text); font-size: 14.5px; margin-top: 3px; line-height: 1.4; } +.board-teaser .ts { font-family: var(--font-mono); font-size: 10px; color: var(--faint); margin-top: 5px; } +.board-teaser .more { display: block; text-align: center; padding: 10px; font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.14em; color: var(--gold); border-top: 1px solid var(--line-soft); } +.board-teaser .more:hover { background: rgba(255, 212, 38, 0.06); text-decoration: none; } + +/* ── reader drawer ────────────────────────────────────────────────────── */ +.drawer { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + justify-content: flex-end; + background: rgba(5, 4, 3, 0.7); + backdrop-filter: blur(3px); +} +.drawer .sheet { + width: min(720px, 100vw); + background: var(--bg-2); + border-left: 2px solid var(--gold-dim); + overflow-y: auto; + animation: slidein 0.22s ease; + box-shadow: -18px 0 40px rgba(0, 0, 0, 0.5); +} +@keyframes slidein { from { transform: translateX(40px); opacity: 0; } to { transform: translateX(0); opacity: 1; } } +.sheet .top { + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: var(--bg-2); + border-bottom: 1px solid var(--line); +} +.sheet .top .k { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.2em; + color: var(--gold); +} +.sheet .close { + margin-left: auto; + background: var(--panel); + color: var(--text); + border: 1px solid var(--line); + border-radius: 4px; + font-family: var(--font-mono); + font-size: 12px; + padding: 6px 12px; +} +.sheet .close:hover { border-color: var(--red); color: var(--red); } +.sheet .img { width: 100%; max-height: 340px; object-fit: cover; border-bottom: 1px solid var(--line); } +.sheet .body { padding: 18px 20px 40px; } +.sheet h2 { + font-family: var(--font-display); + font-size: clamp(19px, 3vw, 25px); + font-weight: 800; + line-height: 1.22; + margin: 0 0 10px; +} +.sheet .byline { font-family: var(--font-mono); font-size: 11.5px; color: var(--faint); letter-spacing: 0.06em; margin-bottom: 16px; } +.sheet .byline b { color: var(--gold); } +.sheet .para { font-size: 17px; line-height: 1.65; margin: 0 0 13px; color: var(--text); font-weight: 500; } +.sheet .para.head { font-weight: 700; color: var(--gold); font-size: 16px; margin-top: 20px; letter-spacing: 0.02em; } +.sheet .srcbtn { + display: inline-block; + margin-top: 8px; + font-family: var(--font-mono); + font-size: 12.5px; + letter-spacing: 0.08em; + color: #12100c; + background: var(--gold); + padding: 10px 18px; + border-radius: 4px; + font-weight: 700; +} +.sheet .srcbtn:hover { background: var(--gold-2); text-decoration: none; box-shadow: var(--glow-soft); } +.sheet .blocked { + border: 1px dashed var(--line); + background: var(--panel); + padding: 18px; + color: var(--muted); + font-size: 15px; + border-radius: 6px; +} +.sheet .loading { padding: 30px 16px; text-align: center; font-family: var(--font-mono); color: var(--faint); font-size: 13px; letter-spacing: 0.14em; } + +/* ── board page ───────────────────────────────────────────────────────── */ +.boardhead { text-align: center; padding: 34px 16px 10px; } +.boardhead .h { + font-family: var(--font-display); + font-weight: 900; + font-size: clamp(24px, 5vw, 40px); + color: var(--gold); + text-shadow: var(--glow-soft); + letter-spacing: 0.02em; +} +.boardhead .sub { color: var(--muted); margin-top: 8px; font-size: 16.5px; font-weight: 500; } +.postform { + max-width: 640px; + margin: 22px auto 8px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + padding: 18px; +} +.postform label { + display: block; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.16em; + color: var(--gold); + margin: 0 0 6px; + text-transform: uppercase; +} +.postform input, .postform textarea { + width: 100%; + background: var(--bg-2); + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + font-size: 16px; + padding: 11px 13px; + margin-bottom: 14px; + outline: none; + font-family: var(--font-body); + font-weight: 500; +} +.postform input:focus, .postform textarea:focus { border-color: var(--gold-dim); box-shadow: var(--glow-soft); } +.postform .go { + width: 100%; + background: var(--gold); + color: #12100c; + border: none; + border-radius: 6px; + font-family: var(--font-display); + font-weight: 800; + font-size: 15px; + letter-spacing: 0.1em; + padding: 13px; + text-transform: uppercase; +} +.postform .go:hover { background: var(--gold-2); box-shadow: var(--glow); } +.postform .go:disabled { opacity: 0.5; cursor: wait; } +.postform .err { color: var(--red); font-size: 14px; margin-top: 10px; font-weight: 600; } +.postform .okmsg { color: var(--neon); font-size: 14px; margin-top: 10px; font-weight: 600; } + +.postlist { max-width: 640px; margin: 18px auto 60px; } +.post { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 13px 16px; + margin-bottom: 10px; +} +.post .toprow { display: flex; align-items: baseline; gap: 10px; } +.post .nick { + font-family: var(--font-mono); + font-size: 13.5px; + font-weight: 700; + color: var(--neon); + letter-spacing: 0.04em; +} +.post .nick::before { content: "▸ "; color: var(--gold-dim); } +.post .ts { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; color: var(--faint); letter-spacing: 0.05em; } +.post .msg { margin-top: 6px; color: var(--text); font-size: 16px; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; } +.post.fresh { border-color: var(--gold-dim); box-shadow: var(--glow-soft); animation: fresh 2.4s ease; } +@keyframes fresh { from { background: rgba(255, 212, 38, 0.14); } to { background: var(--panel); } } +.empty { + text-align: center; + color: var(--faint); + font-family: var(--font-mono); + font-size: 13px; + letter-spacing: 0.1em; + padding: 40px 10px; +} + +/* toast */ +.toast { + position: fixed; + bottom: 22px; + left: 50%; + transform: translateX(-50%); + z-index: 200; + background: var(--panel); + border: 1px solid var(--gold-dim); + color: var(--text); + font-family: var(--font-mono); + font-size: 13px; + padding: 11px 18px; + border-radius: 6px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5); + animation: toastin 0.2s ease; + max-width: 90vw; +} +@keyframes toastin { from { opacity: 0; transform: translate(-50%, 10px); } to { opacity: 1; transform: translate(-50%, 0); } } + +/* footer */ +footer.site { + border-top: 1px solid var(--line); + margin-top: 30px; + padding: 26px 16px 40px; + text-align: center; + color: var(--faint); + font-size: 13.5px; + font-weight: 500; +} +footer.site .fb { + font-family: var(--font-display); + font-weight: 800; + color: var(--muted); + font-size: 13px; + letter-spacing: 0.18em; + margin-bottom: 10px; +} +footer.site .fb .bam { color: var(--gold); } +footer.site .disc { max-width: 640px; margin: 0 auto 10px; line-height: 1.5; } + +/* skeleton shimmer while the edition loads */ +.sk { + background: linear-gradient(90deg, var(--panel) 25%, var(--panel-2) 50%, var(--panel) 75%); + background-size: 200% 100%; + animation: shimmer 1.4s infinite; + border-radius: 6px; +} +@keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } } diff --git a/app/layout.js b/app/layout.js new file mode 100644 index 0000000..9d6019b --- /dev/null +++ b/app/layout.js @@ -0,0 +1,42 @@ +import "./globals.css"; + +export const metadata = { + title: "BidaAngMamamayan — BIDA ANG MAMAMAYAN", + description: + "A tribute news desk for Senator BAM Aquino — the gamer senator, champion of college students and education. Every story that names him, crawled live from the Philippine wire.", + keywords: + "Bam Aquino, Bida ang Mamamayan, senator, education, college, esports, gaming, Philippines Senate, supporter tribute", + openGraph: { + title: "BidaAngMamamayan — BIDA ANG MAMAMAYAN", + description: + "A supporter tribute news desk for Senator BAM Aquino: the latest news on the gamer senator and his fight for college students and education.", + siteName: "BidaAngMamamayan", + type: "website", + }, +}; + +export const viewport = { + width: "device-width", + initialScale: 1, + themeColor: "#0c0b09", +}; + +export default function RootLayout({ children }) { + return ( + + + + + + + + {children} + + ); +} diff --git a/app/page.js b/app/page.js new file mode 100644 index 0000000..d755a3e --- /dev/null +++ b/app/page.js @@ -0,0 +1,501 @@ +"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 ( +
+ BidaAngMamamayan +
+ ); +} + +export default function Home() { + const [view, setView] = useState("front"); // front | | 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 ( + <> +
+ +
Bida ang Mamamayan
+
+ The supporter tribute desk for Senator Bam Aquino — 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. +
+
+ DISCLAIMER — BidaAngMamamayan is built and run by supporters of Senator Bam + Aquino. It is not affiliated with, endorsed by, or operated by the senator or his + office. News items belong to their original publishers; this site aggregates them for + tribute purposes only. +
+
+ + + + {stats && ( +
+
LIVE DESK
+
STORIES {edition ? edition.total : "—"}
+
LAST 24H {stats.today ?? "—"}
+
NAMED BAM {edition ? edition.named : "—"}
+
BOARD {boardCount}
+
EDITION {fmtDate(new Date())}
+
+ )} + {edition && ( +
+ EDITION STRENGTH — {edition.total} WIRE STORIES LOADED +
+
+ )} + + {tickerItems.length > 1 && ( +
+ BAM WIRE +
+ {[...tickerItems, ...tickerItems].map((s, i) => ( + + {ago(s.published) || s.source} + openStory(s)}>{s.title} + + ))} +
+
+ )} + +
+ {loadErr && !edition && ( +
+
{loadErr}
+ +
+ )} + + {view === "front" && ( + <> +
+
+ {!edition && ( + <> +
+
+
+
+ + )} + {lead && ( +
openStory(lead)}> +
+
+ {lead.named && ★ NAMED BAM} + {sectionName(lead.category)} + {lead.source} +
+
+

{lead.title}

+ {lead.summary &&
{dek(lead.summary, 220)}
} +
{ago(lead.published)} · {lead.desk || sectionName(lead.category)} · READ IN THE DESK →
+
+ )} + +
+
On the wire {viewItems.length} stories
+ {viewItems.slice(0, 14).map((s) => ( + openStory(s)} /> + ))} + {viewItems.length === 0 &&
The wire is quiet — the next crawl tick is on the way.
} +
+
+ +
+
+
In this edition {desks.length} desks
+
+ {desks.map((d) => ( +
{ setView(d.key); window.scrollTo({ top: 0 }); }}> +
{d.label}{d.count}
+
{clipText(d.stories[0].title, 90)}
+
{ago(d.stories[0].published)} · {d.stories[0].source}
+
+ ))} + {desks.length === 0 &&
Desks staff as the wire fills up.
} +
+
+ +
+
Thank you, Senator BAM {boardCount} posted
+ {(boardPosts || []).slice(0, 4).map((p) => ( +
+
{p.nick}
+
{clipText(p.message, 120)}
+
{ago(p.createdAt)}
+
+ ))} + {(boardPosts || []).length === 0 &&
No messages yet — be the first to say thank you.
} + { e.preventDefault(); setView("board"); window.scrollTo({ top: 0 }); }}> + SIGN THE BOARD → + +
+ +
+
Who he is
+
+ Francis "Bam" Tan Aquino IV — Senator for + Pampanga. The first gamer to reach the Philippine Senate: a runner-up in the + 2025 PalaPinas esports championship, and + a legislator who has made the fight for college + students, scholarships, and fair electricity his signature. This desk follows + the wire for every story that bears his name. +
+
+
+
+ + )} + + {view !== "front" && view !== "board" && ( +
+
{deskLabel} {viewItems.length} stories
+ {viewItems.map((s) => ( + openStory(s)} /> + ))} + {viewItems.length === 0 && ( +
+ 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. +
+ )} +
+ )} + + {view === "board" && ( +
+
+
Thank you, Senator BAM
+
+ The community board. No account, no email — just your nickname and a word for the + senator. Plain words only: no links, no markup. +
+
+
+ + setNick(e.target.value)} maxLength={24} placeholder="e.g. PampangaProud, GMAmateur, BamGang13" required /> + +