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:
jlightner 2026-04-04 07:34:03 +00:00
parent c9a2d2efbb
commit 19a6ff660c
16 changed files with 688 additions and 10 deletions

View 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")

View file

@ -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")

View file

@ -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()

View file

@ -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,
)

116
backend/routers/follows.py Normal file
View 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,
)

View file

@ -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

View file

@ -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);

View file

@ -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() {
<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/highlights" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><HighlightQueue /></Suspense></ProtectedRoute>} />
<Route path="/creator/tiers" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><CreatorTiers /></Suspense></ProtectedRoute>} />
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />

View file

@ -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<string, number>;
}

View 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`);
}

View file

@ -15,3 +15,4 @@ export * from "./admin-techniques";
export * from "./auth";
export * from "./creator-dashboard";
export * from "./consent";
export * from "./follows";

View file

@ -51,6 +51,14 @@ function SidebarNav() {
</svg>
Settings
</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>
);
}

View file

@ -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<CreatorDetailResponse | null>(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
const [error, setError] = useState<string | null>(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() {
<div className="creator-hero__name-row">
<h1 className="creator-hero__name">{creator.name}</h1>
{!editMode && (
<button
className="creator-hero__edit-btn"
onClick={enterEditMode}
title="Edit profile"
>
Edit
</button>
<>
{isAuthenticated && (
<button
className={`creator-hero__follow-btn${following ? " creator-hero__follow-btn--following" : ""}`}
onClick={handleFollowToggle}
disabled={followLoading}
>
{followLoading ? "…" : following ? "Following" : "Follow"}
</button>
)}
<button
className="creator-hero__edit-btn"
onClick={enterEditMode}
title="Edit profile"
>
Edit
</button>
</>
)}
</div>
@ -319,6 +375,10 @@ export default function CreatorDetail() {
<span className="creator-detail__stats">
{creator.moment_count} moment{creator.moment_count !== 1 ? "s" : ""}
</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 && (
<span className="creator-detail__topic-pills">
{Object.entries(creator.genre_breakdown)

View 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;
}

View 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>
);
}

View file

@ -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"}