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
+39 -21
View File
@@ -8,6 +8,19 @@
let news = $state(null); // {id, title, summary, image}
let art = $state(null); // {title, artist, year, image}
let newsFit = $state('cover'); // 'cover' = full-bleed photo; 'contain' = framed-plate figure
let word = $state(null); // /api/word/today
let quote = $state(null); // /api/quote/today
let fact = $state(null); // /api/onthisday/today
// small-joys display helpers
const POS = { noun: 'n.', adjective: 'adj.', verb: 'v.', adverb: 'adv.', pronoun: 'pron.',
preposition: 'prep.', conjunction: 'conj.', interjection: 'interj.' };
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : '');
const clip = (s, n) => {
if (!s || s.length <= n) return s || '';
const cut = s.slice(0, n), i = cut.lastIndexOf(' ');
return (i > 0 ? cut.slice(0, i) : cut).replace(/[\s,;:.]+$/, '') + '…';
};
// truncation handled by CSS (-webkit-line-clamp:2) — breaks on whole words, fills 2 full lines
let headline = $derived(news?.title ?? 'What went right this week: the good news that actually matters');
@@ -43,6 +56,11 @@
probe.src = news.image;
}
} catch { /* fall back to design copy */ }
// small joys (each falls back to its placeholder if the engine has nothing yet)
try { word = await getJSON('/api/word/today'); } catch { /* placeholder */ }
try { quote = await getJSON('/api/quote/today'); } catch { /* placeholder */ }
try { fact = await getJSON('/api/onthisday/today'); } catch { /* placeholder */ }
});
</script>
@@ -54,35 +72,32 @@
{#snippet joyCard(i)}
{#if i === 0}
<div class="joy joy-word">
<span class="wm" aria-hidden="true">S</span>
<a class="joy joy-word" href="/word">
<span class="wm" aria-hidden="true">{word ? cap(word.word)[0] : 'S'}</span>
<div class="joy-in">
<div class="tag"><span class="rule"></span><span class="tag-label">Word of the day</span></div>
<p class="word-line"><span class="word">Serene</span> <span class="word-pos">adj.</span></p>
<p class="word-pron">/səˈriːn/</p>
<p class="def">Calm, peaceful, and untroubled. The quiet after a storm passes.</p>
<p class="word-line"><span class="word">{word ? cap(word.word) : 'Serene'}</span> <span class="word-pos">{word ? (POS[word.part_of_speech] ?? word.part_of_speech ?? '') : 'adj.'}</span></p>
<p class="word-pron">{word?.phonetic ?? '/səˈriːn/'}</p>
<p class="def">{word ? clip(word.definition, 78) : 'Calm, peaceful, and untroubled. The quiet after a storm passes.'}</p>
</div>
</div>
</a>
{:else if i === 1}
<div class="joy joy-quote">
<a class="joy joy-quote" href="/quote">
<span class="wm wm-q" aria-hidden="true"></span>
<div class="joy-in">
<div class="tag"><span class="rule"></span><span class="tag-label">Quote of the day</span></div>
<p class="quote">Very little is needed to make a happy life.</p>
<div class="attrib"><span class="attrib-rule"></span><span class="attrib-by">Marcus Aurelius</span></div>
<p class="quote">{quote ? clip(quote.text, 92) : 'Very little is needed to make a happy life.'}</p>
<div class="attrib"><span class="attrib-rule"></span><span class="attrib-by">{quote?.author ?? 'Marcus Aurelius'}</span></div>
</div>
</div>
</a>
{:else}
<div class="joy joy-fact">
<a class="joy joy-fact" href="/onthisday">
<div class="joy-in">
<div class="joy-top">
<div class="tag"><span class="rule"></span><span class="tag-label">A good thing today</span></div>
<span class="soon">SOON</span>
</div>
<p class="fact-hero"><span class="year">1928</span> <span class="onthis">ON THIS DAY</span></p>
<p class="fact">Penicillin was discovered by a happy accident.</p>
<div class="tag"><span class="rule"></span><span class="tag-label">A good thing today</span></div>
<p class="fact-hero"><span class="year">{fact?.year ?? '1928'}</span> <span class="onthis">ON THIS DAY</span></p>
<p class="fact">{fact ? clip(fact.text, 96) : 'Penicillin was discovered by a happy accident.'}</p>
</div>
</div>
</a>
{/if}
{/snippet}
@@ -430,7 +445,12 @@
.joys { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
/* all cells share one compact height (tight, not crowded) so every rotation matches */
/* clamp every card to the WORD card's natural height (its tallest) so rotations never jump */
.joy { position: relative; overflow: hidden; border-radius: 20px; padding: 18px 22px; min-height: 170px; box-sizing: border-box; }
.joy {
position: relative; overflow: hidden; border-radius: 20px; padding: 18px 22px; min-height: 170px;
box-sizing: border-box; display: block; text-decoration: none; color: inherit;
transition: transform 0.16s ease, box-shadow 0.16s ease;
}
.joy:hover { transform: translateY(-2px); }
.joy-in { position: relative; } /* content sits above the watermark */
.wm { position: absolute; font-family: 'Newsreader', Georgia, serif; line-height: 1; pointer-events: none; }
@@ -456,8 +476,6 @@
.attrib-rule { width: 22px; height: 1px; background: #d8afc1; }
.attrib-by { font-family: 'Newsreader', Georgia, serif; font-size: 13px; color: #97667f; }
.joy-fact .joy-top { display: flex; align-items: center; justify-content: space-between; }
.joy-fact .soon { color: #b06a45; }
.fact-hero { display: flex; align-items: baseline; gap: 8px; margin: 12px 0 0; }
.year { font-family: 'Newsreader', Georgia, serif; font-weight: 500; font-size: 30px; color: #7a4a30; line-height: 0.9; }
.onthis { font-size: 11px; color: #9e7a64; letter-spacing: 0.04em; }