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