feat: Extract shared citation parser and formatTime utilities, add time…

- "frontend/src/utils/chatCitations.tsx"
- "frontend/src/utils/formatTime.ts"
- "frontend/src/api/chat.ts"
- "frontend/src/pages/ChatPage.tsx"
- "frontend/src/pages/ChatPage.module.css"
- "frontend/src/components/ChatWidget.tsx"
- "frontend/src/components/ChatWidget.module.css"

GSD-Task: S05/T02
This commit is contained in:
jlightner 2026-04-04 11:46:00 +00:00
parent 2f9e3272d9
commit ecfdc76ba6
8 changed files with 211 additions and 129 deletions

View file

@ -19,6 +19,10 @@ export interface ChatSource {
summary: string; summary: string;
section_anchor: string; section_anchor: string;
section_heading: string; section_heading: string;
source_video_id?: string;
start_time?: number;
end_time?: number;
video_filename?: string;
} }
export interface ChatDoneMeta { export interface ChatDoneMeta {

View file

@ -462,6 +462,49 @@
cursor: not-allowed; 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) ──────────────────────────────────── */ /* ── Responsive (mobile) ──────────────────────────────────── */
@media (max-width: 640px) { @media (max-width: 640px) {

View file

@ -9,11 +9,10 @@
import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"; import React, { useState, useRef, useCallback, useEffect, useMemo } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { streamChat, type ChatSource } from "../api/chat"; import { streamChat, type ChatSource } from "../api/chat";
import { parseChatCitations } from "../utils/chatCitations";
import { formatTime } from "../utils/formatTime";
import styles from "./ChatWidget.module.css"; import styles from "./ChatWidget.module.css";
// Same citation regex as ChatPage / utils/citations.tsx
const CITATION_RE = /\[(\d+(?:,\s*\d+)*)\]/g;
interface Technique { interface Technique {
title: string; title: string;
slug: string; slug: string;
@ -60,61 +59,6 @@ function buildSuggestions(creatorName: string, techniques: Technique[]): string[
return suggestions.slice(0, 3); 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(
<Link
key={`${matchStart}-${idx}`}
to={href}
className={styles.citationLink}
title={`${source.title}${source.section_heading ? `${source.section_heading}` : ""}`}
>
{idx}
</Link>,
);
} else {
if (i > 0) links.push(", ");
links.push(String(idx));
}
}
nodes.push(
<sup key={`cite-${matchStart}`} className={styles.citationGroup}>
[{links}]
</sup>,
);
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. */ /** Map personality weight to a human-readable tier label. */
function getTierLabel(weight: number): string { function getTierLabel(weight: number): string {
if (weight < 0.2) return "Encyclopedic"; if (weight < 0.2) return "Encyclopedic";
@ -339,7 +283,7 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
<div className={styles.errorMsg}>{msg.error}</div> <div className={styles.errorMsg}>{msg.error}</div>
) : ( ) : (
<> <>
{parseCitations(msg.text, msg.sources)} {parseChatCitations(msg.text, msg.sources, styles)}
{!msg.done && <span className={styles.cursor} />} {!msg.done && <span className={styles.cursor} />}
{msg.done && msg.sources.length > 0 && ( {msg.done && msg.sources.length > 0 && (
<div className={styles.sources}> <div className={styles.sources}>
@ -352,10 +296,25 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
return ( return (
<li key={src.number} className={styles.sourceItem}> <li key={src.number} className={styles.sourceItem}>
<span className={styles.sourceNum}>{src.number}</span> <span className={styles.sourceNum}>{src.number}</span>
<div className={styles.sourceContent}>
<Link to={href} className={styles.sourceLink}> <Link to={href} className={styles.sourceLink}>
{src.title} {src.title}
{src.section_heading ? `${src.section_heading}` : ""} {src.section_heading ? `${src.section_heading}` : ""}
</Link> </Link>
{src.start_time != null && src.source_video_id && (
<Link
to={`/watch/${src.source_video_id}?t=${Math.floor(src.start_time)}`}
className={styles.timestampBadge}
title="Jump to video timestamp"
>
{formatTime(src.start_time)}
{src.end_time != null && `${formatTime(src.end_time)}`}
</Link>
)}
{src.video_filename && (
<span className={styles.videoMeta}>{src.video_filename}</span>
)}
</div>
</li> </li>
); );
})} })}

View file

@ -252,6 +252,36 @@
font-size: 0.75rem; 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 ───────────────────── */ /* ── Citation superscript links in text ───────────────────── */
.citationGroup { .citationGroup {

View file

@ -10,12 +10,11 @@
import React, { useState, useRef, useCallback, useEffect } from "react"; import React, { useState, useRef, useCallback, useEffect } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { streamChat, type ChatSource } from "../api/chat"; import { streamChat, type ChatSource } from "../api/chat";
import { parseChatCitations } from "../utils/chatCitations";
import { formatTime } from "../utils/formatTime";
import { useDocumentTitle } from "../hooks/useDocumentTitle"; import { useDocumentTitle } from "../hooks/useDocumentTitle";
import styles from "./ChatPage.module.css"; 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 { interface Message {
role: "user" | "assistant"; role: "user" | "assistant";
text: string; text: string;
@ -24,67 +23,6 @@ interface Message {
error?: string; 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(
<Link
key={`${matchStart}-${idx}`}
to={href}
className={styles.citationLink}
title={`${source.title}${source.section_heading ? `${source.section_heading}` : ""}`}
>
{idx}
</Link>,
);
} else {
if (i > 0) links.push(", ");
links.push(String(idx));
}
}
nodes.push(
<sup key={`cite-${matchStart}`} className={styles.citationGroup}>
[{links}]
</sup>,
);
lastIndex = matchStart + match[0].length;
}
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes.length > 0 ? nodes : [text];
}
export default function ChatPage() { export default function ChatPage() {
useDocumentTitle("Chat — Chrysopedia"); useDocumentTitle("Chat — Chrysopedia");
@ -244,7 +182,7 @@ export default function ChatPage() {
) : ( ) : (
<> <>
<div className={styles.responseText}> <div className={styles.responseText}>
{parseChatCitations(msg.text, msg.sources)} {parseChatCitations(msg.text, msg.sources, styles)}
{!msg.done && <span className={styles.cursor} />} {!msg.done && <span className={styles.cursor} />}
</div> </div>
{msg.done && msg.sources.length > 0 && ( {msg.done && msg.sources.length > 0 && (
@ -273,12 +211,28 @@ export default function ChatPage() {
? `${src.section_heading}` ? `${src.section_heading}`
: ""} : ""}
</Link> </Link>
{src.start_time != null && src.source_video_id && (
<Link
to={`/watch/${src.source_video_id}?t=${Math.floor(src.start_time)}`}
className={styles.timestampBadge}
title="Jump to video timestamp"
>
{formatTime(src.start_time)}
{src.end_time != null && `${formatTime(src.end_time)}`}
</Link>
)}
{src.creator_name && ( {src.creator_name && (
<span className={styles.sourceMeta}> <span className={styles.sourceMeta}>
{" "} {" "}
· {src.creator_name} · {src.creator_name}
</span> </span>
)} )}
{src.video_filename && (
<span className={styles.videoMeta}>
{" "}
· {src.video_filename}
</span>
)}
</div> </div>
</li> </li>
); );

View file

@ -0,0 +1,77 @@
/**
* Shared chat citation parser for ChatPage and ChatWidget.
*
* Parses [N] and [N,M] markers in streamed chat text into superscript
* <Link> 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<string, string>,
): 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(
<Link
key={`${matchStart}-${idx}`}
to={href}
className={styles.citationLink}
title={`${source.title}${source.section_heading ? `${source.section_heading}` : ""}`}
>
{idx}
</Link>,
);
} else {
if (i > 0) links.push(", ");
links.push(String(idx));
}
}
nodes.push(
<sup key={`cite-${matchStart}`} className={styles.citationGroup}>
[{links}]
</sup>,
);
lastIndex = matchStart + match[0].length;
}
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes.length > 0 ? nodes : [text];
}

View file

@ -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")}`;
}

View file

@ -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"} {"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"}