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