Build the SvelteKit frontend: calm home with mood modes
- New frontend/ SvelteKit static SPA (Svelte 5), served by FastAPI from frontend/build (falls back to the legacy page if unbuilt). - Calm design system: cream/sage palette, serif headlines, generous space, no urgency colors, gentle motion (respects prefers-reduced-motion). - Home screen: mood-mode nav (Today/Wonder/People Helping/Solutions/Light Only/Grounded), the daily brief as a hero + remaining four, browsable mood lanes, an explicit calm end-state, inline Not today / Less like this / Hide affordances, and device-local Calm Filters mirroring goodnews/filters.py. - Backend: moods.py + GET /api/moods (single source of truth for the modes); FilterPrefs gains max_cortisol/max_ragebait ceilings (for Light Only). - Push categorical filters (include/mute topics+flavors, ceilings) into SQL in queries.feed so low-ranked-but-matching items (e.g. discovery for Wonder) are not truncated by ranking; only avoid-terms stay a Python pass. - PWA manifest + icon (installable; offline deferred per plan). - Multi-stage Dockerfile builds the site then serves it from the API. - Tests: queries.feed categorical filters (63 total). README updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+17
-6
@@ -1,19 +1,30 @@
|
||||
# goodNews web/API image.
|
||||
# goodNews web/API image (multi-stage: build the SvelteKit site, then serve it
|
||||
# from the FastAPI app).
|
||||
#
|
||||
# The SQLite database is NOT baked into the image — mount it at /data so the API
|
||||
# and the ingestion CLI (run separately, e.g. via cron on the host) share one
|
||||
# The SQLite database is NOT baked in — mount it at /data so the API and the
|
||||
# ingestion CLI (run separately, e.g. via cron/systemd on the host) share one
|
||||
# file. Build: docker build -t goodnews .
|
||||
# Run: docker run -p 8000:8000 -v /srv/goodnews/data:/data goodnews
|
||||
FROM python:3.13-slim
|
||||
|
||||
# --- Stage 1: build the static frontend ---
|
||||
FROM node:22-slim AS web
|
||||
WORKDIR /web
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# --- Stage 2: the Python API, serving the built site ---
|
||||
FROM python:3.13-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first for better layer caching.
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY goodnews ./goodnews
|
||||
RUN pip install --no-cache-dir ".[web]"
|
||||
|
||||
# API reads the database from here; mount a host dir or named volume.
|
||||
# FastAPI serves this directory (goodnews/api.py: FRONTEND_DIR = ROOT/frontend/build).
|
||||
COPY --from=web /web/build ./frontend/build
|
||||
|
||||
ENV GOODNEWS_DB=/data/goodnews.sqlite3
|
||||
VOLUME ["/data"]
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ Endpoints:
|
||||
- `GET /` — the static site (daily five + topic/flavor browsing)
|
||||
- `GET /healthz` — liveness + scored-article count
|
||||
- `GET /api/categories` — the topic/flavor taxonomy
|
||||
- `GET /api/moods` — mood modes (the humane front door: Today, Wonder, People Helping, Solutions, Light Only, Grounded)
|
||||
- `GET /api/category-counts` — article counts per topic/flavor
|
||||
- `GET /api/feed?topic=&flavor=&limit=&offset=` — ranked, filtered articles
|
||||
- `GET /api/brief?date=&limit=` — a daily brief (latest if no date)
|
||||
@@ -112,6 +113,22 @@ Endpoints:
|
||||
The ingestion CLI stays pure-stdlib; only the `web` extra pulls in FastAPI/uvicorn,
|
||||
so the two halves can be deployed and upgraded independently.
|
||||
|
||||
### Frontend
|
||||
|
||||
The site is a SvelteKit static SPA in `frontend/` (calm editorial design, mood-mode
|
||||
navigation, the daily brief as a hero, browsable lanes, inline Calm Filters, PWA
|
||||
manifest). It consumes the JSON API above, so the website and a future companion
|
||||
app share one contract. Build it once and FastAPI serves the output:
|
||||
|
||||
```bash
|
||||
cd frontend && npm install && npm run build # -> frontend/build
|
||||
cd .. && python3 -m goodnews serve # serves frontend/build at /
|
||||
```
|
||||
|
||||
If `frontend/build` is absent, the server falls back to the legacy single-page
|
||||
harness in `goodnews/static/`. The Docker image builds the frontend automatically
|
||||
(multi-stage), so deployment is just `docker build`.
|
||||
|
||||
## Calm Filters
|
||||
|
||||
Personal, device-local controls so a reader can stay informed without subjects
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
/build/
|
||||
/.svelte-kit/
|
||||
Generated
+1444
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "goodnews-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.8.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"svelte": "^5.1.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/* goodNews calm design system.
|
||||
Warm but crisp: cream surfaces, sage accent, a serif voice for headlines,
|
||||
strong readable contrast, generous space. No urgency colors (no red). */
|
||||
:root {
|
||||
--bg: #faf6ee;
|
||||
--surface: #fffdf8;
|
||||
--ink: #21281f;
|
||||
--muted: #616c60;
|
||||
--sage: #2f7d5b;
|
||||
--sage-deep: #235e44;
|
||||
--sage-soft: #e6efe6;
|
||||
--line: #e7e1d4;
|
||||
--gold: #a9802f;
|
||||
|
||||
--serif: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, "Times New Roman", serif;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
|
||||
--maxw: 880px;
|
||||
--radius: 16px;
|
||||
--shadow: 0 1px 2px rgba(40, 38, 28, 0.04), 0 10px 30px rgba(40, 38, 28, 0.055);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
font-size: 17px;
|
||||
line-height: 1.62;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
h1, h2, h3 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 600;
|
||||
line-height: 1.18;
|
||||
letter-spacing: -0.012em;
|
||||
margin: 0;
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
img { max-width: 100%; display: block; }
|
||||
button { font-family: inherit; cursor: pointer; }
|
||||
::selection { background: var(--sage-soft); }
|
||||
|
||||
.container { max-width: var(--maxw); margin: 0 auto; padding: 0 20px; }
|
||||
.muted { color: var(--muted); }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(238px, 1fr)); gap: 18px; }
|
||||
|
||||
/* a soft, slow fade for content as it arrives — calm, not flashy */
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||
.rise { animation: rise 0.5s ease both; }
|
||||
@media (prefers-reduced-motion: reduce) { .rise { animation: none; } html { scroll-behavior: auto; } }
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#2f7d5b" />
|
||||
<meta name="description" content="Calm, constructive news worth your attention — and nothing that isn't." />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
export async function getJSON(url) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error((await r.text().catch(() => '')) || r.statusText);
|
||||
return r.json();
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<script>
|
||||
let { article, onaction, hero = false } = $props();
|
||||
|
||||
function act(kind, value) {
|
||||
if (value) onaction?.(kind, value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class:hero>
|
||||
<a class="media" href={article.url} target="_blank" rel="noopener">
|
||||
{#if article.image_url}
|
||||
<img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
|
||||
onerror={(e) => e.currentTarget.parentElement.classList.add('noimg')} />
|
||||
{/if}
|
||||
<span class="fallback">{article.topic ?? 'good news'}</span>
|
||||
</a>
|
||||
|
||||
<div class="body">
|
||||
<div class="tags">
|
||||
{#if article.topic}<span class="tag">{article.topic}</span>{/if}
|
||||
{#if article.flavor}<span class="tag soft">{article.flavor}</span>{/if}
|
||||
<span class="src">{article.source}</span>
|
||||
</div>
|
||||
|
||||
<h3><a href={article.url} target="_blank" rel="noopener">{article.title}</a></h3>
|
||||
|
||||
{#if hero && article.description}
|
||||
<p class="desc">{article.description}</p>
|
||||
{/if}
|
||||
|
||||
{#if article.reason_text}
|
||||
<p class="why">{article.reason_text}</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
{#if article.topic}<button onclick={() => act('notToday', article.topic)}>Not today</button>{/if}
|
||||
{#if article.flavor}<button onclick={() => act('lessLikeThis', article.flavor)}>Less like this</button>{/if}
|
||||
{#if article.topic}<button onclick={() => act('alwaysHide', article.topic)}>Hide {article.topic}</button>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
article {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.media {
|
||||
position: relative;
|
||||
display: block;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: linear-gradient(135deg, var(--sage-soft), #f1ece0);
|
||||
}
|
||||
.media img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.fallback {
|
||||
position: absolute; inset: 0; display: none;
|
||||
align-items: center; justify-content: center;
|
||||
font-family: var(--serif); font-style: italic; color: var(--sage-deep); opacity: 0.6;
|
||||
text-transform: lowercase; letter-spacing: 0.02em;
|
||||
}
|
||||
.media.noimg img { display: none; }
|
||||
.media.noimg .fallback { display: flex; }
|
||||
|
||||
.body { padding: 16px 18px 14px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
|
||||
.tags { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 0.74rem; }
|
||||
.tag {
|
||||
background: var(--sage); color: #fff; border-radius: 999px;
|
||||
padding: 2px 9px; font-weight: 600; text-transform: capitalize;
|
||||
}
|
||||
.tag.soft { background: var(--sage-soft); color: var(--sage-deep); }
|
||||
.src { color: var(--muted); margin-left: auto; }
|
||||
|
||||
h3 { font-size: 1.18rem; }
|
||||
.hero h3 { font-size: 1.95rem; }
|
||||
h3 a:hover { color: var(--sage-deep); }
|
||||
.desc { margin: 2px 0 0; color: #3c463a; }
|
||||
.why {
|
||||
margin: 2px 0 0; font-style: italic; color: var(--muted);
|
||||
font-size: 0.9rem; padding-left: 12px; border-left: 2px solid var(--sage-soft);
|
||||
}
|
||||
.actions { margin-top: auto; padding-top: 10px; display: flex; gap: 14px; flex-wrap: wrap; }
|
||||
.actions button {
|
||||
background: none; border: none; padding: 0; color: var(--muted);
|
||||
font-size: 0.78rem; border-bottom: 1px dotted var(--line);
|
||||
}
|
||||
.actions button:hover { color: var(--sage-deep); border-bottom-color: var(--sage); }
|
||||
|
||||
.hero { display: grid; grid-template-columns: 1.1fr 1fr; }
|
||||
.hero .media { aspect-ratio: auto; height: 100%; min-height: 280px; }
|
||||
.hero .body { padding: 28px 30px; justify-content: center; gap: 12px; }
|
||||
@media (max-width: 640px) {
|
||||
.hero { grid-template-columns: 1fr; }
|
||||
.hero .media { min-height: 200px; }
|
||||
.hero h3 { font-size: 1.6rem; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script>
|
||||
import ArticleCard from './ArticleCard.svelte';
|
||||
let { title, description, items, onaction, onmore } = $props();
|
||||
</script>
|
||||
|
||||
{#if items?.length}
|
||||
<section class="lane rise">
|
||||
<div class="lane-head">
|
||||
<h2>{title}</h2>
|
||||
{#if description}<span class="muted">{description}</span>{/if}
|
||||
{#if onmore}<button class="more" onclick={onmore}>see more →</button>{/if}
|
||||
</div>
|
||||
<div class="grid">
|
||||
{#each items as a (a.id)}
|
||||
<ArticleCard article={a} {onaction} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.lane { margin: 34px 0; }
|
||||
.lane-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 14px; }
|
||||
.lane-head h2 { font-size: 1.35rem; }
|
||||
.more { margin-left: auto; background: none; border: none; color: var(--sage-deep); font-size: 0.85rem; }
|
||||
.more:hover { text-decoration: underline; }
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script>
|
||||
let { moods, selected, onselect } = $props();
|
||||
</script>
|
||||
|
||||
<nav class="moods">
|
||||
{#each moods as m}
|
||||
<button class:active={selected === m.key} title={m.description} onclick={() => onselect(m.key)}>
|
||||
{m.label}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.moods {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
padding: 4px 0 6px;
|
||||
}
|
||||
button {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
border-radius: 999px;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.92rem;
|
||||
transition: all 0.14s ease;
|
||||
}
|
||||
button:hover { color: var(--sage-deep); border-color: var(--sage); }
|
||||
button.active {
|
||||
background: var(--sage);
|
||||
border-color: var(--sage);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 14px rgba(47, 125, 91, 0.25);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
// Calm Filters preferences — the canonical FilterPrefs shape, kept in
|
||||
// localStorage (device-local, no accounts). Mirrors goodnews/filters.py.
|
||||
const KEY = 'goodnews:prefs';
|
||||
|
||||
export function blank() {
|
||||
return {
|
||||
include_topics: [], include_flavors: [],
|
||||
mute_topics: [], mute_flavors: [],
|
||||
avoid_terms: [], pauses: [], max_cortisol: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function load() {
|
||||
let p;
|
||||
try { p = JSON.parse(localStorage.getItem(KEY)) || {}; } catch { p = {}; }
|
||||
p = Object.assign(blank(), p);
|
||||
const now = Date.now();
|
||||
p.pauses = (p.pauses || []).filter((x) => x && x.until && new Date(x.until).getTime() > now);
|
||||
return p;
|
||||
}
|
||||
|
||||
export function save(p) {
|
||||
localStorage.setItem(KEY, JSON.stringify(p));
|
||||
}
|
||||
|
||||
export function active(p) {
|
||||
return !!(p.mute_topics.length || p.mute_flavors.length || p.avoid_terms.length || p.pauses.length);
|
||||
}
|
||||
|
||||
const hours = (h) => new Date(Date.now() + h * 3600e3).toISOString();
|
||||
|
||||
export function notToday(p, topic) {
|
||||
p.pauses = p.pauses.filter((x) => !(x.kind === 'topic' && x.value === topic));
|
||||
p.pauses.push({ kind: 'topic', value: topic, until: hours(24) });
|
||||
return p;
|
||||
}
|
||||
export function lessLikeThis(p, flavor) {
|
||||
p.pauses = p.pauses.filter((x) => !(x.kind === 'flavor' && x.value === flavor));
|
||||
p.pauses.push({ kind: 'flavor', value: flavor, until: hours(72) });
|
||||
return p;
|
||||
}
|
||||
export function alwaysHide(p, topic) {
|
||||
if (!p.mute_topics.includes(topic)) p.mute_topics.push(topic);
|
||||
return p;
|
||||
}
|
||||
|
||||
// Merge a mood's filter preset over the user's standing prefs into one object.
|
||||
export function merge(userPrefs, moodFilter = {}) {
|
||||
const m = Object.assign(blank(), userPrefs);
|
||||
if (moodFilter.include_topics) m.include_topics = moodFilter.include_topics;
|
||||
if (moodFilter.include_flavors) m.include_flavors = moodFilter.include_flavors;
|
||||
if (moodFilter.max_cortisol != null) m.max_cortisol = moodFilter.max_cortisol;
|
||||
return m;
|
||||
}
|
||||
|
||||
// "" or "prefs=<encoded json>" for a query string.
|
||||
export function param(prefs) {
|
||||
const empty =
|
||||
!prefs.include_topics.length && !prefs.include_flavors.length &&
|
||||
!prefs.mute_topics.length && !prefs.mute_flavors.length &&
|
||||
!prefs.avoid_terms.length && !prefs.pauses.length && prefs.max_cortisol == null;
|
||||
return empty ? '' : 'prefs=' + encodeURIComponent(JSON.stringify(prefs));
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Static SPA: prerender the shell, fetch all data client-side from the API.
|
||||
export const prerender = true;
|
||||
export const ssr = false;
|
||||
@@ -0,0 +1,47 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<header class="site">
|
||||
<div class="container">
|
||||
<a class="brand" href="/">good<span>News</span></a>
|
||||
<p class="tagline">Calm, constructive news worth your attention — and nothing that isn't.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<footer class="site">
|
||||
<div class="container">
|
||||
goodNews · metadata & links only, no stored articles · <a href="/docs">API</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
header.site {
|
||||
text-align: center;
|
||||
padding: 38px 0 22px;
|
||||
background: linear-gradient(180deg, var(--surface), var(--bg));
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.brand {
|
||||
font-family: var(--serif);
|
||||
font-size: 2.1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.brand span { color: var(--sage); }
|
||||
.tagline { margin: 6px 0 0; color: var(--muted); font-size: 0.98rem; }
|
||||
main.container { padding-top: 18px; padding-bottom: 40px; min-height: 60vh; }
|
||||
footer.site {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
padding: 26px 0 34px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
footer.site a { color: var(--sage-deep); }
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { getJSON } from '$lib/api.js';
|
||||
import * as P from '$lib/prefs.js';
|
||||
import MoodNav from '$lib/components/MoodNav.svelte';
|
||||
import Lane from '$lib/components/Lane.svelte';
|
||||
import ArticleCard from '$lib/components/ArticleCard.svelte';
|
||||
|
||||
let moods = $state([]);
|
||||
let selected = $state('today');
|
||||
let brief = $state(null);
|
||||
let feed = $state([]);
|
||||
let lanes = $state([]);
|
||||
let userPrefs = $state(P.blank());
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
let filtersOn = $derived(P.active(userPrefs));
|
||||
|
||||
const moodByKey = (k) => moods.find((m) => m.key === k);
|
||||
|
||||
function feedUrl(moodKey, limit) {
|
||||
const merged = P.merge(userPrefs, moodByKey(moodKey)?.filter ?? {});
|
||||
const q = P.param(merged);
|
||||
return `/api/feed?limit=${limit}${q ? '&' + q : ''}`;
|
||||
}
|
||||
function briefUrl() {
|
||||
const q = P.param(userPrefs);
|
||||
return `/api/brief?limit=5${q ? '&' + q : ''}`;
|
||||
}
|
||||
|
||||
async function loadToday() {
|
||||
brief = await getJSON(briefUrl());
|
||||
const keys = ['wonder', 'people-helping', 'solutions'];
|
||||
lanes = await Promise.all(
|
||||
keys.map(async (k) => ({ ...moodByKey(k), items: (await getJSON(feedUrl(k, 4))).items }))
|
||||
);
|
||||
}
|
||||
async function loadMood(key) {
|
||||
feed = (await getJSON(feedUrl(key, 24))).items;
|
||||
}
|
||||
|
||||
async function select(key) {
|
||||
selected = key;
|
||||
error = '';
|
||||
try {
|
||||
if (key === 'today') await loadToday();
|
||||
else await loadMood(key);
|
||||
} catch (e) {
|
||||
error = 'Something went quiet — could not reach the feed.';
|
||||
}
|
||||
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function applyAction(kind, value) {
|
||||
P[kind]?.(userPrefs, value);
|
||||
userPrefs = { ...userPrefs };
|
||||
P.save(userPrefs);
|
||||
select(selected);
|
||||
}
|
||||
function resetFilters() {
|
||||
userPrefs = P.blank();
|
||||
P.save(userPrefs);
|
||||
select(selected);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
userPrefs = P.load();
|
||||
try {
|
||||
moods = await getJSON('/api/moods');
|
||||
await select('today');
|
||||
} catch (e) {
|
||||
error = 'Could not reach goodNews.';
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if moods.length}
|
||||
<MoodNav {moods} {selected} onselect={select} />
|
||||
{/if}
|
||||
|
||||
{#if filtersOn}
|
||||
<div class="calmbar">
|
||||
<span>Calm filters on — your feed is personalized on this device.</span>
|
||||
<button onclick={resetFilters}>reset</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="muted center pad">Gathering the good news…</p>
|
||||
{:else if error}
|
||||
<p class="muted center pad">{error}</p>
|
||||
{:else if selected === 'today'}
|
||||
{#if brief?.items?.length}
|
||||
<section class="rise">
|
||||
<h2 class="kicker">{brief.title ?? 'Five Good Things Today'}</h2>
|
||||
<ArticleCard article={brief.items[0]} hero onaction={applyAction} />
|
||||
{#if brief.items.length > 1}
|
||||
<div class="grid rest">
|
||||
{#each brief.items.slice(1) as a (a.id)}
|
||||
<ArticleCard article={a} onaction={applyAction} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{:else}
|
||||
<p class="muted center pad">No brief yet today — try a calmer filter, or check back soon.</p>
|
||||
{/if}
|
||||
|
||||
{#each lanes as lane (lane.key)}
|
||||
<Lane title={lane.label} description={lane.description} items={lane.items}
|
||||
onaction={applyAction} onmore={() => select(lane.key)} />
|
||||
{/each}
|
||||
|
||||
<p class="endcap rise">✦ that's the good news for today ✦</p>
|
||||
{:else}
|
||||
<section class="rise">
|
||||
<h2 class="kicker">{moodByKey(selected)?.label}</h2>
|
||||
<p class="muted lede">{moodByKey(selected)?.description}</p>
|
||||
{#if feed.length}
|
||||
<div class="grid">
|
||||
{#each feed as a (a.id)}
|
||||
<ArticleCard article={a} onaction={applyAction} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted center pad">Nothing in this lane right now — try another mood or ease a filter.</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.calmbar {
|
||||
display: flex; align-items: center; justify-content: center; gap: 12px;
|
||||
background: var(--sage-soft); color: var(--sage-deep);
|
||||
border-radius: 999px; padding: 6px 16px; margin: 6px auto 0; width: fit-content;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.calmbar button { background: none; border: none; color: var(--sage-deep); text-decoration: underline; font-size: 0.85rem; }
|
||||
.kicker {
|
||||
font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.12em;
|
||||
color: var(--gold); margin: 22px 0 14px; font-family: var(--sans); font-weight: 700;
|
||||
}
|
||||
.lede { margin: -8px 0 18px; font-size: 1.05rem; }
|
||||
.rest { margin-top: 18px; }
|
||||
.center { text-align: center; }
|
||||
.pad { padding: 48px 0; }
|
||||
.endcap {
|
||||
text-align: center; color: var(--muted); font-family: var(--serif);
|
||||
font-style: italic; margin: 40px 0 10px; letter-spacing: 0.02em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#2f7d5b"/>
|
||||
<circle cx="32" cy="29" r="11" fill="#faf6ee"/>
|
||||
<path d="M14 44 q12 9 24 3 q9 -4 12 1" stroke="#faf6ee" stroke-width="3.4" fill="none" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 288 B |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "goodNews",
|
||||
"short_name": "goodNews",
|
||||
"description": "Calm, constructive news worth your attention — and nothing that isn't.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#faf6ee",
|
||||
"theme_color": "#2f7d5b",
|
||||
"icons": [
|
||||
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** Static SPA: prerender the shell, fetch data from the API at runtime.
|
||||
* FastAPI serves the built `build/` directory. */
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({ fallback: 'index.html' }),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
|
||||
export default {
|
||||
plugins: [sveltekit()],
|
||||
// Dev convenience: proxy API calls to the FastAPI server.
|
||||
server: { proxy: { '/api': 'http://127.0.0.1:8000', '/healthz': 'http://127.0.0.1:8000' } },
|
||||
};
|
||||
+35
-8
@@ -31,11 +31,15 @@ from . import feeds, queries
|
||||
from .db import connect, init_db
|
||||
from .filters import filter_articles, prefs_from_json
|
||||
from .llm import LocalModelClient
|
||||
from .moods import MOODS
|
||||
from .taxonomy import FLAVORS, TOPICS
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_DB = ROOT / "data" / "goodnews.sqlite3"
|
||||
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
# Prefer the built SvelteKit site; fall back to the legacy single-page harness.
|
||||
FRONTEND_DIR = ROOT / "frontend" / "build"
|
||||
LEGACY_STATIC = Path(__file__).resolve().parent / "static"
|
||||
STATIC_DIR = FRONTEND_DIR if FRONTEND_DIR.is_dir() else LEGACY_STATIC
|
||||
|
||||
|
||||
def db_path() -> Path:
|
||||
@@ -188,6 +192,12 @@ def create_app() -> FastAPI:
|
||||
flavors=[Category(key=k, description=v) for k, v in FLAVORS.items()],
|
||||
)
|
||||
|
||||
@app.get("/api/moods")
|
||||
def moods() -> list[dict]:
|
||||
# The humane front door: each mood resolves to a filter preset the
|
||||
# client merges with the user's own Calm Filters.
|
||||
return MOODS
|
||||
|
||||
@app.get("/api/category-counts", response_model=list[CategoryCount])
|
||||
def category_counts(accepted_only: bool = True, prefs: str | None = Query(None)) -> list[CategoryCount]:
|
||||
fp = prefs_from_json(prefs)
|
||||
@@ -220,20 +230,37 @@ def create_app() -> FastAPI:
|
||||
if flavor and flavor.lower() not in FLAVORS:
|
||||
raise HTTPException(400, f"unknown flavor: {flavor}")
|
||||
fp = prefs_from_json(prefs)
|
||||
now = datetime.now(timezone.utc)
|
||||
with get_conn() as conn:
|
||||
if fp.is_empty():
|
||||
rows = queries.feed(
|
||||
conn, topic=topic, flavor=flavor, accepted_only=accepted_only, limit=limit, offset=offset
|
||||
)
|
||||
else:
|
||||
# Over-fetch, apply the calm filters in Python (word-boundary
|
||||
# avoid-terms can't be done in SQL), then slice to the page.
|
||||
fetch_n = min(2000, (offset + limit) * 4 + 50)
|
||||
raw = queries.feed(
|
||||
conn, topic=topic, flavor=flavor, accepted_only=accepted_only, limit=fetch_n, offset=0
|
||||
# Categorical filters (include/mute topics+flavors incl. active
|
||||
# pauses, cortisol ceiling) go to SQL so nothing is truncated by
|
||||
# ranking. Only word-boundary avoid-terms need a Python pass, so
|
||||
# over-fetch just enough to cover what they might remove.
|
||||
kw = dict(
|
||||
include_topics=fp.include_topics or None,
|
||||
include_flavors=fp.include_flavors or None,
|
||||
mute_topics=list(fp.muted_topics(now)) or None,
|
||||
mute_flavors=list(fp.muted_flavors(now)) or None,
|
||||
max_cortisol=fp.max_cortisol,
|
||||
max_ragebait=fp.max_ragebait,
|
||||
)
|
||||
if fp.avoid_terms:
|
||||
raw = queries.feed(
|
||||
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
|
||||
limit=min(2000, (offset + limit) * 4 + 50), offset=0, **kw,
|
||||
)
|
||||
kept = filter_articles(raw, fp, now) # drops avoid-term matches
|
||||
rows = kept[offset : offset + limit]
|
||||
else:
|
||||
rows = queries.feed(
|
||||
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
|
||||
limit=limit, offset=offset, **kw,
|
||||
)
|
||||
filtered = filter_articles(raw, fp, datetime.now(timezone.utc))
|
||||
rows = filtered[offset : offset + limit]
|
||||
return FeedResponse(
|
||||
topic=topic,
|
||||
flavor=flavor,
|
||||
|
||||
@@ -69,12 +69,20 @@ class FilterPrefs:
|
||||
mute_flavors: list[str] = field(default_factory=list)
|
||||
avoid_terms: list[str] = field(default_factory=list)
|
||||
pauses: list[Pause] = field(default_factory=list)
|
||||
max_cortisol: int | None = None
|
||||
max_ragebait: int | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict | None) -> "FilterPrefs":
|
||||
if not isinstance(data, dict):
|
||||
return cls()
|
||||
|
||||
def _opt_int(value: object) -> int | None:
|
||||
try:
|
||||
return int(value) if value is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _str_list(value: object) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
@@ -96,6 +104,8 @@ class FilterPrefs:
|
||||
mute_flavors=_str_list(data.get("mute_flavors")),
|
||||
avoid_terms=_str_list(data.get("avoid_terms")),
|
||||
pauses=pauses,
|
||||
max_cortisol=_opt_int(data.get("max_cortisol")),
|
||||
max_ragebait=_opt_int(data.get("max_ragebait")),
|
||||
)
|
||||
|
||||
def muted_topics(self, now: datetime) -> set[str]:
|
||||
@@ -117,6 +127,8 @@ class FilterPrefs:
|
||||
or self.mute_flavors
|
||||
or self.avoid_terms
|
||||
or self.pauses
|
||||
or self.max_cortisol is not None
|
||||
or self.max_ragebait is not None
|
||||
)
|
||||
|
||||
|
||||
@@ -148,6 +160,10 @@ def allows(article: dict, prefs: FilterPrefs, now: datetime) -> bool:
|
||||
return False
|
||||
if flavor in prefs.muted_flavors(now):
|
||||
return False
|
||||
if prefs.max_cortisol is not None and (article.get("cortisol_score") or 0) > prefs.max_cortisol:
|
||||
return False
|
||||
if prefs.max_ragebait is not None and (article.get("ragebait_score") or 0) > prefs.max_ragebait:
|
||||
return False
|
||||
blob = f"{article.get('title') or ''} {article.get('description') or ''}"
|
||||
if text_matches_avoid_terms(blob, prefs.avoid_terms):
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Mood modes — the humane front door over the topic/flavor taxonomy.
|
||||
|
||||
A reader thinks "I want wonder," not "animals/discovery". Each mood resolves to
|
||||
a filter preset (include_topics / include_flavors / a cortisol ceiling) that the
|
||||
feed already understands via FilterPrefs. Topic/flavor remain available as the
|
||||
secondary "browse more precisely" controls; moods don't replace them.
|
||||
|
||||
Single source of truth so the website and any future companion app agree.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# "today" is special: it has no filter — it's the daily brief view.
|
||||
MOODS: list[dict] = [
|
||||
{
|
||||
"key": "today",
|
||||
"label": "Today",
|
||||
"description": "The day's five good things.",
|
||||
"filter": {},
|
||||
},
|
||||
{
|
||||
"key": "wonder",
|
||||
"label": "Wonder",
|
||||
"description": "Awe and discovery.",
|
||||
"filter": {"include_topics": ["science", "animals", "culture"], "include_flavors": ["discovery"]},
|
||||
},
|
||||
{
|
||||
"key": "people-helping",
|
||||
"label": "People Helping",
|
||||
"description": "Community, kindness, and repair.",
|
||||
"filter": {"include_topics": ["community"], "include_flavors": ["solution", "feelgood"]},
|
||||
},
|
||||
{
|
||||
"key": "solutions",
|
||||
"label": "Solutions",
|
||||
"description": "Problems being solved.",
|
||||
"filter": {
|
||||
"include_topics": ["environment", "community", "health"],
|
||||
"include_flavors": ["solution", "breakthrough"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "light",
|
||||
"label": "Light Only",
|
||||
"description": "Just the gentle stuff.",
|
||||
"filter": {"include_flavors": ["feelgood", "discovery"], "max_cortisol": 2},
|
||||
},
|
||||
{
|
||||
"key": "grounded",
|
||||
"label": "Grounded",
|
||||
"description": "Useful, calm perspective.",
|
||||
"filter": {"include_flavors": ["perspective", "solution"]},
|
||||
},
|
||||
]
|
||||
|
||||
_BY_KEY = {m["key"]: m for m in MOODS}
|
||||
|
||||
|
||||
def mood_filter(key: str) -> dict:
|
||||
"""Return the filter preset for a mood key (empty dict if unknown/today)."""
|
||||
mood = _BY_KEY.get(key)
|
||||
return dict(mood["filter"]) if mood else {}
|
||||
+38
-2
@@ -47,8 +47,20 @@ def feed(
|
||||
accepted_only: bool = True,
|
||||
limit: int = 30,
|
||||
offset: int = 0,
|
||||
include_topics: list[str] | None = None,
|
||||
include_flavors: list[str] | None = None,
|
||||
mute_topics: list[str] | None = None,
|
||||
mute_flavors: list[str] | None = None,
|
||||
max_cortisol: int | None = None,
|
||||
max_ragebait: int | None = None,
|
||||
) -> list[dict]:
|
||||
"""Return ranked articles, optionally filtered by topic and/or flavor."""
|
||||
"""Return ranked articles with categorical filters applied in SQL.
|
||||
|
||||
Categorical filters (topic/flavor include & mute, cortisol/ragebait ceilings)
|
||||
must be applied here, not after ranking — otherwise low-ranked-but-matching
|
||||
items (e.g. 'discovery' for a Wonder lane) fall outside any over-fetch window.
|
||||
Word-boundary avoid-terms remain a Python pass on the caller side.
|
||||
"""
|
||||
clauses = ["a.duplicate_of IS NULL"]
|
||||
params: list = []
|
||||
if accepted_only:
|
||||
@@ -59,7 +71,31 @@ def feed(
|
||||
if flavor:
|
||||
clauses.append("s.flavor = ?")
|
||||
params.append(flavor.lower())
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
|
||||
def _in(column: str, values: list[str], negate: bool = False) -> None:
|
||||
vals = [v.lower() for v in values]
|
||||
placeholders = ",".join("?" * len(vals))
|
||||
op = "NOT IN" if negate else "IN"
|
||||
# COALESCE keeps NULL-category rows from being dropped by NOT IN.
|
||||
clauses.append(f"COALESCE({column}, '') {op} ({placeholders})")
|
||||
params.extend(vals)
|
||||
|
||||
if include_topics:
|
||||
_in("s.topic", include_topics)
|
||||
if include_flavors:
|
||||
_in("s.flavor", include_flavors)
|
||||
if mute_topics:
|
||||
_in("s.topic", mute_topics, negate=True)
|
||||
if mute_flavors:
|
||||
_in("s.flavor", mute_flavors, negate=True)
|
||||
if max_cortisol is not None:
|
||||
clauses.append("COALESCE(s.cortisol_score, 0) <= ?")
|
||||
params.append(max_cortisol)
|
||||
if max_ragebait is not None:
|
||||
clauses.append("COALESCE(s.ragebait_score, 0) <= ?")
|
||||
params.append(max_ragebait)
|
||||
|
||||
where = "WHERE " + " AND ".join(clauses)
|
||||
params.extend([limit, offset])
|
||||
|
||||
rows = conn.execute(
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import pytest
|
||||
|
||||
from goodnews import queries
|
||||
from goodnews.db import connect, init_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conn():
|
||||
c = connect(":memory:")
|
||||
init_db(c)
|
||||
c.execute("INSERT INTO sources (id, name, feed_url, trust_score) VALUES (1,'S','http://s/f',5)")
|
||||
rows = [
|
||||
(1, "science", "discovery", 1),
|
||||
(2, "animals", "discovery", 1),
|
||||
(3, "health", "breakthrough", 2),
|
||||
(4, "community", "solution", 1),
|
||||
(5, "environment", "solution", 7), # high cortisol
|
||||
]
|
||||
for aid, topic, flavor, cort in rows:
|
||||
c.execute(
|
||||
"INSERT INTO articles (id, source_id, canonical_url, title, url_hash) VALUES (?,1,?,?,?)",
|
||||
(aid, f"http://s/{aid}", f"t{aid}", f"h{aid}"),
|
||||
)
|
||||
c.execute(
|
||||
"INSERT INTO article_scores (article_id, constructive_score, agency_score, human_benefit_score, "
|
||||
"cortisol_score, ragebait_score, pr_risk_score, accepted, topic, flavor) "
|
||||
"VALUES (?, 6, 2, 2, ?, 0, 2, 1, ?, ?)",
|
||||
(aid, cort, topic, flavor),
|
||||
)
|
||||
c.commit()
|
||||
yield c
|
||||
c.close()
|
||||
|
||||
|
||||
def _topics(rows):
|
||||
return sorted({r["topic"] for r in rows})
|
||||
|
||||
|
||||
def test_include_topics_in_sql(conn):
|
||||
rows = queries.feed(conn, include_topics=["science", "animals"], limit=50)
|
||||
assert _topics(rows) == ["animals", "science"]
|
||||
|
||||
|
||||
def test_include_flavors_in_sql(conn):
|
||||
rows = queries.feed(conn, include_flavors=["discovery"], limit=50)
|
||||
assert sorted({r["flavor"] for r in rows}) == ["discovery"]
|
||||
|
||||
|
||||
def test_include_topic_and_flavor_are_anded(conn):
|
||||
# Wonder-style: (science/animals/culture) AND discovery
|
||||
rows = queries.feed(conn, include_topics=["science", "animals", "culture"], include_flavors=["discovery"], limit=50)
|
||||
assert _topics(rows) == ["animals", "science"] # the two discovery items
|
||||
|
||||
|
||||
def test_mute_topics_excludes(conn):
|
||||
rows = queries.feed(conn, mute_topics=["health", "environment"], limit=50)
|
||||
assert "health" not in _topics(rows) and "environment" not in _topics(rows)
|
||||
|
||||
|
||||
def test_max_cortisol_ceiling(conn):
|
||||
rows = queries.feed(conn, max_cortisol=2, limit=50)
|
||||
assert all((r["cortisol_score"] or 0) <= 2 for r in rows)
|
||||
assert "environment" not in _topics(rows) # the cortisol=7 item is gone
|
||||
|
||||
|
||||
def test_duplicates_excluded(conn):
|
||||
conn.execute("UPDATE articles SET duplicate_of = 1 WHERE id = 2")
|
||||
conn.commit()
|
||||
ids = {r["id"] for r in queries.feed(conn, limit=50)}
|
||||
assert 2 not in ids
|
||||
Reference in New Issue
Block a user