sync: Major UI updates from private repo (Jan 16-24)

Features synced:
- UI Panels Refactor: flexible pane system with ChatPane, NodePane,
  DimensionsPane, WorkflowsPane, ViewsPane, MapPane
- Source Content Reader: content type detection + 4 formatters
  (transcript, book, markdown, raw)
- Source Content Search: Cmd+F search in Source Reader
- Feed Layout Overhaul: LeftToolbar, SplitHandle, CollapsedRail
- Map Panel: promoted to first-class pane
- Edge policy simplification: auto-inference + direction correction

New files:
- src/components/panes/* (pane system)
- src/components/layout/CollapsedRail.tsx
- src/components/layout/LeftToolbar.tsx
- src/components/layout/SplitHandle.tsx
- src/components/focus/source/* (Source Reader + formatters)
- src/components/views/ViewsOverlay.tsx

Updated:
- ThreePanelLayout.tsx (complete refactor)
- FocusPanel.tsx (Source tab integration)
- API routes (dimensions GET, edges auto-inference)
- Database services (nodes, edges, dimensions)

Dependencies added:
- react-markdown
- remark-gfm

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
“BeeRad”
2026-01-24 21:54:21 +11:00
co-authored by Claude Opus 4.5
parent cea1df7f1f
commit 05523b7cb2
34 changed files with 7895 additions and 991 deletions
@@ -0,0 +1,229 @@
"use client";
import { useMemo, useCallback, useRef } from 'react';
interface TranscriptFormatterProps {
content: string;
onTextSelect?: (text: string) => void;
highlightedText?: string | null;
highlightMatchIndex?: number;
}
interface TranscriptSegment {
timestamp: string;
speaker: string | null;
text: string;
}
// Timestamp patterns for parsing
const TIMESTAMP_REGEX = /^(\[?\d{1,2}:\d{2}(?::\d{2})?\]?|\(\d{1,2}:\d{2}(?::\d{2})?\)|\d{1,2}:\d{2}(?::\d{2})?\s*[-–—]|\[\d+(?:\.\d+)?s\])\s*/;
// Speaker pattern - looks for "Name:" at start after timestamp
const SPEAKER_REGEX = /^([A-Z][a-zA-Z\s]{1,30}):?\s+/;
/**
* Parse a line into timestamp, speaker, and text components
*/
function parseLine(line: string): TranscriptSegment | null {
const trimmed = line.trim();
if (!trimmed) return null;
const timestampMatch = trimmed.match(TIMESTAMP_REGEX);
if (!timestampMatch) return null;
const timestamp = timestampMatch[1].replace(/[-–—]\s*$/, '').replace(/[\[\]\(\)]/g, '').trim();
let remaining = trimmed.slice(timestampMatch[0].length);
// Check for speaker name
let speaker: string | null = null;
const speakerMatch = remaining.match(SPEAKER_REGEX);
if (speakerMatch) {
speaker = speakerMatch[1].trim();
remaining = remaining.slice(speakerMatch[0].length);
}
return {
timestamp,
speaker,
text: remaining.trim(),
};
}
/**
* Group consecutive non-timestamp lines with the previous segment
*/
function parseTranscript(content: string): TranscriptSegment[] {
const lines = content.split('\n');
const segments: TranscriptSegment[] = [];
let currentSegment: TranscriptSegment | null = null;
for (const line of lines) {
const parsed = parseLine(line);
if (parsed) {
if (currentSegment) {
segments.push(currentSegment);
}
currentSegment = parsed;
} else if (currentSegment && line.trim()) {
currentSegment.text += '\n' + line.trim();
}
}
if (currentSegment) {
segments.push(currentSegment);
}
return segments;
}
/**
* Transcript Formatter - Clean view for timestamped content
*/
export default function TranscriptFormatter({ content, onTextSelect, highlightedText, highlightMatchIndex = 0 }: TranscriptFormatterProps) {
const containerRef = useRef<HTMLDivElement>(null);
const globalMatchCounter = useRef(0);
// Reset counter before each render
globalMatchCounter.current = 0;
const segments = useMemo(() => parseTranscript(content), [content]);
const handleMouseUp = useCallback(() => {
if (!onTextSelect) return;
const selection = window.getSelection();
const text = selection?.toString().trim();
if (text && text.length > 10) {
const truncatedText = text.length > 2000
? text.slice(0, 2000) + '...'
: text;
onTextSelect(truncatedText);
selection?.removeAllRanges();
}
}, [onTextSelect]);
const renderWithHighlight = useCallback((text: string): React.ReactNode => {
if (!highlightedText) return text;
const textLower = text.toLowerCase();
const searchLower = highlightedText.toLowerCase();
if (!textLower.includes(searchLower)) {
return text;
}
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let pos = 0;
while (pos < textLower.length) {
const index = textLower.indexOf(searchLower, pos);
if (index === -1) break;
if (index > lastIndex) {
parts.push(text.slice(lastIndex, index));
}
const isCurrent = globalMatchCounter.current === highlightMatchIndex;
globalMatchCounter.current++;
parts.push(
<mark
key={`match-${index}`}
data-search-match={isCurrent ? 'current' : 'other'}
style={{
background: isCurrent ? 'rgba(250, 204, 21, 0.4)' : 'rgba(168, 85, 247, 0.2)',
color: isCurrent ? '#fef08a' : '#e9d5ff',
padding: '2px 0',
borderRadius: '2px',
}}
>
{text.slice(index, index + highlightedText.length)}
</mark>
);
lastIndex = index + highlightedText.length;
pos = index + 1;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? <>{parts}</> : text;
}, [highlightedText, highlightMatchIndex]);
if (segments.length === 0) {
return (
<div style={{
color: '#555',
fontSize: '15px',
fontStyle: 'italic',
textAlign: 'center',
padding: '40px 20px',
}}>
No transcript content detected
</div>
);
}
return (
<div
ref={containerRef}
onMouseUp={handleMouseUp}
style={{
maxWidth: '680px',
margin: '0 auto',
padding: '24px 16px',
}}
>
{segments.map((segment, index) => (
<div
key={index}
style={{
marginBottom: '24px',
}}
>
{/* Timestamp and speaker on same line */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '8px',
}}>
<span style={{
fontSize: '11px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
color: '#555',
}}>
{segment.timestamp}
</span>
{segment.speaker && (
<span style={{
fontSize: '12px',
fontWeight: 600,
color: '#888',
}}>
{segment.speaker}
</span>
)}
</div>
{/* Text content */}
<div style={{
fontFamily: "Georgia, 'Times New Roman', serif",
fontSize: '16px',
lineHeight: '1.75',
color: '#d4d4d4',
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
}}>
{renderWithHighlight(segment.text)}
</div>
</div>
))}
</div>
);
}