feat: Added IntersectionObserver scroll-spy to ToC highlighting the act…
- "frontend/src/components/TableOfContents.tsx" - "frontend/src/App.css" GSD-Task: S04/T02
This commit is contained in:
parent
1a7c11cac1
commit
9acfa9bc20
5 changed files with 230 additions and 2 deletions
|
|
@ -2376,3 +2376,56 @@ def _check_stage_prerequisites(video_id: str, stage_name: str) -> tuple[bool, st
|
|||
return False, f"Unknown stage: {stage_name}"
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
# ── Avatar Fetching ─────────────────────────────────────────────────────────
|
||||
|
||||
@celery_app.task
|
||||
def fetch_creator_avatar(creator_id: str) -> dict:
|
||||
"""Fetch avatar for a single creator from TheAudioDB.
|
||||
|
||||
Looks up the creator by ID, calls TheAudioDB, and updates the
|
||||
avatar_url/avatar_source/avatar_fetched_at columns if a confident
|
||||
match is found. Returns a status dict.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from services.avatar import lookup_avatar
|
||||
|
||||
session = _get_sync_session()
|
||||
try:
|
||||
creator = session.execute(
|
||||
select(Creator).where(Creator.id == creator_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if not creator:
|
||||
return {"status": "error", "detail": f"Creator {creator_id} not found"}
|
||||
|
||||
result = lookup_avatar(creator.name, creator.genres)
|
||||
|
||||
if result:
|
||||
creator.avatar_url = result.url
|
||||
creator.avatar_source = result.source
|
||||
creator.avatar_fetched_at = datetime.now(timezone.utc)
|
||||
session.commit()
|
||||
return {
|
||||
"status": "found",
|
||||
"creator": creator.name,
|
||||
"avatar_url": result.url,
|
||||
"confidence": result.confidence,
|
||||
"matched_artist": result.artist_name,
|
||||
}
|
||||
else:
|
||||
creator.avatar_source = "generated"
|
||||
creator.avatar_fetched_at = datetime.now(timezone.utc)
|
||||
session.commit()
|
||||
return {
|
||||
"status": "not_found",
|
||||
"creator": creator.name,
|
||||
"detail": "No confident match from TheAudioDB",
|
||||
}
|
||||
except Exception as exc:
|
||||
session.rollback()
|
||||
logger.error("Avatar fetch failed for creator %s: %s", creator_id, exc)
|
||||
return {"status": "error", "detail": str(exc)}
|
||||
finally:
|
||||
session.close()
|
||||
|
|
|
|||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
109
backend/services/avatar.py
Normal file
109
backend/services/avatar.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""TheAudioDB avatar lookup for music creators.
|
||||
|
||||
Searches TheAudioDB by artist name, applies confidence scoring, and
|
||||
returns the best-match thumbnail URL or None.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("chrysopedia.avatar")
|
||||
|
||||
AUDIODB_BASE = "https://www.theaudiodb.com/api/v1/json"
|
||||
AUDIODB_KEY = "523532" # public test/community key
|
||||
|
||||
|
||||
@dataclass
|
||||
class AvatarResult:
|
||||
url: str
|
||||
source: str # "theaudiodb"
|
||||
artist_name: str # name as returned by the API
|
||||
confidence: float # 0.0–1.0
|
||||
|
||||
|
||||
def _normalize(name: str) -> str:
|
||||
"""Lowercase, strip, collapse whitespace."""
|
||||
return " ".join(name.lower().split())
|
||||
|
||||
|
||||
def _name_similarity(query: str, candidate: str) -> float:
|
||||
"""Simple token-based similarity score (0.0–1.0).
|
||||
|
||||
Checks token overlap between the query and candidate names.
|
||||
Sufficient for distinctive artist names; avoids adding thefuzz dep.
|
||||
"""
|
||||
q_tokens = set(_normalize(query).split())
|
||||
c_tokens = set(_normalize(candidate).split())
|
||||
if not q_tokens or not c_tokens:
|
||||
return 0.0
|
||||
overlap = q_tokens & c_tokens
|
||||
# Jaccard-ish: overlap relative to the shorter set
|
||||
return len(overlap) / min(len(q_tokens), len(c_tokens))
|
||||
|
||||
|
||||
def _genre_overlap(creator_genres: list[str] | None, api_genre: str | None) -> bool:
|
||||
"""Check if any of the creator's genres appear in the API genre string."""
|
||||
if not creator_genres or not api_genre:
|
||||
return False
|
||||
api_lower = api_genre.lower()
|
||||
return any(g.lower() in api_lower for g in creator_genres)
|
||||
|
||||
|
||||
def lookup_avatar(
|
||||
creator_name: str,
|
||||
creator_genres: list[str] | None = None,
|
||||
) -> AvatarResult | None:
|
||||
"""Search TheAudioDB for an artist and return the best avatar match.
|
||||
|
||||
Returns None if no confident match is found. Synchronous — designed
|
||||
to run inside a Celery task.
|
||||
"""
|
||||
url = f"{AUDIODB_BASE}/{AUDIODB_KEY}/search.php"
|
||||
try:
|
||||
resp = httpx.get(url, params={"s": creator_name}, timeout=10.0)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as exc:
|
||||
logger.warning("TheAudioDB lookup failed for %r: %s", creator_name, exc)
|
||||
return None
|
||||
|
||||
artists = data.get("artists")
|
||||
if not artists:
|
||||
logger.info("TheAudioDB: no results for %r", creator_name)
|
||||
return None
|
||||
|
||||
best: AvatarResult | None = None
|
||||
best_score = 0.0
|
||||
|
||||
for artist in artists:
|
||||
name = artist.get("strArtist", "")
|
||||
thumb = artist.get("strArtistThumb")
|
||||
if not thumb:
|
||||
continue
|
||||
|
||||
similarity = _name_similarity(creator_name, name)
|
||||
genre_bonus = 0.1 if _genre_overlap(creator_genres, artist.get("strGenre")) else 0.0
|
||||
score = similarity + genre_bonus
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best = AvatarResult(
|
||||
url=thumb,
|
||||
source="theaudiodb",
|
||||
artist_name=name,
|
||||
confidence=min(score, 1.0),
|
||||
)
|
||||
|
||||
if best and best.confidence >= 0.5:
|
||||
logger.info(
|
||||
"TheAudioDB match for %r → %r (confidence=%.2f)",
|
||||
creator_name, best.artist_name, best.confidence,
|
||||
)
|
||||
return best
|
||||
|
||||
logger.info("TheAudioDB: no confident match for %r (best=%.2f)", creator_name, best_score)
|
||||
return None
|
||||
|
|
@ -2098,6 +2098,19 @@ a.app-footer__repo:hover {
|
|||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Active section highlighting (IntersectionObserver) */
|
||||
.technique-toc__link--active {
|
||||
color: var(--color-accent);
|
||||
font-weight: 500;
|
||||
background: var(--color-accent-focus);
|
||||
}
|
||||
|
||||
.technique-toc__sublink--active {
|
||||
color: var(--color-accent);
|
||||
font-weight: 500;
|
||||
background: var(--color-accent-focus);
|
||||
}
|
||||
|
||||
/* ── V2 subsections ───────────────────────────────────────────────────────── */
|
||||
|
||||
.technique-prose__subsection {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@
|
|||
*
|
||||
* Renders a nested list of anchor links matching the H2/H3 section structure.
|
||||
* Uses slugified headings as IDs for scroll targeting.
|
||||
* Tracks the active section via IntersectionObserver and highlights it.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { BodySectionV2 } from "../api/public-client";
|
||||
|
||||
export function slugify(text: string): string {
|
||||
|
|
@ -19,6 +21,52 @@ interface TableOfContentsProps {
|
|||
}
|
||||
|
||||
export default function TableOfContents({ sections }: TableOfContentsProps) {
|
||||
const [activeId, setActiveId] = useState<string>("");
|
||||
|
||||
// Collect all section/subsection IDs in document order
|
||||
const allIds = useMemo(() => {
|
||||
const ids: string[] = [];
|
||||
for (const section of sections) {
|
||||
const sectionSlug = slugify(section.heading);
|
||||
ids.push(sectionSlug);
|
||||
for (const sub of section.subsections) {
|
||||
ids.push(`${sectionSlug}--${slugify(sub.heading)}`);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}, [sections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (allIds.length === 0) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
// Find the topmost currently-intersecting entry
|
||||
const intersecting = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.boundingClientRect.top - b.boundingClientRect.top
|
||||
);
|
||||
|
||||
if (intersecting.length > 0) {
|
||||
setActiveId(intersecting[0]!.target.id);
|
||||
}
|
||||
},
|
||||
{
|
||||
// Trigger when a section enters the top 30% of the viewport
|
||||
rootMargin: "0px 0px -70% 0px",
|
||||
}
|
||||
);
|
||||
|
||||
for (const id of allIds) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) observer.observe(el);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [allIds]);
|
||||
|
||||
if (sections.length === 0) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -27,20 +75,25 @@ export default function TableOfContents({ sections }: TableOfContentsProps) {
|
|||
<ul className="technique-toc__list">
|
||||
{sections.map((section) => {
|
||||
const sectionSlug = slugify(section.heading);
|
||||
const isActive = activeId === sectionSlug;
|
||||
return (
|
||||
<li key={sectionSlug} className="technique-toc__item">
|
||||
<a href={`#${sectionSlug}`} className="technique-toc__link">
|
||||
<a
|
||||
href={`#${sectionSlug}`}
|
||||
className={`technique-toc__link${isActive ? " technique-toc__link--active" : ""}`}
|
||||
>
|
||||
{section.heading}
|
||||
</a>
|
||||
{section.subsections.length > 0 && (
|
||||
<ul className="technique-toc__sublist">
|
||||
{section.subsections.map((sub) => {
|
||||
const subSlug = `${sectionSlug}--${slugify(sub.heading)}`;
|
||||
const isSubActive = activeId === subSlug;
|
||||
return (
|
||||
<li key={subSlug} className="technique-toc__subitem">
|
||||
<a
|
||||
href={`#${subSlug}`}
|
||||
className="technique-toc__sublink"
|
||||
className={`technique-toc__sublink${isSubActive ? " technique-toc__sublink--active" : ""}`}
|
||||
>
|
||||
{sub.heading}
|
||||
</a>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue