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:
parent
f706afe8f6
commit
0eecee4271
11 changed files with 316 additions and 130 deletions
|
|
@ -18,7 +18,7 @@
|
|||
- Estimate: 30m
|
||||
- Files: backend/search_service.py, backend/chat_service.py
|
||||
- Verify: cd backend && python -c "from chat_service import _build_sources; print('OK')" && python -c "from search_service import SearchService; print('OK')"
|
||||
- [ ] **T02: Timestamp links and enhanced source cards in ChatPage, ChatWidget, and shared citation utility** — Extend the frontend ChatSource type with video fields, update both ChatPage and ChatWidget source cards to show timestamp badges that link to the WatchPage, and consolidate the duplicated citation parser into a shared utility.
|
||||
- [x] **T02: Extract shared citation parser and formatTime utilities, add timestamp badge links and video metadata to chat source cards in ChatPage and ChatWidget** — Extend the frontend ChatSource type with video fields, update both ChatPage and ChatWidget source cards to show timestamp badges that link to the WatchPage, and consolidate the duplicated citation parser into a shared utility.
|
||||
|
||||
## Steps
|
||||
|
||||
|
|
|
|||
16
.gsd/milestones/M024/slices/S05/tasks/T01-VERIFY.json
Normal file
16
.gsd/milestones/M024/slices/S05/tasks/T01-VERIFY.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"taskId": "T01",
|
||||
"unitId": "M024/S05/T01",
|
||||
"timestamp": 1775302859036,
|
||||
"passed": true,
|
||||
"discoverySource": "task-plan",
|
||||
"checks": [
|
||||
{
|
||||
"command": "cd backend",
|
||||
"exitCode": 0,
|
||||
"durationMs": 9,
|
||||
"verdict": "pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
88
.gsd/milestones/M024/slices/S05/tasks/T02-SUMMARY.md
Normal file
88
.gsd/milestones/M024/slices/S05/tasks/T02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
---
|
||||
id: T02
|
||||
parent: S05
|
||||
milestone: M024
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["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"]
|
||||
key_decisions: ["Pass CSS module styles as Record<string, string> to shared citation parser to avoid CSSModuleClasses structural typing mismatch"]
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "Frontend build passes with zero TypeScript and Vite errors. Confirmed local parse functions and CITATION_RE constants fully removed from both ChatPage.tsx and ChatWidget.tsx."
|
||||
completed_at: 2026-04-04T11:45:48.138Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T02: Extract shared citation parser and formatTime utilities, add timestamp badge links and video metadata to chat source cards in ChatPage and ChatWidget
|
||||
|
||||
> Extract shared citation parser and formatTime utilities, add timestamp badge links and video metadata to chat source cards in ChatPage and ChatWidget
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T02
|
||||
parent: S05
|
||||
milestone: M024
|
||||
key_files:
|
||||
- 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
|
||||
key_decisions:
|
||||
- Pass CSS module styles as Record<string, string> to shared citation parser to avoid CSSModuleClasses structural typing mismatch
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-04-04T11:45:48.138Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T02: Extract shared citation parser and formatTime utilities, add timestamp badge links and video metadata to chat source cards in ChatPage and ChatWidget
|
||||
|
||||
**Extract shared citation parser and formatTime utilities, add timestamp badge links and video metadata to chat source cards in ChatPage and ChatWidget**
|
||||
|
||||
## What Happened
|
||||
|
||||
Extended ChatSource interface with video fields (source_video_id, start_time, end_time, video_filename). Created shared parseChatCitations utility in utils/chatCitations.tsx, removing duplicate implementations from both ChatPage and ChatWidget. Created shared formatTime utility with hour-aware formatting. Updated source card rendering in both components to show timestamp badges linking to /watch/:id?t=N and video filename metadata. Added corresponding CSS classes to both module stylesheets.
|
||||
|
||||
## Verification
|
||||
|
||||
Frontend build passes with zero TypeScript and Vite errors. Confirmed local parse functions and CITATION_RE constants fully removed from both ChatPage.tsx and ChatWidget.tsx.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 6700ms |
|
||||
| 2 | `grep -c CITATION_RE ChatPage.tsx ChatWidget.tsx` | 0 | ✅ pass | 100ms |
|
||||
| 3 | `grep -c 'function parseCitations' ChatPage.tsx ChatWidget.tsx` | 0 | ✅ pass | 100ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
Used Record<string, string> for styles param instead of strict interface due to CSSModuleClasses typing. Added .sourceContent wrapper div in ChatWidget for vertical layout.
|
||||
|
||||
## Known Issues
|
||||
|
||||
None.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `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`
|
||||
|
||||
|
||||
## Deviations
|
||||
Used Record<string, string> for styles param instead of strict interface due to CSSModuleClasses typing. Added .sourceContent wrapper div in ChatWidget for vertical layout.
|
||||
|
||||
## Known Issues
|
||||
None.
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<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. */
|
||||
function getTierLabel(weight: number): string {
|
||||
if (weight < 0.2) return "Encyclopedic";
|
||||
|
|
@ -339,7 +283,7 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
|
|||
<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 && msg.sources.length > 0 && (
|
||||
<div className={styles.sources}>
|
||||
|
|
@ -352,10 +296,25 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
|
|||
return (
|
||||
<li key={src.number} className={styles.sourceItem}>
|
||||
<span className={styles.sourceNum}>{src.number}</span>
|
||||
<Link to={href} className={styles.sourceLink}>
|
||||
{src.title}
|
||||
{src.section_heading ? ` — ${src.section_heading}` : ""}
|
||||
</Link>
|
||||
<div className={styles.sourceContent}>
|
||||
<Link to={href} className={styles.sourceLink}>
|
||||
{src.title}
|
||||
{src.section_heading ? ` — ${src.section_heading}` : ""}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<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() {
|
||||
useDocumentTitle("Chat — Chrysopedia");
|
||||
|
||||
|
|
@ -244,7 +182,7 @@ export default function ChatPage() {
|
|||
) : (
|
||||
<>
|
||||
<div className={styles.responseText}>
|
||||
{parseChatCitations(msg.text, msg.sources)}
|
||||
{parseChatCitations(msg.text, msg.sources, styles)}
|
||||
{!msg.done && <span className={styles.cursor} />}
|
||||
</div>
|
||||
{msg.done && msg.sources.length > 0 && (
|
||||
|
|
@ -273,12 +211,28 @@ export default function ChatPage() {
|
|||
? ` — ${src.section_heading}`
|
||||
: ""}
|
||||
</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 && (
|
||||
<span className={styles.sourceMeta}>
|
||||
{" "}
|
||||
· {src.creator_name}
|
||||
</span>
|
||||
)}
|
||||
{src.video_filename && (
|
||||
<span className={styles.videoMeta}>
|
||||
{" "}
|
||||
· {src.video_filename}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
|
|
|||
77
frontend/src/utils/chatCitations.tsx
Normal file
77
frontend/src/utils/chatCitations.tsx
Normal 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];
|
||||
}
|
||||
15
frontend/src/utils/formatTime.ts
Normal file
15
frontend/src/utils/formatTime.ts
Normal 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")}`;
|
||||
}
|
||||
|
|
@ -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"}
|
||||
Loading…
Add table
Reference in a new issue