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
0743d80b6a
commit
89ef2751fa
8 changed files with 330 additions and 3 deletions
|
|
@ -23,7 +23,7 @@ Layout note: The sidebar is already position: sticky; top: 1.5rem. Placing ToC a
|
|||
- Estimate: 30m
|
||||
- Files: frontend/src/pages/TechniquePage.tsx, frontend/src/components/TableOfContents.tsx, frontend/src/App.css
|
||||
- Verify: cd frontend && npm run build 2>&1 | tail -5 && echo 'Build OK'
|
||||
- [ ] **T02: Add IntersectionObserver active section tracking** — Add scroll-based active section highlighting to the ToC using IntersectionObserver.
|
||||
- [x] **T02: Added IntersectionObserver scroll-spy to ToC highlighting the active section as user scrolls through technique page content** — Add scroll-based active section highlighting to the ToC using IntersectionObserver.
|
||||
|
||||
In TableOfContents.tsx:
|
||||
- Add state: const [activeId, setActiveId] = useState<string>('')
|
||||
|
|
|
|||
22
.gsd/milestones/M016/slices/S04/tasks/T01-VERIFY.json
Normal file
22
.gsd/milestones/M016/slices/S04/tasks/T01-VERIFY.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"taskId": "T01",
|
||||
"unitId": "M016/S04/T01",
|
||||
"timestamp": 1775195567361,
|
||||
"passed": true,
|
||||
"discoverySource": "task-plan",
|
||||
"checks": [
|
||||
{
|
||||
"command": "cd frontend",
|
||||
"exitCode": 0,
|
||||
"durationMs": 10,
|
||||
"verdict": "pass"
|
||||
},
|
||||
{
|
||||
"command": "echo 'Build OK'",
|
||||
"exitCode": 0,
|
||||
"durationMs": 8,
|
||||
"verdict": "pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
77
.gsd/milestones/M016/slices/S04/tasks/T02-SUMMARY.md
Normal file
77
.gsd/milestones/M016/slices/S04/tasks/T02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
---
|
||||
id: T02
|
||||
parent: S04
|
||||
milestone: M016
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["frontend/src/components/TableOfContents.tsx", "frontend/src/App.css"]
|
||||
key_decisions: ["Active styles use --color-accent-focus (15% opacity) vs hover's --color-accent-subtle (10% opacity) plus font-weight 500 for visual distinction", "rootMargin 0px 0px -70% 0px triggers active state when section enters top 30% of viewport"]
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "Frontend build passes with zero errors: cd frontend && npm run build (exit 0, 3.4s)"
|
||||
completed_at: 2026-04-03T05:54:07.440Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T02: Added IntersectionObserver scroll-spy to ToC highlighting the active section as user scrolls through technique page content
|
||||
|
||||
> Added IntersectionObserver scroll-spy to ToC highlighting the active section as user scrolls through technique page content
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T02
|
||||
parent: S04
|
||||
milestone: M016
|
||||
key_files:
|
||||
- frontend/src/components/TableOfContents.tsx
|
||||
- frontend/src/App.css
|
||||
key_decisions:
|
||||
- Active styles use --color-accent-focus (15% opacity) vs hover's --color-accent-subtle (10% opacity) plus font-weight 500 for visual distinction
|
||||
- rootMargin 0px 0px -70% 0px triggers active state when section enters top 30% of viewport
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-04-03T05:54:07.440Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T02: Added IntersectionObserver scroll-spy to ToC highlighting the active section as user scrolls through technique page content
|
||||
|
||||
**Added IntersectionObserver scroll-spy to ToC highlighting the active section as user scrolls through technique page content**
|
||||
|
||||
## What Happened
|
||||
|
||||
Added useState for activeId and a useEffect that creates an IntersectionObserver watching all section/subsection heading elements. The observer uses rootMargin '0px 0px -70% 0px' so a section is considered active when it enters the top 30% of the viewport. On intersection, the topmost intersecting entry wins. The active ID drives conditional class names on each link. Added two CSS rules for the active state using --color-accent-focus background and font-weight 500, visually distinct from the hover state.
|
||||
|
||||
## Verification
|
||||
|
||||
Frontend build passes with zero errors: cd frontend && npm run build (exit 0, 3.4s)
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 3400ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
Added non-null assertion on intersecting[0] after length > 0 guard — TypeScript couldn't narrow through .sort() chain.
|
||||
|
||||
## Known Issues
|
||||
|
||||
None.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `frontend/src/components/TableOfContents.tsx`
|
||||
- `frontend/src/App.css`
|
||||
|
||||
|
||||
## Deviations
|
||||
Added non-null assertion on intersecting[0] after length > 0 guard — TypeScript couldn't narrow through .sort() chain.
|
||||
|
||||
## Known Issues
|
||||
None.
|
||||
|
|
@ -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