Files
upbeatBytes/goodnews/static/index.html
T
thejayman77 2f4bdf2d00 Add FastAPI web/API layer and static site
- queries.py: shared read-only query helpers (feed, brief, category counts)
  returning plain dicts, used by the API and available to the CLI.
- api.py: FastAPI service with Pydantic response models (the companion-app
  contract), CORS, and endpoints for categories, feed, brief, and health;
  mounts a static site at /.
- static/index.html: minimal dependency-free site rendering the daily five
  and topic/flavor category browsing.
- 'goodnews serve' command launches uvicorn (lazy import; core CLI stays
  pure-stdlib). Web deps live behind the optional [web] extra.
- Dockerfile + .dockerignore + build-system metadata so the service installs
  and deploys cleanly, with the DB mounted as a shared volume.
- README: web/API and deployment docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:51:07 +00:00

164 lines
6.6 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>goodNews — calm, constructive news</title>
<style>
:root {
--bg: #f7f5f0; --card: #fff; --ink: #1f2a24; --muted: #6b7770;
--accent: #2f7d5b; --accent-soft: #e4efe8; --line: #e3e0d8;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--ink);
font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
header {
padding: 28px 20px 18px; text-align: center; border-bottom: 1px solid var(--line);
background: var(--card);
}
header h1 { margin: 0; font-size: 1.7rem; letter-spacing: -0.02em; }
header h1 span { color: var(--accent); }
header p { margin: 6px 0 0; color: var(--muted); font-size: 0.95rem; }
main { max-width: 760px; margin: 0 auto; padding: 20px; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 6px 0 18px; }
.chip {
border: 1px solid var(--line); background: var(--card); color: var(--ink);
border-radius: 999px; padding: 6px 13px; font-size: 0.85rem; cursor: pointer;
transition: all .12s ease;
}
.chip:hover { border-color: var(--accent); }
.chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.chip-group-label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); margin: 4px 0; width: 100%; }
.section-title { font-size: 0.95rem; color: var(--muted); margin: 22px 0 10px;
text-transform: uppercase; letter-spacing: .06em; }
article {
background: var(--card); border: 1px solid var(--line); border-radius: 12px;
padding: 16px 18px; margin-bottom: 12px;
}
article .meta { display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
font-size: 0.78rem; color: var(--muted); margin-bottom: 6px; }
.tag { background: var(--accent-soft); color: var(--accent); border-radius: 6px;
padding: 1px 8px; font-weight: 600; }
article h3 { margin: 0 0 6px; font-size: 1.08rem; line-height: 1.35; }
article h3 a { color: var(--ink); text-decoration: none; }
article h3 a:hover { color: var(--accent); }
article p.desc { margin: 0 0 8px; color: #44514a; font-size: 0.92rem; }
article .why { font-size: 0.8rem; color: var(--muted); font-style: italic; }
.rank-badge { background: var(--accent); color: #fff; border-radius: 50%;
width: 24px; height: 24px; display: inline-flex; align-items: center;
justify-content: center; font-size: 0.8rem; font-weight: 700; }
.empty { color: var(--muted); text-align: center; padding: 30px; }
footer { text-align: center; color: var(--muted); font-size: 0.78rem; padding: 20px; }
footer a { color: var(--accent); }
</style>
</head>
<body>
<header>
<h1>good<span>News</span></h1>
<p>Calm, constructive news worth your attention — and nothing that isn't.</p>
</header>
<main>
<div id="brief-block">
<div class="section-title">Five Good Things</div>
<div id="brief"><div class="empty">Loading…</div></div>
</div>
<div class="section-title">Browse by category</div>
<div id="topic-chips" class="chips"></div>
<div id="flavor-chips" class="chips"></div>
<div id="feed"></div>
</main>
<footer>
goodNews · metadata &amp; links only, no stored articles ·
<a href="/docs">API</a>
</footer>
<script>
const state = { topic: null, flavor: null };
const el = (id) => document.getElementById(id);
async function getJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(await r.text());
return r.json();
}
function articleCard(a, showRank) {
const rank = showRank && a.rank ? `<span class="rank-badge">${a.rank}</span>` : "";
const tags = [a.topic, a.flavor].filter(Boolean)
.map(t => `<span class="tag">${t}</span>`).join(" ");
const desc = a.description ? `<p class="desc">${a.description}</p>` : "";
const why = a.reason_text ? `<div class="why">${a.reason_text}</div>` : "";
return `<article>
<div class="meta">${rank}${tags}<span>${a.source}</span>
${a.published_at ? `<span>· ${a.published_at.slice(0,10)}</span>` : ""}</div>
<h3><a href="${a.url}" target="_blank" rel="noopener">${a.title}</a></h3>
${desc}${why}
</article>`;
}
function renderList(target, items, showRank) {
target.innerHTML = items.length
? items.map(a => articleCard(a, showRank)).join("")
: `<div class="empty">Nothing here yet.</div>`;
}
function chip(label, active, onClick) {
const c = document.createElement("button");
c.className = "chip" + (active ? " active" : "");
c.textContent = label;
c.onclick = onClick;
return c;
}
async function loadBrief() {
try {
const b = await getJSON("/api/brief?limit=5");
if (b.title) el("brief-block").querySelector(".section-title").textContent = b.title;
renderList(el("brief"), b.items, true);
} catch (e) { el("brief").innerHTML = `<div class="empty">No brief yet.</div>`; }
}
async function loadFeed() {
const params = new URLSearchParams({ limit: "30" });
if (state.topic) params.set("topic", state.topic);
if (state.flavor) params.set("flavor", state.flavor);
const f = await getJSON("/api/feed?" + params.toString());
renderList(el("feed"), f.items, false);
}
async function init() {
loadBrief();
const cats = await getJSON("/api/categories");
const tc = el("topic-chips");
tc.appendChild(Object.assign(document.createElement("span"),
{ className: "chip-group-label", textContent: "Topic" }));
tc.appendChild(chip("all", true, () => setTopic(null)));
cats.topics.forEach(t => tc.appendChild(chip(t.key, false, () => setTopic(t.key))));
const fc = el("flavor-chips");
fc.appendChild(Object.assign(document.createElement("span"),
{ className: "chip-group-label", textContent: "Flavor" }));
fc.appendChild(chip("all", true, () => setFlavor(null)));
cats.flavors.forEach(f => fc.appendChild(chip(f.key, false, () => setFlavor(f.key))));
loadFeed();
}
function refreshActive(containerId, value) {
[...el(containerId).querySelectorAll(".chip")].forEach(c => {
c.classList.toggle("active", c.textContent === (value || "all"));
});
}
function setTopic(t) { state.topic = t; refreshActive("topic-chips", t); loadFeed(); }
function setFlavor(f) { state.flavor = f; refreshActive("flavor-chips", f); loadFeed(); }
init();
</script>
</body>
</html>