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
+429 -384
View File
@@ -10,6 +10,7 @@ import { Node, NodeConnection, Chunk } from '@/types/database';
import DimensionTags from './dimensions/DimensionTags';
import { getNodeIcon } from '@/utils/nodeIcons';
import ConfirmDialog from '../common/ConfirmDialog';
import { SourceReader } from './source';
interface PopularDimension {
dimension: string;
@@ -36,10 +37,17 @@ interface FocusPanelProps {
onTabClose: (nodeId: number) => void;
refreshTrigger?: number;
onReorderTabs?: (fromIndex: number, toIndex: number) => void;
onOpenInOtherSlot?: (nodeId: number) => void;
hideTabBar?: boolean;
onTextSelect?: (nodeId: number, nodeTitle: string, text: string) => void;
highlightedPassage?: { nodeId: number; selectedText: string } | null;
}
export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeClick, onTabClose, refreshTrigger }: FocusPanelProps) {
const [nodesData, setNodesData] = useState<Record<number, Node>>({});
export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeClick, onTabClose, refreshTrigger, onOpenInOtherSlot, hideTabBar, onTextSelect, highlightedPassage }: FocusPanelProps) {
const [nodesData, setNodesData] = useState<Record<number, Node>>({});
// Context menu state
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: number } | null>(null);
const [loadingNodes, setLoadingNodes] = useState<Set<number>>(new Set());
const [editingField, setEditingField] = useState<string | null>(null);
const [editingValue, setEditingValue] = useState<string>('');
@@ -62,6 +70,21 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
}
}, [showReembedPrompt]);
// Close context menu when clicking outside
useEffect(() => {
if (!contextMenu) return;
const handleClick = () => setContextMenu(null);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setContextMenu(null);
};
document.addEventListener('click', handleClick);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('click', handleClick);
document.removeEventListener('keydown', handleKeyDown);
};
}, [contextMenu]);
// Edges state management (following same patterns as nodes)
const [edgesData, setEdgesData] = useState<{ [key: number]: NodeConnection[] }>({});
const [loadingEdges, setLoadingEdges] = useState<Set<number>>(new Set());
@@ -112,11 +135,40 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
const [sourceEditValue, setSourceEditValue] = useState('');
const [sourceSaving, setSourceSaving] = useState(false);
// Source reader mode: 'raw' (monospace) or 'reader' (formatted typography)
const [sourceReaderMode, setSourceReaderMode] = useState<'raw' | 'reader'>('reader');
// Embedded chunks state (actual chunks from chunks table)
const [chunksData, setChunksData] = useState<Record<number, Chunk[]>>({});
const [loadingChunks, setLoadingChunks] = useState<Set<number>>(new Set());
const [chunksExpanded, setChunksExpanded] = useState<Record<number, boolean>>({});
// Helper: preview edge type based on heuristics (mirrors backend logic)
const previewEdgeType = (explanation: string): { type: string; label: string } | null => {
const norm = (explanation || '').trim().toLowerCase();
if (!norm) return null;
const startsWithAny = (prefixes: string[]) => prefixes.some((p) => norm.startsWith(p));
if (startsWithAny(['created by', 'made by', 'authored by', 'written by', 'founded by'])) {
return { type: 'created_by', label: 'created by' };
}
if (startsWithAny(['part of', 'episode of', 'belongs to', 'in the series', 'in this series'])) {
return { type: 'part_of', label: 'part of' };
}
if (startsWithAny(['features', 'mentions', 'hosted by', 'guest:', 'host:'])) {
return { type: 'part_of', label: 'part of' };
}
if (startsWithAny(['came from', 'inspired by', 'derived from', 'from'])) {
return { type: 'source_of', label: 'source of' };
}
if (startsWithAny(['related to', 'related'])) {
return { type: 'related_to', label: 'related to' };
}
// No heuristic match - will be AI-inferred
return { type: 'inferred', label: 'will be inferred' };
};
// Fetch priority dimensions on mount
useEffect(() => {
fetchPriorityDimensions();
@@ -184,6 +236,20 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
}
}, [refreshTrigger, activeTab]);
// Clear editing state when switching nodes
useEffect(() => {
// Clear all edit modes when switching to a different node
setEditingField(null);
setEditingValue('');
setEditingNodeId(null);
// Also clear notes/desc/source edit modes
setNotesEditMode(false);
setNotesEditValue('');
setDescEditMode(false);
setDescEditValue('');
setSourceEditMode(false);
setSourceEditValue('');
}, [activeTab]);
const fetchPriorityDimensions = async () => {
try {
@@ -1024,9 +1090,9 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
// Edge management functions (following same patterns as node functions)
const handleEdgeNodeSelect = (targetNodeId: number, _targetNodeTitle?: string) => {
setPendingEdgeTarget({ id: targetNodeId, title: _targetNodeTitle || `Node ${targetNodeId}` });
setEdgeExplanation('');
const handleEdgeNodeSelect = async (targetNodeId: number, _targetNodeTitle?: string) => {
// Immediately create edge - backend auto-infers explanation and type
await createEdgeAuto(targetNodeId);
};
// Handle node search keyboard navigation
@@ -1043,19 +1109,16 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
}
};
const handleSelectNodeSuggestion = (suggestion: NodeSearchResult) => {
setPendingEdgeTarget({ id: suggestion.id, title: suggestion.title });
setEdgeExplanation('');
const handleSelectNodeSuggestion = async (suggestion: NodeSearchResult) => {
// Immediately create edge - backend auto-infers explanation and type
await createEdgeAuto(suggestion.id);
setNodeSearchSuggestions([]);
setNodeSearchQuery('');
};
const createEdgeWithExplanation = async (targetNodeId: number, explanation: string) => {
// Auto-create edge - backend handles explanation generation and type inference
const createEdgeAuto = async (targetNodeId: number) => {
if (activeNodeId === null) return;
const trimmed = (explanation || '').trim();
if (!trimmed) {
alert('Please add a short explanation for why this connection exists.');
return;
}
try {
const response = await fetch('/api/edges', {
method: 'POST',
@@ -1066,7 +1129,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
from_node_id: activeNodeId,
to_node_id: targetNodeId,
source: 'user',
explanation: trimmed
explanation: '' // Empty - backend will auto-generate
}),
});
@@ -1076,7 +1139,43 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
// Refresh edges data
await fetchEdgesData(activeNodeId);
// Reset state
setAddingEdge(null);
setEdgeExplanation('');
setPendingEdgeTarget(null);
setShowConnectionsModal(false);
} catch (error) {
console.error('Error creating edge:', error);
alert('Failed to create edge. Please try again.');
}
};
// Legacy function for manual explanation (kept for compatibility)
const createEdgeWithExplanation = async (targetNodeId: number, explanation: string) => {
if (activeNodeId === null) return;
try {
const response = await fetch('/api/edges', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
from_node_id: activeNodeId,
to_node_id: targetNodeId,
source: 'user',
explanation: explanation || '' // Backend handles empty
}),
});
if (!response.ok) {
throw new Error('Failed to create edge');
}
// Refresh edges data
await fetchEdgesData(activeNodeId);
// Reset state
setAddingEdge(null);
setEdgeExplanation('');
@@ -1084,7 +1183,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
setNodeSearchQuery('');
setNodeSearchSuggestions([]);
setShowConnectionsModal(false);
} catch (error) {
console.error('Error creating edge:', error);
alert('Failed to create edge. Please try again.');
@@ -1264,122 +1363,6 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
</div>
)}
{/* Explanation (required) */}
{pendingEdgeTarget && (
<div style={{
marginTop: '10px',
background: '#0f0f0f',
border: '1px solid #262626',
borderRadius: '12px',
padding: '12px',
display: 'flex',
flexDirection: 'column',
gap: '10px'
}}>
<div style={{ color: '#e5e5e5', fontSize: '13px', fontWeight: 500 }}>
Connecting to: <span style={{ color: '#a3e635' }}>{pendingEdgeTarget.title}</span>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{[
{ label: 'Made by', value: 'Created by ' },
{ label: 'Part of', value: 'Part of ' },
{ label: 'Came from', value: 'Came from ' },
{ label: 'Related', value: 'Related to ' },
].map((chip) => (
<button
key={chip.label}
type="button"
onClick={() => {
setEdgeExplanation((prev) => {
const trimmed = (prev || '').trim();
return trimmed.length > 0 ? prev : chip.value;
});
}}
style={{
padding: '6px 10px',
fontSize: '12px',
borderRadius: '999px',
border: '1px solid #262626',
background: '#141414',
color: '#e5e5e5',
cursor: 'pointer',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#1a1a1a'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#141414'; }}
title={`Prefill: ${chip.value.trim()}`}
>
{chip.label}
</button>
))}
</div>
<textarea
value={edgeExplanation}
onChange={(e) => setEdgeExplanation(e.target.value)}
placeholder="Why does this connect? (e.g., 'Author of this book', 'Inspired this insight')"
rows={2}
style={{
width: '100%',
resize: 'vertical',
background: '#141414',
border: '1px solid #1f1f1f',
color: '#fafafa',
borderRadius: '10px',
padding: '10px',
fontSize: '13px',
outline: 'none',
fontFamily: 'inherit',
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation);
}
if (e.key === 'Escape') {
e.preventDefault();
setPendingEdgeTarget(null);
setEdgeExplanation('');
}
}}
/>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button
onClick={() => {
setPendingEdgeTarget(null);
setEdgeExplanation('');
}}
style={{
padding: '8px 10px',
background: 'transparent',
border: '1px solid #262626',
color: '#a3a3a3',
borderRadius: '10px',
cursor: 'pointer',
fontSize: '12px'
}}
>
Cancel
</button>
<button
onClick={() => createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation)}
style={{
padding: '8px 10px',
background: '#22c55e',
border: '1px solid #16a34a',
color: '#0a0a0a',
borderRadius: '10px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: 600
}}
>
Create connection
</button>
</div>
<div style={{ color: '#737373', fontSize: '11px' }}>
Tip: press <span style={{ fontFamily: 'monospace' }}></span>+<span style={{ fontFamily: 'monospace' }}>Enter</span> to create.
</div>
</div>
)}
</div>
{/* Existing Connections */}
@@ -1401,6 +1384,16 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
<div key={connection.id} style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{/* Direction arrow: → if current node is FROM (outgoing), ← if current node is TO (incoming) */}
<span style={{
fontSize: '12px',
color: connection.edge.from_node_id === activeTab ? '#22c55e' : '#f59e0b',
fontWeight: 600,
width: '16px',
textAlign: 'center'
}}>
{connection.edge.from_node_id === activeTab ? '→' : '←'}
</span>
<span style={{
display: 'inline-block',
fontSize: '10px',
@@ -1622,86 +1615,100 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
return (
<>
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: 'transparent' }}>
{/* Tab Bar */}
<div style={{
display: 'flex',
borderBottom: '1px solid #1a1a1a',
background: '#0f0f0f',
flexShrink: 0,
overflowX: 'auto',
overflowY: 'hidden'
}}>
{openTabs.length === 0 ? (
<div style={{
padding: '10px 16px',
fontSize: '12px',
color: '#666'
}}>
No tabs open
</div>
) : (
openTabs.map((tabId) => {
const node = nodesData[tabId];
const isActive = activeTab === tabId;
const label = node ? truncateTitle(node.title || 'Untitled') : 'Loading...';
{/* Tab Bar - hidden when rendered from NodePane which has its own tab bar */}
{!hideTabBar && (
<div style={{
display: 'flex',
borderBottom: '1px solid #1a1a1a',
background: '#0f0f0f',
flexShrink: 0,
overflowX: 'auto',
overflowY: 'hidden'
}}>
{openTabs.length === 0 ? (
<div style={{
padding: '10px 16px',
fontSize: '12px',
color: '#666'
}}>
No tabs open
</div>
) : (
openTabs.map((tabId) => {
const node = nodesData[tabId];
const isActive = activeTab === tabId;
const label = node ? truncateTitle(node.title || 'Untitled') : 'Loading...';
return (
<div
key={tabId}
style={{
display: 'flex',
alignItems: 'center',
borderRight: '1px solid #1a1a1a',
background: isActive ? '#121212' : '#0f0f0f',
borderBottom: isActive ? '2px solid #666' : 'none',
paddingBottom: isActive ? '0' : '2px',
minWidth: '120px',
maxWidth: '200px'
}}
>
<button
onClick={() => onTabSelect(tabId)}
style={{
flex: 1,
padding: '8px 12px',
fontSize: '12px',
fontFamily: 'inherit',
color: isActive ? '#fff' : '#999',
background: 'transparent',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
return (
<div
key={tabId}
draggable
onDragStart={(e) => {
const title = node?.title || 'Untitled';
e.dataTransfer.effectAllowed = 'copyMove';
e.dataTransfer.setData('application/x-rah-tab', JSON.stringify({ id: tabId, title }));
e.dataTransfer.setData('text/plain', `[NODE:${tabId}:"${title}"]`);
}}
>
{label}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onTabClose(tabId);
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, tabId });
}}
style={{
padding: '4px 8px',
fontSize: '14px',
color: '#666',
background: 'transparent',
border: 'none',
cursor: 'pointer',
transition: 'color 0.2s'
display: 'flex',
alignItems: 'center',
borderRight: '1px solid #1a1a1a',
background: isActive ? '#121212' : '#0f0f0f',
borderBottom: isActive ? '2px solid #666' : 'none',
paddingBottom: isActive ? '0' : '2px',
minWidth: '120px',
maxWidth: '200px',
cursor: 'grab'
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
>
×
</button>
</div>
);
})
)}
</div>
<button
onClick={() => onTabSelect(tabId)}
style={{
flex: 1,
padding: '8px 12px',
fontSize: '12px',
fontFamily: 'inherit',
color: isActive ? '#fff' : '#999',
background: 'transparent',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{label}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onTabClose(tabId);
}}
style={{
padding: '4px 8px',
fontSize: '14px',
color: '#666',
background: 'transparent',
border: 'none',
cursor: 'pointer',
transition: 'color 0.2s'
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
>
×
</button>
</div>
);
})
)}
</div>
)}
{/* Content Area */}
<div style={{
@@ -2237,30 +2244,6 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
/>
</div>
)}
{/* Action buttons for Source tab */}
{activeContentTab === 'source' && !sourceEditMode && (
<div style={{ display: 'flex', gap: '8px', marginBottom: '4px' }}>
<button
onClick={startSourceEdit}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
fontSize: '10px',
color: '#888',
background: 'transparent',
border: '1px solid #2a2a2a',
borderRadius: '4px',
cursor: 'pointer'
}}
title="Edit source"
>
<Pencil size={12} />
Edit
</button>
</div>
)}
</div>
{/* Desc Tab Content */}
@@ -2820,46 +2803,133 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
</div>
</div>
) : (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'auto' }}>
{nodesData[activeTab]?.chunk ? (
<div
style={{
color: '#ccc',
fontSize: '12px',
lineHeight: '1.5',
padding: '12px',
background: '#0a0a0a',
border: '1px solid #1a1a1a',
borderRadius: '4px',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
flex: 1,
overflow: 'auto'
}}
>
{nodesData[activeTab].chunk}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Mode toggle and edit button */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '8px',
flexShrink: 0,
}}>
{/* Raw / Reader toggle */}
<div style={{
display: 'flex',
background: '#111',
borderRadius: '4px',
border: '1px solid #222',
overflow: 'hidden',
}}>
<button
onClick={() => setSourceReaderMode('raw')}
style={{
padding: '4px 10px',
fontSize: '10px',
fontWeight: 500,
color: sourceReaderMode === 'raw' ? '#fff' : '#666',
background: sourceReaderMode === 'raw' ? '#2a2a2a' : 'transparent',
border: 'none',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
>
Raw
</button>
<button
onClick={() => setSourceReaderMode('reader')}
style={{
padding: '4px 10px',
fontSize: '10px',
fontWeight: 500,
color: sourceReaderMode === 'reader' ? '#fff' : '#666',
background: sourceReaderMode === 'reader' ? '#2a2a2a' : 'transparent',
border: 'none',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
>
Reader
</button>
</div>
) : (
<div
{/* Edit button */}
<button
onClick={startSourceEdit}
style={{
color: '#555',
fontSize: '12px',
fontStyle: 'italic',
cursor: 'pointer',
padding: '12px',
border: '1px dashed #1a1a1a',
borderRadius: '4px',
textAlign: 'center',
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
gap: '4px',
padding: '4px 8px',
fontSize: '10px',
color: '#666',
background: 'transparent',
border: '1px solid #222',
borderRadius: '4px',
cursor: 'pointer',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#888';
e.currentTarget.style.borderColor = '#333';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '#666';
e.currentTarget.style.borderColor = '#222';
}}
>
No source content. Click to add.
</div>
)}
<Pencil size={10} />
Edit
</button>
</div>
{/* Content display */}
<div style={{ flex: 1, overflow: 'auto' }}>
{nodesData[activeTab]?.chunk ? (
sourceReaderMode === 'reader' ? (
<SourceReader
content={nodesData[activeTab].chunk}
onTextSelect={onTextSelect ? (text) => onTextSelect(activeTab, nodesData[activeTab]?.title || 'Untitled', text) : undefined}
highlightedText={highlightedPassage?.nodeId === activeTab ? highlightedPassage.selectedText : null}
/>
) : (
<div
style={{
color: '#ccc',
fontSize: '12px',
lineHeight: '1.5',
padding: '12px',
background: '#0a0a0a',
border: '1px solid #1a1a1a',
borderRadius: '4px',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
minHeight: '100%',
}}
>
{nodesData[activeTab].chunk}
</div>
)
) : (
<div
onClick={startSourceEdit}
style={{
color: '#555',
fontSize: '12px',
fontStyle: 'italic',
cursor: 'pointer',
padding: '12px',
border: '1px dashed #1a1a1a',
borderRadius: '4px',
textAlign: 'center',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
No source content. Click to add.
</div>
)}
</div>
</div>
)}
</div>
@@ -3062,126 +3132,6 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
</div>
)}
{/* Explanation prompt (required) */}
{pendingEdgeTarget && (
<div style={{
marginTop: '10px',
background: '#141414',
border: '1px solid #262626',
borderRadius: '16px',
padding: '16px 18px',
boxShadow: '0 0 0 1px rgba(255, 255, 255, 0.04), 0 24px 48px -12px rgba(0, 0, 0, 0.6)',
display: 'flex',
flexDirection: 'column',
gap: '12px'
}}>
<div style={{ color: '#e5e5e5', fontSize: '13px', fontWeight: 600 }}>
Create connection to: <span style={{ color: '#a3e635' }}>{pendingEdgeTarget.title}</span>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{[
{ label: 'Made by', value: 'Created by ' },
{ label: 'Part of', value: 'Part of ' },
{ label: 'Came from', value: 'Came from ' },
{ label: 'Related', value: 'Related to ' },
].map((chip) => (
<button
key={chip.label}
type="button"
onClick={() => {
setEdgeExplanation((prev) => {
const trimmed = (prev || '').trim();
return trimmed.length > 0 ? prev : chip.value;
});
}}
style={{
padding: '6px 10px',
fontSize: '12px',
borderRadius: '999px',
border: '1px solid #262626',
background: '#0f0f0f',
color: '#e5e5e5',
cursor: 'pointer',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#1a1a1a'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#0f0f0f'; }}
title={`Prefill: ${chip.value.trim()}`}
>
{chip.label}
</button>
))}
</div>
<textarea
value={edgeExplanation}
onChange={(e) => setEdgeExplanation(e.target.value)}
placeholder="Why does this connect? (e.g., 'Author of this book', 'Inspired this insight')"
rows={2}
style={{
width: '100%',
resize: 'vertical',
background: '#0f0f0f',
border: '1px solid #333',
color: '#fafafa',
borderRadius: '12px',
padding: '10px 12px',
fontSize: '13px',
outline: 'none',
fontFamily: 'inherit'
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation);
}
if (e.key === 'Escape') {
e.preventDefault();
setPendingEdgeTarget(null);
setEdgeExplanation('');
}
}}
autoFocus
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
onClick={() => {
setPendingEdgeTarget(null);
setEdgeExplanation('');
}}
style={{
padding: '8px 12px',
background: '#262626',
border: 'none',
borderRadius: '10px',
color: '#a3a3a3',
fontSize: '12px',
fontWeight: 600,
cursor: 'pointer'
}}
>
Cancel
</button>
<button
onClick={() => createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation)}
style={{
padding: '8px 12px',
background: '#22c55e',
border: '1px solid #16a34a',
borderRadius: '10px',
color: '#0a0a0a',
fontSize: '12px',
fontWeight: 700,
cursor: 'pointer'
}}
>
Create
</button>
</div>
<div style={{ color: '#737373', fontSize: '11px' }}>
Tip: press <span style={{ fontFamily: 'monospace' }}></span>+<span style={{ fontFamily: 'monospace' }}>Enter</span> to create.
</div>
</div>
)}
{/* Existing Connections */}
{!nodeSearchQuery && (
<div style={{
@@ -3231,6 +3181,17 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
>
{/* Connection header row */}
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{/* Direction arrow: → if current node is FROM (outgoing), ← if current node is TO (incoming) */}
<span style={{
fontSize: '14px',
color: connection.edge.from_node_id === activeTab ? '#22c55e' : '#f59e0b',
fontWeight: 600,
width: '18px',
textAlign: 'center',
flexShrink: 0
}}>
{connection.edge.from_node_id === activeTab ? '→' : '←'}
</span>
<span style={{
display: 'inline-flex',
alignItems: 'center',
@@ -3426,6 +3387,90 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
</div>
)}
{/* Tab Context Menu */}
{contextMenu && (
<div
style={{
position: 'fixed',
top: contextMenu.y,
left: contextMenu.x,
background: '#1a1a1a',
border: '1px solid #2a2a2a',
borderRadius: '6px',
padding: '4px',
zIndex: 9999,
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
minWidth: '160px',
}}
onClick={(e) => e.stopPropagation()}
>
{onOpenInOtherSlot && (
<button
onClick={() => {
onOpenInOtherSlot(contextMenu.tabId);
setContextMenu(null);
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
width: '100%',
padding: '8px 12px',
background: 'transparent',
border: 'none',
borderRadius: '4px',
color: '#ccc',
fontSize: '12px',
cursor: 'pointer',
textAlign: 'left',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2a2a2a';
e.currentTarget.style.color = '#fff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = '#ccc';
}}
>
<span style={{ fontSize: '14px' }}></span>
Open in other panel
</button>
)}
<button
onClick={() => {
onTabClose(contextMenu.tabId);
setContextMenu(null);
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
width: '100%',
padding: '8px 12px',
background: 'transparent',
border: 'none',
borderRadius: '4px',
color: '#ccc',
fontSize: '12px',
cursor: 'pointer',
textAlign: 'left',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2a2a2a';
e.currentTarget.style.color = '#fff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = '#ccc';
}}
>
<span style={{ fontSize: '14px' }}>×</span>
Close tab
</button>
</div>
)}
</>
);
}
@@ -90,14 +90,31 @@ export default function DimensionTags({
}
};
const addDimension = async (dimension: string) => {
const addDimension = async (dimension: string, description?: string) => {
if (!dimension.trim() || dimensions.includes(dimension.trim())) {
return;
}
// If description is provided, create/update the dimension in the database first
if (description) {
try {
await fetch('/api/dimensions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: dimension.trim(),
description: description.trim(),
isPriority: false
})
});
} catch (error) {
console.error('Error creating dimension with description:', error);
}
}
const newDimensions = [...dimensions, dimension.trim()];
await onUpdate(newDimensions);
setSearchQuery('');
setSuggestions([]);
setIsAdding(false);
@@ -387,8 +404,8 @@ export default function DimensionTags({
<DimensionSearchModal
isOpen={isAdding}
onClose={() => setIsAdding(false)}
onDimensionSelect={(dim) => {
addDimension(dim);
onDimensionSelect={(dim, description) => {
addDimension(dim, description);
}}
existingDimensions={dimensions}
/>
@@ -0,0 +1,92 @@
/**
* Content type detection for Source Reader
* Analyzes raw content to determine the best formatting approach
*/
export type ContentType = 'transcript' | 'book' | 'markdown' | 'article' | 'raw';
// Timestamp patterns for transcript detection
const TIMESTAMP_PATTERNS = [
/^\[?\d{1,2}:\d{2}(:\d{2})?\]?\s*/, // [00:00] or [00:00:00]
/^\d{1,2}:\d{2}(:\d{2})?\s*[-–—]\s*/, // 00:00 - or 00:00:00 -
/^\(\d{1,2}:\d{2}(:\d{2})?\)\s*/, // (00:00) or (00:00:00)
/^\[\d+(\.\d+)?s\]\s*/, // [0.1s] or [12.5s] - decimal seconds
];
// Simple book detection - look for explicit chapter markers
// We no longer try to parse numbered sections (too unreliable)
const BOOK_INDICATORS = [
/\b(CHAPTER|Chapter)\s+\d+/i,
/\b(Part|PART)\s+(I|II|III|IV|V|VI|VII|VIII|IX|X|\d+)\b/i,
/\bTable\s+of\s+Contents\b/i,
/\bPREFACE\b/,
/\bINTRODUCTION\b/,
/\bAPPENDIX\b/,
];
// Markdown syntax markers
const MARKDOWN_MARKERS = [
/^#{1,6}\s/m, // Headers
/\*\*[^*]+\*\*/, // Bold
/\*[^*]+\*/, // Italic (but not bold)
/^[-*+]\s/m, // Unordered lists
/^\d+\.\s/m, // Ordered lists
/```[\s\S]*?```/, // Code blocks
/`[^`]+`/, // Inline code
/\[.+\]\(.+\)/, // Links
];
/**
* Detect the content type of raw source content
*/
export function detectContentType(content: string): ContentType {
if (!content || content.length < 50) return 'raw';
const lines = content.split('\n').slice(0, 50); // Check first 50 lines
// 1. Check for transcript patterns (timestamps)
const timestampLines = lines.filter(line =>
TIMESTAMP_PATTERNS.some(pattern => pattern.test(line.trim()))
);
if (timestampLines.length >= 3) return 'transcript';
// 2. Check for book indicators (explicit markers like "Chapter", "Table of Contents")
const bookIndicatorCount = BOOK_INDICATORS.filter(pattern => pattern.test(content)).length;
if (bookIndicatorCount >= 2) return 'book';
// Also consider very long content as book-like (>20K chars)
if (content.length > 20000 && bookIndicatorCount >= 1) return 'book';
// 3. Check for markdown patterns
const markdownScore = calculateMarkdownScore(content);
if (markdownScore > 0.3) return 'markdown';
// 4. Check for article structure (paragraphs with clear breaks)
const paragraphs = content.split(/\n\n+/).filter(p => p.trim().length > 100);
if (paragraphs.length >= 3) return 'article';
// 5. Default to raw
return 'raw';
}
/**
* Calculate a "markdown-ness" score for content
* Returns a value between 0 and 1
*/
function calculateMarkdownScore(content: string): number {
const matchCount = MARKDOWN_MARKERS.filter(m => m.test(content)).length;
return matchCount / MARKDOWN_MARKERS.length;
}
/**
* Get a human-readable label for content type
*/
export function getContentTypeLabel(type: ContentType): string {
switch (type) {
case 'transcript': return 'Transcript';
case 'book': return 'Book/Chapter';
case 'markdown': return 'Markdown';
case 'article': return 'Article';
case 'raw': return 'Plain Text';
}
}
@@ -0,0 +1,170 @@
"use client";
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import { detectContentType, getContentTypeLabel } from './ContentDetector';
import RawFormatter from './formatters/RawFormatter';
import TranscriptFormatter from './formatters/TranscriptFormatter';
import BookFormatter from './formatters/BookFormatter';
import MarkdownFormatter from './formatters/MarkdownFormatter';
import SourceSearchBar from './SourceSearchBar';
interface SourceReaderProps {
content: string;
onTextSelect?: (text: string) => void;
highlightedText?: string | null;
}
/**
* Source Reader - Read-only formatted view of source content
*
* This is a pure presentation component that NEVER modifies the source data.
* It detects content type and applies appropriate formatting for comfortable reading.
*/
export default function SourceReader({
content,
onTextSelect,
highlightedText,
}: SourceReaderProps) {
const [showSearch, setShowSearch] = useState(false);
const [searchHighlight, setSearchHighlight] = useState<string | null>(null);
const [searchMatchIndex, setSearchMatchIndex] = useState(0);
const [scrollTrigger, setScrollTrigger] = useState(0);
const contentRef = useRef<HTMLDivElement>(null);
// Detect content type (memoized for performance)
const contentType = useMemo(() => detectContentType(content), [content]);
const contentTypeLabel = getContentTypeLabel(contentType);
// Combined highlight: search takes precedence over external highlight
const activeHighlight = searchHighlight || highlightedText;
// Keyboard shortcut for Cmd+F
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
e.preventDefault();
setShowSearch(true);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
// Scroll to current match when search highlight changes
useEffect(() => {
if (!searchHighlight || !contentRef.current) return;
// Small delay to let React render the mark element
const timer = setTimeout(() => {
const currentMatch = contentRef.current?.querySelector('mark[data-search-match="current"]');
if (currentMatch) {
currentMatch.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 50);
return () => clearTimeout(timer);
}, [searchHighlight, scrollTrigger]);
const handleSearchClose = useCallback(() => {
setShowSearch(false);
setSearchHighlight(null);
setSearchMatchIndex(0);
}, []);
const handleHighlightChange = useCallback((text: string | null, matchIndex: number) => {
setSearchHighlight(text);
setSearchMatchIndex(matchIndex);
// Trigger scroll even if same text (for navigating between matches)
setScrollTrigger(prev => prev + 1);
}, []);
// Render appropriate formatter based on content type
const renderContent = () => {
const highlightIndex = showSearch ? searchMatchIndex : undefined;
switch (contentType) {
case 'transcript':
return <TranscriptFormatter content={content} onTextSelect={onTextSelect} highlightedText={activeHighlight} highlightMatchIndex={highlightIndex} />;
case 'book':
case 'article':
return <BookFormatter content={content} onTextSelect={onTextSelect} highlightedText={activeHighlight} highlightMatchIndex={highlightIndex} />;
case 'markdown':
return <MarkdownFormatter content={content} onTextSelect={onTextSelect} highlightedText={activeHighlight} highlightMatchIndex={highlightIndex} />;
default:
return <RawFormatter content={content} onTextSelect={onTextSelect} highlightedText={activeHighlight} highlightMatchIndex={highlightIndex} />;
}
};
return (
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
background: '#0f0f0f',
borderRadius: '4px',
overflow: 'hidden',
}}>
{/* Header with content type and search */}
{content && content.length >= 50 && (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 16px',
borderBottom: '1px solid #1a1a1a',
}}>
<span style={{
fontSize: '10px',
color: '#555',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>
Detected: {contentTypeLabel}
</span>
<button
onClick={() => setShowSearch(!showSearch)}
title="Search content (⌘F)"
style={{
background: showSearch ? '#262626' : 'transparent',
border: 'none',
borderRadius: '4px',
padding: '4px 8px',
cursor: 'pointer',
color: showSearch ? '#fafafa' : '#555',
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '10px',
transition: 'all 150ms ease',
}}
>
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
</svg>
<span>Search</span>
</button>
</div>
)}
{/* Search bar */}
{showSearch && content && (
<SourceSearchBar
content={content}
onClose={handleSearchClose}
onHighlightChange={handleHighlightChange}
/>
)}
{/* Formatted content */}
<div
ref={contentRef}
style={{
flex: 1,
overflow: 'auto',
}}
>
{renderContent()}
</div>
</div>
);
}
@@ -0,0 +1,233 @@
"use client";
import { useState, useEffect, useRef } from 'react';
interface SourceSearchBarProps {
content: string;
onClose: () => void;
onHighlightChange: (text: string | null, matchIndex: number) => void;
}
interface SearchMatch {
index: number;
text: string;
}
export default function SourceSearchBar({
content,
onClose,
onHighlightChange
}: SourceSearchBarProps) {
const [query, setQuery] = useState('');
const [matches, setMatches] = useState<SearchMatch[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
// Focus input on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
// Search when query changes
useEffect(() => {
if (!query.trim()) {
setMatches([]);
setCurrentIndex(0);
onHighlightChange(null, 0);
return;
}
const searchQuery = query.toLowerCase();
const contentLower = content.toLowerCase();
const foundMatches: SearchMatch[] = [];
let pos = 0;
while (pos < contentLower.length) {
const index = contentLower.indexOf(searchQuery, pos);
if (index === -1) break;
foundMatches.push({
index,
text: content.slice(index, index + query.length)
});
pos = index + 1;
}
setMatches(foundMatches);
setCurrentIndex(0);
if (foundMatches.length > 0) {
onHighlightChange(foundMatches[0].text, 0);
} else {
onHighlightChange(null, 0);
}
}, [query, content, onHighlightChange]);
// Update highlight when navigating
useEffect(() => {
if (matches.length > 0 && matches[currentIndex]) {
onHighlightChange(matches[currentIndex].text, currentIndex);
}
}, [currentIndex, matches, onHighlightChange]);
const goToNext = () => {
if (matches.length === 0) return;
setCurrentIndex((prev) => (prev + 1) % matches.length);
};
const goToPrev = () => {
if (matches.length === 0) return;
setCurrentIndex((prev) => (prev - 1 + matches.length) % matches.length);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
} else if (e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) {
goToPrev();
} else {
goToNext();
}
}
};
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
background: '#141414',
border: '1px solid #262626',
borderRadius: '12px',
padding: '10px 16px',
margin: '8px 12px',
}}>
{/* Search icon */}
<svg
width="16"
height="16"
viewBox="0 0 20 20"
fill="#525252"
style={{ flexShrink: 0 }}
>
<path
fillRule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clipRule="evenodd"
/>
</svg>
{/* Input */}
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search in source..."
style={{
flex: 1,
background: 'none',
border: 'none',
outline: 'none',
color: '#fafafa',
fontSize: '14px',
fontFamily: 'inherit',
}}
/>
{/* Match count */}
{query && (
<span style={{
fontSize: '12px',
color: matches.length > 0 ? '#737373' : '#ef4444',
whiteSpace: 'nowrap',
}}>
{matches.length > 0
? `${currentIndex + 1} of ${matches.length}`
: 'No matches'
}
</span>
)}
{/* Navigation arrows */}
{matches.length > 1 && (
<div style={{ display: 'flex', gap: '4px' }}>
<button
onClick={goToPrev}
title="Previous (Shift+Enter)"
style={{
background: '#262626',
border: 'none',
borderRadius: '4px',
padding: '4px 6px',
cursor: 'pointer',
color: '#a3a3a3',
display: 'flex',
alignItems: 'center',
}}
>
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M14.77 12.79a.75.75 0 01-1.06-.02L10 8.832 6.29 12.77a.75.75 0 11-1.08-1.04l4.25-4.5a.75.75 0 011.08 0l4.25 4.5a.75.75 0 01-.02 1.06z" clipRule="evenodd" />
</svg>
</button>
<button
onClick={goToNext}
title="Next (Enter)"
style={{
background: '#262626',
border: 'none',
borderRadius: '4px',
padding: '4px 6px',
cursor: 'pointer',
color: '#a3a3a3',
display: 'flex',
alignItems: 'center',
}}
>
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clipRule="evenodd" />
</svg>
</button>
</div>
)}
{/* Close button */}
<button
onClick={onClose}
title="Close (Esc)"
style={{
background: 'none',
border: 'none',
padding: '4px',
cursor: 'pointer',
color: '#525252',
display: 'flex',
alignItems: 'center',
}}
>
<svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
{/* Keyboard hint */}
<kbd style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2px 6px',
background: '#262626',
borderRadius: '4px',
fontSize: '10px',
fontFamily: 'inherit',
color: '#525252',
border: '1px solid #333',
}}>
esc
</kbd>
</div>
);
}
@@ -0,0 +1,156 @@
"use client";
import { useCallback, useRef, useMemo } from 'react';
interface BookFormatterProps {
content: string;
onTextSelect?: (text: string) => void;
highlightedText?: string | null;
highlightMatchIndex?: number;
}
/**
* Book Formatter - Clean typography for long-form text
*
* Focuses on readability without attempting to parse chapter structure.
* Structure parsing can be done on-demand via AI.
*/
export default function BookFormatter({ content, onTextSelect, highlightedText, highlightMatchIndex = 0 }: BookFormatterProps) {
const containerRef = useRef<HTMLDivElement>(null);
// Handle text selection
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]);
// Compute all match positions in the full content
const matchPositions = useMemo(() => {
if (!highlightedText) return [];
const positions: number[] = [];
const searchText = highlightedText.toLowerCase();
const contentLower = content.toLowerCase();
let pos = 0;
while (pos < contentLower.length) {
const index = contentLower.indexOf(searchText, pos);
if (index === -1) break;
positions.push(index);
pos = index + 1;
}
return positions;
}, [content, highlightedText]);
// Track which global match index we're at as we render paragraphs
const globalMatchCounter = useRef(0);
// Reset counter before each render
globalMatchCounter.current = 0;
// Render text with all highlights, marking current one specially
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;
}
// Find all occurrences in this paragraph
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let pos = 0;
while (pos < textLower.length) {
const index = textLower.indexOf(searchLower, pos);
if (index === -1) break;
// Add text before match
if (index > lastIndex) {
parts.push(text.slice(lastIndex, index));
}
// Determine if this is the current match
const isCurrent = globalMatchCounter.current === highlightMatchIndex;
globalMatchCounter.current++;
// Add highlighted match
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;
}
// Add remaining text
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? <>{parts}</> : text;
}, [highlightedText, highlightMatchIndex]);
// Split content into paragraphs (double newlines or single newlines with blank lines)
const paragraphs = content
.split(/\n\n+/)
.map(p => p.trim())
.filter(p => p.length > 0);
return (
<div
ref={containerRef}
onMouseUp={handleMouseUp}
style={{
maxWidth: '680px',
margin: '0 auto',
padding: '24px 16px',
}}
>
{/* Clean text display */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5em' }}>
{paragraphs.map((para, idx) => (
<p
key={idx}
style={{
fontFamily: "Georgia, 'Times New Roman', serif",
fontSize: '16px',
lineHeight: '1.75',
color: '#d4d4d4',
margin: 0,
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
textAlign: 'justify',
textJustify: 'inter-word',
hyphens: 'auto',
}}
>
{renderWithHighlight(para)}
</p>
))}
</div>
</div>
);
}
@@ -0,0 +1,52 @@
"use client";
import { useCallback, useRef } from 'react';
import MarkdownWithNodeTokens from '@/components/helpers/MarkdownWithNodeTokens';
interface MarkdownFormatterProps {
content: string;
onTextSelect?: (text: string) => void;
highlightedText?: string | null;
highlightMatchIndex?: number;
}
/**
* Markdown Formatter - Renders markdown content with good typography
* Note: Search highlighting not yet implemented for markdown content
*/
export default function MarkdownFormatter({ content, onTextSelect }: MarkdownFormatterProps) {
const containerRef = useRef<HTMLDivElement>(null);
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]);
return (
<div
ref={containerRef}
onMouseUp={handleMouseUp}
style={{
maxWidth: '680px',
margin: '0 auto',
padding: '24px 16px',
fontFamily: "Georgia, 'Times New Roman', serif",
fontSize: '16px',
lineHeight: '1.75',
color: '#d4d4d4',
}}
>
<MarkdownWithNodeTokens content={content} />
</div>
);
}
@@ -0,0 +1,149 @@
"use client";
import { useMemo, useCallback, useRef } from 'react';
interface RawFormatterProps {
content: string;
onTextSelect?: (text: string) => void;
highlightedText?: string | null;
highlightMatchIndex?: number;
}
/**
* Raw/Typography formatter for the Source Reader
* Applies comfortable reading typography without any content transformation
* Works as the default/fallback for all content types
*/
export default function RawFormatter({ content, onTextSelect, highlightedText, highlightMatchIndex = 0 }: RawFormatterProps) {
const containerRef = useRef<HTMLDivElement>(null);
const globalMatchCounter = useRef(0);
// Reset counter before each render
globalMatchCounter.current = 0;
// Handle text selection - fires on mouseup
const handleMouseUp = useCallback(() => {
if (!onTextSelect) return;
const selection = window.getSelection();
const text = selection?.toString().trim();
// Only trigger for meaningful selections (>10 chars)
if (text && text.length > 10) {
// Truncate very long selections to 2000 chars
const truncatedText = text.length > 2000
? text.slice(0, 2000) + '...'
: text;
onTextSelect(truncatedText);
// Clear browser selection so our custom highlight shows
selection?.removeAllRanges();
}
}, [onTextSelect]);
// Render text with all highlights, marking current one specially
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]);
// Split content into paragraphs for better rendering
const paragraphs = useMemo(() => {
if (!content) return [];
// Split on double newlines to detect paragraphs
// But preserve single newlines within paragraphs
return content
.split(/\n\n+/)
.map(p => p.trim())
.filter(p => p.length > 0);
}, [content]);
if (!content) {
return (
<div style={{
color: '#555',
fontSize: '15px',
fontStyle: 'italic',
textAlign: 'center',
padding: '40px 20px',
}}>
No source content
</div>
);
}
return (
<div
ref={containerRef}
onMouseUp={handleMouseUp}
style={{
maxWidth: '680px',
margin: '0 auto',
padding: '24px 16px',
}}
>
{paragraphs.map((paragraph, index) => (
<p
key={index}
style={{
fontFamily: "Georgia, 'Times New Roman', serif",
fontSize: '15px',
lineHeight: '1.7',
color: '#d4d4d4',
margin: 0,
marginBottom: index < paragraphs.length - 1 ? '1.5em' : 0,
whiteSpace: 'pre-wrap', // Preserve single newlines within paragraphs
wordWrap: 'break-word',
}}
>
{renderWithHighlight(paragraph)}
</p>
))}
</div>
);
}
@@ -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>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { default as SourceReader } from './SourceReader';
export { detectContentType, getContentTypeLabel } from './ContentDetector';
export type { ContentType } from './ContentDetector';