diff --git a/alembic/versions/015_add_creator_profile.py b/alembic/versions/015_add_creator_profile.py new file mode 100644 index 0000000..451120e --- /dev/null +++ b/alembic/versions/015_add_creator_profile.py @@ -0,0 +1,25 @@ +"""Add bio, social_links, and featured columns to creators table. + +Revision ID: 015_add_creator_profile +Revises: 014_add_creator_avatar +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + +revision = "015_add_creator_profile" +down_revision = "014_add_creator_avatar" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("creators", sa.Column("bio", sa.Text(), nullable=True)) + op.add_column("creators", sa.Column("social_links", JSONB(), nullable=True)) + op.add_column("creators", sa.Column("featured", sa.Boolean(), server_default="false", nullable=False)) + + +def downgrade() -> None: + op.drop_column("creators", "featured") + op.drop_column("creators", "social_links") + op.drop_column("creators", "bio") diff --git a/backend/models.py b/backend/models.py index 6b6e497..e4aeb46 100644 --- a/backend/models.py +++ b/backend/models.py @@ -106,6 +106,9 @@ class Creator(Base): avatar_url: Mapped[str | None] = mapped_column(String(1000), nullable=True) avatar_source: Mapped[str | None] = mapped_column(String(50), nullable=True) avatar_fetched_at: Mapped[datetime | None] = mapped_column(nullable=True) + bio: Mapped[str | None] = mapped_column(Text, nullable=True) + social_links: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + featured: Mapped[bool] = mapped_column(default=False, server_default="false") view_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0") hidden: Mapped[bool] = mapped_column(default=False, server_default="false") created_at: Mapped[datetime] = mapped_column( diff --git a/backend/routers/creators.py b/backend/routers/creators.py index b38ec40..a82faa0 100644 --- a/backend/routers/creators.py +++ b/backend/routers/creators.py @@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from database import get_session from models import Creator, SourceVideo, TechniquePage -from schemas import CreatorBrowseItem, CreatorDetail, CreatorRead +from schemas import CreatorBrowseItem, CreatorDetail, CreatorRead, CreatorTechniqueItem logger = logging.getLogger("chrysopedia.creators") @@ -111,7 +111,11 @@ async def get_creator( slug: str, db: AsyncSession = Depends(get_session), ) -> CreatorDetail: - """Get a single creator by slug, including video count.""" + """Get a single creator by slug with full profile data. + + Returns video/technique counts, full technique list, genre breakdown, + bio, social links, and avatar for the creator landing page. + """ stmt = select(Creator).where(Creator.slug == slug) result = await db.execute(stmt) creator = result.scalar_one_or_none() @@ -119,14 +123,46 @@ async def get_creator( if creator is None: raise HTTPException(status_code=404, detail=f"Creator '{slug}' not found") - # Count videos for this creator - count_stmt = ( - select(func.count()) - .select_from(SourceVideo) + # Video count + video_count = (await db.execute( + select(func.count()).select_from(SourceVideo) .where(SourceVideo.creator_id == creator.id) - ) - count_result = await db.execute(count_stmt) - video_count = count_result.scalar() or 0 + )).scalar() or 0 + + # Technique pages for this creator + technique_rows = (await db.execute( + select( + TechniquePage.title, + TechniquePage.slug, + TechniquePage.topic_category, + TechniquePage.created_at, + ) + .where(TechniquePage.creator_id == creator.id) + .order_by(TechniquePage.created_at.desc()) + )).all() + + techniques = [ + CreatorTechniqueItem( + title=t.title, slug=t.slug, + topic_category=t.topic_category, created_at=t.created_at, + ) + for t in technique_rows + ] + + # Genre breakdown across this creator's techniques + genre_breakdown: dict[str, int] = {} + for t in technique_rows: + cat = t.topic_category + genre_breakdown[cat] = genre_breakdown.get(cat, 0) + 1 creator_data = CreatorRead.model_validate(creator) - return CreatorDetail(**creator_data.model_dump(), video_count=video_count) + return CreatorDetail( + **creator_data.model_dump(), + bio=creator.bio, + social_links=creator.social_links, + featured=creator.featured, + video_count=video_count, + technique_count=len(techniques), + techniques=techniques, + genre_breakdown=genre_breakdown, + ) diff --git a/backend/routers/pipeline.py b/backend/routers/pipeline.py index d685631..f545632 100644 --- a/backend/routers/pipeline.py +++ b/backend/routers/pipeline.py @@ -1306,6 +1306,41 @@ async def reindex_all( } +# ── Admin: Creator profile editing ─────────────────────────────────────────── + +@router.put("/admin/pipeline/creators/{creator_id}") +async def update_creator_profile( + creator_id: str, + body: dict, + db: AsyncSession = Depends(get_session), +): + """Update creator profile fields (bio, social_links, featured, avatar_url). + + Only provided fields are updated — omitted fields are left unchanged. + """ + creator = (await db.execute( + select(Creator).where(Creator.id == creator_id) + )).scalar_one_or_none() + if not creator: + raise HTTPException(status_code=404, detail="Creator not found") + + updatable = {"bio", "social_links", "featured", "avatar_url"} + updated = [] + for field in updatable: + if field in body: + setattr(creator, field, body[field]) + updated.append(field) + + if not updated: + raise HTTPException(status_code=400, detail="No updatable fields provided") + + if "avatar_url" in body and body["avatar_url"]: + creator.avatar_source = "manual" + + await db.commit() + return {"status": "updated", "creator": creator.name, "fields": updated} + + # ── Admin: Avatar fetching ─────────────────────────────────────────────────── @router.post("/admin/pipeline/creators/{creator_id}/fetch-avatar") diff --git a/backend/schemas.py b/backend/schemas.py index 9f4d11d..66b374b 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -43,9 +43,31 @@ class CreatorRead(CreatorBase): updated_at: datetime +class CreatorTechniqueItem(BaseModel): + """Minimal technique page info for creator detail.""" + title: str + slug: str + topic_category: str + created_at: datetime + + class CreatorDetail(CreatorRead): - """Creator with nested video count.""" + """Full creator profile for landing page.""" + bio: str | None = None + social_links: dict | None = None + featured: bool = False video_count: int = 0 + technique_count: int = 0 + techniques: list[CreatorTechniqueItem] = [] + genre_breakdown: dict[str, int] = {} + + +class CreatorProfileUpdate(BaseModel): + """Admin update payload for creator profile fields.""" + bio: str | None = None + social_links: dict | None = None + featured: bool | None = None + avatar_url: str | None = None # ── SourceVideo ────────────────────────────────────────────────────────────── diff --git a/frontend/src/App.css b/frontend/src/App.css index 856c24a..f6d77fb 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -204,6 +204,7 @@ body { padding: 0.75rem 1.5rem; background: var(--color-bg-header); color: var(--color-text-on-header-hover); + border-bottom: 2px solid var(--color-accent); } .app-header__brand span { @@ -1067,11 +1068,26 @@ a.app-footer__repo:hover { background: var(--color-bg-surface-hover); } +/* ── Section Heading Utility ───────────────────────────────────────────────── */ + +.section-heading { + font-size: 0.8125rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-muted); + margin: 0 0 0.75rem; +} + +.section-heading--accent { + color: var(--color-accent); +} + /* ── Home / Hero ──────────────────────────────────────────────────────────── */ .home-hero { text-align: center; - padding: 2rem 1rem 2.5rem; + padding: 1.5rem 1rem 1.5rem; } .home-hero__title { @@ -1089,7 +1105,7 @@ a.app-footer__repo:hover { .home-hero__value-prop { max-width: 32rem; - margin: 1.5rem auto 0; + margin: 1rem auto 0; font-size: 1.0625rem; line-height: 1.6; color: var(--color-text-secondary); @@ -1100,9 +1116,9 @@ a.app-footer__repo:hover { .home-how-it-works { display: grid; grid-template-columns: repeat(3, 1fr); - gap: 1.25rem; + gap: 0.75rem; max-width: 42rem; - margin: 2rem auto 0; + margin: 1.25rem auto 0; } .home-how-it-works__step { @@ -1167,12 +1183,8 @@ a.app-footer__repo:hover { } .home-popular-topics__title { - font-size: 0.875rem; - font-weight: 600; + /* inherits .section-heading — section-specific overrides only */ color: var(--color-text-secondary); - text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: 0.75rem; } .home-popular-topics__list { @@ -1532,11 +1544,7 @@ a.app-footer__repo:hover { } .home-featured__label { - font-size: 0.6875rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-accent); + /* inherits .section-heading + .section-heading--accent — section-specific overrides only */ margin-bottom: 0.5rem; } @@ -1594,9 +1602,7 @@ a.app-footer__repo:hover { } .recent-section__title { - font-size: 1.125rem; - font-weight: 700; - margin-bottom: 0.75rem; + /* inherits .section-heading — no section-specific overrides */ } .recent-list { @@ -3383,12 +3389,7 @@ a.app-footer__repo:hover { } .home-trending__title { - font-size: 0.75rem; - color: var(--color-text-muted); - text-transform: uppercase; - letter-spacing: 0.08em; - margin: 0 0 0.75rem; - font-weight: 600; + /* inherits .section-heading — no section-specific overrides */ } .home-trending__list { diff --git a/frontend/src/hooks/useCountUp.ts b/frontend/src/hooks/useCountUp.ts new file mode 100644 index 0000000..bf7c27d --- /dev/null +++ b/frontend/src/hooks/useCountUp.ts @@ -0,0 +1,81 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Animates a number from 0 → target using requestAnimationFrame with ease-out timing. + * Animation triggers when the referenced element enters the viewport (IntersectionObserver). + * + * @param target - The number to animate to. If it changes from 0 to a positive value, + * the animation resets and re-triggers. + * @param duration - Animation duration in ms (default 1200). + * @returns `{ value, ref }` — current display value and a ref to attach to the observed element. + */ +export function useCountUp( + target: number, + duration = 1200, +): { value: number; ref: React.RefObject } { + const [value, setValue] = useState(0); + const ref = useRef(null); + const rafId = useRef(null); + const hasAnimated = useRef(false); + const isVisible = useRef(false); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting) { + isVisible.current = true; + // Trigger animation if we have a real target and haven't animated yet + if (target > 0 && !hasAnimated.current) { + startAnimation(); + } + } + }, + { threshold: 0.3 }, + ); + observer.observe(el); + + function startAnimation() { + hasAnimated.current = true; + const start = performance.now(); + + function tick(now: number) { + const elapsed = now - start; + const t = Math.min(elapsed / duration, 1); + // Ease-out cubic: 1 - (1 - t)^3 + const eased = 1 - Math.pow(1 - t, 3); + setValue(Math.round(eased * target)); + + if (t < 1) { + rafId.current = requestAnimationFrame(tick); + } + } + + rafId.current = requestAnimationFrame(tick); + } + + // If already visible when target arrives (e.g., stats loaded after scroll) + if (isVisible.current && target > 0 && !hasAnimated.current) { + startAnimation(); + } + + return () => { + observer.disconnect(); + if (rafId.current !== null) { + cancelAnimationFrame(rafId.current); + } + }; + }, [target, duration]); + + // Reset when target changes from 0 → positive (async data load) + useEffect(() => { + if (target === 0) { + hasAnimated.current = false; + setValue(0); + } + }, [target]); + + return { value, ref }; +} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 0ef31c7..09b84d0 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -10,6 +10,7 @@ import SearchAutocomplete from "../components/SearchAutocomplete"; import TagList from "../components/TagList"; import { useEffect, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; +import { useCountUp } from "../hooks/useCountUp"; import { useDocumentTitle } from "../hooks/useDocumentTitle"; import { fetchTechniques, @@ -34,6 +35,10 @@ export default function Home() { const [trending, setTrending] = useState(null); const navigate = useNavigate(); + // Animated stat counters — called unconditionally, target updates when stats load + const techniqueCount = useCountUp(stats?.technique_count ?? 0); + const creatorCount = useCountUp(stats?.creator_count ?? 0); + const handleRandomTechnique = async () => { setRandomLoading(true); setRandomError(false); @@ -184,7 +189,7 @@ export default function Home() { {popularTopics.length > 0 && (
-

Popular Topics

+

Popular Topics

{popularTopics.map((topic) => ( +
- {stats.technique_count} + {techniqueCount.value} Articles
-
- {stats.creator_count} +
+ {creatorCount.value} Creators
@@ -233,7 +245,7 @@ export default function Home() { {/* Trending Searches */} {trending && trending.length > 0 && (
-

Trending Searches

+

Trending Searches

{trending.map((item, i) => ( -

Featured Technique

+

Featured Technique

{featured.title} @@ -293,7 +305,7 @@ export default function Home() { {/* Recently Added */}
-

Recently Added

+

Recently Added

{recentLoading ? (
Loading…
) : recent.length === 0 ? ( diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index dc521b4..32fedc2 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReadingHeader.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/hooks/useDocumentTitle.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReadingHeader.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"} \ No newline at end of file