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:
@@ -0,0 +1,115 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { getJSON } from '$lib/api.js';
|
||||
import HubShell from '$lib/components/HubShell.svelte';
|
||||
|
||||
let w = $state(null);
|
||||
let state = $state('loading'); // loading | ready | empty
|
||||
|
||||
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : '');
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
w = await getJSON('/api/word/today');
|
||||
state = w ? 'ready' : 'empty';
|
||||
} catch {
|
||||
state = 'empty';
|
||||
}
|
||||
});
|
||||
|
||||
function speak() {
|
||||
if (w?.audio_url) {
|
||||
const a = new Audio(w.audio_url);
|
||||
a.play().catch(ttsFallback);
|
||||
} else {
|
||||
ttsFallback();
|
||||
}
|
||||
}
|
||||
function ttsFallback() {
|
||||
try {
|
||||
const u = new SpeechSynthesisUtterance(w.word);
|
||||
u.rate = 0.9;
|
||||
speechSynthesis.cancel();
|
||||
speechSynthesis.speak(u);
|
||||
} catch { /* no speech support — silent */ }
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{w ? cap(w.word) : 'Word of the Day'} · upbeatBytes</title>
|
||||
<meta name="description" content="A new uplifting word every day — with pronunciation, meaning, and how to use it." />
|
||||
</svelte:head>
|
||||
|
||||
<HubShell active="">
|
||||
<article class="word-page">
|
||||
{#if state === 'ready'}
|
||||
<p class="eyebrow">Word of the day</p>
|
||||
<h1 class="word">{cap(w.word)}</h1>
|
||||
<div class="meta">
|
||||
{#if w.part_of_speech}<span class="pos">{w.part_of_speech}</span>{/if}
|
||||
{#if w.phonetic}<span class="pron">{w.phonetic}</span>{/if}
|
||||
<button class="listen" onclick={speak} aria-label="Hear it spoken">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M11 5 6 9H3v6h3l5 4z" /><path d="M15.5 8.5a5 5 0 0 1 0 7" /><path d="M18.5 6a8 8 0 0 1 0 12" />
|
||||
</svg>
|
||||
Listen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="definition">{w.definition}</p>
|
||||
|
||||
{#if w.examples?.length}
|
||||
<section class="examples">
|
||||
<h2>In a sentence</h2>
|
||||
<ul>
|
||||
{#each w.examples as ex}<li>{ex}</li>{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
{:else if state === 'empty'}
|
||||
<p class="note">Today's word is on its way. Check back soon.</p>
|
||||
{:else}
|
||||
<p class="note">Finding today's word…</p>
|
||||
{/if}
|
||||
</article>
|
||||
</HubShell>
|
||||
|
||||
<style>
|
||||
.word-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: #4f7da8; margin: clamp(8px, 3vw, 28px) 0 0;
|
||||
}
|
||||
.word {
|
||||
font-family: 'Newsreader', Georgia, serif; font-weight: 500; letter-spacing: -0.02em;
|
||||
font-size: clamp(3rem, 9vw, 5.5rem); line-height: 1; margin: 14px 0 0; color: var(--ink);
|
||||
}
|
||||
.meta { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 14px; margin-top: 18px; }
|
||||
.pos { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 1.1rem; color: #8298ad; }
|
||||
.pron { font-size: 1.05rem; color: #5f7791; }
|
||||
.listen {
|
||||
display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
|
||||
font-family: inherit; font-size: 0.92rem; font-weight: 600; color: #fff;
|
||||
background: #4f7da8; border: none; border-radius: 999px; padding: 8px 16px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.listen:hover { background: #3f6c97; }
|
||||
|
||||
.definition {
|
||||
font-size: clamp(1.15rem, 2.2vw, 1.45rem); line-height: 1.5; color: #2c3a48;
|
||||
margin: clamp(26px, 5vw, 44px) 0 0; text-align: left;
|
||||
}
|
||||
|
||||
.examples { margin-top: clamp(28px, 5vw, 44px); text-align: left; border-top: 1px solid #eadfca; padding-top: 24px; }
|
||||
.examples h2 {
|
||||
font-size: 12px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase;
|
||||
color: var(--muted); margin: 0 0 14px;
|
||||
}
|
||||
.examples ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.examples li {
|
||||
font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 1.15rem; line-height: 1.45;
|
||||
color: #4a5560; padding-left: 16px; border-left: 3px solid #d2e1f0;
|
||||
}
|
||||
|
||||
.note { text-align: center; color: var(--muted); font-size: 1.05rem; margin-top: 60px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user