diff --git a/data/wotd_audio/compassion.mp3 b/data/wotd_audio/compassion.mp3 new file mode 100644 index 0000000..8447209 Binary files /dev/null and b/data/wotd_audio/compassion.mp3 differ diff --git a/data/wotd_audio/dawn.mp3 b/data/wotd_audio/dawn.mp3 new file mode 100644 index 0000000..a4ba3d5 Binary files /dev/null and b/data/wotd_audio/dawn.mp3 differ diff --git a/data/wotd_audio/gentle.mp3 b/data/wotd_audio/gentle.mp3 new file mode 100644 index 0000000..aeba3a4 Binary files /dev/null and b/data/wotd_audio/gentle.mp3 differ diff --git a/data/wotd_audio/grace.mp3 b/data/wotd_audio/grace.mp3 new file mode 100644 index 0000000..f4bb839 Binary files /dev/null and b/data/wotd_audio/grace.mp3 differ diff --git a/data/wotd_audio/gratitude.mp3 b/data/wotd_audio/gratitude.mp3 new file mode 100644 index 0000000..becceef Binary files /dev/null and b/data/wotd_audio/gratitude.mp3 differ diff --git a/data/wotd_audio/harmony.mp3 b/data/wotd_audio/harmony.mp3 new file mode 100644 index 0000000..052f153 Binary files /dev/null and b/data/wotd_audio/harmony.mp3 differ diff --git a/data/wotd_audio/kindness.mp3 b/data/wotd_audio/kindness.mp3 new file mode 100644 index 0000000..4f069c7 Binary files /dev/null and b/data/wotd_audio/kindness.mp3 differ diff --git a/data/wotd_audio/radiance.mp3 b/data/wotd_audio/radiance.mp3 new file mode 100644 index 0000000..f400e7b Binary files /dev/null and b/data/wotd_audio/radiance.mp3 differ diff --git a/data/wotd_audio/serene.mp3 b/data/wotd_audio/serene.mp3 new file mode 100644 index 0000000..bf72ed1 Binary files /dev/null and b/data/wotd_audio/serene.mp3 differ diff --git a/data/wotd_audio/solace.mp3 b/data/wotd_audio/solace.mp3 new file mode 100644 index 0000000..6c7aaca Binary files /dev/null and b/data/wotd_audio/solace.mp3 differ diff --git a/data/wotd_audio/tranquil.mp3 b/data/wotd_audio/tranquil.mp3 new file mode 100644 index 0000000..a1c757e Binary files /dev/null and b/data/wotd_audio/tranquil.mp3 differ diff --git a/data/wotd_audio/wonder.mp3 b/data/wotd_audio/wonder.mp3 new file mode 100644 index 0000000..1cb486d Binary files /dev/null and b/data/wotd_audio/wonder.mp3 differ diff --git a/frontend/src/lib/components/HubShell.svelte b/frontend/src/lib/components/HubShell.svelte new file mode 100644 index 0000000..26f12bd --- /dev/null +++ b/frontend/src/lib/components/HubShell.svelte @@ -0,0 +1,73 @@ + + +
+
+ + upbeatBytes + + +
+ +
{@render children?.()}
+ + +
+ + diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 573ec4e..239629f 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -218,6 +218,30 @@ try { await delJSON('/api/admin/bloom/overrides/' + encodeURIComponent(w)); await loadBloomQueue(); } catch { /* ignore */ } } + // --- Small Joys: On This Day / Quote / Word pools --- + let joyKind = $state('onthisday'); + let joyItems = $state([]); + let joyLoading = $state(false); + let joyAdd = $state({}); + let joyAddErr = $state(''); + async function loadJoys(k) { + joyLoading = true; + try { joyItems = await getJSON('/api/admin/joys/' + k); } catch { joyItems = []; } + joyLoading = false; + } + async function joyMutate(id, action, fields) { + try { await postJSON(`/api/admin/joys/${joyKind}/${id}`, { action, fields }); await loadJoys(joyKind); } catch { /* ignore */ } + } + async function joyAddSubmit() { + joyAddErr = ''; + try { await postJSON(`/api/admin/joys/${joyKind}/add`, joyAdd); joyAdd = {}; await loadJoys(joyKind); } + catch (e) { joyAddErr = e?.message || 'Could not add'; } + } + async function joyRepick() { + try { await postJSON(`/api/admin/joys/${joyKind}/repick`, {}); } catch { /* ignore */ } + } + $effect(() => { const k = joyKind; if (section === 'joys') loadJoys(k); }); + // --- Games: Daily Word pool --- let wpWord = $state(''); let wpResult = $state(null); // lookup result for the current input @@ -335,6 +359,7 @@ { key: 'audience', label: 'Audience' }, { key: 'feedback', label: 'Feedback' }, { key: 'games', label: 'Games' }, + { key: 'joys', label: 'Small Joys' }, { key: 'publish', label: 'Publishing' }, ]; const VALID_SECTIONS = new Set(TABS.map((t) => t.key)); @@ -1460,6 +1485,63 @@ {/if} + + {:else if section === 'joys'} +
+
+ + + +
+ +
+ +
+ Add {joyKind === 'word' ? 'a word' : joyKind === 'quote' ? 'a quote' : 'a fact'} +
+ {#if joyKind === 'word'} + + {:else if joyKind === 'quote'} + + + + {:else} + + + + + {/if} + + {#if joyAddErr}{joyAddErr}{/if} +
+
+ + {#if joyLoading}

Loading…

{/if} + {/if} {/if} @@ -1940,4 +2022,26 @@ .pub-actions { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; margin-top: 12px; } .pub-confirm { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--accent); border-radius: 10px; background: var(--accent-soft); } .hin.wide { width: 100%; box-sizing: border-box; margin: 4px 0; } + + /* Small Joys */ + .joy-bar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 14px; } + .joy-kinds { display: flex; gap: 8px; } + .joy-kinds button { padding: 6px 14px; border: 1px solid var(--line); border-radius: 999px; background: #fff; cursor: pointer; font-size: 0.9rem; } + .joy-kinds button.on { background: var(--accent); color: #fff; border-color: var(--accent); } + .joy-repick { padding: 6px 14px; border: 1px solid var(--line); border-radius: 8px; background: #fff; cursor: pointer; font-size: 0.9rem; } + .joy-add { margin-bottom: 14px; } + .joy-add summary { cursor: pointer; font-weight: 600; color: var(--accent); } + .joy-add-fields { display: flex; flex-direction: column; gap: 8px; max-width: 540px; margin-top: 10px; } + .joy-add-fields input, .joy-add-fields textarea { width: 100%; box-sizing: border-box; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; font: inherit; } + .joy-add-fields textarea { min-height: 60px; resize: vertical; } + .joy-add-fields button { align-self: flex-start; padding: 7px 16px; border: none; border-radius: 8px; background: var(--accent); color: #fff; cursor: pointer; } + .joy-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } + .joy-row { display: flex; justify-content: space-between; gap: 14px; align-items: flex-start; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: #fff; } + .joy-row.blocked { opacity: 0.5; } + .joy-main { min-width: 0; flex: 1; } + .joy-sub { margin-top: 3px; line-height: 1.4; } + .joy-acts { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; flex-shrink: 0; } + .joy-acts button { padding: 4px 10px; border: 1px solid var(--line); border-radius: 7px; background: #fff; cursor: pointer; font-size: 0.82rem; } + .joy-acts .joy-del { color: #b23b3b; border-color: #e6c4c4; } + .joy-badge { font-size: 0.72rem; font-weight: 700; color: var(--accent); background: var(--accent-soft); border-radius: 999px; padding: 2px 8px; } diff --git a/frontend/src/routes/home3/+page.svelte b/frontend/src/routes/home3/+page.svelte index b7cbbf9..51f2e78 100644 --- a/frontend/src/routes/home3/+page.svelte +++ b/frontend/src/routes/home3/+page.svelte @@ -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 */ } }); @@ -54,35 +72,32 @@ {#snippet joyCard(i)} {#if i === 0} -
- + +
Word of the day
-

Serene adj.

-

/səˈriːn/

-

Calm, peaceful, and untroubled. The quiet after a storm passes.

+

{word ? cap(word.word) : 'Serene'} {word ? (POS[word.part_of_speech] ?? word.part_of_speech ?? '') : 'adj.'}

+

{word?.phonetic ?? '/səˈriːn/'}

+

{word ? clip(word.definition, 78) : 'Calm, peaceful, and untroubled. The quiet after a storm passes.'}

-
+ {:else if i === 1} -
+
Quote of the day
-

Very little is needed to make a happy life.

-
Marcus Aurelius
+

{quote ? clip(quote.text, 92) : 'Very little is needed to make a happy life.'}

+
{quote?.author ?? 'Marcus Aurelius'}
-
+ {:else} -
+
-
-
A good thing today
- SOON -
-

1928 ON THIS DAY

-

Penicillin was discovered by a happy accident.

+
A good thing today
+

{fact?.year ?? '1928'} ON THIS DAY

+

{fact ? clip(fact.text, 96) : 'Penicillin was discovered by a happy accident.'}

-
+ {/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; } diff --git a/frontend/src/routes/onthisday/+page.svelte b/frontend/src/routes/onthisday/+page.svelte new file mode 100644 index 0000000..c19f7f0 --- /dev/null +++ b/frontend/src/routes/onthisday/+page.svelte @@ -0,0 +1,99 @@ + + + + On This Day · upbeatBytes + + + + +
+ {#if state === 'ready'} +

A good thing today

+

{dayLabel(f.date)}

+ +
+

{f.year}on this day in history

+

{f.text}

+
+ + {#if f.image_url} +
+ {/if} + + {#if f.summary} +

{f.summary}

+ {/if} + + {#if f.source_url} + Read more on Wikipedia → + {/if} + {:else if state === 'empty'} +

Today's moment in history is on its way. Check back soon.

+ {:else} +

Finding today's good thing…

+ {/if} +
+
+ + diff --git a/frontend/src/routes/quote/+page.svelte b/frontend/src/routes/quote/+page.svelte new file mode 100644 index 0000000..119f75f --- /dev/null +++ b/frontend/src/routes/quote/+page.svelte @@ -0,0 +1,83 @@ + + + + Quote of the Day · upbeatBytes + + + + +
+ {#if state === 'ready'} +

Quote of the day

+
+ +

{q.text}

+
+ + {q.author}{#if q.work}, {q.work}{/if} +
+
+ + {#if q.meaning} +
+

What it means

+

{q.meaning}

+
+ {/if} + {:else if state === 'empty'} +

Today's quote is on its way. Check back soon.

+ {:else} +

Finding today's quote…

+ {/if} +
+
+ + diff --git a/frontend/src/routes/word/+page.svelte b/frontend/src/routes/word/+page.svelte new file mode 100644 index 0000000..963fa26 --- /dev/null +++ b/frontend/src/routes/word/+page.svelte @@ -0,0 +1,115 @@ + + + + {w ? cap(w.word) : 'Word of the Day'} · upbeatBytes + + + + +
+ {#if state === 'ready'} +

Word of the day

+

{cap(w.word)}

+
+ {#if w.part_of_speech}{w.part_of_speech}{/if} + {#if w.phonetic}{w.phonetic}{/if} + +
+ +

{w.definition}

+ + {#if w.examples?.length} +
+

In a sentence

+
    + {#each w.examples as ex}
  • {ex}
  • {/each} +
+
+ {/if} + {:else if state === 'empty'} +

Today's word is on its way. Check back soon.

+ {:else} +

Finding today's word…

+ {/if} +
+
+ + diff --git a/goodnews/api.py b/goodnews/api.py index 8d5d232..c476b94 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -36,7 +36,7 @@ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel -from . import art, auth, bloom, email_send, feeds, games, oauth_google, onthisday, publishing, queries, quote, share, sources, summarize, wotd +from . import art, auth, bloom, daily, email_send, feeds, games, oauth_google, onthisday, publishing, queries, quote, share, sources, summarize, wotd from .localtime import local_today from .markup import reply_html_to_text, sanitize_reply_html from .db import connect @@ -386,6 +386,23 @@ class RejectedExample(BaseModel): reason: str +class JoyAction(BaseModel): + action: str # block | unblock | feature | unfeature | delete | edit + fields: dict | None = None # for edit: {column: value} + + +class JoyAdd(BaseModel): + text: str | None = None # quote / onthisday + author: str | None = None # quote + work: str | None = None # quote + md: str | None = None # onthisday 'MM-DD' + year: int | None = None # onthisday + summary: str | None = None # onthisday + image_url: str | None = None # onthisday + page_url: str | None = None # onthisday + word: str | None = None # word + + class Candidate(BaseModel): id: int feed_url: str @@ -2339,6 +2356,91 @@ def create_app() -> FastAPI: raise HTTPException(status_code=404, detail="No audio.") return FileResponse(str(matches[0]), headers={"Cache-Control": "public, max-age=31536000, immutable"}) + # --- Small-joys admin: manage the WOTD / QOTD / On-This-Day pools ---------------- + _JOY_TABLES = {"onthisday": "onthisday_pool", "quote": "quote_pool", "word": "wotd_pool"} + _JOY_MODULES = {"onthisday": onthisday, "quote": quote, "word": wotd} + _JOY_EDITABLE = { # whitelist of editable columns + "onthisday": {"text", "summary", "year"}, + "quote": {"text", "author", "work", "year", "meaning"}, + "word": {"definition", "part_of_speech", "phonetic"}, + } + + @app.get("/api/admin/joys/{kind}") + def admin_joys_list(kind: str, request: Request, limit: int = Query(300, ge=1, le=2000)) -> list[dict]: + table = _JOY_TABLES.get(kind) + if not table: + raise HTTPException(status_code=404, detail="Unknown joy.") + with get_conn() as conn: + _require_admin(conn, request) + rows = conn.execute(f"SELECT * FROM {table} ORDER BY featured DESC, id DESC LIMIT ?", (limit,)).fetchall() + return [dict(r) for r in rows] + + @app.post("/api/admin/joys/{kind}/{item_id}") + def admin_joys_mutate(kind: str, item_id: int, body: JoyAction, request: Request) -> dict: + table = _JOY_TABLES.get(kind) + if not table: + raise HTTPException(status_code=404, detail="Unknown joy.") + with get_conn() as conn: + _require_admin(conn, request) + a = body.action + if a in ("block", "unblock"): + conn.execute(f"UPDATE {table} SET blocked=? WHERE id=?", (1 if a == "block" else 0, item_id)) + elif a in ("feature", "unfeature"): + conn.execute(f"UPDATE {table} SET featured=? WHERE id=?", (1 if a == "feature" else 0, item_id)) + elif a == "delete": + conn.execute(f"DELETE FROM {table} WHERE id=?", (item_id,)) + elif a == "edit": + cols = _JOY_EDITABLE[kind] & set((body.fields or {}).keys()) + if cols: + sets = ", ".join(f"{c}=?" for c in cols) + conn.execute(f"UPDATE {table} SET {sets} WHERE id=?", (*(body.fields[c] for c in cols), item_id)) + else: + raise HTTPException(status_code=400, detail="Unknown action.") + conn.commit() + return {"ok": True} + + @app.post("/api/admin/joys/{kind}/add") + def admin_joys_add(kind: str, body: JoyAdd, request: Request) -> dict: + with get_conn() as conn: + _require_admin(conn, request) + if kind == "quote": + if not body.text: + raise HTTPException(status_code=400, detail="text required") + conn.execute("INSERT OR IGNORE INTO quote_pool (source, ckey, text, author, work) VALUES ('admin',?,?,?,?)", + (daily.content_key(body.text, body.author), body.text, body.author, body.work)) + elif kind == "onthisday": + if not body.text or not body.md: + raise HTTPException(status_code=400, detail="md + text required") + conn.execute("INSERT OR IGNORE INTO onthisday_pool (source, md, year, ckey, text, summary, image_url, page_url) " + "VALUES ('admin',?,?,?,?,?,?,?)", + (body.md, body.year, daily.content_key(body.md, body.year, body.text), + body.text, body.summary, body.image_url, body.page_url)) + elif kind == "word": + if not body.word: + raise HTTPException(status_code=400, detail="word required") + info = wotd._lookup(body.word.strip().lower()) # network up front + if not info: + raise HTTPException(status_code=400, detail="Word not found in dictionary.") + audio_file = wotd._cache_audio(info["audio_url"], info["word"]) + conn.execute("INSERT OR IGNORE INTO wotd_pool (source, word, part_of_speech, phonetic, audio_file, audio_url, definition, examples) " + "VALUES ('admin',?,?,?,?,?,?,?)", + (info["word"], info["part_of_speech"], info["phonetic"], audio_file, info["audio_url"], + info["definition"], json.dumps(info["examples"]))) + else: + raise HTTPException(status_code=404, detail="Unknown joy.") + conn.commit() + return {"ok": True} + + @app.post("/api/admin/joys/{kind}/repick") + def admin_joys_repick(kind: str, request: Request) -> dict: + mod = _JOY_MODULES.get(kind) + if not mod: + raise HTTPException(status_code=404, detail="Unknown joy.") + with get_conn() as conn: + _require_admin(conn, request) + picked = mod.pick_daily(conn, force=True) + return {"ok": True, "picked": bool(picked)} + @app.get("/api/replacement", response_model=Article | None) def replacement( exclude: str = Query("", description="comma-separated article ids already shown"),