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}"
|
return False, f"Unknown stage: {stage_name}"
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
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);
|
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 ───────────────────────────────────────────────────────── */
|
/* ── V2 subsections ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.technique-prose__subsection {
|
.technique-prose__subsection {
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@
|
||||||
*
|
*
|
||||||
* Renders a nested list of anchor links matching the H2/H3 section structure.
|
* Renders a nested list of anchor links matching the H2/H3 section structure.
|
||||||
* Uses slugified headings as IDs for scroll targeting.
|
* 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";
|
import type { BodySectionV2 } from "../api/public-client";
|
||||||
|
|
||||||
export function slugify(text: string): string {
|
export function slugify(text: string): string {
|
||||||
|
|
@ -19,6 +21,52 @@ interface TableOfContentsProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TableOfContents({ sections }: 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;
|
if (sections.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -27,20 +75,25 @@ export default function TableOfContents({ sections }: TableOfContentsProps) {
|
||||||
<ul className="technique-toc__list">
|
<ul className="technique-toc__list">
|
||||||
{sections.map((section) => {
|
{sections.map((section) => {
|
||||||
const sectionSlug = slugify(section.heading);
|
const sectionSlug = slugify(section.heading);
|
||||||
|
const isActive = activeId === sectionSlug;
|
||||||
return (
|
return (
|
||||||
<li key={sectionSlug} className="technique-toc__item">
|
<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}
|
{section.heading}
|
||||||
</a>
|
</a>
|
||||||
{section.subsections.length > 0 && (
|
{section.subsections.length > 0 && (
|
||||||
<ul className="technique-toc__sublist">
|
<ul className="technique-toc__sublist">
|
||||||
{section.subsections.map((sub) => {
|
{section.subsections.map((sub) => {
|
||||||
const subSlug = `${sectionSlug}--${slugify(sub.heading)}`;
|
const subSlug = `${sectionSlug}--${slugify(sub.heading)}`;
|
||||||
|
const isSubActive = activeId === subSlug;
|
||||||
return (
|
return (
|
||||||
<li key={subSlug} className="technique-toc__subitem">
|
<li key={subSlug} className="technique-toc__subitem">
|
||||||
<a
|
<a
|
||||||
href={`#${subSlug}`}
|
href={`#${subSlug}`}
|
||||||
className="technique-toc__sublink"
|
className={`technique-toc__sublink${isSubActive ? " technique-toc__sublink--active" : ""}`}
|
||||||
>
|
>
|
||||||
{sub.heading}
|
{sub.heading}
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue