Small joys: wire homepage rail to live data + rich pages (/word /quote /onthisday) + admin

- /home3: small-joys rail now reads live /api/word|quote|onthisday/today (placeholders only
  as fallback); each cell links to its detail page.
- HubShell component (shared bar/footer/fonts/tokens) for the hub + detail pages.
- /word: big word, IPA, Listen (cached clip + browser-TTS fallback), definition, sentences.
- /quote: the quote, attribution, and the AI "what it means".
- /onthisday: the date, year + fact, image, summary, source.
- Admin "Small Joys" tab: per-pool list with feature/block/delete/add + re-pick, for all
  three kinds. New admin API: GET/POST /api/admin/joys/{kind}[/{id}|/add|/repick].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-22 18:52:38 -04:00
parent 67d4bc32cb
commit 3bde6534e9
19 changed files with 616 additions and 22 deletions
@@ -0,0 +1,99 @@
<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import HubShell from '$lib/components/HubShell.svelte';
let f = $state(null);
let state = $state('loading');
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
function dayLabel(dateStr) {
if (!dateStr) return '';
const [, m, d] = dateStr.split('-').map(Number);
return `${MONTHS[m - 1]} ${d}`;
}
onMount(async () => {
try {
f = await getJSON('/api/onthisday/today');
state = f ? 'ready' : 'empty';
} catch {
state = 'empty';
}
});
</script>
<svelte:head>
<title>On This Day · upbeatBytes</title>
<meta name="description" content="A good thing that happened on today's date in history." />
</svelte:head>
<HubShell active="">
<article class="otd-page">
{#if state === 'ready'}
<p class="eyebrow">A good thing today</p>
<h1 class="date">{dayLabel(f.date)}</h1>
<div class="event">
<p class="year-line"><span class="year">{f.year}</span><span class="ago">on this day in history</span></p>
<p class="fact">{f.text}</p>
</div>
{#if f.image_url}
<div class="photo" style="background-image:url({f.image_url})"></div>
{/if}
{#if f.summary}
<p class="summary">{f.summary}</p>
{/if}
{#if f.source_url}
<a class="source" href={f.source_url} target="_blank" rel="noopener">Read more on Wikipedia →</a>
{/if}
{:else if state === 'empty'}
<p class="note">Today's moment in history is on its way. Check back soon.</p>
{:else}
<p class="note">Finding today's good thing…</p>
{/if}
</article>
</HubShell>
<style>
.otd-page { max-width: 680px; margin: 0 auto; text-align: center; }
.eyebrow {
font-size: 12px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase;
color: #3f9a66; margin: clamp(8px, 3vw, 28px) 0 0;
}
.date {
font-family: 'Newsreader', Georgia, serif; font-weight: 500; letter-spacing: -0.015em;
font-size: clamp(2.4rem, 7vw, 4rem); line-height: 1; margin: 12px 0 0; color: var(--ink);
}
.event { margin-top: clamp(22px, 4vw, 34px); }
.year-line { display: flex; align-items: baseline; justify-content: center; gap: 12px; margin: 0; }
.year { font-family: 'Newsreader', Georgia, serif; font-weight: 500; font-size: clamp(1.8rem, 4vw, 2.6rem); color: #1e5b3b; line-height: 1; }
.ago { font-size: 0.9rem; letter-spacing: 0.04em; color: #6f9683; text-transform: uppercase; }
.fact {
font-family: 'Newsreader', Georgia, serif; font-size: clamp(1.3rem, 3vw, 1.7rem); line-height: 1.34;
color: #214a35; margin: 14px 0 0;
}
.photo {
margin: clamp(26px, 5vw, 40px) auto 0; max-width: 460px; aspect-ratio: 3/2;
background-size: cover; background-position: center; border-radius: 16px;
border: 1px solid #d8e6da; box-shadow: 0 10px 30px -16px rgba(40, 120, 75, 0.4);
}
.summary {
margin: clamp(26px, 5vw, 38px) auto 0; max-width: 600px; text-align: left;
font-size: 1.05rem; line-height: 1.65; color: #4a5a50;
}
.source {
display: inline-block; margin-top: clamp(24px, 4vw, 34px);
font-size: 0.95rem; font-weight: 600; color: #3f9a66; text-decoration: none;
}
.source:hover { text-decoration: underline; }
.note { text-align: center; color: var(--muted); font-size: 1.05rem; margin-top: 60px; }
</style>