feat: Follow system + tier config page (M022/S02)
- CreatorFollow model + pure SQL migration 022
- Follow router: POST/DELETE /follows/{creator_id}, GET /follows/me, GET /follows/{id}/status
- follower_count on creator detail endpoint
- Follow button on CreatorDetail (authenticated users only)
- CreatorTiers page with Free/Pro/Premium cards, Coming Soon modals
- Tiers link in creator sidebar nav
- Route /creator/tiers (protected)
This commit is contained in:
parent
c9a2d2efbb
commit
19a6ff660c
16 changed files with 688 additions and 10 deletions
31
alembic/versions/022_add_creator_follows.py
Normal file
31
alembic/versions/022_add_creator_follows.py
Normal file
|
|
@ -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")
|
||||||
|
|
@ -12,7 +12,7 @@ from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from config import get_settings
|
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:
|
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_chapters.router, prefix="/api/v1")
|
||||||
app.include_router(creator_highlights.router, prefix="/api/v1")
|
app.include_router(creator_highlights.router, prefix="/api/v1")
|
||||||
app.include_router(creators.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(highlights.router, prefix="/api/v1")
|
||||||
app.include_router(ingest.router, prefix="/api/v1")
|
app.include_router(ingest.router, prefix="/api/v1")
|
||||||
app.include_router(pipeline.router, prefix="/api/v1")
|
app.include_router(pipeline.router, prefix="/api/v1")
|
||||||
|
|
|
||||||
|
|
@ -737,3 +737,28 @@ class HighlightCandidate(Base):
|
||||||
# relationships
|
# relationships
|
||||||
key_moment: Mapped[KeyMoment] = sa_relationship()
|
key_moment: Mapped[KeyMoment] = sa_relationship()
|
||||||
source_video: Mapped[SourceVideo] = 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()
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_session
|
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
|
from schemas import CreatorBrowseItem, CreatorDetail, CreatorRead, CreatorTechniqueItem
|
||||||
|
|
||||||
logger = logging.getLogger("chrysopedia.creators")
|
logger = logging.getLogger("chrysopedia.creators")
|
||||||
|
|
@ -175,6 +175,13 @@ async def get_creator(
|
||||||
genre_breakdown[cat] = genre_breakdown.get(cat, 0) + 1
|
genre_breakdown[cat] = genre_breakdown.get(cat, 0) + 1
|
||||||
|
|
||||||
creator_data = CreatorRead.model_validate(creator)
|
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(
|
return CreatorDetail(
|
||||||
**creator_data.model_dump(),
|
**creator_data.model_dump(),
|
||||||
bio=creator.bio,
|
bio=creator.bio,
|
||||||
|
|
@ -182,6 +189,8 @@ async def get_creator(
|
||||||
featured=creator.featured,
|
featured=creator.featured,
|
||||||
video_count=video_count,
|
video_count=video_count,
|
||||||
technique_count=len(techniques),
|
technique_count=len(techniques),
|
||||||
|
moment_count=moment_count,
|
||||||
techniques=techniques,
|
techniques=techniques,
|
||||||
genre_breakdown=genre_breakdown,
|
genre_breakdown=genre_breakdown,
|
||||||
|
follower_count=follower_count,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
116
backend/routers/follows.py
Normal file
116
backend/routers/follows.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -62,6 +62,7 @@ class CreatorDetail(CreatorRead):
|
||||||
video_count: int = 0
|
video_count: int = 0
|
||||||
technique_count: int = 0
|
technique_count: int = 0
|
||||||
moment_count: int = 0
|
moment_count: int = 0
|
||||||
|
follower_count: int = 0
|
||||||
techniques: list[CreatorTechniqueItem] = []
|
techniques: list[CreatorTechniqueItem] = []
|
||||||
genre_breakdown: dict[str, int] = {}
|
genre_breakdown: dict[str, int] = {}
|
||||||
|
|
||||||
|
|
@ -705,3 +706,28 @@ class ChapterReorderRequest(BaseModel):
|
||||||
class ChapterBulkApproveRequest(BaseModel):
|
class ChapterBulkApproveRequest(BaseModel):
|
||||||
"""Bulk-approve chapters by IDs."""
|
"""Bulk-approve chapters by IDs."""
|
||||||
chapter_ids: list[uuid.UUID]
|
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
|
||||||
|
|
|
||||||
|
|
@ -2483,6 +2483,37 @@ a.app-footer__repo:hover {
|
||||||
gap: 0.75rem;
|
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 {
|
.creator-hero__edit-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ const AdminAuditLog = React.lazy(() => import("./pages/AdminAuditLog"));
|
||||||
const ChatPage = React.lazy(() => import("./pages/ChatPage"));
|
const ChatPage = React.lazy(() => import("./pages/ChatPage"));
|
||||||
const ChapterReview = React.lazy(() => import("./pages/ChapterReview"));
|
const ChapterReview = React.lazy(() => import("./pages/ChapterReview"));
|
||||||
const HighlightQueue = React.lazy(() => import("./pages/HighlightQueue"));
|
const HighlightQueue = React.lazy(() => import("./pages/HighlightQueue"));
|
||||||
|
const CreatorTiers = React.lazy(() => import("./pages/CreatorTiers"));
|
||||||
import AdminDropdown from "./components/AdminDropdown";
|
import AdminDropdown from "./components/AdminDropdown";
|
||||||
import ImpersonationBanner from "./components/ImpersonationBanner";
|
import ImpersonationBanner from "./components/ImpersonationBanner";
|
||||||
import AppFooter from "./components/AppFooter";
|
import AppFooter from "./components/AppFooter";
|
||||||
|
|
@ -205,6 +206,7 @@ function AppShell() {
|
||||||
<Route path="/creator/chapters" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} />
|
<Route path="/creator/chapters" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} />
|
||||||
<Route path="/creator/chapters/:videoId" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} />
|
<Route path="/creator/chapters/:videoId" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} />
|
||||||
<Route path="/creator/highlights" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><HighlightQueue /></Suspense></ProtectedRoute>} />
|
<Route path="/creator/highlights" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><HighlightQueue /></Suspense></ProtectedRoute>} />
|
||||||
|
<Route path="/creator/tiers" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><CreatorTiers /></Suspense></ProtectedRoute>} />
|
||||||
|
|
||||||
{/* Fallback */}
|
{/* Fallback */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export interface CreatorDetailResponse {
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
technique_count: number;
|
technique_count: number;
|
||||||
moment_count: number;
|
moment_count: number;
|
||||||
|
follower_count: number;
|
||||||
techniques: CreatorTechniqueItem[];
|
techniques: CreatorTechniqueItem[];
|
||||||
genre_breakdown: Record<string, number>;
|
genre_breakdown: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
39
frontend/src/api/follows.ts
Normal file
39
frontend/src/api/follows.ts
Normal file
|
|
@ -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<FollowResponse> {
|
||||||
|
return request<FollowResponse>(`${BASE}/follows/${creatorId}`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unfollowCreator(creatorId: string): Promise<FollowResponse> {
|
||||||
|
return request<FollowResponse>(`${BASE}/follows/${creatorId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFollowStatus(creatorId: string): Promise<FollowStatusResponse> {
|
||||||
|
return request<FollowStatusResponse>(`${BASE}/follows/${creatorId}/status`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMyFollows(): Promise<FollowedCreatorItem[]> {
|
||||||
|
return request<FollowedCreatorItem[]>(`${BASE}/follows/me`);
|
||||||
|
}
|
||||||
|
|
@ -15,3 +15,4 @@ export * from "./admin-techniques";
|
||||||
export * from "./auth";
|
export * from "./auth";
|
||||||
export * from "./creator-dashboard";
|
export * from "./creator-dashboard";
|
||||||
export * from "./consent";
|
export * from "./consent";
|
||||||
|
export * from "./follows";
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,14 @@ function SidebarNav() {
|
||||||
</svg>
|
</svg>
|
||||||
Settings
|
Settings
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink to="/creator/tiers" className={linkClass}>
|
||||||
|
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||||
|
<path d="M2 17l10 5 10-5" />
|
||||||
|
<path d="M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
Tiers
|
||||||
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,12 @@ import { Link, useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
fetchCreator,
|
fetchCreator,
|
||||||
updateCreatorProfile,
|
updateCreatorProfile,
|
||||||
|
followCreator,
|
||||||
|
unfollowCreator,
|
||||||
|
getFollowStatus,
|
||||||
type CreatorDetailResponse,
|
type CreatorDetailResponse,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
import CreatorAvatar from "../components/CreatorAvatar";
|
import CreatorAvatar from "../components/CreatorAvatar";
|
||||||
import { SocialIcon } from "../components/SocialIcons";
|
import { SocialIcon } from "../components/SocialIcons";
|
||||||
import SortDropdown from "../components/SortDropdown";
|
import SortDropdown from "../components/SortDropdown";
|
||||||
|
|
@ -40,12 +44,18 @@ const PLATFORM_OPTIONS = [
|
||||||
|
|
||||||
export default function CreatorDetail() {
|
export default function CreatorDetail() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
const [creator, setCreator] = useState<CreatorDetailResponse | null>(null);
|
const [creator, setCreator] = useState<CreatorDetailResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [notFound, setNotFound] = useState(false);
|
const [notFound, setNotFound] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [sort, setSort] = useSortPreference("newest");
|
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
|
// Edit mode state
|
||||||
const [editMode, setEditMode] = useState(false);
|
const [editMode, setEditMode] = useState(false);
|
||||||
const [editBio, setEditBio] = useState("");
|
const [editBio, setEditBio] = useState("");
|
||||||
|
|
@ -89,6 +99,41 @@ export default function CreatorDetail() {
|
||||||
};
|
};
|
||||||
}, [slug]);
|
}, [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() {
|
function enterEditMode() {
|
||||||
if (!creator) return;
|
if (!creator) return;
|
||||||
setEditBio(creator.bio ?? "");
|
setEditBio(creator.bio ?? "");
|
||||||
|
|
@ -202,13 +247,24 @@ export default function CreatorDetail() {
|
||||||
<div className="creator-hero__name-row">
|
<div className="creator-hero__name-row">
|
||||||
<h1 className="creator-hero__name">{creator.name}</h1>
|
<h1 className="creator-hero__name">{creator.name}</h1>
|
||||||
{!editMode && (
|
{!editMode && (
|
||||||
<button
|
<>
|
||||||
className="creator-hero__edit-btn"
|
{isAuthenticated && (
|
||||||
onClick={enterEditMode}
|
<button
|
||||||
title="Edit profile"
|
className={`creator-hero__follow-btn${following ? " creator-hero__follow-btn--following" : ""}`}
|
||||||
>
|
onClick={handleFollowToggle}
|
||||||
✎ Edit
|
disabled={followLoading}
|
||||||
</button>
|
>
|
||||||
|
{followLoading ? "…" : following ? "Following" : "Follow"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="creator-hero__edit-btn"
|
||||||
|
onClick={enterEditMode}
|
||||||
|
title="Edit profile"
|
||||||
|
>
|
||||||
|
✎ Edit
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -319,6 +375,10 @@ export default function CreatorDetail() {
|
||||||
<span className="creator-detail__stats">
|
<span className="creator-detail__stats">
|
||||||
{creator.moment_count} moment{creator.moment_count !== 1 ? "s" : ""}
|
{creator.moment_count} moment{creator.moment_count !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="creator-detail__stats-sep">·</span>
|
||||||
|
<span className="creator-detail__stats">
|
||||||
|
{followerCount} follower{followerCount !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
{Object.keys(creator.genre_breakdown).length > 0 && (
|
{Object.keys(creator.genre_breakdown).length > 0 && (
|
||||||
<span className="creator-detail__topic-pills">
|
<span className="creator-detail__topic-pills">
|
||||||
{Object.entries(creator.genre_breakdown)
|
{Object.entries(creator.genre_breakdown)
|
||||||
|
|
|
||||||
211
frontend/src/pages/CreatorTiers.module.css
Normal file
211
frontend/src/pages/CreatorTiers.module.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
117
frontend/src/pages/CreatorTiers.tsx
Normal file
117
frontend/src/pages/CreatorTiers.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className={styles.layout}>
|
||||||
|
<SidebarNav />
|
||||||
|
<div className={styles.content}>
|
||||||
|
<h1 className={styles.pageTitle}>Tier Configuration</h1>
|
||||||
|
<p className={styles.subtitle}>
|
||||||
|
Choose how you want to share your knowledge. Payment tiers are coming soon.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className={styles.tierGrid}>
|
||||||
|
{TIERS.map((tier) => (
|
||||||
|
<div
|
||||||
|
key={tier.name}
|
||||||
|
className={`${styles.tierCard} ${tier.active ? styles.tierCardActive : ""}`}
|
||||||
|
>
|
||||||
|
{tier.active && <span className={styles.activeBadge}>Active</span>}
|
||||||
|
<h2 className={styles.tierName}>{tier.name}</h2>
|
||||||
|
<div className={styles.tierPrice}>
|
||||||
|
<span className={styles.priceAmount}>{tier.price}</span>
|
||||||
|
<span className={styles.pricePeriod}>{tier.period}</span>
|
||||||
|
</div>
|
||||||
|
<p className={styles.tierDescription}>{tier.description}</p>
|
||||||
|
<ul className={styles.featureList}>
|
||||||
|
{tier.features.map((f) => (
|
||||||
|
<li key={f} className={styles.featureItem}>
|
||||||
|
<span className={styles.featureCheck}>✓</span>
|
||||||
|
{f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
className={`${styles.tierCta} ${tier.active ? styles.tierCtaActive : styles.tierCtaDisabled}`}
|
||||||
|
onClick={() => { if (!tier.active) setShowModal(true); }}
|
||||||
|
disabled={tier.active}
|
||||||
|
>
|
||||||
|
{tier.cta}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Coming Soon modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className={styles.modalOverlay} onClick={() => setShowModal(false)}>
|
||||||
|
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 className={styles.modalTitle}>Coming Soon</h3>
|
||||||
|
<p className={styles.modalText}>
|
||||||
|
Paid tiers with file distribution, gated downloads, and premium features
|
||||||
|
are currently in development. We'll notify you when they're ready.
|
||||||
|
</p>
|
||||||
|
<button className={styles.modalClose} onClick={() => setShowModal(false)}>
|
||||||
|
Got it
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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"}
|
{"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"}
|
||||||
Loading…
Add table
Reference in a new issue