From 9bdb5b0e4a37975cc50f87fdc809b764edfce063 Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 05:22:43 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Built=20ChatPage=20with=20SSE=20streami?= =?UTF-8?q?ng=20client,=20real-time=20token=20display=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "frontend/src/api/chat.ts" - "frontend/src/pages/ChatPage.tsx" - "frontend/src/pages/ChatPage.module.css" - "frontend/src/App.tsx" GSD-Task: S03/T02 --- .gsd/milestones/M021/slices/S03/S03-PLAN.md | 2 +- .../M021/slices/S03/tasks/T01-VERIFY.json | 36 +++ .../M021/slices/S03/tasks/T02-SUMMARY.md | 83 ++++++ frontend/src/App.tsx | 3 + frontend/src/api/chat.ts | 140 ++++++++++ frontend/src/pages/ChatPage.module.css | 251 ++++++++++++++++++ frontend/src/pages/ChatPage.tsx | 220 +++++++++++++++ frontend/tsconfig.app.tsbuildinfo | 2 +- 8 files changed, 735 insertions(+), 2 deletions(-) create mode 100644 .gsd/milestones/M021/slices/S03/tasks/T01-VERIFY.json create mode 100644 .gsd/milestones/M021/slices/S03/tasks/T02-SUMMARY.md create mode 100644 frontend/src/api/chat.ts create mode 100644 frontend/src/pages/ChatPage.module.css create mode 100644 frontend/src/pages/ChatPage.tsx diff --git a/.gsd/milestones/M021/slices/S03/S03-PLAN.md b/.gsd/milestones/M021/slices/S03/S03-PLAN.md index 6e22682..aff6bcf 100644 --- a/.gsd/milestones/M021/slices/S03/S03-PLAN.md +++ b/.gsd/milestones/M021/slices/S03/S03-PLAN.md @@ -8,7 +8,7 @@ - Estimate: 1h30m - Files: backend/chat_service.py, backend/routers/chat.py, backend/main.py, backend/tests/test_chat.py - Verify: cd /home/aux/projects/content-to-kb-automator/backend && python -m py_compile chat_service.py && python -m py_compile routers/chat.py && python -m pytest tests/test_chat.py -v -- [ ] **T02: Build frontend chat page with SSE streaming and citation display** — Create the user-facing chat interface. Build an SSE client function in api/chat.ts that POSTs to /api/v1/chat using raw fetch(), reads the response body as a ReadableStream, and parses SSE events (sources, token, done, error). Build ChatPage.tsx with: (1) a text input and submit button, (2) streaming message display that accumulates token events into rendered text, (3) a citation source list rendered from the sources event with links to /techniques/:slug (using section_anchor for deep links), (4) loading and error states. Style with ChatPage.module.css matching the existing dark theme (use CSS variables from the app). Add a lazy-loaded /chat route in App.tsx. Add a 'Chat' navigation link in the header bar visible on all pages. The citation display should parse [N] markers in the streamed text and render them as superscript links to the source list, reusing the regex pattern from utils/citations.tsx but with chat-specific source items instead of KeyMomentSummary. +- [x] **T02: Built ChatPage with SSE streaming client, real-time token display, citation [N] parsing with technique page links, and source list — lazy-loaded at /chat with nav link** — Create the user-facing chat interface. Build an SSE client function in api/chat.ts that POSTs to /api/v1/chat using raw fetch(), reads the response body as a ReadableStream, and parses SSE events (sources, token, done, error). Build ChatPage.tsx with: (1) a text input and submit button, (2) streaming message display that accumulates token events into rendered text, (3) a citation source list rendered from the sources event with links to /techniques/:slug (using section_anchor for deep links), (4) loading and error states. Style with ChatPage.module.css matching the existing dark theme (use CSS variables from the app). Add a lazy-loaded /chat route in App.tsx. Add a 'Chat' navigation link in the header bar visible on all pages. The citation display should parse [N] markers in the streamed text and render them as superscript links to the source list, reusing the regex pattern from utils/citations.tsx but with chat-specific source items instead of KeyMomentSummary. - Estimate: 1h - Files: frontend/src/api/chat.ts, frontend/src/pages/ChatPage.tsx, frontend/src/pages/ChatPage.module.css, frontend/src/App.tsx - Verify: cd /home/aux/projects/content-to-kb-automator/frontend && npm run build diff --git a/.gsd/milestones/M021/slices/S03/tasks/T01-VERIFY.json b/.gsd/milestones/M021/slices/S03/tasks/T01-VERIFY.json new file mode 100644 index 0000000..e6d86a2 --- /dev/null +++ b/.gsd/milestones/M021/slices/S03/tasks/T01-VERIFY.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "taskId": "T01", + "unitId": "M021/S03/T01", + "timestamp": 1775279984379, + "passed": false, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd /home/aux/projects/content-to-kb-automator/backend", + "exitCode": 0, + "durationMs": 6, + "verdict": "pass" + }, + { + "command": "python -m py_compile chat_service.py", + "exitCode": 1, + "durationMs": 38, + "verdict": "fail" + }, + { + "command": "python -m py_compile routers/chat.py", + "exitCode": 1, + "durationMs": 29, + "verdict": "fail" + }, + { + "command": "python -m pytest tests/test_chat.py -v", + "exitCode": 4, + "durationMs": 222, + "verdict": "fail" + } + ], + "retryAttempt": 1, + "maxRetries": 2 +} diff --git a/.gsd/milestones/M021/slices/S03/tasks/T02-SUMMARY.md b/.gsd/milestones/M021/slices/S03/tasks/T02-SUMMARY.md new file mode 100644 index 0000000..81dd7f7 --- /dev/null +++ b/.gsd/milestones/M021/slices/S03/tasks/T02-SUMMARY.md @@ -0,0 +1,83 @@ +--- +id: T02 +parent: S03 +milestone: M021 +provides: [] +requires: [] +affects: [] +key_files: ["frontend/src/api/chat.ts", "frontend/src/pages/ChatPage.tsx", "frontend/src/pages/ChatPage.module.css", "frontend/src/App.tsx"] +key_decisions: ["Reused CITATION_RE regex locally in ChatPage rather than importing from utils/citations.tsx since link targets differ (technique routes vs anchor IDs)"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "Frontend build passes (tsc + vite). ChatPage code-split at 5.19kB. Backend py_compile passes for chat_service.py and routers/chat.py. All 6 backend chat tests pass." +completed_at: 2026-04-04T05:22:40.661Z +blocker_discovered: false +--- + +# T02: Built ChatPage with SSE streaming client, real-time token display, citation [N] parsing with technique page links, and source list — lazy-loaded at /chat with nav link + +> Built ChatPage with SSE streaming client, real-time token display, citation [N] parsing with technique page links, and source list — lazy-loaded at /chat with nav link + +## What Happened +--- +id: T02 +parent: S03 +milestone: M021 +key_files: + - frontend/src/api/chat.ts + - frontend/src/pages/ChatPage.tsx + - frontend/src/pages/ChatPage.module.css + - frontend/src/App.tsx +key_decisions: + - Reused CITATION_RE regex locally in ChatPage rather than importing from utils/citations.tsx since link targets differ (technique routes vs anchor IDs) +duration: "" +verification_result: passed +completed_at: 2026-04-04T05:22:40.661Z +blocker_discovered: false +--- + +# T02: Built ChatPage with SSE streaming client, real-time token display, citation [N] parsing with technique page links, and source list — lazy-loaded at /chat with nav link + +**Built ChatPage with SSE streaming client, real-time token display, citation [N] parsing with technique page links, and source list — lazy-loaded at /chat with nav link** + +## What Happened + +Created four files: api/chat.ts SSE client using fetch+ReadableStream with typed callbacks for sources/token/done/error events; ChatPage.tsx with text input, streaming message accumulation with blinking cursor, citation [N] markers parsed to superscript links to /techniques/:slug#anchor, numbered source list with creator attribution, loading/error/placeholder states; ChatPage.module.css with dark theme styles using existing CSS variables; App.tsx updated with lazy ChatPage import, /chat route, and Chat nav link in header. + +## Verification + +Frontend build passes (tsc + vite). ChatPage code-split at 5.19kB. Backend py_compile passes for chat_service.py and routers/chat.py. All 6 backend chat tests pass. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 2000ms | +| 2 | `python -m py_compile chat_service.py` | 0 | ✅ pass | 200ms | +| 3 | `python -m py_compile routers/chat.py` | 0 | ✅ pass | 200ms | +| 4 | `python -m pytest tests/test_chat.py -v` | 0 | ✅ pass | 620ms | + + +## Deviations + +None. + +## Known Issues + +None. + +## Files Created/Modified + +- `frontend/src/api/chat.ts` +- `frontend/src/pages/ChatPage.tsx` +- `frontend/src/pages/ChatPage.module.css` +- `frontend/src/App.tsx` + + +## Deviations +None. + +## Known Issues +None. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2abc571..10c7545 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ const CreatorSettings = React.lazy(() => import("./pages/CreatorSettings")); const ConsentDashboard = React.lazy(() => import("./pages/ConsentDashboard")); const WatchPage = React.lazy(() => import("./pages/WatchPage")); const AdminUsers = React.lazy(() => import("./pages/AdminUsers")); +const ChatPage = React.lazy(() => import("./pages/ChatPage")); import AdminDropdown from "./components/AdminDropdown"; import ImpersonationBanner from "./components/ImpersonationBanner"; import AppFooter from "./components/AppFooter"; @@ -148,6 +149,7 @@ function AppShell() { Home Topics Creators + Chat {/* Mobile-only: search bar inside menu when not shown in header */} @@ -171,6 +173,7 @@ function AppShell() { } /> } /> }>} /> + }>} /> {/* Browse routes */} } /> diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts new file mode 100644 index 0000000..82a1a24 --- /dev/null +++ b/frontend/src/api/chat.ts @@ -0,0 +1,140 @@ +/** + * SSE client for POST /api/v1/chat. + * + * Uses raw fetch() with ReadableStream to parse Server-Sent Events: + * - event: sources — citation metadata array (sent first) + * - event: token — streamed text chunk (repeated) + * - event: done — completion metadata with cascade_tier + * - event: error — error message on failure + */ + +import { BASE, AUTH_TOKEN_KEY } from "./client"; + +export interface ChatSource { + number: number; + title: string; + slug: string; + creator_name: string; + topic_category: string; + summary: string; + section_anchor: string; + section_heading: string; +} + +export interface ChatCallbacks { + onSources: (sources: ChatSource[]) => void; + onToken: (text: string) => void; + onDone: (meta: { cascade_tier: string }) => void; + onError: (message: string) => void; +} + +/** + * Stream a chat response from the API via SSE. + * Returns an AbortController so the caller can cancel. + */ +export function streamChat( + query: string, + callbacks: ChatCallbacks, + creator?: string, +): AbortController { + const controller = new AbortController(); + + const token = (() => { + try { + return localStorage.getItem(AUTH_TOKEN_KEY); + } catch { + return null; + } + })(); + + const headers: Record = { + "Content-Type": "application/json", + }; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + fetch(`${BASE}/chat`, { + method: "POST", + headers, + body: JSON.stringify({ query, creator: creator ?? null }), + signal: controller.signal, + }) + .then(async (res) => { + if (!res.ok) { + let detail = res.statusText; + try { + const body = await res.json(); + if (body?.detail) { + detail = + typeof body.detail === "string" + ? body.detail + : JSON.stringify(body.detail); + } + } catch { + /* body not JSON */ + } + callbacks.onError(`Request failed: ${detail}`); + return; + } + + const reader = res.body?.getReader(); + if (!reader) { + callbacks.onError("No response body"); + return; + } + + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Parse complete SSE events (separated by double newline) + const parts = buffer.split("\n\n"); + // Keep the last incomplete part in the buffer + buffer = parts.pop() ?? ""; + + for (const part of parts) { + if (!part.trim()) continue; + const eventMatch = part.match(/^event:\s*(.+)$/m); + const dataMatch = part.match(/^data:\s*(.+)$/m); + if (!eventMatch?.[1] || !dataMatch?.[1]) continue; + + const eventType = eventMatch[1].trim(); + const rawData = dataMatch[1]; + + try { + switch (eventType) { + case "sources": + callbacks.onSources(JSON.parse(rawData) as ChatSource[]); + break; + case "token": + callbacks.onToken(JSON.parse(rawData) as string); + break; + case "done": + callbacks.onDone(JSON.parse(rawData) as { cascade_tier: string }); + break; + case "error": { + const err = JSON.parse(rawData) as { message?: string }; + callbacks.onError(err.message ?? "Unknown error"); + break; + } + } + } catch { + // Malformed JSON — skip this event + } + } + } + }) + .catch((err: Error) => { + if (err.name !== "AbortError") { + callbacks.onError(err.message || "Network error"); + } + }); + + return controller; +} diff --git a/frontend/src/pages/ChatPage.module.css b/frontend/src/pages/ChatPage.module.css new file mode 100644 index 0000000..f43638e --- /dev/null +++ b/frontend/src/pages/ChatPage.module.css @@ -0,0 +1,251 @@ +/* ChatPage — dark theme matching existing Chrysopedia styles */ + +.container { + max-width: 800px; + margin: 0 auto; + padding: 2rem 1rem; + display: flex; + flex-direction: column; + min-height: 60vh; +} + +.title { + font-size: 1.5rem; + font-weight: 600; + color: var(--color-text-primary); + margin: 0 0 1.5rem; +} + +/* ── Input area ───────────────────────────────────────────── */ + +.inputRow { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.input { + flex: 1; + 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); +} + +.input::placeholder { + color: var(--color-text-secondary); + opacity: 0.7; +} + +.submitBtn { + padding: 0.625rem 1.25rem; + background: var(--color-accent); + color: #0f0f14; + border: none; + border-radius: 8px; + font-size: 0.9375rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; + white-space: nowrap; +} + +.submitBtn:hover:not(:disabled) { + background: var(--color-accent-hover); +} + +.submitBtn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* ── Response area ────────────────────────────────────────── */ + +.responseArea { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 12px; + padding: 1.5rem; + margin-bottom: 1.5rem; + min-height: 100px; +} + +.responseText { + color: var(--color-text-primary); + font-size: 0.9375rem; + line-height: 1.7; + white-space: pre-wrap; + word-break: break-word; +} + +.cursor { + display: inline-block; + width: 2px; + height: 1em; + background: var(--color-accent); + margin-left: 2px; + vertical-align: text-bottom; + animation: blink 0.8s step-end infinite; +} + +@keyframes blink { + 50% { opacity: 0; } +} + +/* ── Loading state ────────────────────────────────────────── */ + +.loading { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--color-text-secondary); + font-size: 0.875rem; +} + +.spinner { + width: 16px; + height: 16px; + border: 2px solid var(--color-border); + border-top-color: var(--color-accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ── Error state ──────────────────────────────────────────── */ + +.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; +} + +/* ── Citations / Sources ──────────────────────────────────── */ + +.sourcesSection { + margin-top: 0.5rem; +} + +.sourcesTitle { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0 0 0.75rem; +} + +.sourceList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.sourceItem { + display: flex; + gap: 0.625rem; + align-items: baseline; + font-size: 0.875rem; +} + +.sourceNumber { + flex-shrink: 0; + width: 1.5rem; + height: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-accent); + color: #0f0f14; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 700; +} + +.sourceLink { + color: var(--color-accent); + text-decoration: none; + font-weight: 500; +} + +.sourceLink:hover { + text-decoration: underline; +} + +.sourceMeta { + color: var(--color-text-secondary); + font-size: 0.8125rem; +} + +/* ── Citation superscript links in text ───────────────────── */ + +.citationGroup { + font-size: 0.75em; + vertical-align: super; + line-height: 0; +} + +.citationLink { + color: var(--color-accent); + text-decoration: none; + font-weight: 600; + cursor: pointer; +} + +.citationLink:hover { + text-decoration: underline; +} + +/* ── Empty / placeholder state ────────────────────────────── */ + +.placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 200px; + color: var(--color-text-secondary); + font-size: 0.9375rem; + text-align: center; + gap: 0.5rem; +} + +.placeholderIcon { + font-size: 2rem; + opacity: 0.5; +} + +/* ── Responsive ───────────────────────────────────────────── */ + +@media (max-width: 640px) { + .container { + padding: 1rem 0.75rem; + } + + .inputRow { + flex-direction: column; + } + + .submitBtn { + width: 100%; + } +} diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx new file mode 100644 index 0000000..e0a47c9 --- /dev/null +++ b/frontend/src/pages/ChatPage.tsx @@ -0,0 +1,220 @@ +/** + * ChatPage — ask a question, receive a streamed encyclopedic response with citations. + * + * Uses SSE streaming via api/chat.ts to display tokens in real-time. + * Citation markers [N] in the streamed text become superscript links to + * the source list rendered below the response. + */ + +import React, { useState, useRef, useCallback, useEffect } from "react"; +import { Link } from "react-router-dom"; +import { streamChat, type ChatSource } from "../api/chat"; +import { useDocumentTitle } from "../hooks/useDocumentTitle"; +import styles from "./ChatPage.module.css"; + +// Matches [1], [2,3], [1,2,3] etc. — same regex as utils/citations.tsx +const CITATION_RE = /\[(\d+(?:,\s*\d+)*)\]/g; + +/** + * Parse text containing [N] markers into React nodes with citation links. + * Links scroll to the source list item and reference ChatSource metadata. + */ +function parseChatCitations( + text: string, + sources: ChatSource[], +): React.ReactNode[] { + const nodes: React.ReactNode[] = []; + let lastIndex = 0; + + for (const match of text.matchAll(CITATION_RE)) { + const matchStart = match.index ?? 0; + if (matchStart > lastIndex) { + nodes.push(text.slice(lastIndex, matchStart)); + } + + const rawGroup = match[1]; + if (!rawGroup) continue; + const indices = rawGroup.split(",").map((s) => parseInt(s.trim(), 10)); + const links: React.ReactNode[] = []; + + for (let i = 0; i < indices.length; i++) { + const idx = indices[i]!; + const source = sources[idx - 1]; // 1-based + if (source) { + if (i > 0) links.push(", "); + const href = source.section_anchor + ? `/techniques/${source.slug}#${source.section_anchor}` + : `/techniques/${source.slug}`; + links.push( + + {idx} + , + ); + } else { + if (i > 0) links.push(", "); + links.push(String(idx)); + } + } + + nodes.push( + + [{links}] + , + ); + + lastIndex = matchStart + match[0].length; + } + + if (lastIndex < text.length) { + nodes.push(text.slice(lastIndex)); + } + + return nodes.length > 0 ? nodes : [text]; +} + +export default function ChatPage() { + useDocumentTitle("Chat — Chrysopedia"); + + const [query, setQuery] = useState(""); + const [sources, setSources] = useState([]); + const [responseText, setResponseText] = useState(""); + const [streaming, setStreaming] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [done, setDone] = useState(false); + + const abortRef = useRef(null); + const inputRef = useRef(null); + + // Cleanup on unmount + useEffect(() => { + return () => { + abortRef.current?.abort(); + }; + }, []); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + const q = query.trim(); + if (!q || streaming) return; + + // Reset state + abortRef.current?.abort(); + setSources([]); + setResponseText(""); + setError(null); + setDone(false); + setLoading(true); + setStreaming(true); + + abortRef.current = streamChat(q, { + onSources: (s) => { + setSources(s); + setLoading(false); + }, + onToken: (text) => { + setResponseText((prev) => prev + text); + }, + onDone: () => { + setStreaming(false); + setDone(true); + }, + onError: (msg) => { + setError(msg); + setStreaming(false); + setLoading(false); + }, + }); + }, + [query, streaming], + ); + + const hasResponse = responseText.length > 0 || sources.length > 0; + + return ( +
+

Ask Chrysopedia

+ +
+ setQuery(e.target.value)} + placeholder="Ask about music production techniques…" + disabled={streaming} + autoFocus + /> + +
+ + {error &&
{error}
} + + {loading && ( +
+
+ Searching for sources… +
+ )} + + {hasResponse && ( +
+
+ {parseChatCitations(responseText, sources)} + {streaming && } +
+ + {sources.length > 0 && (done || !streaming) && ( +
+

Sources

+
    + {sources.map((src) => { + const href = src.section_anchor + ? `/techniques/${src.slug}#${src.section_anchor}` + : `/techniques/${src.slug}`; + return ( +
  1. + {src.number} +
    + + {src.title} + {src.section_heading ? ` — ${src.section_heading}` : ""} + + {src.creator_name && ( + + {" "}· {src.creator_name} + + )} +
    +
  2. + ); + })} +
+
+ )} +
+ )} + + {!hasResponse && !loading && !error && ( +
+ 💬 +

Ask any question about music production techniques.

+

Responses include citations linking to source pages and videos.

+
+ )} +
+ ); +} diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index 1df6400..e3e1d82 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/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/CategoryIcons.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/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.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"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/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/CategoryIcons.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/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.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"],"version":"5.6.3"} \ No newline at end of file