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:
+103
-1
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user