diff --git a/alembic/versions/022_add_creator_follows.py b/alembic/versions/022_add_creator_follows.py new file mode 100644 index 0000000..411a3bc --- /dev/null +++ b/alembic/versions/022_add_creator_follows.py @@ -0,0 +1,31 @@ +"""Add creator_follows table for user follow system. + +Revision ID: 022_add_creator_follows +Revises: 021_add_highlight_trim_columns +""" + +from alembic import op + + +revision = "022_add_creator_follows" +down_revision = "021_add_highlight_trim_columns" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + CREATE TABLE IF NOT EXISTS creator_follows ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + creator_id UUID NOT NULL REFERENCES creators(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT uq_creator_follow_user_creator UNIQUE (user_id, creator_id) + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_creator_follows_user_id ON creator_follows (user_id)") + op.execute("CREATE INDEX IF NOT EXISTS ix_creator_follows_creator_id ON creator_follows (creator_id)") + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS creator_follows") diff --git a/backend/main.py b/backend/main.py index 69b2f65..3d6958e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,7 +12,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from config import get_settings -from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creator_highlights, creators, health, highlights, ingest, pipeline, reports, search, stats, techniques, topics, videos +from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creator_highlights, creators, follows, health, highlights, ingest, pipeline, reports, search, stats, techniques, topics, videos def _setup_logging() -> None: @@ -86,6 +86,7 @@ app.include_router(creator_dashboard.router, prefix="/api/v1") app.include_router(creator_chapters.router, prefix="/api/v1") app.include_router(creator_highlights.router, prefix="/api/v1") app.include_router(creators.router, prefix="/api/v1") +app.include_router(follows.router, prefix="/api/v1") app.include_router(highlights.router, prefix="/api/v1") app.include_router(ingest.router, prefix="/api/v1") app.include_router(pipeline.router, prefix="/api/v1") diff --git a/backend/models.py b/backend/models.py index de30624..cd9a7f1 100644 --- a/backend/models.py +++ b/backend/models.py @@ -737,3 +737,28 @@ class HighlightCandidate(Base): # relationships key_moment: Mapped[KeyMoment] = sa_relationship() source_video: Mapped[SourceVideo] = sa_relationship() + + +# ── Follow System ──────────────────────────────────────────────────────────── + +class CreatorFollow(Base): + """A user following a creator.""" + __tablename__ = "creator_follows" + __table_args__ = ( + UniqueConstraint("user_id", "creator_id", name="uq_creator_follow_user_creator"), + ) + + id: Mapped[uuid.UUID] = _uuid_pk() + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, + ) + creator_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("creators.id", ondelete="CASCADE"), nullable=False, index=True, + ) + created_at: Mapped[datetime] = mapped_column( + default=_now, server_default=func.now() + ) + + # relationships + user: Mapped[User] = sa_relationship() + creator: Mapped[Creator] = sa_relationship() diff --git a/backend/routers/creators.py b/backend/routers/creators.py index 79365f0..2d2cd0a 100644 --- a/backend/routers/creators.py +++ b/backend/routers/creators.py @@ -12,7 +12,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from database import get_session -from models import Creator, KeyMoment, SourceVideo, TechniquePage +from models import Creator, CreatorFollow, KeyMoment, SourceVideo, TechniquePage from schemas import CreatorBrowseItem, CreatorDetail, CreatorRead, CreatorTechniqueItem logger = logging.getLogger("chrysopedia.creators") @@ -175,6 +175,13 @@ async def get_creator( genre_breakdown[cat] = genre_breakdown.get(cat, 0) + 1 creator_data = CreatorRead.model_validate(creator) + + # Follower count + follower_count = (await db.execute( + select(func.count()).select_from(CreatorFollow) + .where(CreatorFollow.creator_id == creator.id) + )).scalar() or 0 + return CreatorDetail( **creator_data.model_dump(), bio=creator.bio, @@ -182,6 +189,8 @@ async def get_creator( featured=creator.featured, video_count=video_count, technique_count=len(techniques), + moment_count=moment_count, techniques=techniques, genre_breakdown=genre_breakdown, + follower_count=follower_count, ) diff --git a/backend/routers/follows.py b/backend/routers/follows.py new file mode 100644 index 0000000..e8fb757 --- /dev/null +++ b/backend/routers/follows.py @@ -0,0 +1,116 @@ +"""Follow system — users follow creators. + +Endpoints: + POST /follows/{creator_id} — follow a creator + DELETE /follows/{creator_id} — unfollow a creator + GET /follows/{creator_id}/status — check if following + GET /follows/me — list followed creators +""" + +from __future__ import annotations + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import delete, func, select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.ext.asyncio import AsyncSession + +from auth import get_current_user +from database import get_session +from models import Creator, CreatorFollow, User +from schemas import FollowResponse, FollowStatusResponse, FollowedCreatorItem + +router = APIRouter(prefix="/follows", tags=["follows"]) + + +async def _follower_count(session: AsyncSession, creator_id: uuid.UUID) -> int: + result = await session.execute( + select(func.count()).select_from(CreatorFollow).where( + CreatorFollow.creator_id == creator_id + ) + ) + return result.scalar_one() + + +@router.post("/{creator_id}", response_model=FollowResponse) +async def follow_creator( + creator_id: uuid.UUID, + current_user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """Follow a creator. Idempotent — following twice is a no-op.""" + # Verify creator exists + result = await session.execute(select(Creator).where(Creator.id == creator_id)) + if result.scalar_one_or_none() is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Creator not found") + + # Upsert — idempotent + stmt = pg_insert(CreatorFollow).values( + user_id=current_user.id, + creator_id=creator_id, + ).on_conflict_do_nothing(constraint="uq_creator_follow_user_creator") + await session.execute(stmt) + await session.commit() + + count = await _follower_count(session, creator_id) + return FollowResponse(followed=True, creator_id=creator_id, follower_count=count) + + +@router.delete("/{creator_id}", response_model=FollowResponse) +async def unfollow_creator( + creator_id: uuid.UUID, + current_user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """Unfollow a creator. Idempotent — unfollowing when not following is a no-op.""" + await session.execute( + delete(CreatorFollow).where( + CreatorFollow.user_id == current_user.id, + CreatorFollow.creator_id == creator_id, + ) + ) + await session.commit() + + count = await _follower_count(session, creator_id) + return FollowResponse(followed=False, creator_id=creator_id, follower_count=count) + + +@router.get("/me", response_model=list[FollowedCreatorItem]) +async def my_follows( + current_user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """List all creators the current user follows.""" + result = await session.execute( + select( + CreatorFollow.creator_id, + Creator.name.label("creator_name"), + Creator.slug.label("creator_slug"), + CreatorFollow.created_at.label("followed_at"), + ) + .join(Creator, CreatorFollow.creator_id == Creator.id) + .where(CreatorFollow.user_id == current_user.id) + .order_by(CreatorFollow.created_at.desc()) + ) + return [FollowedCreatorItem(**row._mapping) for row in result.all()] + + +@router.get("/{creator_id}/status", response_model=FollowStatusResponse) +async def follow_status( + creator_id: uuid.UUID, + current_user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """Check if the current user follows a specific creator.""" + result = await session.execute( + select(CreatorFollow.id).where( + CreatorFollow.user_id == current_user.id, + CreatorFollow.creator_id == creator_id, + ) + ) + return FollowStatusResponse( + following=result.scalar_one_or_none() is not None, + creator_id=creator_id, + ) diff --git a/backend/schemas.py b/backend/schemas.py index 9180f28..5a7ad14 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -62,6 +62,7 @@ class CreatorDetail(CreatorRead): video_count: int = 0 technique_count: int = 0 moment_count: int = 0 + follower_count: int = 0 techniques: list[CreatorTechniqueItem] = [] genre_breakdown: dict[str, int] = {} @@ -705,3 +706,28 @@ class ChapterReorderRequest(BaseModel): class ChapterBulkApproveRequest(BaseModel): """Bulk-approve chapters by IDs.""" chapter_ids: list[uuid.UUID] + + +# ── Follow System ──────────────────────────────────────────────────────────── + +class FollowResponse(BaseModel): + """Response after follow/unfollow action.""" + followed: bool + creator_id: uuid.UUID + follower_count: int + + +class FollowStatusResponse(BaseModel): + """Whether the current user is following a creator.""" + following: bool + creator_id: uuid.UUID + + +class FollowedCreatorItem(BaseModel): + """A creator the current user follows.""" + model_config = ConfigDict(from_attributes=True) + + creator_id: uuid.UUID + creator_name: str + creator_slug: str + followed_at: datetime diff --git a/frontend/src/App.css b/frontend/src/App.css index 09e342a..c45a677 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2483,6 +2483,37 @@ a.app-footer__repo:hover { gap: 0.75rem; } +.creator-hero__follow-btn { + background: transparent; + border: 1px solid var(--color-accent); + color: var(--color-accent); + font-size: 0.8rem; + font-weight: 600; + padding: 0.3rem 0.85rem; + border-radius: 20px; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} +.creator-hero__follow-btn:hover { + background: var(--color-accent); + color: var(--color-bg-page); +} +.creator-hero__follow-btn--following { + background: var(--color-accent-subtle); + border-color: var(--color-accent); + color: var(--color-accent); +} +.creator-hero__follow-btn--following:hover { + background: var(--color-error-bg); + border-color: var(--color-error); + color: var(--color-error); +} +.creator-hero__follow-btn:disabled { + opacity: 0.5; + cursor: default; +} + .creator-hero__edit-btn { background: none; border: 1px solid var(--color-border); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f856d47..bade708 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,7 @@ const AdminAuditLog = React.lazy(() => import("./pages/AdminAuditLog")); const ChatPage = React.lazy(() => import("./pages/ChatPage")); const ChapterReview = React.lazy(() => import("./pages/ChapterReview")); const HighlightQueue = React.lazy(() => import("./pages/HighlightQueue")); +const CreatorTiers = React.lazy(() => import("./pages/CreatorTiers")); import AdminDropdown from "./components/AdminDropdown"; import ImpersonationBanner from "./components/ImpersonationBanner"; import AppFooter from "./components/AppFooter"; @@ -205,6 +206,7 @@ function AppShell() { }>} /> }>} /> }>} /> + }>} /> {/* Fallback */} } /> diff --git a/frontend/src/api/creators.ts b/frontend/src/api/creators.ts index 7ddd812..f7e467e 100644 --- a/frontend/src/api/creators.ts +++ b/frontend/src/api/creators.ts @@ -49,6 +49,7 @@ export interface CreatorDetailResponse { avatar_url: string | null; technique_count: number; moment_count: number; + follower_count: number; techniques: CreatorTechniqueItem[]; genre_breakdown: Record; } diff --git a/frontend/src/api/follows.ts b/frontend/src/api/follows.ts new file mode 100644 index 0000000..19d12ba --- /dev/null +++ b/frontend/src/api/follows.ts @@ -0,0 +1,39 @@ +import { request, BASE } from "./client"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface FollowResponse { + followed: boolean; + creator_id: string; + follower_count: number; +} + +export interface FollowStatusResponse { + following: boolean; + creator_id: string; +} + +export interface FollowedCreatorItem { + creator_id: string; + creator_name: string; + creator_slug: string; + followed_at: string; +} + +// ── Functions ──────────────────────────────────────────────────────────────── + +export async function followCreator(creatorId: string): Promise { + return request(`${BASE}/follows/${creatorId}`, { method: "POST" }); +} + +export async function unfollowCreator(creatorId: string): Promise { + return request(`${BASE}/follows/${creatorId}`, { method: "DELETE" }); +} + +export async function getFollowStatus(creatorId: string): Promise { + return request(`${BASE}/follows/${creatorId}/status`); +} + +export async function getMyFollows(): Promise { + return request(`${BASE}/follows/me`); +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index e7e27c4..541ddad 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -15,3 +15,4 @@ export * from "./admin-techniques"; export * from "./auth"; export * from "./creator-dashboard"; export * from "./consent"; +export * from "./follows"; diff --git a/frontend/src/pages/CreatorDashboard.tsx b/frontend/src/pages/CreatorDashboard.tsx index 60a0b6f..e2038d5 100644 --- a/frontend/src/pages/CreatorDashboard.tsx +++ b/frontend/src/pages/CreatorDashboard.tsx @@ -51,6 +51,14 @@ function SidebarNav() { Settings + + + + + + + Tiers + ); } diff --git a/frontend/src/pages/CreatorDetail.tsx b/frontend/src/pages/CreatorDetail.tsx index 5ba13a8..7271e3e 100644 --- a/frontend/src/pages/CreatorDetail.tsx +++ b/frontend/src/pages/CreatorDetail.tsx @@ -10,8 +10,12 @@ import { Link, useParams } from "react-router-dom"; import { fetchCreator, updateCreatorProfile, + followCreator, + unfollowCreator, + getFollowStatus, type CreatorDetailResponse, } from "../api"; +import { useAuth } from "../context/AuthContext"; import CreatorAvatar from "../components/CreatorAvatar"; import { SocialIcon } from "../components/SocialIcons"; import SortDropdown from "../components/SortDropdown"; @@ -40,12 +44,18 @@ const PLATFORM_OPTIONS = [ export default function CreatorDetail() { const { slug } = useParams<{ slug: string }>(); + const { isAuthenticated } = useAuth(); const [creator, setCreator] = useState(null); const [loading, setLoading] = useState(true); const [notFound, setNotFound] = useState(false); const [error, setError] = useState(null); const [sort, setSort] = useSortPreference("newest"); + // Follow state + const [following, setFollowing] = useState(false); + const [followLoading, setFollowLoading] = useState(false); + const [followerCount, setFollowerCount] = useState(0); + // Edit mode state const [editMode, setEditMode] = useState(false); const [editBio, setEditBio] = useState(""); @@ -89,6 +99,41 @@ export default function CreatorDetail() { }; }, [slug]); + // Check follow status when creator loads and user is authenticated + useEffect(() => { + if (!creator || !isAuthenticated) { + setFollowing(false); + return; + } + setFollowerCount(creator.follower_count); + let cancelled = false; + void (async () => { + try { + const status = await getFollowStatus(creator.id); + if (!cancelled) setFollowing(status.following); + } catch { + // Non-critical — leave as not following + } + })(); + return () => { cancelled = true; }; + }, [creator?.id, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps + + async function handleFollowToggle() { + if (!creator || followLoading) return; + setFollowLoading(true); + try { + const res = following + ? await unfollowCreator(creator.id) + : await followCreator(creator.id); + setFollowing(res.followed); + setFollowerCount(res.follower_count); + } catch { + // Silently fail — button state stays as-is + } finally { + setFollowLoading(false); + } + } + function enterEditMode() { if (!creator) return; setEditBio(creator.bio ?? ""); @@ -202,13 +247,24 @@ export default function CreatorDetail() {

{creator.name}

{!editMode && ( - + <> + {isAuthenticated && ( + + )} + + )}
@@ -319,6 +375,10 @@ export default function CreatorDetail() { {creator.moment_count} moment{creator.moment_count !== 1 ? "s" : ""} + · + + {followerCount} follower{followerCount !== 1 ? "s" : ""} + {Object.keys(creator.genre_breakdown).length > 0 && ( {Object.entries(creator.genre_breakdown) diff --git a/frontend/src/pages/CreatorTiers.module.css b/frontend/src/pages/CreatorTiers.module.css new file mode 100644 index 0000000..5484b0c --- /dev/null +++ b/frontend/src/pages/CreatorTiers.module.css @@ -0,0 +1,211 @@ +.layout { + display: flex; + gap: 2rem; + max-width: 64rem; + margin: 0 auto; + padding: 2rem 1rem; +} + +.content { + flex: 1; + min-width: 0; +} + +.pageTitle { + font-size: 1.5rem; + font-weight: 700; + margin: 0 0 0.25rem; + color: var(--color-text-primary); +} + +.subtitle { + color: var(--color-text-secondary); + font-size: 0.9rem; + margin: 0 0 2rem; +} + +/* Tier grid */ +.tierGrid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.25rem; +} + +@media (max-width: 900px) { + .tierGrid { + grid-template-columns: 1fr; + } + .layout { + flex-direction: column; + gap: 1rem; + } +} + +.tierCard { + position: relative; + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 10px; + padding: 1.5rem; + display: flex; + flex-direction: column; + transition: border-color 0.2s ease; +} + +.tierCard:hover { + border-color: var(--color-text-muted); +} + +.tierCardActive { + border-color: var(--color-accent); + box-shadow: 0 0 0 1px var(--color-accent-subtle); +} + +.activeBadge { + position: absolute; + top: -0.5rem; + right: 1rem; + background: var(--color-accent); + color: var(--color-bg-page); + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.15rem 0.5rem; + border-radius: 10px; +} + +.tierName { + font-size: 1.1rem; + font-weight: 700; + color: var(--color-text-primary); + margin: 0 0 0.75rem; +} + +.tierPrice { + display: flex; + align-items: baseline; + gap: 0.25rem; + margin-bottom: 0.75rem; +} + +.priceAmount { + font-size: 2rem; + font-weight: 800; + color: var(--color-text-primary); + font-variant-numeric: tabular-nums; +} + +.pricePeriod { + font-size: 0.85rem; + color: var(--color-text-muted); +} + +.tierDescription { + font-size: 0.85rem; + color: var(--color-text-secondary); + line-height: 1.5; + margin: 0 0 1.25rem; + flex: 1; +} + +.featureList { + list-style: none; + padding: 0; + margin: 0 0 1.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.featureItem { + font-size: 0.8rem; + color: var(--color-text-secondary); + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +.featureCheck { + color: var(--color-accent); + font-weight: 700; + flex-shrink: 0; +} + +.tierCta { + width: 100%; + padding: 0.6rem; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + border: none; +} + +.tierCtaActive { + background: var(--color-accent-subtle); + color: var(--color-accent); + cursor: default; +} + +.tierCtaDisabled { + background: var(--color-bg-surface-hover); + color: var(--color-text-muted); + border: 1px solid var(--color-border); +} + +.tierCtaDisabled:hover { + border-color: var(--color-accent); + color: var(--color-accent); +} + +/* Coming Soon modal */ +.modalOverlay { + position: fixed; + inset: 0; + background: var(--color-overlay); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.modalContent { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 12px; + padding: 2rem; + max-width: 24rem; + text-align: center; +} + +.modalTitle { + font-size: 1.25rem; + font-weight: 700; + color: var(--color-accent); + margin: 0 0 0.75rem; +} + +.modalText { + font-size: 0.85rem; + color: var(--color-text-secondary); + line-height: 1.6; + margin: 0 0 1.5rem; +} + +.modalClose { + background: var(--color-accent); + color: var(--color-bg-page); + border: none; + padding: 0.5rem 1.5rem; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s ease; +} + +.modalClose:hover { + opacity: 0.85; +} diff --git a/frontend/src/pages/CreatorTiers.tsx b/frontend/src/pages/CreatorTiers.tsx new file mode 100644 index 0000000..52c1385 --- /dev/null +++ b/frontend/src/pages/CreatorTiers.tsx @@ -0,0 +1,117 @@ +import { useState } from "react"; +import { SidebarNav } from "./CreatorDashboard"; +import { useDocumentTitle } from "../hooks/useDocumentTitle"; +import styles from "./CreatorTiers.module.css"; + +const TIERS = [ + { + name: "Free", + price: "$0", + period: "forever", + description: "All your techniques, tutorials, and knowledge — free for everyone to discover.", + features: [ + "Full technique page listings", + "Search & discovery visibility", + "Creator profile & bio", + "Follower count display", + ], + cta: "Current Plan", + active: true, + }, + { + name: "Pro", + price: "$9", + period: "/month", + description: "Gated downloads, sample packs, and presets. Monetize your production knowledge.", + features: [ + "Everything in Free", + "Gated file downloads", + "Sample pack distribution", + "Preset file hosting", + "Download analytics", + ], + cta: "Coming Soon", + active: false, + }, + { + name: "Premium", + price: "$29", + period: "/month", + description: "Priority placement, exclusive content, and direct fan engagement tools.", + features: [ + "Everything in Pro", + "Priority search placement", + "Exclusive content sections", + "Fan messaging (DMs)", + "Revenue analytics dashboard", + "Custom branding options", + ], + cta: "Coming Soon", + active: false, + }, +]; + +export default function CreatorTiers() { + useDocumentTitle("Tier Configuration — Chrysopedia"); + const [showModal, setShowModal] = useState(false); + + return ( +
+ +
+

Tier Configuration

+

+ Choose how you want to share your knowledge. Payment tiers are coming soon. +

+ +
+ {TIERS.map((tier) => ( +
+ {tier.active && Active} +

{tier.name}

+
+ {tier.price} + {tier.period} +
+

{tier.description}

+
    + {tier.features.map((f) => ( +
  • + + {f} +
  • + ))} +
+ +
+ ))} +
+ + {/* Coming Soon modal */} + {showModal && ( +
setShowModal(false)}> +
e.stopPropagation()}> +

Coming Soon

+

+ Paid tiers with file distribution, gated downloads, and premium features + are currently in development. We'll notify you when they're ready. +

+ +
+
+ )} +
+
+ ); +} diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index 6fd5928..4f20965 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/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/index.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ConfirmModal.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PlayerControls.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminAuditLog.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"errors":true,"version":"5.6.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/follows.ts","./src/api/highlights.ts","./src/api/index.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ConfirmModal.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PlayerControls.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminAuditLog.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorTiers.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/HighlightQueue.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"} \ No newline at end of file