feat: Added AuthContext provider with JWT persistence, auth API client…

- "frontend/src/context/AuthContext.tsx"
- "frontend/src/api/public-client.ts"
- "frontend/src/pages/Login.tsx"
- "frontend/src/pages/Login.module.css"
- "frontend/src/pages/Register.tsx"
- "frontend/src/pages/Register.module.css"
- "frontend/src/App.tsx"

GSD-Task: S02/T03
This commit is contained in:
jlightner 2026-04-03 21:58:08 +00:00
parent 77f44b0b48
commit b344307a89
11 changed files with 808 additions and 7 deletions

View file

@ -100,7 +100,7 @@
- Estimate: 1.5h - Estimate: 1.5h
- Files: backend/routers/auth.py, backend/main.py, backend/auth.py, backend/tests/conftest.py, backend/tests/test_auth.py - Files: backend/routers/auth.py, backend/main.py, backend/auth.py, backend/tests/conftest.py, backend/tests/test_auth.py
- Verify: cd backend && python -m pytest tests/test_auth.py -v && python -m pytest tests/test_public_api.py -v - Verify: cd backend && python -m pytest tests/test_auth.py -v && python -m pytest tests/test_public_api.py -v
- [ ] **T03: Build frontend AuthContext, API auth functions, login and register pages** — Create the React auth infrastructure: AuthContext provider with JWT persistence, API client auth functions, login page, register page, and wire AuthContext into App.tsx. - [x] **T03: Added AuthContext provider with JWT persistence, auth API client functions, Login and Register pages with CSS modules, and wired routes into App.tsx** — Create the React auth infrastructure: AuthContext provider with JWT persistence, API client auth functions, login page, register page, and wire AuthContext into App.tsx.
## Steps ## Steps

View file

@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"taskId": "T02",
"unitId": "M019/S02/T02",
"timestamp": 1775253251505,
"passed": false,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd backend",
"exitCode": 0,
"durationMs": 7,
"verdict": "pass"
},
{
"command": "python -m pytest tests/test_auth.py -v",
"exitCode": 4,
"durationMs": 253,
"verdict": "fail"
},
{
"command": "python -m pytest tests/test_public_api.py -v",
"exitCode": 4,
"durationMs": 218,
"verdict": "fail"
}
],
"retryAttempt": 1,
"maxRetries": 2
}

View file

@ -0,0 +1,94 @@
---
id: T03
parent: S02
milestone: M019
provides: []
requires: []
affects: []
key_files: ["frontend/src/context/AuthContext.tsx", "frontend/src/api/public-client.ts", "frontend/src/pages/Login.tsx", "frontend/src/pages/Login.module.css", "frontend/src/pages/Register.tsx", "frontend/src/pages/Register.module.css", "frontend/src/App.tsx"]
key_decisions: ["Stored token key as AUTH_TOKEN_KEY constant exported from public-client.ts for shared access", "request<T> auto-injects Authorization header from localStorage — existing unauthenticated calls unaffected"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Frontend build passes with zero TypeScript errors. All 8 verification checks pass: AuthProvider wired in App.tsx, AuthContext.tsx exists, Login.tsx and Register.tsx exist, CSS modules exist, backend models/auth/schemas imports all succeed."
completed_at: 2026-04-03T21:58:04.681Z
blocker_discovered: false
---
# T03: Added AuthContext provider with JWT persistence, auth API client functions, Login and Register pages with CSS modules, and wired routes into App.tsx
> Added AuthContext provider with JWT persistence, auth API client functions, Login and Register pages with CSS modules, and wired routes into App.tsx
## What Happened
---
id: T03
parent: S02
milestone: M019
key_files:
- frontend/src/context/AuthContext.tsx
- frontend/src/api/public-client.ts
- frontend/src/pages/Login.tsx
- frontend/src/pages/Login.module.css
- frontend/src/pages/Register.tsx
- frontend/src/pages/Register.module.css
- frontend/src/App.tsx
key_decisions:
- Stored token key as AUTH_TOKEN_KEY constant exported from public-client.ts for shared access
- request<T> auto-injects Authorization header from localStorage — existing unauthenticated calls unaffected
duration: ""
verification_result: passed
completed_at: 2026-04-03T21:58:04.682Z
blocker_discovered: false
---
# T03: Added AuthContext provider with JWT persistence, auth API client functions, Login and Register pages with CSS modules, and wired routes into App.tsx
**Added AuthContext provider with JWT persistence, auth API client functions, Login and Register pages with CSS modules, and wired routes into App.tsx**
## What Happened
Created the complete frontend auth infrastructure: AuthContext with login/register/logout/session rehydration from localStorage, auth API functions with TypeScript types in public-client.ts (with auto-inject Authorization header), Login page with email/password form and error handling, Register page with invite code + full validation, CSS modules for both pages, and routes wired into App.tsx with AuthProvider wrapper.
## Verification
Frontend build passes with zero TypeScript errors. All 8 verification checks pass: AuthProvider wired in App.tsx, AuthContext.tsx exists, Login.tsx and Register.tsx exist, CSS modules exist, backend models/auth/schemas imports all succeed.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 3600ms |
| 2 | `grep -c 'AuthProvider' frontend/src/App.tsx` | 0 | ✅ pass | 100ms |
| 3 | `test -f frontend/src/context/AuthContext.tsx` | 0 | ✅ pass | 100ms |
| 4 | `test -f frontend/src/pages/Login.tsx && test -f frontend/src/pages/Register.tsx` | 0 | ✅ pass | 100ms |
| 5 | `test -f frontend/src/pages/Login.module.css && test -f frontend/src/pages/Register.module.css` | 0 | ✅ pass | 100ms |
| 6 | `cd backend && python -c "from models import User, InviteCode, UserRole; print('OK')"` | 0 | ✅ pass | 300ms |
| 7 | `cd backend && python -c "from auth import hash_password, verify_password, create_access_token, decode_access_token; print('OK')"` | 0 | ✅ pass | 300ms |
| 8 | `cd backend && python -c "from schemas import RegisterRequest, LoginRequest, TokenResponse, UserResponse, UpdateProfileRequest; print('OK')"` | 0 | ✅ pass | 300ms |
## Deviations
Exported ApiError class from public-client.ts (was previously unexported) for use in AuthContext and pages. Added AUTH_TOKEN_KEY as exported constant.
## Known Issues
Verification gate used wrong test file paths (tests/test_auth.py instead of backend/tests/test_auth.py) — not a code issue.
## Files Created/Modified
- `frontend/src/context/AuthContext.tsx`
- `frontend/src/api/public-client.ts`
- `frontend/src/pages/Login.tsx`
- `frontend/src/pages/Login.module.css`
- `frontend/src/pages/Register.tsx`
- `frontend/src/pages/Register.module.css`
- `frontend/src/App.tsx`
## Deviations
Exported ApiError class from public-client.ts (was previously unexported) for use in AuthContext and pages. Added AUTH_TOKEN_KEY as exported constant.
## Known Issues
Verification gate used wrong test file paths (tests/test_auth.py instead of backend/tests/test_auth.py) — not a code issue.

View file

@ -11,9 +11,12 @@ import AdminReports from "./pages/AdminReports";
import AdminPipeline from "./pages/AdminPipeline"; import AdminPipeline from "./pages/AdminPipeline";
import AdminTechniquePages from "./pages/AdminTechniquePages"; import AdminTechniquePages from "./pages/AdminTechniquePages";
import About from "./pages/About"; import About from "./pages/About";
import Login from "./pages/Login";
import Register from "./pages/Register";
import AdminDropdown from "./components/AdminDropdown"; import AdminDropdown from "./components/AdminDropdown";
import AppFooter from "./components/AppFooter"; import AppFooter from "./components/AppFooter";
import SearchAutocomplete from "./components/SearchAutocomplete"; import SearchAutocomplete from "./components/SearchAutocomplete";
import { AuthProvider } from "./context/AuthContext";
export default function App() { export default function App() {
const location = useLocation(); const location = useLocation();
@ -58,6 +61,7 @@ export default function App() {
}, [menuOpen, handleOutsideClick]); }, [menuOpen, handleOutsideClick]);
return ( return (
<AuthProvider>
<div className="app"> <div className="app">
<a href="#main-content" className="skip-link">Skip to content</a> <a href="#main-content" className="skip-link">Skip to content</a>
<header className="app-header" ref={headerRef}> <header className="app-header" ref={headerRef}>
@ -140,6 +144,10 @@ export default function App() {
{/* Info routes */} {/* Info routes */}
<Route path="/about" element={<About />} /> <Route path="/about" element={<About />} />
{/* Auth routes */}
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
{/* Fallback */} {/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
@ -147,5 +155,6 @@ export default function App() {
<AppFooter /> <AppFooter />
</div> </div>
</AuthProvider>
); );
} }

View file

@ -204,11 +204,48 @@ export interface CreatorDetailResponse {
genre_breakdown: Record<string, number>; genre_breakdown: Record<string, number>;
} }
// ── Auth Types ───────────────────────────────────────────────────────────────
export interface RegisterRequest {
email: string;
password: string;
display_name: string;
invite_code: string;
creator_slug?: string | null;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface TokenResponse {
access_token: string;
token_type: string;
}
export interface UserResponse {
id: string;
email: string;
display_name: string;
role: string;
creator_id: string | null;
is_active: boolean;
created_at: string;
}
export interface UpdateProfileRequest {
display_name?: string | null;
current_password?: string | null;
new_password?: string | null;
}
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
const BASE = "/api/v1"; const BASE = "/api/v1";
const AUTH_TOKEN_KEY = "chrysopedia_token";
class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(
public status: number, public status: number,
public detail: string, public detail: string,
@ -218,13 +255,27 @@ class ApiError extends Error {
} }
} }
function getStoredToken(): string | null {
try {
return localStorage.getItem(AUTH_TOKEN_KEY);
} catch {
return null;
}
}
async function request<T>(url: string, init?: RequestInit): Promise<T> { async function request<T>(url: string, init?: RequestInit): Promise<T> {
const token = getStoredToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(init?.headers as Record<string, string>),
};
if (token && !headers["Authorization"]) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(url, { const res = await fetch(url, {
...init, ...init,
headers: { headers,
"Content-Type": "application/json",
...init?.headers,
},
}); });
if (!res.ok) { if (!res.ok) {
@ -856,3 +907,39 @@ export async function fetchAdminTechniquePages(
`${BASE}/admin/pipeline/technique-pages${query ? `?${query}` : ""}`, `${BASE}/admin/pipeline/technique-pages${query ? `?${query}` : ""}`,
); );
} }
// ── Auth ─────────────────────────────────────────────────────────────────────
export { AUTH_TOKEN_KEY };
export async function authRegister(data: RegisterRequest): Promise<UserResponse> {
return request<UserResponse>(`${BASE}/auth/register`, {
method: "POST",
body: JSON.stringify(data),
});
}
export async function authLogin(email: string, password: string): Promise<TokenResponse> {
return request<TokenResponse>(`${BASE}/auth/login`, {
method: "POST",
body: JSON.stringify({ email, password }),
});
}
export async function authGetMe(token: string): Promise<UserResponse> {
return request<UserResponse>(`${BASE}/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
});
}
export async function authUpdateProfile(
token: string,
data: UpdateProfileRequest,
): Promise<UserResponse> {
return request<UserResponse>(`${BASE}/auth/me`, {
method: "PUT",
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify(data),
});
}

View file

@ -0,0 +1,109 @@
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from "react";
import {
AUTH_TOKEN_KEY,
authLogin,
authGetMe,
authRegister,
ApiError,
type UserResponse,
type RegisterRequest,
} from "../api/public-client";
interface AuthContextValue {
user: UserResponse | null;
token: string | null;
isAuthenticated: boolean;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (data: RegisterRequest) => Promise<UserResponse>;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserResponse | null>(null);
const [token, setToken] = useState<string | null>(() => {
try {
return localStorage.getItem(AUTH_TOKEN_KEY);
} catch {
return null;
}
});
const [loading, setLoading] = useState(!!token);
// Rehydrate session from stored token on mount
useEffect(() => {
if (!token) return;
let cancelled = false;
authGetMe(token)
.then((u) => {
if (!cancelled) setUser(u);
})
.catch(() => {
// Token expired or invalid — clear it
if (!cancelled) {
localStorage.removeItem(AUTH_TOKEN_KEY);
setToken(null);
setUser(null);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const login = useCallback(async (email: string, password: string) => {
const resp = await authLogin(email, password);
localStorage.setItem(AUTH_TOKEN_KEY, resp.access_token);
setToken(resp.access_token);
const me = await authGetMe(resp.access_token);
setUser(me);
}, []);
const register = useCallback(async (data: RegisterRequest) => {
return authRegister(data);
}, []);
const logout = useCallback(() => {
localStorage.removeItem(AUTH_TOKEN_KEY);
setToken(null);
setUser(null);
}, []);
return (
<AuthContext.Provider
value={{
user,
token,
isAuthenticated: !!user,
loading,
login,
register,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used within an AuthProvider");
}
return ctx;
}
export { ApiError };

View file

@ -0,0 +1,113 @@
.container {
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 60vh;
padding: 3rem 1rem;
}
.card {
width: 100%;
max-width: 400px;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 2rem;
}
.title {
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0 0 1.5rem;
text-align: center;
}
.form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.label {
display: flex;
flex-direction: column;
gap: 0.375rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
font-weight: 500;
}
.input {
padding: 0.625rem 0.75rem;
background: var(--color-bg-input);
border: 1px solid var(--color-border);
border-radius: 8px;
color: var(--color-text-primary);
font-size: 0.9375rem;
transition: border-color 0.15s;
}
.input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-focus);
}
.button {
margin-top: 0.5rem;
padding: 0.75rem;
background: var(--color-accent);
color: #0f0f14;
border: none;
border-radius: 8px;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.button:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
background: rgba(239, 68, 68, 0.1);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 8px;
padding: 0.75rem;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.success {
background: rgba(34, 197, 94, 0.1);
color: #4ade80;
border: 1px solid rgba(34, 197, 94, 0.2);
border-radius: 8px;
padding: 0.75rem;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.footer {
margin-top: 1.25rem;
text-align: center;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.link {
color: var(--color-accent);
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}

View file

@ -0,0 +1,97 @@
import { useState, type FormEvent } from "react";
import { Link, useNavigate, useLocation } from "react-router-dom";
import { useAuth, ApiError } from "../context/AuthContext";
import { useDocumentTitle } from "../hooks/useDocumentTitle";
import styles from "./Login.module.css";
export default function Login() {
useDocumentTitle("Login");
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
// Check for success message from registration redirect
const params = new URLSearchParams(location.search);
const registered = params.get("registered") === "1";
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
if (!email.trim() || !password) {
setError("Email and password are required.");
return;
}
setSubmitting(true);
try {
await login(email.trim(), password);
navigate("/creator/dashboard", { replace: true });
} catch (err) {
if (err instanceof ApiError) {
setError(err.detail);
} else {
setError("An unexpected error occurred. Please try again.");
}
} finally {
setSubmitting(false);
}
};
return (
<div className={styles.container}>
<div className={styles.card}>
<h1 className={styles.title}>Sign In</h1>
{registered && (
<div className={styles.success}>
Registration successful! Please sign in.
</div>
)}
{error && <div className={styles.error}>{error}</div>}
<form onSubmit={handleSubmit} className={styles.form}>
<label className={styles.label}>
Email
<input
type="email"
className={styles.input}
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
autoFocus
required
/>
</label>
<label className={styles.label}>
Password
<input
type="password"
className={styles.input}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</label>
<button
type="submit"
className={styles.button}
disabled={submitting}
>
{submitting ? "Signing in…" : "Sign In"}
</button>
</form>
<p className={styles.footer}>
Don&apos;t have an account?{" "}
<Link to="/register" className={styles.link}>
Register with invite code
</Link>
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,116 @@
.container {
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 60vh;
padding: 3rem 1rem;
}
.card {
width: 100%;
max-width: 420px;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 2rem;
}
.title {
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0 0 0.25rem;
text-align: center;
}
.subtitle {
font-size: 0.875rem;
color: var(--color-text-muted);
text-align: center;
margin: 0 0 1.5rem;
}
.form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.label {
display: flex;
flex-direction: column;
gap: 0.375rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
font-weight: 500;
}
.input {
padding: 0.625rem 0.75rem;
background: var(--color-bg-input);
border: 1px solid var(--color-border);
border-radius: 8px;
color: var(--color-text-primary);
font-size: 0.9375rem;
transition: border-color 0.15s;
}
.input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-focus);
}
.hint {
font-size: 0.75rem;
color: var(--color-text-muted);
font-weight: 400;
}
.button {
margin-top: 0.5rem;
padding: 0.75rem;
background: var(--color-accent);
color: #0f0f14;
border: none;
border-radius: 8px;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.button:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
background: rgba(239, 68, 68, 0.1);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 8px;
padding: 0.75rem;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.footer {
margin-top: 1.25rem;
text-align: center;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.link {
color: var(--color-accent);
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}

View file

@ -0,0 +1,146 @@
import { useState, type FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth, ApiError } from "../context/AuthContext";
import { useDocumentTitle } from "../hooks/useDocumentTitle";
import styles from "./Register.module.css";
export default function Register() {
useDocumentTitle("Register");
const { register } = useAuth();
const navigate = useNavigate();
const [inviteCode, setInviteCode] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [displayName, setDisplayName] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const validate = (): string | null => {
if (!inviteCode.trim()) return "Invite code is required.";
if (!email.trim()) return "Email is required.";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()))
return "Please enter a valid email address.";
if (!displayName.trim()) return "Display name is required.";
if (password.length < 8)
return "Password must be at least 8 characters.";
if (password !== confirmPassword)
return "Passwords do not match.";
return null;
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
const validationError = validate();
if (validationError) {
setError(validationError);
return;
}
setSubmitting(true);
try {
await register({
invite_code: inviteCode.trim(),
email: email.trim(),
password,
display_name: displayName.trim(),
});
navigate("/login?registered=1", { replace: true });
} catch (err) {
if (err instanceof ApiError) {
setError(err.detail);
} else {
setError("An unexpected error occurred. Please try again.");
}
} finally {
setSubmitting(false);
}
};
return (
<div className={styles.container}>
<div className={styles.card}>
<h1 className={styles.title}>Create Account</h1>
<p className={styles.subtitle}>
An invite code is required to register.
</p>
{error && <div className={styles.error}>{error}</div>}
<form onSubmit={handleSubmit} className={styles.form}>
<label className={styles.label}>
Invite Code
<input
type="text"
className={styles.input}
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
autoFocus
required
/>
</label>
<label className={styles.label}>
Email
<input
type="email"
className={styles.input}
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
/>
</label>
<label className={styles.label}>
Display Name
<input
type="text"
className={styles.input}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
autoComplete="name"
required
/>
</label>
<label className={styles.label}>
Password
<input
type="password"
className={styles.input}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
required
/>
<span className={styles.hint}>Minimum 8 characters</span>
</label>
<label className={styles.label}>
Confirm Password
<input
type="password"
className={styles.input}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
/>
</label>
<button
type="submit"
className={styles.button}
disabled={submitting}
>
{submitting ? "Creating account…" : "Create Account"}
</button>
</form>
<p className={styles.footer}>
Already have an account?{" "}
<Link to="/login" className={styles.link}>
Sign in
</Link>
</p>
</div>
</div>
);
}

View file

@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/public-client.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"}