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
+83
View File
@@ -0,0 +1,83 @@
<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import HubShell from '$lib/components/HubShell.svelte';
let q = $state(null);
let state = $state('loading');
onMount(async () => {
try {
q = await getJSON('/api/quote/today');
state = q ? 'ready' : 'empty';
} catch {
state = 'empty';
}
});
</script>
<svelte:head>
<title>Quote of the Day · upbeatBytes</title>
<meta name="description" content="A hopeful quote every day — with its source and a plain-language take on what it means." />
</svelte:head>
<HubShell active="">
<article class="quote-page">
{#if state === 'ready'}
<p class="eyebrow">Quote of the day</p>
<blockquote class="quote">
<span class="glyph" aria-hidden="true"></span>
<p class="text">{q.text}</p>
<footer class="attrib">
<span class="dash"></span>
<span class="by">{q.author}{#if q.work}, <span class="work">{q.work}</span>{/if}</span>
</footer>
</blockquote>
{#if q.meaning}
<section class="meaning">
<h2>What it means</h2>
<p>{q.meaning}</p>
</section>
{/if}
{:else if state === 'empty'}
<p class="note">Today's quote is on its way. Check back soon.</p>
{:else}
<p class="note">Finding today's quote…</p>
{/if}
</article>
</HubShell>
<style>
.quote-page { max-width: 720px; margin: 0 auto; }
.eyebrow {
font-size: 12px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase;
color: #b06a86; margin: clamp(8px, 3vw, 28px) 0 0; text-align: center;
}
.quote { margin: clamp(18px, 4vw, 34px) 0 0; position: relative; text-align: center; }
.glyph {
font-family: 'Newsreader', Georgia, serif; font-size: clamp(4rem, 11vw, 7rem); line-height: 0.6;
color: rgba(176, 106, 134, 0.22); display: block; height: clamp(2rem, 5vw, 3rem);
}
.quote .text {
font-family: 'Newsreader', Georgia, serif; font-style: italic; font-weight: 500;
font-size: clamp(1.6rem, 4.5vw, 2.6rem); line-height: 1.28; letter-spacing: -0.01em;
color: #2f2240; margin: 8px 0 0;
}
.attrib { display: flex; align-items: center; justify-content: center; gap: 12px; margin-top: clamp(22px, 4vw, 32px); }
.dash { width: 30px; height: 1px; background: #c9a6ba; }
.by { font-family: 'Newsreader', Georgia, serif; font-size: 1.1rem; color: #7c64a0; }
.work { font-style: italic; }
.meaning {
margin: clamp(36px, 6vw, 56px) auto 0; max-width: 600px;
border-top: 1px solid #ecdce4; padding-top: 26px;
}
.meaning h2 {
font-size: 12px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase;
color: var(--muted); margin: 0 0 12px; text-align: center;
}
.meaning p { font-size: clamp(1.05rem, 2vw, 1.2rem); line-height: 1.6; color: #4a4255; margin: 0; text-align: center; }
.note { text-align: center; color: var(--muted); font-size: 1.05rem; margin-top: 60px; }
</style>