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:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user