fix: admin dropdown hover gap bridge + recent card footer layout (creator left, moments, date right)

This commit is contained in:
jlightner 2026-04-03 05:07:06 +00:00
parent b4bea10067
commit e52506511d
4 changed files with 338 additions and 17 deletions

View file

@ -0,0 +1,142 @@
# M016: UX Polish, Brand & Reading Experience
> **Stream:** Frontend — intended for a dedicated GSD milestone instance
> **Conflict zone:** `frontend/src/` only — no backend Python changes
> **Deploy cadence:** commit-build-redeploy after each slice completion
---
## Goal
Modernize the public site's visual identity and reading experience, fix pipeline admin UI bugs, and establish a brand baseline (logo, favicon, OG tags). Every change in this milestone lives entirely in the frontend — CSS, React components, and static assets.
---
## Slice Breakdown
### S01: Landing Page Visual Fixes (Quick Wins)
**Risk:** Low | **Effort:** Small | **Files:** `App.css`, `Home.tsx`
Research found 5 concrete bugs/inconsistencies on the homepage:
| # | Issue | Fix |
|---|-------|-----|
| 1 | Duplicate `.btn` rule at App.css:3185 overrides CTA sizing (renders 131x38 instead of ~195x48) | Remove or merge the duplicate `.btn` block |
| 2 | `.home-featured` uses `border-image` which kills `border-radius` — card renders square | Replace with pseudo-element gradient border technique |
| 3 | Three different `max-width` tracks (36rem, 42rem, none) create jagged center column | Unify to 42rem for all content sections |
| 4 | Vertical spacing irregular — Random→Featured gap is only 8px vs 24px elsewhere | Normalize section margins to 1.5rem |
| 5 | Two `border-radius` values (0.5rem vs 0.625rem) on home cards | Unify to 0.625rem |
**Verification:** Visual screenshot comparison before/after on desktop (1280px) and mobile (375px).
---
### S02: Pipeline Admin UI Fixes
**Risk:** Low | **Effort:** Small-Medium | **Files:** `AdminPipeline.tsx`, `App.css`
Four issues identified with root causes already diagnosed:
| # | Issue | Root Cause | Fix |
|---|-------|-----------|-----|
| 1 | Most-recent run won't collapse / flickers | `expandedRunId` in `load()` useCallback dependency array (line 729) causes race condition — collapsing sets null, which triggers load recreation, which re-expands | Remove `expandedRunId` from dependency array + use `useRef` for initial-load tracking |
| 2 | Mobile job cards show vertical text ("C h e e") | `.pipeline-video__creator` missing overflow rules (App.css:4477) | Add `overflow: hidden; text-overflow: ellipsis; white-space: nowrap` (matches `.pipeline-video__filename` pattern) |
| 3 | No stage direction chevrons | Pipeline stages listed without visual flow indicator | Add CSS chevron/arrow between stage indicators using `::after` pseudo-elements or inline SVG |
| 4 | Filter text box should be replaced with button group | Current text input for status filter; should be "ALL \| Not Started \| In Progress \| Complete" buttons, end-aligned | Replace `<input>` with `<div className="filter-buttons">` flexbox, `justify-content: flex-end`, verify vertical alignment against adjacent elements |
**Verification:** Test collapse toggle on most-recent run, resize to 375px and check creator name truncation, confirm chevrons render between stages, confirm filter buttons align right and sit level with row.
---
### S03: Brand Minimum (Favicon, OG Tags, Logo)
**Risk:** Low | **Effort:** Small | **Files:** `index.html`, `App.tsx`, `App.css`, new static assets
The site currently has:
- No favicon (browser default icon)
- No OG meta tags (no preview image when sharing URL via text/Discord)
- No logo next to "Chrysopedia" in the header
Tasks:
1. **Design a simple logo** — something that fits the dark theme + cyan accent aesthetic. Could be a stylized book/page icon, a knowledge/crystal motif matching the "chryso-" (gold) prefix, or an abstract mark. Generate an SVG.
2. **Add favicon** — export logo as favicon.ico + apple-touch-icon + 192/512 PNG for PWA manifest
3. **Add OG meta tags**`og:title`, `og:description`, `og:image`, `og:url`, `twitter:card` in index.html. Create a 1200x630 OG image using the logo + brand colors.
4. **Place logo in header** — render the SVG inline next to "Chrysopedia" text with appropriate sizing
**Verification:** Share URL in Discord/iMessage and confirm preview card renders. Check favicon in browser tab. Visual check logo in header.
---
### S04: ToC Modernization
**Risk:** Medium | **Effort:** Medium | **Files:** `TableOfContents.tsx`, `App.css`, `TechniquePage.tsx`
Research identified these dated elements:
- CSS counter numbering ("1.", "1.2") — biggest offender
- Boxed card container with solid border
- Uppercase "CONTENTS" label
- No active-section highlighting
- Underline-only hover states
Modernization plan:
1. **Remove numbered counters** — switch to unordered list with clean indentation
2. **Replace box border** with left accent bar (`border-left: 2px solid var(--color-accent)`)
3. **Change heading** from "CONTENTS" to "On this page" in sentence case
4. **Add hover background** (`rgba(34, 211, 238, 0.08)`) instead of underline
5. **Add IntersectionObserver** — track which section heading is in the viewport, highlight the corresponding ToC entry with accent left-border + brighter text color
6. **Make ToC sticky** — position it at the top of the existing sidebar (above Key Moments), `position: sticky; top: 1.5rem`
**Verification:** Navigate to a technique page with 4+ sections. Scroll through — ToC should highlight current section. ToC stays visible in sidebar while scrolling. Hover states work. No numbering visible.
---
### S05: Sticky Reading Header
**Risk:** Medium | **Effort:** Medium | **Files:** new `ReadingHeader.tsx`, `App.css`, `TechniquePage.tsx`
New component that slides in when user scrolls past the article title:
- Shows: article title (truncated) + current section name
- `position: sticky; top: 0; z-index: 50`
- Thin bar (~40px height), `var(--color-bg-header)` background, subtle bottom border
- Hidden by default, slides in via `transform: translateY(-100%)``translateY(0)` transition
- Uses IntersectionObserver on the technique header element as show/hide trigger
- Shares the section-tracking observer from S04's ToC work
- On mobile: compact single-line with optional dropdown for section jump
- Update `scroll-margin-top` values on section anchors to account for new header height
**Verification:** Open long technique page. Scroll past title — reading header appears. Correct section name updates as you scroll. Works on mobile (375px). Doesn't break existing header.
---
### S06: Landing Page Personality Pass
**Risk:** Low | **Effort:** Small | **Files:** `Home.tsx`, `App.css`
After S01 fixes the bugs, this slice adds polish:
1. **Hero tightening** — reduce hero bottom padding and how-it-works top margin to get content above the fold faster (~40-50px reclaim)
2. **Stats scorecard enhancement** — animated count-up on first view (simple `requestAnimationFrame` counter), subtle glow on numbers
3. **Random button treatment** — wrap in a small card with "Feeling adventurous?" tagline, or embed as secondary action inside Trending Searches
4. **Section heading standardization** — pick one treatment (title-case with left accent bar) and apply consistently to "Recently Added", "Trending Searches", "Popular Topics"
5. **Header brand accent** — apply `color: var(--color-accent)` or subtle gradient to "Chrysopedia" text (pairs with S03 logo)
**Verification:** Visual check desktop + mobile. Stats animate on page load. Section headings consistent. Content peeks above fold on standard viewport.
---
## Dependency Graph
```
S01 (landing fixes) ──→ S06 (personality pass)
S02 (pipeline fixes) [independent]
S03 (brand minimum) ──→ S06 (header accent uses logo)
S04 (ToC modern) ──→ S05 (reading header shares IntersectionObserver pattern)
```
S01, S02, S03, S04 can all start in parallel. S05 depends on S04. S06 depends on S01 + S03.
Recommended execution order: **S01 → S02 → S03 → S04 → S05 → S06**
---
## Out of Scope (for this milestone)
- Creator landing page redesign (depends on backend social links API — see M017)
- Auto-avatar images (backend-gated — see M017)
- Embed tab (needs backend investigation first — see M017)
- Any backend Python changes
- M015 S04/S05 leftovers (trending searches block, admin dropdown hover) — should be completed by M015's own GSD session first

View file

@ -0,0 +1,164 @@
# M017: Backend Performance & Creator Features
> **Stream:** Backend — intended for a separate Claude Code session (not GSD)
> **Conflict zone:** `backend/` only + one frontend API client fix
> **Deploy cadence:** commit-build-redeploy after each task group
---
## Goal
Fix critical performance bottlenecks in the admin pipeline API, implement auto-avatar fetching for creators, and lay the backend groundwork for creator landing page improvements. Almost entirely backend Python — minimal frontend changes.
---
## Task Groups (execute sequentially)
### 1. Critical Fixes (do first, immediate impact)
#### 1a. Fix `worker-status` async event loop blocking
**File:** `backend/routers/pipeline.py` lines 1266-1313
**Problem:** The endpoint is `async def` but calls three synchronous Celery inspect methods (`inspector.active()`, `.reserved()`, `.stats()`), each with a 1-second timeout. This blocks the entire uvicorn event loop for ~3 seconds, stalling ALL concurrent API requests.
**Evidence:** Every parallel API call during page load takes ~3,024ms instead of their natural 6-38ms.
**Fix:**
```python
import asyncio
active = await asyncio.to_thread(inspector.active) or {}
reserved = await asyncio.to_thread(inspector.reserved) or {}
stats = await asyncio.to_thread(inspector.stats) or {}
```
Also consider: reduce inspect timeout to 0.5s, add Redis cache with 10-15s TTL to avoid repeated slow calls.
**Impact:** Page load drops from ~3s to ~50ms.
#### 1b. Fix creators endpoint 422 error
**File (frontend):** `frontend/src/pages/AdminPipeline.tsx` line 1126
**File (backend):** `backend/routers/creators.py` line 28
**Problem:** Frontend requests `fetchCreators({ limit: 200 })` but backend validates `le=100`. Returns 422 on every pipeline page load — creator filter dropdown never populates.
**Fix:** Change frontend call to `limit: 100`. (This is the ONE frontend file touch in this stream.)
---
### 2. Pipeline API Performance
#### 2a. Rewrite `stale-pages` to eliminate N+1 queries
**File:** `backend/routers/pipeline.py` lines 906-973
**Problem:** Loads ALL technique pages, then runs a separate query per page for latest version + another for creator name. Currently 44 extra queries for 22 pages. Fast today (~30ms) but degrades linearly.
**Fix:** Single query joining `technique_pages` with a lateral/window subquery for latest version + join to creators.
#### 2b. Add pagination to videos endpoint
**File:** `backend/routers/pipeline.py` lines 72-188
**Problem:** Returns all 43 videos (23KB) with no offset/limit. Client-side filtering only.
**Fix:** Add `offset`, `limit`, `status`, `creator_id` query params. Return paginated response with `total` count. Frontend can adopt server-side filtering later (or the M016 frontend stream can wire it up).
#### 2c. Optimize `_find_dynamic_related` for technique pages
**File:** `backend/routers/techniques.py` lines 33-111
**Problem:** Loads ALL technique pages into memory to score relatedness in Python. O(n) in total page count.
**Fix:** Move scoring to SQL (keyword overlap via `ts_rank` or simple tag intersection) or cache related links per technique page with invalidation on new page creation.
---
### 3. Auto-Avatar Integration (TheAudioDB)
Research concluded: **TheAudioDB is the best first source** — free, no OAuth, no caching restrictions, decent coverage for established artists.
#### 3a. Database migration
Add to `Creator` model:
- `avatar_url: String | None` — stored image URL or local path
- `avatar_source: String` — enum: `"generated"`, `"theaudiodb"`, `"manual"`
- `avatar_fetched_at: DateTime | None` — for cache invalidation
Alembic migration (will be 014 or later depending on M015 state).
#### 3b. TheAudioDB lookup service
New file: `backend/services/avatar.py`
- `async def fetch_avatar(creator_name: str, creator_genres: list[str]) -> AvatarResult | None`
- Calls `https://www.theaudiodb.com/api/v1/json/{key}/search.php?s={name}`
- Confidence scoring: name match via `thefuzz.fuzz.token_sort_ratio` (threshold ≥ 85%), genre overlap as tiebreaker
- Returns `strArtistThumb` URL if confident match, None otherwise
- Handle: no results, multiple results, missing image fields
#### 3c. Celery worker task
New task: `tasks.fetch_creator_avatar`
- Called on creator creation or manually via admin endpoint
- Runs TheAudioDB lookup → downloads image → stores locally (or stores URL)
- Updates `Creator.avatar_url`, `avatar_source`, `avatar_fetched_at`
- Falls back gracefully — if no match, leaves fields null (frontend already renders generated SVG as fallback)
#### 3d. Admin endpoint for manual trigger
`POST /admin/pipeline/creators/{id}/fetch-avatar` — triggers the worker task for a specific creator.
`POST /admin/pipeline/creators/fetch-all-avatars` — batch trigger for all creators missing avatars.
#### 3e. Wire avatar_url into creators API responses
Add `avatar_url` to `CreatorBrowseItem` and `CreatorDetail` schemas. The frontend `CreatorAvatar` component already accepts an `imageUrl` prop — it will just work once the API returns it.
---
### 4. Creator Landing Page API Groundwork
#### 4a. Social links model + migration
Add to `Creator` model:
- `social_links: JSON | None` — structured as `{"spotify": "url", "instagram": "url", "bandcamp": "url", "website": "url", ...}`
- `bio: Text | None` — short creator bio/description
- `featured: Boolean` — flag for homepage featuring
#### 4b. Creator detail endpoint enhancement
Expand `GET /api/v1/creators/{slug}` to return:
- `social_links`
- `bio`
- `avatar_url`
- `technique_count`
- Full technique list with titles, slugs, created_at
- Genre breakdown
#### 4c. Admin endpoint for creator profile editing
`PUT /admin/pipeline/creators/{id}` — update `bio`, `social_links`, `featured` flag, manually set `avatar_url`.
---
### 5. Embed Tab Investigation
The "Embed" tab under pipeline jobs is non-functional. Before building, need to:
- Read the existing frontend component to understand what it expects
- Determine what "Embed" should show (embedding vectors? embed codes? embedded content?)
- If it's about Qdrant vector embeddings: add an endpoint to query embedding status per technique page
- If it's about iframe embed codes: generate shareable snippet per technique
**This task starts as investigation — scope will be defined after reading the code.**
---
## General Load Time Optimization (apply throughout)
As each endpoint is touched, also consider:
- Add `Cache-Control` headers for public GET endpoints (technique pages, creators, search suggestions)
- Add Redis caching (30s-5min TTL) for expensive or frequently-hit endpoints
- Ensure database indexes exist on commonly filtered/sorted columns
- Consider adding `select_in_loading` for SQLAlchemy relationships to avoid implicit lazy loads
---
## Key Files
| File | What changes |
|------|-------------|
| `backend/routers/pipeline.py` | worker-status async fix, stale-pages rewrite, videos pagination, avatar endpoints |
| `backend/routers/creators.py` | Creator detail expansion, social links |
| `backend/routers/techniques.py` | Related techniques optimization |
| `backend/models.py` | Creator model additions (avatar, social_links, bio) |
| `backend/schemas.py` | New response schemas |
| `backend/services/avatar.py` | New — TheAudioDB integration |
| `backend/tasks.py` | New avatar fetch task |
| `alembic/versions/014_*.py` | Migration for creator columns |
| `frontend/src/pages/AdminPipeline.tsx` | Line 1126 only — fix limit: 200 → 100 |
---
## Merge Coordination with M016
These two streams are designed to have minimal file overlap:
- **M016 touches:** `App.css`, `Home.tsx`, `TechniquePage.tsx`, `TableOfContents.tsx`, `AdminPipeline.tsx` (CSS/JSX only), new frontend components, static assets
- **M017 touches:** `backend/` (routers, models, schemas, services, tasks), `alembic/`, one line in `AdminPipeline.tsx`
The single conflict point is `AdminPipeline.tsx` — M017's creators limit fix (line 1126) vs M016's pipeline UI fixes (collapse bug at line 729, filter buttons, chevrons). Resolve by merging M017's one-liner first, then M016's broader changes on top.
For the avatar/social-links frontend wiring: M017 ships the API, M016 (or a follow-up) consumes it. No conflict — just sequencing.

View file

@ -1028,6 +1028,16 @@ a.app-footer__repo:hover {
padding: 0.375rem 0; padding: 0.375rem 0;
} }
/* Bridge the gap between trigger and menu so hover doesn't break */
.admin-dropdown__menu::before {
content: '';
position: absolute;
top: -0.5rem;
right: 0;
width: 100%;
height: 0.5rem;
}
.admin-dropdown__item { .admin-dropdown__item {
display: block; display: block;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
@ -5097,13 +5107,9 @@ a.app-footer__repo:hover {
} }
.recent-card__creator { .recent-card__creator {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
white-space: nowrap; white-space: nowrap;
flex-shrink: 0;
} }
.recent-card__header-right { .recent-card__header-right {
@ -5114,11 +5120,22 @@ a.app-footer__repo:hover {
flex-shrink: 0; flex-shrink: 0;
} }
.recent-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-top: 0.25rem;
padding-top: 0.375rem;
border-top: 1px solid var(--color-border);
}
.recent-card__date { .recent-card__date {
font-size: 0.7rem; font-size: 0.7rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
opacity: 0.65; opacity: 0.65;
white-space: nowrap; white-space: nowrap;
margin-left: auto;
} }
/* ── Search result card creator ───────────────────────────────────────── */ /* ── Search result card creator ───────────────────────────────────────── */

View file

@ -310,19 +310,7 @@ export default function Home() {
className="recent-card card-stagger" className="recent-card card-stagger"
style={{ '--stagger-index': i } as React.CSSProperties} style={{ '--stagger-index': i } as React.CSSProperties}
> >
<span className="recent-card__header">
<span className="recent-card__title">{t.title}</span> <span className="recent-card__title">{t.title}</span>
<span className="recent-card__header-right">
{t.creator_name && (
<span className="recent-card__creator">{t.creator_name}</span>
)}
{t.created_at && (
<span className="recent-card__date">
{new Date(t.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
)}
</span>
</span>
<span className="recent-card__meta"> <span className="recent-card__meta">
<span className="badge badge--category"> <span className="badge badge--category">
{t.topic_category} {t.topic_category}
@ -337,11 +325,21 @@ export default function Home() {
: t.summary} : t.summary}
</span> </span>
)} )}
</span>
<span className="recent-card__footer">
<span className="recent-card__creator">
{t.creator_name || ''}
</span>
{t.key_moment_count > 0 && ( {t.key_moment_count > 0 && (
<span className="recent-card__moments"> <span className="recent-card__moments">
{t.key_moment_count} moment{t.key_moment_count !== 1 ? 's' : ''} {t.key_moment_count} moment{t.key_moment_count !== 1 ? 's' : ''}
</span> </span>
)} )}
{t.created_at && (
<span className="recent-card__date">
{new Date(t.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
)}
</span> </span>
</Link> </Link>
))} ))}