5601022cf7
- 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>
103 lines
3.6 KiB
Svelte
103 lines
3.6 KiB
Svelte
<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>
|