diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts index fb0e319..98f656c 100644 --- a/frontend/src/api/chat.ts +++ b/frontend/src/api/chat.ts @@ -19,6 +19,10 @@ export interface ChatSource { summary: string; section_anchor: string; section_heading: string; + source_video_id?: string; + start_time?: number; + end_time?: number; + video_filename?: string; } export interface ChatDoneMeta { diff --git a/frontend/src/components/ChatWidget.module.css b/frontend/src/components/ChatWidget.module.css index 5d32aaa..441f785 100644 --- a/frontend/src/components/ChatWidget.module.css +++ b/frontend/src/components/ChatWidget.module.css @@ -462,6 +462,49 @@ cursor: not-allowed; } +/* ── Source card content wrapper ───────────────────────────── */ + +.sourceContent { + display: flex; + flex-direction: column; + gap: 0.125rem; + overflow: hidden; + min-width: 0; +} + +/* ── Timestamp badge & video metadata ─────────────────────── */ + +.timestampBadge { + display: inline-flex; + align-items: center; + gap: 0.1875rem; + padding: 0.0625rem 0.25rem; + background: rgba(var(--color-accent-rgb, 217, 186, 140), 0.12); + color: var(--color-accent); + border-radius: 3px; + font-size: 0.625rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + text-decoration: none; + transition: background 0.15s; + white-space: nowrap; + width: fit-content; +} + +.timestampBadge:hover { + background: rgba(var(--color-accent-rgb, 217, 186, 140), 0.22); + text-decoration: none; +} + +.videoMeta { + color: var(--color-text-secondary); + font-size: 0.5625rem; + opacity: 0.6; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* ── Responsive (mobile) ──────────────────────────────────── */ @media (max-width: 640px) { diff --git a/frontend/src/components/ChatWidget.tsx b/frontend/src/components/ChatWidget.tsx index 624c082..088d75d 100644 --- a/frontend/src/components/ChatWidget.tsx +++ b/frontend/src/components/ChatWidget.tsx @@ -9,11 +9,10 @@ import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"; import { Link } from "react-router-dom"; import { streamChat, type ChatSource } from "../api/chat"; +import { parseChatCitations } from "../utils/chatCitations"; +import { formatTime } from "../utils/formatTime"; 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; @@ -60,61 +59,6 @@ function buildSuggestions(creatorName: string, techniques: Technique[]): string[ 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]; -} - /** Map personality weight to a human-readable tier label. */ function getTierLabel(weight: number): string { if (weight < 0.2) return "Encyclopedic"; @@ -339,7 +283,7 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
{msg.error}
) : ( <> - {parseCitations(msg.text, msg.sources)} + {parseChatCitations(msg.text, msg.sources, styles)} {!msg.done && } {msg.done && msg.sources.length > 0 && (
@@ -352,10 +296,25 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps) return (
  • {src.number} - - {src.title} - {src.section_heading ? ` — ${src.section_heading}` : ""} - +
    + + {src.title} + {src.section_heading ? ` — ${src.section_heading}` : ""} + + {src.start_time != null && src.source_video_id && ( + + ▶ {formatTime(src.start_time)} + {src.end_time != null && `–${formatTime(src.end_time)}`} + + )} + {src.video_filename && ( + {src.video_filename} + )} +
  • ); })} diff --git a/frontend/src/pages/ChatPage.module.css b/frontend/src/pages/ChatPage.module.css index f56c293..e56bc1b 100644 --- a/frontend/src/pages/ChatPage.module.css +++ b/frontend/src/pages/ChatPage.module.css @@ -252,6 +252,36 @@ font-size: 0.75rem; } +/* ── Timestamp badge & video metadata ─────────────────────── */ + +.timestampBadge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + margin-left: 0.5rem; + padding: 0.125rem 0.375rem; + background: rgba(var(--color-accent-rgb, 217, 186, 140), 0.12); + color: var(--color-accent); + border-radius: 4px; + font-size: 0.6875rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + text-decoration: none; + transition: background 0.15s; + white-space: nowrap; +} + +.timestampBadge:hover { + background: rgba(var(--color-accent-rgb, 217, 186, 140), 0.22); + text-decoration: none; +} + +.videoMeta { + color: var(--color-text-secondary); + font-size: 0.6875rem; + opacity: 0.7; +} + /* ── Citation superscript links in text ───────────────────── */ .citationGroup { diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index 674bcd4..4310cdc 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -10,12 +10,11 @@ import React, { useState, useRef, useCallback, useEffect } from "react"; import { Link } from "react-router-dom"; import { streamChat, type ChatSource } from "../api/chat"; +import { parseChatCitations } from "../utils/chatCitations"; +import { formatTime } from "../utils/formatTime"; 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; - interface Message { role: "user" | "assistant"; text: string; @@ -24,67 +23,6 @@ interface Message { error?: string; } -/** - * Parse text containing [N] markers into React nodes with citation links. - */ -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"); @@ -244,7 +182,7 @@ export default function ChatPage() { ) : ( <>
    - {parseChatCitations(msg.text, msg.sources)} + {parseChatCitations(msg.text, msg.sources, styles)} {!msg.done && }
    {msg.done && msg.sources.length > 0 && ( @@ -273,12 +211,28 @@ export default function ChatPage() { ? ` — ${src.section_heading}` : ""} + {src.start_time != null && src.source_video_id && ( + + ▶ {formatTime(src.start_time)} + {src.end_time != null && `–${formatTime(src.end_time)}`} + + )} {src.creator_name && ( {" "} · {src.creator_name} )} + {src.video_filename && ( + + {" "} + · {src.video_filename} + + )}
    ); diff --git a/frontend/src/utils/chatCitations.tsx b/frontend/src/utils/chatCitations.tsx new file mode 100644 index 0000000..3b6a13b --- /dev/null +++ b/frontend/src/utils/chatCitations.tsx @@ -0,0 +1,77 @@ +/** + * Shared chat citation parser for ChatPage and ChatWidget. + * + * Parses [N] and [N,M] markers in streamed chat text into superscript + * elements pointing to technique pages with optional section anchors. + */ + +import React from "react"; +import { Link } from "react-router-dom"; +import type { ChatSource } from "../api/chat"; + +/** Matches [1], [2,3], [1,2,3] etc. */ +const CITATION_RE = /\[(\d+(?:,\s*\d+)*)\]/g; + +/** + * Parse text containing [N] markers into React nodes with citation links. + * Takes a styles object so both ChatPage and ChatWidget can pass their own CSS modules. + * Expects `citationGroup` and `citationLink` keys in the styles object. + */ +export function parseChatCitations( + text: string, + sources: ChatSource[], + styles: Record, +): 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]; +} diff --git a/frontend/src/utils/formatTime.ts b/frontend/src/utils/formatTime.ts new file mode 100644 index 0000000..dcb500c --- /dev/null +++ b/frontend/src/utils/formatTime.ts @@ -0,0 +1,15 @@ +/** + * Format a duration in seconds as M:SS (< 1 hour) or H:MM:SS (>= 1 hour). + * Used by player controls, chapter markers, key moment badges, and chat source cards. + */ +export function formatTime(seconds: number): string { + const total = Math.floor(seconds); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + + if (h > 0) { + return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; + } + return `${m}:${s.toString().padStart(2, "0")}`; +} diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index 2238751..f7690f0 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/posts.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/shorts.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/templates.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/PersonalityProfile.tsx","./src/components/PlayerControls.tsx","./src/components/PostsFeed.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/EmbedPlayer.tsx","./src/pages/HighlightQueue.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/PostEditor.tsx","./src/pages/PostsList.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/ShortPlayer.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","./src/utils/clipboard.ts"],"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/posts.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/shorts.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/templates.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/PersonalityProfile.tsx","./src/components/PlayerControls.tsx","./src/components/PostsFeed.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/EmbedPlayer.tsx","./src/pages/HighlightQueue.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/PostEditor.tsx","./src/pages/PostsList.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/ShortPlayer.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/chatCitations.tsx","./src/utils/citations.tsx","./src/utils/clipboard.ts","./src/utils/formatTime.ts"],"version":"5.6.3"} \ No newline at end of file