From 0098254fddb213c9987b1dcf70f142eda87733fa Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 07:41:59 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Built=20floating=20ChatWidget=20with=20?= =?UTF-8?q?streaming=20responses,=20citation=20link=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "frontend/src/components/ChatWidget.tsx" - "frontend/src/components/ChatWidget.module.css" - "frontend/src/pages/CreatorDetail.tsx" GSD-Task: S03/T01 --- frontend/src/components/ChatWidget.module.css | 388 ++++++++++++++++++ frontend/src/components/ChatWidget.tsx | 359 ++++++++++++++++ frontend/src/pages/CreatorDetail.tsx | 3 + frontend/tsconfig.app.tsbuildinfo | 2 +- 4 files changed, 751 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/ChatWidget.module.css create mode 100644 frontend/src/components/ChatWidget.tsx diff --git a/frontend/src/components/ChatWidget.module.css b/frontend/src/components/ChatWidget.module.css new file mode 100644 index 0000000..3a36018 --- /dev/null +++ b/frontend/src/components/ChatWidget.module.css @@ -0,0 +1,388 @@ +/* ChatWidget — floating chat bubble + expandable panel */ + +/* ── Bubble ───────────────────────────────────────────────── */ + +.bubble { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--color-accent); + color: #0f0f14; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + box-shadow: 0 4px 16px var(--color-shadow-heavy); + transition: transform 0.15s, background 0.15s; + z-index: 1000; +} + +.bubble:hover { + background: var(--color-accent-hover); + transform: scale(1.08); +} + +/* ── Panel ────────────────────────────────────────────────── */ + +.panel { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + width: 400px; + max-height: 60vh; + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 16px; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px var(--color-shadow-heavy); + z-index: 1001; + animation: slideUp 0.2s ease-out; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ── Header ───────────────────────────────────────────────── */ + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.headerTitle { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text-primary); + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.headerLabel { + font-size: 0.6875rem; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 500; +} + +.closeBtn { + background: none; + border: none; + color: var(--color-text-secondary); + font-size: 1.25rem; + cursor: pointer; + padding: 0.25rem; + line-height: 1; + border-radius: 4px; + transition: color 0.15s, background 0.15s; + flex-shrink: 0; +} + +.closeBtn:hover { + color: var(--color-text-primary); + background: var(--color-bg-surface-hover); +} + +/* ── Messages area ────────────────────────────────────────── */ + +.messages { + flex: 1; + overflow-y: auto; + padding: 0.75rem 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + min-height: 0; +} + +.userMsg { + align-self: flex-end; + background: var(--color-accent); + color: #0f0f14; + padding: 0.5rem 0.75rem; + border-radius: 12px 12px 4px 12px; + font-size: 0.875rem; + max-width: 85%; + word-break: break-word; + font-weight: 500; +} + +.assistantMsg { + align-self: flex-start; + background: var(--color-bg-page); + border: 1px solid var(--color-border); + padding: 0.625rem 0.75rem; + border-radius: 12px 12px 12px 4px; + font-size: 0.875rem; + color: var(--color-text-primary); + max-width: 90%; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; +} + +/* ── Cursor ───────────────────────────────────────────────── */ + +.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; } +} + +/* ── Typing indicator ─────────────────────────────────────── */ + +.typing { + display: flex; + gap: 4px; + align-items: center; + padding: 0.5rem 0.75rem; + align-self: flex-start; +} + +.typingDot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-text-secondary); + animation: bounce 1.2s infinite; +} + +.typingDot:nth-child(2) { + animation-delay: 0.15s; +} + +.typingDot:nth-child(3) { + animation-delay: 0.3s; +} + +@keyframes bounce { + 0%, 60%, 100% { transform: translateY(0); } + 30% { transform: translateY(-4px); } +} + +/* ── Sources ──────────────────────────────────────────────── */ + +.sources { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--color-border); +} + +.sourcesLabel { + font-size: 0.6875rem; + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 0 0 0.375rem; +} + +.sourceList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.sourceItem { + display: flex; + gap: 0.375rem; + align-items: baseline; + font-size: 0.75rem; +} + +.sourceNum { + flex-shrink: 0; + width: 1.125rem; + height: 1.125rem; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-accent); + color: #0f0f14; + border-radius: 3px; + font-size: 0.625rem; + font-weight: 700; +} + +.sourceLink { + color: var(--color-accent); + text-decoration: none; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sourceLink:hover { + text-decoration: underline; +} + +/* ── Citation superscripts ────────────────────────────────── */ + +.citationGroup { + font-size: 0.7em; + vertical-align: super; + line-height: 0; +} + +.citationLink { + color: var(--color-accent); + text-decoration: none; + font-weight: 600; + cursor: pointer; +} + +.citationLink:hover { + text-decoration: underline; +} + +/* ── Error ────────────────────────────────────────────────── */ + +.errorMsg { + background: rgba(239, 68, 68, 0.1); + color: #f87171; + border: 1px solid rgba(239, 68, 68, 0.2); + border-radius: 8px; + padding: 0.5rem 0.625rem; + font-size: 0.8125rem; + align-self: flex-start; +} + +/* ── Suggestions ──────────────────────────────────────────── */ + +.suggestions { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem 0; +} + +.suggestionsLabel { + font-size: 0.75rem; + color: var(--color-text-secondary); + margin: 0; +} + +.suggestionBtn { + background: var(--color-bg-page); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 0.5rem 0.625rem; + color: var(--color-text-primary); + font-size: 0.8125rem; + cursor: pointer; + text-align: left; + transition: border-color 0.15s, background 0.15s; +} + +.suggestionBtn:hover { + border-color: var(--color-accent); + background: var(--color-bg-surface-hover); +} + +/* ── Input row ────────────────────────────────────────────── */ + +.inputRow { + display: flex; + gap: 0.375rem; + padding: 0.625rem 0.75rem; + border-top: 1px solid var(--color-border); + flex-shrink: 0; +} + +.input { + flex: 1; + padding: 0.5rem 0.625rem; + background: var(--color-bg-input); + border: 1px solid var(--color-border); + border-radius: 8px; + color: var(--color-text-primary); + font-size: 0.8125rem; + 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; +} + +.sendBtn { + padding: 0.5rem 0.75rem; + background: var(--color-accent); + color: #0f0f14; + border: none; + border-radius: 8px; + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; + white-space: nowrap; +} + +.sendBtn:hover:not(:disabled) { + background: var(--color-accent-hover); +} + +.sendBtn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ── Responsive (mobile) ──────────────────────────────────── */ + +@media (max-width: 640px) { + .bubble { + bottom: 1rem; + right: 1rem; + width: 48px; + height: 48px; + font-size: 1.25rem; + } + + .panel { + bottom: 0; + right: 0; + left: 0; + width: 100%; + max-height: 75vh; + border-radius: 16px 16px 0 0; + border-bottom: none; + } +} diff --git a/frontend/src/components/ChatWidget.tsx b/frontend/src/components/ChatWidget.tsx new file mode 100644 index 0000000..0a64071 --- /dev/null +++ b/frontend/src/components/ChatWidget.tsx @@ -0,0 +1,359 @@ +/** + * ChatWidget — floating chat bubble + expandable conversation panel. + * + * Renders a fixed-position bubble (bottom-right). Clicking opens a slide-up + * panel with message history, streaming responses, citation rendering, + * suggested questions, and a typing indicator. Scoped to a specific creator. + */ + +import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"; +import { Link } from "react-router-dom"; +import { streamChat, type ChatSource } from "../api/chat"; +import styles from "./ChatWidget.module.css"; + +// Same citation regex as ChatPage / utils/citations.tsx +const CITATION_RE = /\[(\d+(?:,\s*\d+)*)\]/g; + +interface Technique { + title: string; + slug: string; + topic_category: string; +} + +interface ChatWidgetProps { + creatorName: string; + techniques: Technique[]; +} + +interface Message { + role: "user" | "assistant"; + text: string; + sources: ChatSource[]; + done: boolean; + error?: string; +} + +/** Generate 3 suggested questions from the creator's techniques. */ +function buildSuggestions(creatorName: string, techniques: Technique[]): string[] { + if (techniques.length === 0) { + return [`What techniques does ${creatorName} use?`]; + } + + const suggestions: string[] = []; + + // First technique by name + if (techniques[0]) { + suggestions.push(`How does ${creatorName} approach ${techniques[0].title.toLowerCase()}?`); + } + + // Category-based question if there are multiple categories + const categories = [...new Set(techniques.map((t) => t.topic_category).filter(Boolean))]; + if (categories.length > 1 && categories[0]) { + suggestions.push(`What ${categories[0].toLowerCase()} techniques does ${creatorName} teach?`); + } else if (techniques[1]) { + suggestions.push(`Tell me about ${techniques[1].title.toLowerCase()}`); + } + + // General question + suggestions.push(`What makes ${creatorName}'s production style unique?`); + + return suggestions.slice(0, 3); +} + +/** Parse citation markers [N] into React nodes with superscript links. */ +function parseCitations(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]; + 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 ChatWidget({ creatorName, techniques }: ChatWidgetProps) { + const [open, setOpen] = useState(false); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [streaming, setStreaming] = useState(false); + + const abortRef = useRef(null); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + + const suggestions = useMemo( + () => buildSuggestions(creatorName, techniques), + [creatorName, techniques], + ); + + // Scroll to bottom when messages update + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + // Focus input when panel opens + useEffect(() => { + if (open) { + // Small delay to let animation settle + const t = setTimeout(() => inputRef.current?.focus(), 100); + return () => clearTimeout(t); + } + }, [open]); + + // Cleanup on unmount + useEffect(() => { + return () => { + abortRef.current?.abort(); + }; + }, []); + + // Escape key closes panel + useEffect(() => { + if (!open) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [open]); + + const sendMessage = useCallback( + (text: string) => { + const q = text.trim(); + if (!q || streaming) return; + + abortRef.current?.abort(); + + const userMsg: Message = { role: "user", text: q, sources: [], done: true }; + const assistantMsg: Message = { role: "assistant", text: "", sources: [], done: false }; + const newMessages = [...messages, userMsg, assistantMsg]; + setMessages(newMessages); + setInput(""); + setStreaming(true); + + const assistantIndex = newMessages.length - 1; + + abortRef.current = streamChat( + q, + { + onSources: (sources) => { + setMessages((prev) => + prev.map((m, i) => (i === assistantIndex ? { ...m, sources } : m)), + ); + }, + onToken: (token) => { + setMessages((prev) => + prev.map((m, i) => + i === assistantIndex ? { ...m, text: m.text + token } : m, + ), + ); + }, + onDone: () => { + setMessages((prev) => + prev.map((m, i) => (i === assistantIndex ? { ...m, done: true } : m)), + ); + setStreaming(false); + }, + onError: (msg) => { + setMessages((prev) => + prev.map((m, i) => + i === assistantIndex ? { ...m, done: true, error: msg } : m, + ), + ); + setStreaming(false); + }, + }, + creatorName, + ); + }, + [messages, streaming, creatorName], + ); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + sendMessage(input); + }; + + const handleSuggestion = (q: string) => { + sendMessage(q); + }; + + if (!open) { + return ( + + ); + } + + const showSuggestions = messages.length === 0; + + return ( + <> + {/* Keep bubble underneath for z-index stacking, hidden visually */} +
+ {/* Header */} +
+
+ Chat with +

{creatorName}

+
+ +
+ + {/* Messages */} +
+ {showSuggestions && ( +
+

Suggested questions

+ {suggestions.map((q) => ( + + ))} +
+ )} + + {messages.map((msg, i) => { + if (msg.role === "user") { + return ( +
+ {msg.text} +
+ ); + } + + // Assistant message + return ( +
+ {msg.error ? ( +
{msg.error}
+ ) : ( + <> + {parseCitations(msg.text, msg.sources)} + {!msg.done && } + {msg.done && msg.sources.length > 0 && ( +
+

Sources

+
    + {msg.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}` : ""} + +
  2. + ); + })} +
+
+ )} + + )} +
+ ); + })} + + {/* Typing indicator during loading before first token */} + {streaming && + messages.length > 0 && + messages[messages.length - 1]?.role === "assistant" && + messages[messages.length - 1]?.text === "" && + !messages[messages.length - 1]?.error && ( +
+ + + +
+ )} + +
+
+ + {/* Input */} +
+ setInput(e.target.value)} + placeholder="Ask a question…" + disabled={streaming} + /> + +
+
+ + ); +} diff --git a/frontend/src/pages/CreatorDetail.tsx b/frontend/src/pages/CreatorDetail.tsx index 7271e3e..bce3116 100644 --- a/frontend/src/pages/CreatorDetail.tsx +++ b/frontend/src/pages/CreatorDetail.tsx @@ -18,6 +18,7 @@ import { import { useAuth } from "../context/AuthContext"; import CreatorAvatar from "../components/CreatorAvatar"; import { SocialIcon } from "../components/SocialIcons"; +import ChatWidget from "../components/ChatWidget"; import SortDropdown from "../components/SortDropdown"; import TagList from "../components/TagList"; import { catSlug } from "../utils/catSlug"; @@ -478,6 +479,8 @@ export default function CreatorDetail() { )} + +
); } diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index 4f20965..698cd57 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/follows.ts","./src/api/highlights.ts","./src/api/index.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ConfirmModal.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PlayerControls.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminAuditLog.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorTiers.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/HighlightQueue.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"} \ No newline at end of file +{"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/ChatWidget.tsx","./src/components/ConfirmModal.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PlayerControls.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminAuditLog.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorTiers.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/HighlightQueue.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"} \ No newline at end of file