feat: sync panel redesign updates
This commit is contained in:
@@ -30,8 +30,8 @@ export default function ConfirmDialog({
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.85)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
background: 'var(--rah-backdrop)',
|
||||
backdropFilter: 'blur(6px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -42,19 +42,19 @@ export default function ConfirmDialog({
|
||||
<div
|
||||
className="modal-content"
|
||||
style={{
|
||||
width: '380px',
|
||||
width: '420px',
|
||||
maxWidth: '100%',
|
||||
background: '#121212',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '8px',
|
||||
padding: '24px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.8), 0 0 0 1px rgba(255, 255, 255, 0.05)'
|
||||
background: 'var(--rah-bg-modal)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
boxShadow: 'var(--rah-shadow-modal)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
color: '#e5e5e5',
|
||||
color: 'var(--rah-text-base)',
|
||||
marginBottom: '12px',
|
||||
letterSpacing: '0.01em',
|
||||
fontFamily: "'Geist', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
|
||||
@@ -63,7 +63,7 @@ export default function ConfirmDialog({
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
color: '#a8a8a8',
|
||||
color: 'var(--rah-text-soft)',
|
||||
marginBottom: '24px',
|
||||
lineHeight: 1.6,
|
||||
wordWrap: 'break-word',
|
||||
@@ -77,25 +77,23 @@ export default function ConfirmDialog({
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #1f1f1f',
|
||||
border: '1px solid var(--rah-border)',
|
||||
background: 'transparent',
|
||||
color: '#94a3b8',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--rah-text-soft)',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#0f0f0f';
|
||||
e.currentTarget.style.borderColor = '#2a2a2a';
|
||||
e.currentTarget.style.color = '#cbd5f5';
|
||||
e.currentTarget.style.background = 'var(--rah-bg-base)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||
e.currentTarget.style.color = 'var(--rah-text-secondary)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.borderColor = '#1f1f1f';
|
||||
e.currentTarget.style.color = '#94a3b8';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border)';
|
||||
e.currentTarget.style.color = 'var(--rah-text-soft)';
|
||||
}}
|
||||
>
|
||||
{cancelLabel}
|
||||
@@ -108,10 +106,8 @@ export default function ConfirmDialog({
|
||||
border: '1px solid #dc2626',
|
||||
background: '#7f1d1d',
|
||||
color: '#fca5a5',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
|
||||
+204
-336
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type DragEvent } from 'react';
|
||||
import { Trash2, Loader, Database, RefreshCw, Pencil, X, Save, Plus, Link2, Tag, Share2, AlignLeft, ChevronDown, ChevronRight, Check, Folder } from 'lucide-react';
|
||||
import { Trash2, Loader, Database, RefreshCw, Pencil, X, Save, Plus, Link2, Tag, Share2, AlignLeft, ChevronDown, ChevronRight, Check } from 'lucide-react';
|
||||
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
|
||||
import { Node, NodeConnection } from '@/types/database';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
@@ -51,7 +51,7 @@ export default function FocusPanel({
|
||||
const [edgesExpanded, setEdgesExpanded] = useState<Record<number, boolean>>({});
|
||||
const [edgeSearchOpen, setEdgeSearchOpen] = useState(false);
|
||||
const [hoveredConnectionId, setHoveredConnectionId] = useState<number | null>(null);
|
||||
const [propsCollapsed, setPropsCollapsed] = useState(false);
|
||||
const [metadataCollapsed, setMetadataCollapsed] = useState(true);
|
||||
|
||||
const [titleEditMode, setTitleEditMode] = useState(false);
|
||||
const [titleEditValue, setTitleEditValue] = useState('');
|
||||
@@ -168,6 +168,7 @@ export default function FocusPanel({
|
||||
setEdgeEditingId(null);
|
||||
setEdgeEditingValue('');
|
||||
setHoveredSection(null);
|
||||
setMetadataCollapsed(true);
|
||||
}, [activeTab]);
|
||||
|
||||
const fetchNodeData = async (id: number) => {
|
||||
@@ -242,23 +243,6 @@ export default function FocusPanel({
|
||||
throw new Error('Missing updated node in response');
|
||||
};
|
||||
|
||||
const toggleProcessedState = async () => {
|
||||
if (activeTab === null || !currentNode) return;
|
||||
|
||||
const nextState = currentProcessedState === 'processed' ? 'not_processed' : 'processed';
|
||||
|
||||
try {
|
||||
await updateNode(activeTab, {
|
||||
metadata: {
|
||||
state: nextState,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating processed state:', error);
|
||||
window.alert('Failed to update processed state. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const renderMetadataSection = () => {
|
||||
const metadataEntries = Object.entries(currentNodeMetadata).filter(([, value]) => value !== undefined);
|
||||
const rawJson = metadataEntries.length > 0 ? JSON.stringify(currentNodeMetadata, null, 2) : '';
|
||||
@@ -266,11 +250,19 @@ export default function FocusPanel({
|
||||
return (
|
||||
<section style={{ ...S.section, minWidth: 0, width: '100%', overflow: 'hidden' }}>
|
||||
<div style={S.sectionHeader}>
|
||||
<span style={S.sectionLabel}>Metadata</span>
|
||||
<button
|
||||
type="button"
|
||||
className="metadata-toggle"
|
||||
onClick={() => setMetadataCollapsed((current) => !current)}
|
||||
aria-expanded={!metadataCollapsed}
|
||||
>
|
||||
{metadataCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
<span style={S.sectionLabel}>Metadata</span>
|
||||
</button>
|
||||
<div style={S.sectionRule} />
|
||||
</div>
|
||||
|
||||
{metadataEntries.length === 0 ? (
|
||||
{metadataCollapsed ? null : metadataEntries.length === 0 ? (
|
||||
<div style={{ color: 'var(--rah-text-muted)', fontSize: '12px', fontStyle: 'italic' }}>
|
||||
No metadata stored for this node.
|
||||
</div>
|
||||
@@ -1062,52 +1054,34 @@ export default function FocusPanel({
|
||||
) : !currentNode ? (
|
||||
<div className="empty-state">Node not found.</div>
|
||||
) : (
|
||||
<div className={`document-view ${currentProcessedState === 'processed' ? 'document-view-processed' : ''}`}>
|
||||
<div className="document-view">
|
||||
|
||||
{/* ── Title ── */}
|
||||
<div className="title-row">
|
||||
<div className="title-stack">
|
||||
{titleEditMode ? (
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
value={titleEditValue}
|
||||
onChange={(e) => setTitleEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); void saveTitle(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); skipTitleBlurRef.current = true; setTitleEditMode(false); setTitleEditValue(''); }
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (skipTitleBlurRef.current) { skipTitleBlurRef.current = false; return; }
|
||||
void saveTitle();
|
||||
}}
|
||||
disabled={titleSaving}
|
||||
className="title-input"
|
||||
placeholder="Enter title..."
|
||||
/>
|
||||
) : (
|
||||
<button type="button" className="title-button" onClick={startTitleEdit}>
|
||||
{currentNode.title || 'Untitled'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="node-status-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleProcessedState()}
|
||||
className={`processed-control ${currentProcessedState === 'processed' ? 'is-processed' : ''}`}
|
||||
title="Toggle processed state"
|
||||
>
|
||||
<span className={`processed-control-box ${currentProcessedState === 'processed' ? 'is-processed' : ''}`}>
|
||||
<Check size={12} strokeWidth={2.8} />
|
||||
</span>
|
||||
<span className="processed-control-copy">
|
||||
<span className="processed-control-label">processed</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{currentProcessedState === 'processed' && (
|
||||
<span className="processed-indicator-badge">processed</span>
|
||||
<div className="title-heading-row">
|
||||
{titleEditMode ? (
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
value={titleEditValue}
|
||||
onChange={(e) => setTitleEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); void saveTitle(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); skipTitleBlurRef.current = true; setTitleEditMode(false); setTitleEditValue(''); }
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (skipTitleBlurRef.current) { skipTitleBlurRef.current = false; return; }
|
||||
void saveTitle();
|
||||
}}
|
||||
disabled={titleSaving}
|
||||
className="title-input"
|
||||
placeholder="Enter title..."
|
||||
/>
|
||||
) : (
|
||||
<button type="button" className="title-button" onClick={startTitleEdit}>
|
||||
{currentNode.title || 'Untitled'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1116,27 +1090,26 @@ export default function FocusPanel({
|
||||
{/* ── Properties block ── */}
|
||||
<div className="props-block">
|
||||
|
||||
{/* Node identity row — with collapse toggle + delete */}
|
||||
<div className="prop-row node-row">
|
||||
<div className="prop-label">node</div>
|
||||
<div className="prop-value">
|
||||
<span className="node-meta-value">
|
||||
<span className="node-meta-icon">{getNodeIcon(currentNode, 12)}</span>
|
||||
<span className="node-id-pill">#{currentNode.id}</span>
|
||||
</span>
|
||||
{/* Status indicator row (only when relevant) */}
|
||||
{renderStatusIndicator() !== null && (
|
||||
<div className="prop-row">
|
||||
<div className="prop-label">embed</div>
|
||||
<div className="prop-value">{renderStatusIndicator()}</div>
|
||||
</div>
|
||||
<div className="node-row-actions">
|
||||
)}
|
||||
|
||||
<div className="prop-row">
|
||||
<div className="prop-label">node</div>
|
||||
<div className="prop-value node-row-value">
|
||||
<span className="title-node-label">#{currentNode.id}</span>
|
||||
{currentProcessedState === 'processed' ? (
|
||||
<span className="node-processed-indicator" title="Processed">
|
||||
<Check size={11} strokeWidth={3} />
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="props-toggle"
|
||||
onClick={() => setPropsCollapsed(c => !c)}
|
||||
title={propsCollapsed ? 'Expand properties' : 'Collapse properties'}
|
||||
>
|
||||
{propsCollapsed ? <ChevronRight size={18} strokeWidth={2.5} /> : <ChevronDown size={18} strokeWidth={2.5} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="delete-node-button"
|
||||
className="delete-node-button node-delete-button"
|
||||
onClick={() => setPendingDeleteNodeId(activeTab)}
|
||||
disabled={deletingNode === activeTab}
|
||||
title="Delete node"
|
||||
@@ -1146,16 +1119,6 @@ export default function FocusPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!propsCollapsed && (<>
|
||||
|
||||
{/* Status indicator row (only when relevant) */}
|
||||
{renderStatusIndicator() !== null && (
|
||||
<div className="prop-row">
|
||||
<div className="prop-label">embed</div>
|
||||
<div className="prop-value">{renderStatusIndicator()}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link */}
|
||||
<div className="prop-row">
|
||||
<div className="prop-label">link</div>
|
||||
@@ -1178,30 +1141,41 @@ export default function FocusPanel({
|
||||
className="prop-input"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
) : currentNode.link ? (
|
||||
normalizedCurrentLink ? (
|
||||
<a
|
||||
href={normalizedCurrentLink}
|
||||
className="prop-link"
|
||||
onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey) { e.preventDefault(); startLinkEdit(); return; }
|
||||
if (!shouldOpenExternally(normalizedCurrentLink)) return;
|
||||
e.preventDefault();
|
||||
void openExternalUrl(normalizedCurrentLink).catch(() => window.alert(`Unable to open ${currentNode.link}`));
|
||||
}}
|
||||
title={`${normalizedCurrentLink} (Cmd+Click to edit)`}
|
||||
>
|
||||
{currentNode.link}
|
||||
</a>
|
||||
) : (
|
||||
<button type="button" className="prop-empty-btn" onClick={startLinkEdit} title="Invalid link. Click to edit.">
|
||||
{currentNode.link}
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<button type="button" className="prop-empty-btn" onClick={startLinkEdit}>
|
||||
Empty
|
||||
</button>
|
||||
<div className="link-inline-row">
|
||||
{currentNode.link ? (
|
||||
normalizedCurrentLink ? (
|
||||
<a
|
||||
href={normalizedCurrentLink}
|
||||
className="prop-link"
|
||||
onClick={(e) => {
|
||||
if (!shouldOpenExternally(normalizedCurrentLink)) return;
|
||||
e.preventDefault();
|
||||
void openExternalUrl(normalizedCurrentLink).catch(() => window.alert(`Unable to open ${currentNode.link}`));
|
||||
}}
|
||||
title={normalizedCurrentLink}
|
||||
>
|
||||
{currentNode.link}
|
||||
</a>
|
||||
) : (
|
||||
<button type="button" className="prop-empty-btn" onClick={startLinkEdit} title="Invalid link. Click to edit.">
|
||||
{currentNode.link}
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<button type="button" className="prop-empty-btn" onClick={startLinkEdit}>
|
||||
Empty
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="context-inline-button"
|
||||
onClick={startLinkEdit}
|
||||
title={currentNode.link ? 'Edit link' : 'Add link'}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1210,25 +1184,23 @@ export default function FocusPanel({
|
||||
<div className="prop-label">context</div>
|
||||
<div className="prop-value context-prop-value">
|
||||
<div className="context-select-shell" ref={contextMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="context-select-trigger"
|
||||
disabled={contextSaving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setContextMenuOpen((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<span className="context-select-current">
|
||||
<span className="context-select-badge">
|
||||
<Folder size={13} />
|
||||
</span>
|
||||
<span className={currentNode.context ? 'context-select-name' : 'context-select-empty'}>
|
||||
{currentNode.context?.name || 'No context'}
|
||||
</span>
|
||||
<div className="context-inline-row">
|
||||
<span className={currentNode.context ? 'context-select-name' : 'context-select-empty'}>
|
||||
{currentNode.context?.name || 'No context'}
|
||||
</span>
|
||||
<ChevronDown size={13} className={`context-select-chevron ${contextMenuOpen ? 'open' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="context-inline-button"
|
||||
disabled={contextSaving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setContextMenuOpen((prev) => !prev);
|
||||
}}
|
||||
title={currentNode.context ? 'Change context' : 'Add context'}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{contextMenuOpen ? (
|
||||
<div className="context-select-menu">
|
||||
@@ -1315,9 +1287,12 @@ export default function FocusPanel({
|
||||
{currentEdgesExpanded ? '↑ Show less' : `+ ${currentEdges.length - 3} more`}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="conn-add-btn" onClick={() => setEdgeSearchOpen(true)}>
|
||||
<Plus size={11} /> Add connection
|
||||
</button>
|
||||
<div className="conn-add-inline">
|
||||
<span className="conn-add-label">Add connection</span>
|
||||
<button type="button" className="context-inline-button" onClick={() => setEdgeSearchOpen(true)} title="Add connection">
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1377,8 +1352,6 @@ export default function FocusPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>)}
|
||||
|
||||
{/* Drag handle / ID (hidden, for drag-to-chat) */}
|
||||
<span
|
||||
draggable
|
||||
@@ -1445,21 +1418,6 @@ export default function FocusPanel({
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.document-view-processed {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.document-view-processed::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: -14px;
|
||||
bottom: 4px;
|
||||
width: 2px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgba(74, 222, 128, 0.85), rgba(74, 222, 128, 0.15));
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--rah-text-muted);
|
||||
font-size: 13px;
|
||||
@@ -1469,53 +1427,33 @@ export default function FocusPanel({
|
||||
|
||||
/* ── Title row ── */
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
display: block;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.title-stack {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.node-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.node-meta-value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.node-meta-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--rah-text-muted);
|
||||
}
|
||||
|
||||
.node-id-pill {
|
||||
font-size: 10px;
|
||||
.title-node-label {
|
||||
font-size: 11px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
|
||||
color: var(--rah-text-muted);
|
||||
background: var(--rah-bg-surface);
|
||||
border: 1px solid var(--rah-border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
line-height: 1.4;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.04em;
|
||||
display: inline;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.title-heading-row {
|
||||
min-width: 0;
|
||||
display: block;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.title-button,
|
||||
.title-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -1525,6 +1463,7 @@ export default function FocusPanel({
|
||||
}
|
||||
|
||||
.title-button {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
@@ -1536,6 +1475,8 @@ export default function FocusPanel({
|
||||
}
|
||||
|
||||
.title-input {
|
||||
display: inline-block;
|
||||
width: calc(100% - 34px);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
@@ -1544,90 +1485,6 @@ export default function FocusPanel({
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.processed-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--rah-border);
|
||||
border-radius: 999px;
|
||||
padding: 7px 10px;
|
||||
cursor: pointer;
|
||||
color: var(--rah-text-base);
|
||||
transition: border-color 140ms ease, background 140ms ease, transform 140ms ease, box-shadow 140ms ease;
|
||||
min-width: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.processed-control:hover {
|
||||
border-color: var(--rah-border-strong);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.processed-control.is-processed {
|
||||
border-color: rgba(74, 222, 128, 0.26);
|
||||
background: rgba(74, 222, 128, 0.07);
|
||||
}
|
||||
|
||||
.processed-control-box {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--rah-border-strong);
|
||||
background: var(--rah-bg-surface);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: transparent;
|
||||
flex-shrink: 0;
|
||||
transition: all 140ms ease;
|
||||
}
|
||||
|
||||
.processed-control-box.is-processed {
|
||||
border-color: rgba(74, 222, 128, 0.65);
|
||||
background: rgba(74, 222, 128, 0.18);
|
||||
color: #86efac;
|
||||
box-shadow: inset 0 0 0 1px rgba(74, 222, 128, 0.15);
|
||||
}
|
||||
|
||||
.processed-control-copy {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.processed-control-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--rah-text-active);
|
||||
line-height: 1.2;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.processed-indicator-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
background: rgba(74, 222, 128, 0.10);
|
||||
border: 1px solid rgba(74, 222, 128, 0.22);
|
||||
color: #86efac;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.node-row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.delete-node-button {
|
||||
color: #ef4444;
|
||||
background: transparent;
|
||||
@@ -1643,6 +1500,31 @@ export default function FocusPanel({
|
||||
transition: color 120ms ease, background 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.node-row-value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.node-processed-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
color: var(--rah-accent-green);
|
||||
background: color-mix(in srgb, var(--rah-accent-green) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--rah-accent-green) 32%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-delete-button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.delete-node-button:hover:enabled {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
@@ -1686,27 +1568,6 @@ export default function FocusPanel({
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.props-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: var(--rah-text-soft);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: color 120ms ease, background 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.props-toggle:hover {
|
||||
background: var(--rah-bg-hover);
|
||||
color: var(--rah-text-active);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Link */
|
||||
.prop-link {
|
||||
color: var(--rah-text-soft);
|
||||
@@ -1723,6 +1584,14 @@ export default function FocusPanel({
|
||||
|
||||
.prop-link:hover { color: #3b82f6; }
|
||||
|
||||
.link-inline-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.prop-input {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
@@ -1740,39 +1609,34 @@ export default function FocusPanel({
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.context-select-trigger {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 0 10px 0 8px;
|
||||
border: 1px solid var(--rah-border-strong);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, var(--rah-bg-surface), var(--rah-bg-panel));
|
||||
color: var(--rah-text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.context-select-current {
|
||||
.context-inline-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.context-select-badge {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
.context-inline-button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 7px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--rah-border);
|
||||
background: var(--rah-bg-base);
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--rah-text-muted);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 120ms ease, border-color 120ms ease, background 120ms ease;
|
||||
}
|
||||
|
||||
.context-inline-button:hover:enabled {
|
||||
color: var(--rah-text-soft);
|
||||
border-color: var(--rah-border-strong);
|
||||
background: var(--rah-bg-hover);
|
||||
}
|
||||
|
||||
.context-select-name,
|
||||
@@ -1794,19 +1658,12 @@ export default function FocusPanel({
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.context-select-chevron {
|
||||
color: var(--rah-text-muted);
|
||||
flex-shrink: 0;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.context-select-chevron.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.context-select-menu {
|
||||
position: relative;
|
||||
margin-top: 6px;
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
min-width: 220px;
|
||||
max-width: min(320px, 100vw - 48px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
@@ -1970,21 +1827,32 @@ export default function FocusPanel({
|
||||
|
||||
.conn-more-btn:hover { color: var(--rah-text-soft); }
|
||||
|
||||
.conn-add-btn {
|
||||
display: flex;
|
||||
.conn-add-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--rah-text-muted);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
padding: 3px 4px;
|
||||
transition: color 120ms ease;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.conn-add-btn:hover { color: var(--rah-accent-green); }
|
||||
.conn-add-label {
|
||||
color: var(--rah-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.metadata-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--rah-text-muted);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.metadata-toggle:hover {
|
||||
color: var(--rah-text-soft);
|
||||
}
|
||||
|
||||
.metadata-group {
|
||||
display: flex;
|
||||
|
||||
@@ -166,7 +166,7 @@ export default function NodeSearchModal({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '560px',
|
||||
maxWidth: selectedNode ? '480px' : '560px',
|
||||
maxHeight: '70vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -174,52 +174,53 @@ export default function NodeSearchModal({
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
background: '#141414',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '12px',
|
||||
padding: '14px 18px',
|
||||
boxShadow: '0 24px 48px -12px rgba(0,0,0,0.6)',
|
||||
}}>
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor" style={{ color: '#555', 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
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setSelectedNode(null);
|
||||
setExplanation('');
|
||||
}}
|
||||
onKeyDown={(e) => { void handleSearchKeyDown(e); }}
|
||||
placeholder="Search nodes to connect..."
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: '#f0f0f0',
|
||||
fontSize: '15px',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
<kbd style={{
|
||||
display: 'inline-flex',
|
||||
{!selectedNode && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '3px 7px',
|
||||
background: '#222',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '5px',
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
}}>esc</kbd>
|
||||
</div>
|
||||
gap: '12px',
|
||||
background: '#141414',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '12px',
|
||||
padding: '14px 18px',
|
||||
boxShadow: '0 24px 48px -12px rgba(0,0,0,0.6)',
|
||||
}}>
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor" style={{ color: '#555', 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
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setSelectedNode(null);
|
||||
setExplanation('');
|
||||
}}
|
||||
onKeyDown={(e) => { void handleSearchKeyDown(e); }}
|
||||
placeholder="Search nodes to connect..."
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: '#f0f0f0',
|
||||
fontSize: '15px',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
<kbd style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
padding: '3px 7px',
|
||||
background: '#222',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '5px',
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
}}>esc</kbd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{!selectedNode && suggestions.length > 0 && (
|
||||
@@ -285,8 +286,8 @@ export default function NodeSearchModal({
|
||||
marginBottom: '14px',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ color: '#555', fontSize: '10px', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '5px' }}>
|
||||
connecting to
|
||||
<div style={{ color: '#777', fontSize: '12px', marginBottom: '5px' }}>
|
||||
Connecting to
|
||||
</div>
|
||||
<div style={{ color: '#e0e0e0', fontSize: '14px' }}>{selectedNode.title}</div>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,8 @@ interface LeftToolbarProps {
|
||||
onSearchClick: () => void;
|
||||
onAddStuffClick: () => void;
|
||||
onRefreshClick: () => void;
|
||||
visiblePaneCount: 1 | 2 | 3;
|
||||
onVisiblePaneCountChange: (count: 1 | 2 | 3) => void;
|
||||
onSettingsClick: () => void;
|
||||
onPaneTypeClick: (paneType: PaneType) => void;
|
||||
isExpanded: boolean;
|
||||
@@ -111,6 +113,8 @@ export default function LeftToolbar({
|
||||
onSearchClick,
|
||||
onAddStuffClick,
|
||||
onRefreshClick,
|
||||
visiblePaneCount,
|
||||
onVisiblePaneCountChange,
|
||||
onSettingsClick,
|
||||
onPaneTypeClick,
|
||||
isExpanded,
|
||||
@@ -124,6 +128,122 @@ export default function LeftToolbar({
|
||||
onContextQuickSelect,
|
||||
}: LeftToolbarProps) {
|
||||
const [contextsExpanded, setContextsExpanded] = useState(false);
|
||||
const [paneSelectorOpen, setPaneSelectorOpen] = useState(false);
|
||||
|
||||
const renderPaneGlyph = (count: 1 | 2 | 3, active: boolean) => (
|
||||
<span style={{ display: 'flex', width: '100%', gap: '3px', height: '12px', maxWidth: '24px' }}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<span
|
||||
key={`${count}-${index}`}
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: '3px',
|
||||
background: active ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
|
||||
opacity: active ? 1 : 0.7,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
|
||||
const renderPaneCountButton = (count: 1 | 2 | 3) => {
|
||||
const active = visiblePaneCount === count;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={count}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onVisiblePaneCountChange(count);
|
||||
setPaneSelectorOpen(false);
|
||||
}}
|
||||
title={`${count} pane${count === 1 ? '' : 's'}`}
|
||||
style={{
|
||||
width: isExpanded ? '100%' : '36px',
|
||||
height: '30px',
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${active ? 'var(--rah-border-stronger)' : 'var(--rah-border)'}`,
|
||||
background: active ? 'var(--rah-bg-hover)' : 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: isExpanded ? 'space-between' : 'center',
|
||||
cursor: 'pointer',
|
||||
padding: isExpanded ? '0 8px' : '0 6px',
|
||||
transition: 'all 0.15s ease',
|
||||
alignSelf: isExpanded ? 'stretch' : 'center',
|
||||
}}
|
||||
>
|
||||
{renderPaneGlyph(count, active)}
|
||||
{isExpanded ? (
|
||||
<span style={{ fontSize: '11px', color: active ? 'var(--rah-text-active)' : 'var(--rah-text-muted)' }}>
|
||||
{count} pane{count === 1 ? '' : 's'}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPaneSelector = () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaneSelectorOpen((prev) => !prev)}
|
||||
title={isExpanded ? undefined : 'Pane layout'}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '36px',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid var(--rah-border-stronger)',
|
||||
background: paneSelectorOpen ? 'var(--rah-bg-hover)' : 'var(--rah-bg-elevated)',
|
||||
color: 'var(--rah-text-active)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: isExpanded ? 'space-between' : 'center',
|
||||
padding: isExpanded ? '0 10px' : '0',
|
||||
transition: 'all 0.15s ease',
|
||||
boxShadow: paneSelectorOpen ? '0 0 0 1px color-mix(in srgb, var(--rah-text-active) 8%, transparent)' : 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0 }}>
|
||||
<span
|
||||
style={{
|
||||
width: '18px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--rah-text-active)',
|
||||
opacity: 0.98,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{renderPaneGlyph(visiblePaneCount, true)}
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<span style={{ fontSize: '13px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
Layout
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
paneSelectorOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{paneSelectorOpen ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{([1, 2, 3] as const).map((count) => renderPaneCountButton(count))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderActionButtons = () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
@@ -157,7 +277,11 @@ export default function LeftToolbar({
|
||||
onClick={onToggleExpanded}
|
||||
/>
|
||||
|
||||
{renderPaneSelector()}
|
||||
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
{renderActionButtons()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', minHeight: 0 }}>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import { PanelLeftOpen, GripVertical, X } from 'lucide-react';
|
||||
import SettingsModal, { SettingsTab } from '../settings/SettingsModal';
|
||||
import SearchModal from '../nodes/SearchModal';
|
||||
import type { ContextSummary, Node } from '@/types/database';
|
||||
@@ -29,6 +28,7 @@ export interface PendingNode {
|
||||
const SLOT_A_KEY = 'ui.slotA.v7';
|
||||
const SLOT_B_KEY = 'ui.slotB.v7';
|
||||
const SLOT_C_KEY = 'ui.slotC.v7';
|
||||
const VISIBLE_PANE_COUNT_KEY = 'ui.visiblePaneCount.v1';
|
||||
const PANEL_A_EXPANDED_KEY = 'ui.panelA.expanded.v1';
|
||||
const PANEL_B_EXPANDED_KEY = 'ui.panelB.expanded.v1';
|
||||
const PANEL_C_EXPANDED_KEY = 'ui.panelC.expanded.v1';
|
||||
@@ -46,6 +46,7 @@ const DEFAULT_SLOT_A: SlotState = {
|
||||
};
|
||||
|
||||
const EMPTY_SEARCH_FILTERS: Array<{ type: 'context' | 'title' | 'tag'; value: string }> = [];
|
||||
const SLOT_ORDER: SlotId[] = ['A', 'B', 'C'];
|
||||
|
||||
function createSingletonState(type: Exclude<PaneType, 'node'>): SlotState {
|
||||
return {
|
||||
@@ -144,6 +145,7 @@ export default function ThreePanelLayout() {
|
||||
const [slotA, setSlotA] = usePersistentState<SlotState | null>(SLOT_A_KEY, DEFAULT_SLOT_A);
|
||||
const [slotB, setSlotB] = usePersistentState<SlotState | null>(SLOT_B_KEY, null);
|
||||
const [slotC, setSlotC] = usePersistentState<SlotState | null>(SLOT_C_KEY, null);
|
||||
const [visiblePaneCount, setVisiblePaneCount] = usePersistentState<number>(VISIBLE_PANE_COUNT_KEY, 2);
|
||||
|
||||
const [panelAExpanded, setPanelAExpanded] = usePersistentState<boolean>(PANEL_A_EXPANDED_KEY, true);
|
||||
const [panelBExpanded, setPanelBExpanded] = usePersistentState<boolean>(PANEL_B_EXPANDED_KEY, false);
|
||||
@@ -233,30 +235,23 @@ export default function ThreePanelLayout() {
|
||||
}
|
||||
}, [setSlotA, setSlotB, setSlotC]);
|
||||
|
||||
const visibleSlots = useMemo(
|
||||
() => SLOT_ORDER.slice(0, Math.max(1, Math.min(3, visiblePaneCount))),
|
||||
[visiblePaneCount]
|
||||
);
|
||||
|
||||
const isPanelExpanded = useCallback((slot: SlotId) => {
|
||||
switch (slot) {
|
||||
case 'A':
|
||||
return panelAExpanded;
|
||||
case 'B':
|
||||
return panelBExpanded;
|
||||
case 'C':
|
||||
return panelCExpanded;
|
||||
}
|
||||
}, [panelAExpanded, panelBExpanded, panelCExpanded]);
|
||||
return visibleSlots.includes(slot);
|
||||
}, [visibleSlots]);
|
||||
|
||||
const setPanelExpanded = useCallback((slot: SlotId, expanded: boolean) => {
|
||||
switch (slot) {
|
||||
case 'A':
|
||||
setPanelAExpanded(expanded);
|
||||
break;
|
||||
case 'B':
|
||||
setPanelBExpanded(expanded);
|
||||
break;
|
||||
case 'C':
|
||||
setPanelCExpanded(expanded);
|
||||
break;
|
||||
if (!expanded) {
|
||||
return;
|
||||
}
|
||||
}, [setPanelAExpanded, setPanelBExpanded, setPanelCExpanded]);
|
||||
|
||||
const requiredCount = SLOT_ORDER.indexOf(slot) + 1;
|
||||
setVisiblePaneCount((current) => Math.max(current, requiredCount));
|
||||
}, [setVisiblePaneCount]);
|
||||
|
||||
const getPanelWeight = useCallback((slot: SlotId) => {
|
||||
switch (slot) {
|
||||
@@ -300,6 +295,18 @@ export default function ThreePanelLayout() {
|
||||
openNodeIdsRef.current = allOpenNodeIds;
|
||||
}, [allOpenNodeIds]);
|
||||
|
||||
useEffect(() => {
|
||||
setPanelAExpanded(true);
|
||||
setPanelBExpanded(visiblePaneCount >= 2);
|
||||
setPanelCExpanded(visiblePaneCount >= 3);
|
||||
}, [setPanelAExpanded, setPanelBExpanded, setPanelCExpanded, visiblePaneCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visibleSlots.includes(activePane)) {
|
||||
setActivePane(visibleSlots[0] ?? 'A');
|
||||
}
|
||||
}, [activePane, visibleSlots]);
|
||||
|
||||
const activeNodeId = useMemo(() => {
|
||||
const activeSlotState = slotStates[activePane];
|
||||
const activeTab = activeSlotState ? getActiveTab(activeSlotState) : undefined;
|
||||
@@ -427,14 +434,7 @@ export default function ThreePanelLayout() {
|
||||
const tabId = createTabId(paneType);
|
||||
const setter = getSlotSetter(slot);
|
||||
|
||||
setter((prev) => {
|
||||
const current = sanitizeSlotState(prev);
|
||||
const existingTabs = current?.tabs ?? [];
|
||||
const tabs = existingTabs.some((tab) => tab.id === tabId)
|
||||
? existingTabs
|
||||
: [...existingTabs.filter((tab) => tab.type !== paneType), { id: tabId, type: paneType }];
|
||||
return { tabs, activeTabId: tabId };
|
||||
});
|
||||
setter({ tabs: [{ id: tabId, type: paneType }], activeTabId: tabId });
|
||||
|
||||
setPanelExpanded(slot, true);
|
||||
setActivePane(slot);
|
||||
@@ -446,12 +446,12 @@ export default function ThreePanelLayout() {
|
||||
|
||||
setter((prev) => {
|
||||
const current = sanitizeSlotState(prev);
|
||||
const tabs = current?.tabs ?? [];
|
||||
if (tabs.some((tab) => tab.id === nodeTabId)) {
|
||||
return { tabs, activeTabId: nodeTabId };
|
||||
const nodeTabs = (current?.tabs ?? []).filter((tab) => tab.type === 'node');
|
||||
if (nodeTabs.some((tab) => tab.id === nodeTabId)) {
|
||||
return { tabs: nodeTabs, activeTabId: nodeTabId };
|
||||
}
|
||||
return {
|
||||
tabs: [...tabs, { id: nodeTabId, type: 'node', nodeId }],
|
||||
tabs: [...nodeTabs, { id: nodeTabId, type: 'node', nodeId }],
|
||||
activeTabId: nodeTabId,
|
||||
};
|
||||
});
|
||||
@@ -481,32 +481,48 @@ export default function ThreePanelLayout() {
|
||||
}, [getSlotSetter]);
|
||||
|
||||
const openNodeFromSlot = useCallback((nodeId: number, fromSlot?: SlotId) => {
|
||||
for (const slot of ['A', 'B', 'C'] as SlotId[]) {
|
||||
const existingTabId = createTabId('node', nodeId);
|
||||
|
||||
for (const slot of visibleSlots) {
|
||||
const state = getSlotState(slot);
|
||||
if (state?.tabs.some((tab) => tab.type === 'node' && tab.nodeId === nodeId)) {
|
||||
getSlotSetter(slot)({ tabs: state.tabs, activeTabId: createTabId('node', nodeId) });
|
||||
if (state?.tabs.some((tab) => tab.id === existingTabId)) {
|
||||
getSlotSetter(slot)({ tabs: state.tabs, activeTabId: existingTabId });
|
||||
setActivePane(slot);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const preferredOrder: SlotId[] = fromSlot === 'A'
|
||||
? ['B', 'C', 'A']
|
||||
: fromSlot === 'B'
|
||||
? ['C', 'A', 'B']
|
||||
: fromSlot === 'C'
|
||||
? ['B', 'A', 'C']
|
||||
: ['B', 'C', 'A'];
|
||||
const visibleNodeSlots = visibleSlots.filter((slot) => {
|
||||
const state = getSlotState(slot);
|
||||
return state?.tabs.some((tab) => tab.type === 'node');
|
||||
});
|
||||
|
||||
const target = preferredOrder.find((slot) => !getSlotState(slot))
|
||||
?? preferredOrder.find((slot) => !isPanelExpanded(slot))
|
||||
?? preferredOrder[0];
|
||||
const preferredNodeTarget = fromSlot && visibleNodeSlots.includes(fromSlot)
|
||||
? fromSlot
|
||||
: visibleNodeSlots.includes(activePane)
|
||||
? activePane
|
||||
: visibleNodeSlots[0];
|
||||
|
||||
if (preferredNodeTarget) {
|
||||
addNodeTabToSlot(preferredNodeTarget, nodeId);
|
||||
setActivePane(preferredNodeTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
const emptyTarget = visibleSlots.find((slot) => {
|
||||
const state = getSlotState(slot);
|
||||
return !state || state.tabs.length === 0;
|
||||
});
|
||||
const target = emptyTarget
|
||||
?? (visibleSlots.includes(activePane) ? activePane : null)
|
||||
?? visibleSlots[visibleSlots.length - 1]
|
||||
?? 'A';
|
||||
|
||||
addNodeTabToSlot(target, nodeId);
|
||||
}, [addNodeTabToSlot, getSlotSetter, getSlotState, isPanelExpanded]);
|
||||
}, [activePane, addNodeTabToSlot, getSlotSetter, getSlotState, visibleSlots]);
|
||||
|
||||
const openPaneSingleton = useCallback((paneType: Exclude<PaneType, 'node'>, preferredSlot?: SlotId) => {
|
||||
for (const slot of ['A', 'B', 'C'] as SlotId[]) {
|
||||
for (const slot of visibleSlots) {
|
||||
const state = getSlotState(slot);
|
||||
if (state?.tabs.some((tab) => tab.type === paneType)) {
|
||||
getSlotSetter(slot)({ tabs: state.tabs, activeTabId: createTabId(paneType) });
|
||||
@@ -516,17 +532,21 @@ export default function ThreePanelLayout() {
|
||||
}
|
||||
}
|
||||
|
||||
const orderedSlots: SlotId[] = preferredSlot
|
||||
? [preferredSlot, ...(['A', 'B', 'C'] as SlotId[]).filter((slot) => slot !== preferredSlot)]
|
||||
: ['A', 'B', 'C'];
|
||||
|
||||
const target = orderedSlots.find((slot) => !getSlotState(slot))
|
||||
?? orderedSlots.find((slot) => !isPanelExpanded(slot))
|
||||
?? activePane;
|
||||
const orderedSlots = preferredSlot && visibleSlots.includes(preferredSlot)
|
||||
? [preferredSlot, ...visibleSlots.filter((slot) => slot !== preferredSlot)]
|
||||
: visibleSlots;
|
||||
const emptyTarget = orderedSlots.find((slot) => {
|
||||
const state = getSlotState(slot);
|
||||
return !state || state.tabs.length === 0;
|
||||
});
|
||||
const target = emptyTarget
|
||||
?? (orderedSlots.includes(activePane) ? activePane : null)
|
||||
?? orderedSlots[orderedSlots.length - 1]
|
||||
?? 'A';
|
||||
|
||||
upsertSingletonTab(target, paneType);
|
||||
return target;
|
||||
}, [activePane, getSlotSetter, getSlotState, isPanelExpanded, setPanelExpanded, upsertSingletonTab]);
|
||||
}, [activePane, getSlotSetter, getSlotState, setPanelExpanded, upsertSingletonTab, visibleSlots]);
|
||||
|
||||
const handleTabSelect = useCallback((slot: SlotId, tabId: string) => {
|
||||
const state = getSlotState(slot);
|
||||
@@ -632,12 +652,11 @@ export default function ThreePanelLayout() {
|
||||
|
||||
const closeActiveSlot = useCallback((slot: SlotId) => {
|
||||
getSlotSetter(slot)(null);
|
||||
setPanelExpanded(slot, false);
|
||||
if (activePane === slot) {
|
||||
const fallback = (['A', 'B', 'C'] as SlotId[]).find((candidate) => candidate !== slot && isPanelExpanded(candidate)) ?? 'A';
|
||||
const fallback = visibleSlots.find((candidate) => candidate !== slot) ?? 'A';
|
||||
setActivePane(fallback);
|
||||
}
|
||||
}, [activePane, getSlotSetter, isPanelExpanded, setPanelExpanded]);
|
||||
}, [activePane, getSlotSetter, visibleSlots]);
|
||||
|
||||
const handleSwapPanes = useCallback((source: SlotId, target: SlotId) => {
|
||||
if (source === target) return;
|
||||
@@ -781,11 +800,9 @@ export default function ThreePanelLayout() {
|
||||
externalContextFilterId={browseContextFilters[slot]}
|
||||
onContextFilterSelect={(contextId) => {
|
||||
setBrowseContextFilters((prev) => ({ ...prev, [slot]: contextId }));
|
||||
setActiveContextId(contextId);
|
||||
}}
|
||||
onClearExternalContextFilter={() => {
|
||||
setBrowseContextFilters((prev) => ({ ...prev, [slot]: null }));
|
||||
setActiveContextId(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -819,89 +836,25 @@ export default function ThreePanelLayout() {
|
||||
|
||||
const getSlotContainerStyle = (slot: SlotId) => {
|
||||
const state = slotStates[slot];
|
||||
const expanded = isPanelExpanded(slot);
|
||||
const weight = getPanelWeight(slot);
|
||||
|
||||
return {
|
||||
flex: expanded ? `${weight} ${weight} 0` : '0 0 44px',
|
||||
minWidth: expanded ? 0 : '44px',
|
||||
flex: `${weight} ${weight} 0`,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
background: expanded ? 'var(--rah-bg-surface)' : 'var(--rah-bg-subtle)',
|
||||
background: 'var(--rah-bg-surface)',
|
||||
borderRadius: '10px',
|
||||
border: expanded && state ? '1px solid transparent' : '1px dashed var(--rah-border)',
|
||||
border: state ? '1px solid transparent' : '1px dashed var(--rah-border)',
|
||||
outline: dragOverSlot === slot ? '2px dashed var(--rah-accent-green)' : 'none',
|
||||
outlineOffset: '-4px',
|
||||
transition: 'outline 0.15s ease, background 0.15s ease',
|
||||
};
|
||||
};
|
||||
|
||||
const renderCollapsedPanel = (slot: SlotId) => (
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '8px 0' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPanelExpanded(slot, true);
|
||||
setActivePane(slot);
|
||||
}}
|
||||
title="Expand panel"
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
background: 'var(--rah-bg-elevated)',
|
||||
color: 'var(--rah-text-secondary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<PanelLeftOpen size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderExpandedEmptyPanel = (slot: SlotId) => (
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.setData('application/x-rah-pane', slot);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
style={{
|
||||
minHeight: '48px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
cursor: 'grab',
|
||||
}}
|
||||
>
|
||||
<GripVertical size={14} color="var(--rah-text-muted)" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeActiveSlot(slot)}
|
||||
title="Collapse panel"
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
background: 'var(--rah-bg-elevated)',
|
||||
color: 'var(--rah-text-secondary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--rah-text-muted)', fontSize: '13px' }}>
|
||||
Select a pane from the nav
|
||||
</div>
|
||||
@@ -923,6 +876,8 @@ export default function ThreePanelLayout() {
|
||||
onSearchClick={() => setShowSearchModal(true)}
|
||||
onAddStuffClick={() => setShowAddStuff(true)}
|
||||
onRefreshClick={handleRefreshAll}
|
||||
visiblePaneCount={visiblePaneCount as 1 | 2 | 3}
|
||||
onVisiblePaneCountChange={setVisiblePaneCount as (count: 1 | 2 | 3) => void}
|
||||
onSettingsClick={() => {
|
||||
setSettingsInitialTab(undefined);
|
||||
setShowSettings(true);
|
||||
@@ -947,9 +902,8 @@ export default function ThreePanelLayout() {
|
||||
/>
|
||||
|
||||
<div ref={containerRef} style={{ flex: 1, display: 'flex', overflow: 'hidden', padding: '8px', gap: '4px' }}>
|
||||
{(['A', 'B', 'C'] as SlotId[]).flatMap((slot, index, allSlots) => {
|
||||
{visibleSlots.flatMap((slot, index) => {
|
||||
const state = slotStates[slot];
|
||||
const expanded = isPanelExpanded(slot);
|
||||
const items: React.ReactNode[] = [];
|
||||
|
||||
items.push(
|
||||
@@ -964,12 +918,12 @@ export default function ThreePanelLayout() {
|
||||
onDrop={(event) => handleSlotDrop(event, slot)}
|
||||
style={getSlotContainerStyle(slot)}
|
||||
>
|
||||
{!expanded ? renderCollapsedPanel(slot) : state ? renderSlot(slot, state) : renderExpandedEmptyPanel(slot)}
|
||||
{state ? renderSlot(slot, state) : renderExpandedEmptyPanel(slot)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const nextSlot = allSlots[index + 1];
|
||||
if (nextSlot && expanded && isPanelExpanded(nextSlot)) {
|
||||
const nextSlot = visibleSlots[index + 1];
|
||||
if (nextSlot) {
|
||||
items.push(
|
||||
<SplitHandle
|
||||
key={`split-${slot}-${nextSlot}`}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import Chip from '../common/Chip';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
@@ -25,6 +25,10 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||
const existingFiltersKey = useMemo(
|
||||
() => JSON.stringify(existingFilters),
|
||||
[existingFilters]
|
||||
);
|
||||
|
||||
// Store the element that triggered the modal for return focus
|
||||
useEffect(() => {
|
||||
@@ -94,18 +98,24 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
|
||||
// Generate suggestions based on search query
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSuggestions((prev) => (prev.length === 0 ? prev : []));
|
||||
setSelectedIndex(0);
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!searchQuery.trim()) {
|
||||
setSuggestions((current) => (current.length === 0 ? current : []));
|
||||
setSelectedIndex((current) => (current === 0 ? current : 0));
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const fetchSuggestions = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/nodes/search?q=${encodeURIComponent(searchQuery)}&limit=20`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
if (!cancelled && result.success) {
|
||||
const nodeSuggestions: NodeSuggestion[] = result.data.map((node: any) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
@@ -116,14 +126,19 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching suggestions:', error);
|
||||
setSuggestions([]);
|
||||
if (!cancelled) {
|
||||
console.error('Error fetching suggestions:', error);
|
||||
setSuggestions([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(fetchSuggestions, 200);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [searchQuery]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [isOpen, searchQuery, existingFiltersKey]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -204,9 +219,11 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`search-result-item ${index === selectedIndex ? 'selected' : ''}`}
|
||||
>
|
||||
<span className="result-id">{suggestion.id}</span>
|
||||
<span className="result-icon">{getNodeIcon(suggestion as any, 14)}</span>
|
||||
<span className="result-title">{suggestion.title}</span>
|
||||
<span className="result-main">
|
||||
<span className="result-title">{suggestion.title}</span>
|
||||
<span className="result-id">{suggestion.id}</span>
|
||||
</span>
|
||||
{index === selectedIndex && (
|
||||
<span className="result-hint">↵</span>
|
||||
)}
|
||||
@@ -334,19 +351,11 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
.search-result-item.selected {
|
||||
background: var(--rah-bg-active);
|
||||
}
|
||||
|
||||
|
||||
.result-id {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--rah-text-inverse);
|
||||
background: var(--rah-accent-green);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
min-width: 28px;
|
||||
color: var(--rah-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -356,8 +365,15 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
.result-main {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
color: var(--rah-text-base);
|
||||
font-size: 15px;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { GripVertical, X } from 'lucide-react';
|
||||
import { PaneHeaderProps } from './types';
|
||||
|
||||
export default function PaneHeader({
|
||||
@@ -50,9 +50,6 @@ export default function PaneHeader({
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable={!!slot && !!onSwapPanes}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
@@ -61,14 +58,38 @@ export default function PaneHeader({
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
background: isDragOver ? 'rgba(34, 197, 94, 0.1)' : 'transparent',
|
||||
cursor: slot && onSwapPanes ? 'grab' : 'default',
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
borderRadius: isDragOver ? '6px' : '0',
|
||||
transition: 'background 0.15s ease',
|
||||
transition: 'background 0.15s ease, opacity 0.15s ease',
|
||||
padding: '8px 12px',
|
||||
minHeight: '48px',
|
||||
}}
|
||||
>
|
||||
{slot && onSwapPanes ? (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
title="Drag to swap panes"
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
background: isDragging ? 'var(--rah-bg-hover)' : 'var(--rah-bg-elevated)',
|
||||
color: isDragging ? 'var(--rah-text-soft)' : 'var(--rah-text-muted)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'grab',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
|
||||
{tabBar ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', minWidth: 0 }}>
|
||||
@@ -96,6 +117,7 @@ export default function PaneHeader({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Close button (when onCollapse is provided) */}
|
||||
{onCollapse && (
|
||||
<button
|
||||
onClick={onCollapse}
|
||||
|
||||
@@ -2,20 +2,23 @@
|
||||
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, X, ArrowUpDown, Search, ExternalLink } from 'lucide-react';
|
||||
import { ChevronDown, X, ArrowUpDown, Search, Check } from 'lucide-react';
|
||||
import type { Node } from '@/types/database';
|
||||
import { formatRelativeDate } from '@/utils/formatDate';
|
||||
import { getNodeProcessedState } from '@/services/nodes/metadata';
|
||||
|
||||
type SortOrder = 'updated' | 'edges' | 'created' | 'event_date';
|
||||
|
||||
const SORT_LABELS: Record<SortOrder, string> = {
|
||||
updated: 'Recently Updated',
|
||||
edges: 'Most Edges',
|
||||
edges: 'Most Connections',
|
||||
created: 'Creation Date',
|
||||
event_date: 'Event Date',
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const FETCH_LIMIT = 2000;
|
||||
const ROW_HEIGHT = 52;
|
||||
const OVERSCAN = 10;
|
||||
|
||||
interface DatabaseTableViewProps {
|
||||
onNodeClick: (nodeId: number) => void;
|
||||
@@ -23,62 +26,63 @@ interface DatabaseTableViewProps {
|
||||
toolbarHost?: HTMLDivElement | null;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '\u2014';
|
||||
try {
|
||||
return dateStr.slice(0, 10);
|
||||
} catch {
|
||||
return '\u2014';
|
||||
function getSourceSignal(node: Node): string {
|
||||
if (node.link) {
|
||||
return node.link.replace(/^https?:\/\/(www\.)?/, '');
|
||||
}
|
||||
|
||||
if (node.source) {
|
||||
return node.source.replace(/\s+/g, ' ').trim().slice(0, 120);
|
||||
}
|
||||
|
||||
return '—';
|
||||
}
|
||||
|
||||
export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolbarHost }: DatabaseTableViewProps) {
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('updated');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeSearch, setActiveSearch] = useState('');
|
||||
const [showSortDropdown, setShowSortDropdown] = useState(false);
|
||||
const [hoveredRow, setHoveredRow] = useState<number | null>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState(480);
|
||||
|
||||
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch nodes
|
||||
const fetchNodes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(PAGE_SIZE),
|
||||
offset: String((page - 1) * PAGE_SIZE),
|
||||
limit: String(FETCH_LIMIT),
|
||||
offset: '0',
|
||||
sortBy: sortOrder,
|
||||
});
|
||||
if (activeSearch) params.set('search', activeSearch);
|
||||
if (activeSearch) {
|
||||
params.set('search', activeSearch);
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/nodes?${params}`);
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setNodes(data.data || []);
|
||||
setTotal(data.total ?? data.count ?? 0);
|
||||
} else {
|
||||
setNodes([]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching nodes:', e);
|
||||
} catch (error) {
|
||||
console.error('Error fetching nodes:', error);
|
||||
setNodes([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, sortOrder, activeSearch]);
|
||||
}, [activeSearch, sortOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes();
|
||||
}, [fetchNodes, refreshToken]);
|
||||
|
||||
// Reset to page 1 when filters/sort/search change
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [sortOrder, activeSearch]);
|
||||
|
||||
// Close dropdowns on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (showSortDropdown && sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as HTMLElement)) {
|
||||
@@ -89,382 +93,282 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showSortDropdown]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = listRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const updateHeight = () => {
|
||||
setViewportHeight(node.clientHeight);
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
const observer = new ResizeObserver(updateHeight);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setActiveSearch(searchQuery);
|
||||
};
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const startItem = (page - 1) * PAGE_SIZE + 1;
|
||||
const endItem = Math.min(page * PAGE_SIZE, total);
|
||||
const totalRows = nodes.length;
|
||||
const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
|
||||
const visibleCount = Math.ceil(viewportHeight / ROW_HEIGHT) + OVERSCAN * 2;
|
||||
const endIndex = Math.min(totalRows, startIndex + visibleCount);
|
||||
const visibleRows = nodes.slice(startIndex, endIndex);
|
||||
const topSpacer = startIndex * ROW_HEIGHT;
|
||||
const bottomSpacer = Math.max(0, (totalRows - endIndex) * ROW_HEIGHT);
|
||||
|
||||
const toolbar = (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<div style={{ width: '100%', display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
|
||||
<form onSubmit={handleSearchSubmit} style={{ display: 'flex', alignItems: 'center', gap: '0' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
background: 'var(--rah-bg-base)',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderRadius: '6px',
|
||||
padding: '0 8px',
|
||||
gap: '6px',
|
||||
}}>
|
||||
<Search size={12} style={{ color: 'var(--rah-text-muted)', flexShrink: 0 }} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search nodes..."
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--rah-text-active)',
|
||||
fontSize: '12px',
|
||||
padding: '5px 0',
|
||||
outline: 'none',
|
||||
width: '140px',
|
||||
}}
|
||||
/>
|
||||
{activeSearch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSearchQuery(''); setActiveSearch(''); }}
|
||||
style={{ background: 'transparent', border: 'none', color: 'var(--rah-text-muted)', cursor: 'pointer', padding: 0, display: 'flex' }}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowSortDropdown(!showSortDropdown)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', background: 'var(--rah-bg-base)', border: '1px solid var(--rah-border)', borderRadius: '8px', padding: '0 8px', gap: '6px' }}>
|
||||
<Search size={12} style={{ color: 'var(--rah-text-muted)', flexShrink: 0 }} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search nodes..."
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '4px',
|
||||
padding: '4px 7px', background: 'transparent',
|
||||
border: '1px solid var(--rah-border)', borderRadius: '5px',
|
||||
color: 'var(--rah-text-soft)', fontSize: '11px', cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--rah-text-active)',
|
||||
fontSize: '12px',
|
||||
padding: '7px 0',
|
||||
outline: 'none',
|
||||
width: '160px',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<ArrowUpDown size={11} />
|
||||
{SORT_LABELS[sortOrder]}
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
|
||||
{showSortDropdown && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', right: 0, marginTop: '4px',
|
||||
background: 'var(--rah-bg-panel)', border: '1px solid var(--rah-border)', borderRadius: '10px',
|
||||
padding: '4px', minWidth: '160px', zIndex: 1000,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
||||
}}>
|
||||
{(Object.keys(SORT_LABELS) as SortOrder[]).map(key => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => { setSortOrder(key); setShowSortDropdown(false); }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
width: '100%', padding: '7px 10px',
|
||||
background: sortOrder === key ? 'rgba(255,255,255,0.04)' : 'transparent',
|
||||
border: 'none', borderRadius: '5px',
|
||||
color: sortOrder === key ? 'var(--rah-text-active)' : 'var(--rah-text-soft)',
|
||||
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = sortOrder === key ? 'rgba(255,255,255,0.04)' : 'transparent'; }}
|
||||
>
|
||||
{sortOrder === key && <span style={{ color: 'var(--rah-accent-green)', fontSize: '12px' }}>✓</span>}
|
||||
{SORT_LABELS[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
/>
|
||||
{activeSearch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setActiveSearch('');
|
||||
}}
|
||||
style={{ background: 'transparent', border: 'none', color: 'var(--rah-text-muted)', cursor: 'pointer', padding: 0, display: 'flex' }}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Pagination */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
fontSize: '11px', color: 'var(--rah-text-muted)', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<span>{total > 0 ? `${startItem}-${endItem} of ${total}` : '0 nodes'}</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowSortDropdown((prev) => !prev)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '6px 8px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--rah-text-soft)',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<ArrowUpDown size={11} />
|
||||
{SORT_LABELS[sortOrder]}
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
|
||||
{showSortDropdown && (
|
||||
<div
|
||||
style={{
|
||||
background: 'transparent', border: '1px solid var(--rah-border)', borderRadius: '4px',
|
||||
color: page <= 1 ? 'var(--rah-text-muted)' : 'var(--rah-text-soft)', cursor: page <= 1 ? 'default' : 'pointer',
|
||||
padding: '2px 4px', display: 'flex',
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
right: 0,
|
||||
marginTop: '6px',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderRadius: '12px',
|
||||
padding: '4px',
|
||||
minWidth: '180px',
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
style={{
|
||||
background: 'transparent', border: '1px solid var(--rah-border)', borderRadius: '4px',
|
||||
color: page >= totalPages ? 'var(--rah-text-muted)' : 'var(--rah-text-soft)',
|
||||
cursor: page >= totalPages ? 'default' : 'pointer',
|
||||
padding: '2px 4px', display: 'flex',
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{(Object.keys(SORT_LABELS) as SortOrder[]).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => {
|
||||
setSortOrder(key);
|
||||
setShowSortDropdown(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
background: sortOrder === key ? 'rgba(255,255,255,0.04)' : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: sortOrder === key ? 'var(--rah-text-active)' : 'var(--rah-text-soft)',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{sortOrder === key && <span style={{ color: 'var(--rah-accent-green)', fontSize: '12px' }}>✓</span>}
|
||||
{SORT_LABELS[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '11px', color: 'var(--rah-text-muted)', whiteSpace: 'nowrap' }}>
|
||||
{loading ? 'Loading…' : `${nodes.length} nodes`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'transparent' }}>
|
||||
{toolbarHost ? createPortal(toolbar, toolbarHost) : (
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid var(--rah-border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--rah-border)', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{toolbar}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: '40px', color: 'var(--rah-text-muted)', textAlign: 'center', fontSize: '13px' }}>Loading...</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<div style={{ padding: '40px', color: 'var(--rah-text-muted)', textAlign: 'center', fontSize: '13px' }}>
|
||||
{activeSearch ? 'No nodes match your search.' : 'No nodes yet.'}
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ minWidth: '1600px', width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<th style={thStyle({ width: '240px' })}>TITLE</th>
|
||||
<th style={thStyle({ width: '55px', textAlign: 'right' })}>ID</th>
|
||||
<th style={thStyle({ width: '200px' })}>DESCRIPTION</th>
|
||||
<th style={thStyle({ width: '160px' })}>SOURCE</th>
|
||||
<th style={thStyle({ width: '180px' })}>LINK</th>
|
||||
<th style={thStyle({ width: '160px' })}>CONTEXT</th>
|
||||
<th style={thStyle({ width: '50px', textAlign: 'right' })}>EDGES</th>
|
||||
<th style={thStyle({ width: '90px' })}>EVENT</th>
|
||||
<th style={thStyle({ width: '85px' })}>UPDATED</th>
|
||||
<th style={thStyle({ width: '85px' })}>CREATED</th>
|
||||
<th style={thStyle({ width: '160px' })}>METADATA</th>
|
||||
<th style={thStyle({ width: '160px' })}>CHUNK</th>
|
||||
<th style={thStyle({ width: '80px' })}>CHUNK STATUS</th>
|
||||
<th style={thStyle({ width: '85px' })}>EMB UPDATED</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodes.map((node, i) => {
|
||||
const metaStr = node.metadata
|
||||
? (typeof node.metadata === 'string' ? node.metadata : JSON.stringify(node.metadata))
|
||||
: '';
|
||||
<div style={{ padding: '0 12px 12px', display: 'flex', flexDirection: 'column', minHeight: 0, flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '40px minmax(240px, 2fr) 72px 140px 64px minmax(220px, 1.3fr) 110px minmax(180px, 1fr)',
|
||||
gap: '12px',
|
||||
alignItems: 'center',
|
||||
minHeight: '38px',
|
||||
borderBottom: '1px solid var(--rah-border)',
|
||||
color: 'var(--rah-text-muted)',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
padding: '0 8px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span>✓</span>
|
||||
<span>Title</span>
|
||||
<span>ID</span>
|
||||
<span>Context</span>
|
||||
<span>Edges</span>
|
||||
<span>Description</span>
|
||||
<span>Updated</span>
|
||||
<span>Source</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
|
||||
style={{ flex: 1, minHeight: 0, overflow: 'auto' }}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ padding: '40px', color: 'var(--rah-text-muted)', textAlign: 'center', fontSize: '13px' }}>Loading...</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<div style={{ padding: '40px', color: 'var(--rah-text-muted)', textAlign: 'center', fontSize: '13px' }}>
|
||||
{activeSearch ? 'No nodes match your search.' : 'No nodes yet.'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ paddingTop: `${topSpacer}px`, paddingBottom: `${bottomSpacer}px` }}>
|
||||
{visibleRows.map((node) => {
|
||||
const processed = getNodeProcessedState(node.metadata) === 'processed';
|
||||
const sourceSignal = getSourceSignal(node);
|
||||
const description = node.description?.replace(/\s+/g, ' ').trim() || '—';
|
||||
|
||||
return (
|
||||
<tr
|
||||
<button
|
||||
key={node.id}
|
||||
type="button"
|
||||
onClick={() => onNodeClick(node.id)}
|
||||
onMouseEnter={() => setHoveredRow(node.id)}
|
||||
onMouseLeave={() => setHoveredRow(null)}
|
||||
style={{
|
||||
height: '44px',
|
||||
width: '100%',
|
||||
height: `${ROW_HEIGHT}px`,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '40px minmax(240px, 2fr) 72px 140px 64px minmax(220px, 1.3fr) 110px minmax(180px, 1fr)',
|
||||
gap: '12px',
|
||||
alignItems: 'center',
|
||||
border: 'none',
|
||||
borderBottom: '1px solid var(--rah-border)',
|
||||
background: hoveredRow === node.id ? 'var(--rah-bg-panel)' : 'transparent',
|
||||
color: 'inherit',
|
||||
textAlign: 'left',
|
||||
padding: '0 8px',
|
||||
cursor: 'pointer',
|
||||
background: hoveredRow === node.id
|
||||
? 'var(--rah-bg-panel)'
|
||||
: i % 2 === 0 ? 'var(--rah-bg-base)' : 'var(--rah-bg-subtle)',
|
||||
transition: 'background 0.1s ease',
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<td style={tdStyle()}>
|
||||
<div style={truncCell}>
|
||||
<span style={{ fontSize: '13px', color: 'var(--rah-text-base)', fontWeight: 400 }}>
|
||||
{node.title || 'Untitled'}
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: processed ? 'var(--rah-accent-green)' : 'var(--rah-text-muted)' }}>
|
||||
{processed ? <Check size={14} strokeWidth={3} /> : null}
|
||||
</span>
|
||||
|
||||
<span style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<span style={{ fontSize: '13px', color: 'var(--rah-text-base)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{node.title || 'Untitled'}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: '11px', color: 'var(--rah-text-muted)' }}>
|
||||
{node.id}
|
||||
</span>
|
||||
|
||||
<span style={{ minWidth: 0 }}>
|
||||
{node.context?.name ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
maxWidth: '100%',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid var(--rah-border)',
|
||||
background: 'var(--rah-bg-surface)',
|
||||
color: 'var(--rah-text-soft)',
|
||||
fontSize: '11px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{node.context.name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>—</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* ID */}
|
||||
<td style={tdStyle({ textAlign: 'right' })}>
|
||||
<span style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
|
||||
fontSize: '11px', color: 'var(--rah-text-muted)',
|
||||
}}>
|
||||
{node.id}
|
||||
</span>
|
||||
</td>
|
||||
<span style={{ fontSize: '12px', color: node.edge_count ? 'var(--rah-text-soft)' : 'var(--rah-text-muted)' }}>
|
||||
{node.edge_count ?? 0}
|
||||
</span>
|
||||
|
||||
{/* Description */}
|
||||
<td style={tdStyle()}>
|
||||
<div style={truncCell}>
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-soft)' }}>
|
||||
{node.description || '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-soft)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{description}
|
||||
</span>
|
||||
|
||||
{/* Source */}
|
||||
<td style={tdStyle()}>
|
||||
<div style={truncCell}>
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-soft)' }}>
|
||||
{node.source ? node.source.slice(0, 120) : '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>
|
||||
{formatRelativeDate(node.updated_at)}
|
||||
</span>
|
||||
|
||||
{/* Link */}
|
||||
<td style={tdStyle()}>
|
||||
<div style={truncCell}>
|
||||
{node.link ? (
|
||||
<span style={{ fontSize: '11px', color: '#6a9fd8', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<ExternalLink size={10} style={{ flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{node.link.replace(/^https?:\/\/(www\.)?/, '')}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>{'\u2014'}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Context */}
|
||||
<td style={tdStyle()}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px', overflow: 'hidden', maxHeight: '36px' }}>
|
||||
{node.context?.name ? (
|
||||
<>
|
||||
<span style={{
|
||||
fontSize: '9px', padding: '1px 5px',
|
||||
background: 'var(--rah-accent-green-soft)', border: '1px solid var(--rah-accent-green-soft-strong)',
|
||||
color: 'var(--rah-accent-green)', borderRadius: '3px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{node.context.name}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ fontSize: '10px', color: 'var(--rah-text-muted)' }}>{'\u2014'}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Edges */}
|
||||
<td style={tdStyle({ textAlign: 'right' })}>
|
||||
<span style={{ fontSize: '12px', color: node.edge_count ? 'var(--rah-text-soft)' : 'var(--rah-text-muted)' }}>
|
||||
{node.edge_count ?? 0}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Event Date */}
|
||||
<td style={tdStyle()}>
|
||||
<span style={{ fontSize: '11px', color: node.event_date ? 'var(--rah-text-soft)' : 'var(--rah-text-muted)' }}>
|
||||
{formatDate(node.event_date)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Updated */}
|
||||
<td style={tdStyle()}>
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>
|
||||
{formatRelativeDate(node.updated_at)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Created */}
|
||||
<td style={tdStyle()}>
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>
|
||||
{formatRelativeDate(node.created_at)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Metadata */}
|
||||
<td style={tdStyle()}>
|
||||
<div style={truncCell}>
|
||||
<span style={{ fontSize: '10px', color: 'var(--rah-text-muted)', fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace' }}>
|
||||
{metaStr || '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Chunk */}
|
||||
<td style={tdStyle()}>
|
||||
<div style={truncCell}>
|
||||
<span style={{ fontSize: '10px', color: 'var(--rah-text-muted)' }}>
|
||||
{node.source ? node.source.slice(0, 100) : '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Chunk Status */}
|
||||
<td style={tdStyle()}>
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
color: node.chunk_status === 'chunked' ? 'var(--rah-accent-green)' : node.chunk_status === 'error' ? '#e55' : 'var(--rah-text-muted)',
|
||||
}}>
|
||||
{node.chunk_status || '\u2014'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Embedding Updated */}
|
||||
<td style={tdStyle()}>
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>
|
||||
{node.embedding_updated_at ? formatRelativeDate(node.embedding_updated_at) : '\u2014'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<span style={{ fontSize: '11px', color: sourceSignal === '—' ? 'var(--rah-text-muted)' : 'var(--rah-text-soft)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{sourceSignal}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function thStyle(extra: React.CSSProperties = {}): React.CSSProperties {
|
||||
return {
|
||||
position: 'sticky' as const,
|
||||
top: 0,
|
||||
background: 'var(--rah-bg-base)',
|
||||
padding: '8px 12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--rah-text-muted)',
|
||||
textAlign: 'left',
|
||||
letterSpacing: '0.05em',
|
||||
whiteSpace: 'nowrap',
|
||||
borderBottom: '1px solid var(--rah-border)',
|
||||
zIndex: 1,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function tdStyle(extra: React.CSSProperties = {}): React.CSSProperties {
|
||||
return {
|
||||
padding: '0 12px',
|
||||
verticalAlign: 'middle',
|
||||
borderBottom: '1px solid var(--rah-border)',
|
||||
overflow: 'hidden',
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const truncCell: React.CSSProperties = {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
@@ -502,31 +502,6 @@ export default function ViewsOverlay({
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleNodeProcessed(node);
|
||||
}}
|
||||
title="Toggle processed"
|
||||
aria-label="Toggle processed"
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '999px',
|
||||
border: `1px solid ${isProcessed ? 'rgba(74, 222, 128, 0.55)' : 'var(--rah-border-strong)'}`,
|
||||
background: isProcessed ? 'rgba(74, 222, 128, 0.16)' : 'var(--rah-bg-panel)',
|
||||
color: isProcessed ? '#86efac' : 'transparent',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<Check size={11} strokeWidth={2.8} />
|
||||
</button>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -535,6 +510,32 @@ export default function ViewsOverlay({
|
||||
flex: 1,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '6px',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
border: '1px solid var(--rah-border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{nodeIcon}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
border: '1px solid var(--rah-border)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '999px',
|
||||
fontFamily: 'monospace',
|
||||
flexShrink: 0,
|
||||
lineHeight: 1.2,
|
||||
}}>
|
||||
#{node.id}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
@@ -547,25 +548,6 @@ export default function ViewsOverlay({
|
||||
}}>
|
||||
{node.title || 'Untitled'}
|
||||
</span>
|
||||
{node.context?.name ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--rah-accent-green)',
|
||||
background: 'color-mix(in srgb, var(--rah-accent-green) 12%, var(--rah-bg-panel))',
|
||||
border: '1px solid color-mix(in srgb, var(--rah-accent-green) 32%, var(--rah-border))',
|
||||
padding: '3px 7px',
|
||||
borderRadius: '999px',
|
||||
lineHeight: 1.2,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{node.context.name}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
|
||||
@@ -589,50 +571,32 @@ export default function ViewsOverlay({
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '6px',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
border: '1px solid var(--rah-border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{nodeIcon}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '999px',
|
||||
fontFamily: 'monospace',
|
||||
flexShrink: 0,
|
||||
lineHeight: 1.2,
|
||||
}}>
|
||||
#{node.id}
|
||||
</span>
|
||||
{node.edge_count != null && node.edge_count > 0 ? (
|
||||
<span style={{
|
||||
minWidth: '18px',
|
||||
height: '18px',
|
||||
padding: '0 5px',
|
||||
borderRadius: '999px',
|
||||
background: 'var(--rah-accent-green-soft)',
|
||||
border: '1px solid var(--rah-accent-green-soft-strong)',
|
||||
color: 'var(--rah-accent-green)',
|
||||
display: 'flex',
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleNodeProcessed(node);
|
||||
}}
|
||||
title="Toggle processed"
|
||||
aria-label="Toggle processed"
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '6px',
|
||||
border: `1px solid ${isProcessed ? 'rgba(74, 222, 128, 0.7)' : 'var(--rah-border-strong)'}`,
|
||||
background: isProcessed ? 'rgba(74, 222, 128, 0.16)' : 'var(--rah-bg-panel)',
|
||||
color: isProcessed ? '#86efac' : 'var(--rah-text-muted)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
{node.edge_count}
|
||||
</span>
|
||||
) : null}
|
||||
cursor: 'pointer',
|
||||
boxShadow: isProcessed ? 'inset 0 0 0 1px rgba(74, 222, 128, 0.12)' : 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<Check size={11} strokeWidth={2.8} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user