feat: sync panel redesign updates

This commit is contained in:
“BeeRad”
2026-04-15 13:58:08 +10:00
parent fd56f6f86f
commit 528aab8e8c
9 changed files with 864 additions and 1015 deletions
+21 -25
View File
@@ -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'
}}
+156 -288
View File
@@ -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}>
<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,11 +1054,12 @@ 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">
<div className="title-heading-row">
{titleEditMode ? (
<input
ref={titleInputRef}
@@ -1090,25 +1083,6 @@ export default function FocusPanel({
{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>
</div>
</div>
@@ -1116,38 +1090,6 @@ 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>
</div>
<div className="node-row-actions">
<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"
onClick={() => setPendingDeleteNodeId(activeTab)}
disabled={deletingNode === activeTab}
title="Delete node"
>
{deletingNode === activeTab ? '…' : <Trash2 size={11} />}
</button>
</div>
</div>
{!propsCollapsed && (<>
{/* Status indicator row (only when relevant) */}
{renderStatusIndicator() !== null && (
<div className="prop-row">
@@ -1156,6 +1098,27 @@ export default function FocusPanel({
</div>
)}
<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="delete-node-button node-delete-button"
onClick={() => setPendingDeleteNodeId(activeTab)}
disabled={deletingNode === activeTab}
title="Delete node"
>
{deletingNode === activeTab ? '…' : <Trash2 size={11} />}
</button>
</div>
</div>
{/* Link */}
<div className="prop-row">
<div className="prop-label">link</div>
@@ -1178,18 +1141,19 @@ export default function FocusPanel({
className="prop-input"
placeholder="https://..."
/>
) : currentNode.link ? (
) : (
<div className="link-inline-row">
{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)`}
title={normalizedCurrentLink}
>
{currentNode.link}
</a>
@@ -1203,6 +1167,16 @@ export default function FocusPanel({
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}>
<div className="context-inline-row">
<span className={currentNode.context ? 'context-select-name' : 'context-select-empty'}>
{currentNode.context?.name || 'No context'}
</span>
<button
type="button"
className="context-select-trigger"
className="context-inline-button"
disabled={contextSaving}
onClick={(event) => {
event.stopPropagation();
setContextMenuOpen((prev) => !prev);
}}
title={currentNode.context ? 'Change context' : 'Add context'}
>
<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>
</span>
<ChevronDown size={13} className={`context-select-chevron ${contextMenuOpen ? 'open' : ''}`} />
<Plus size={12} />
</button>
</div>
{contextMenuOpen ? (
<div className="context-select-menu">
@@ -1315,10 +1287,13 @@ 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
<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>
</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,7 +174,7 @@ export default function NodeSearchModal({
alignSelf: 'flex-start',
}}
>
{/* Search input */}
{!selectedNode && (
<div style={{
display: 'flex',
alignItems: 'center',
@@ -220,6 +220,7 @@ export default function NodeSearchModal({
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>
+124
View File
@@ -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,8 +277,12 @@ 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 }}>
<div style={{ borderTop: '1px solid var(--rah-border)', paddingTop: '14px' }}>
+88 -134
View File
@@ -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}`}
+35 -19
View File
@@ -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) {
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-main">
<span className="result-title">{suggestion.title}</span>
<span className="result-id">{suggestion.id}</span>
</span>
{index === selectedIndex && (
<span className="result-hint"></span>
)}
@@ -336,17 +353,9 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
}
.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;
+28 -6
View File
@@ -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}
+209 -305
View File
@@ -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,33 +93,37 @@ 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',
}}>
<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"
@@ -127,15 +135,18 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
border: 'none',
color: 'var(--rah-text-active)',
fontSize: '12px',
padding: '5px 0',
padding: '7px 0',
outline: 'none',
width: '140px',
width: '160px',
}}
/>
{activeSearch && (
<button
type="button"
onClick={() => { setSearchQuery(''); setActiveSearch(''); }}
onClick={() => {
setSearchQuery('');
setActiveSearch('');
}}
style={{ background: 'transparent', border: 'none', color: 'var(--rah-text-muted)', cursor: 'pointer', padding: 0, display: 'flex' }}
>
<X size={11} />
@@ -146,18 +157,22 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
<div style={{ flex: 1 }} />
{/* Sort dropdown */}
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
<button
onClick={() => setShowSortDropdown(!showSortDropdown)}
onClick={() => setShowSortDropdown((prev) => !prev)}
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',
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',
}}
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]}
@@ -165,26 +180,42 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
</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,
<div
style={{
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)',
}}>
{(Object.keys(SORT_LABELS) as SortOrder[]).map(key => (
}}
>
{(Object.keys(SORT_LABELS) as SortOrder[]).map((key) => (
<button
key={key}
onClick={() => { setSortOrder(key); setShowSortDropdown(false); }}
onClick={() => {
setSortOrder(key);
setShowSortDropdown(false);
}}
style={{
display: 'flex', alignItems: 'center', gap: '8px',
width: '100%', padding: '7px 10px',
display: 'flex',
alignItems: 'center',
gap: '8px',
width: '100%',
padding: '8px 10px',
background: sortOrder === key ? 'rgba(255,255,255,0.04)' : 'transparent',
border: 'none', borderRadius: '5px',
border: 'none',
borderRadius: '8px',
color: sortOrder === key ? 'var(--rah-text-active)' : 'var(--rah-text-soft)',
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
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]}
@@ -194,35 +225,8 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
)}
</div>
{/* 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}
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',
}}
>
<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 style={{ fontSize: '11px', color: 'var(--rah-text-muted)', whiteSpace: 'nowrap' }}>
{loading ? 'Loading…' : `${nodes.length} nodes`}
</div>
</div>
);
@@ -230,19 +234,44 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
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' }}>
<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 ? (
@@ -250,221 +279,96 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
{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={{ 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 }}>
<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>
</div>
</td>
</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)',
}}>
<span style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: '11px', color: 'var(--rah-text-muted)' }}>
{node.id}
</span>
</td>
{/* Description */}
<td style={tdStyle()}>
<div style={truncCell}>
<span style={{ fontSize: '11px', color: 'var(--rah-text-soft)' }}>
{node.description || '\u2014'}
</span>
</div>
</td>
{/* 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>
{/* 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' }}>
<span style={{ minWidth: 0 }}>
{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>
);
})}
</tbody>
</table>
)}
</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 = {
<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>
) : (
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}></span>
)}
</span>
<span style={{ fontSize: '12px', color: node.edge_count ? 'var(--rah-text-soft)' : 'var(--rah-text-muted)' }}>
{node.edge_count ?? 0}
</span>
<span style={{ fontSize: '11px', color: 'var(--rah-text-soft)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{description}
</span>
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>
{formatRelativeDate(node.updated_at)}
</span>
<span style={{ fontSize: '11px', color: sourceSignal === '—' ? 'var(--rah-text-muted)' : 'var(--rah-text-soft)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{sourceSignal}
</span>
</button>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
+46 -82
View File
@@ -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={{
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void toggleNodeProcessed(node);
}}
title="Toggle processed"
aria-label="Toggle processed"
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',
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>