feat: port holistic node refinement contract

This commit is contained in:
“BeeRad”
2026-04-11 21:37:52 +10:00
parent 35f9ecf89c
commit 3ae46245ec
119 changed files with 6596 additions and 10982 deletions
+19 -172
View File
@@ -1,8 +1,8 @@
"use client";
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useEffect, useState, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Filter, ChevronDown, ChevronLeft, ChevronRight, X, ArrowUpDown, Search, ExternalLink } from 'lucide-react';
import { ChevronDown, ChevronLeft, ChevronRight, X, ArrowUpDown, Search, ExternalLink } from 'lucide-react';
import type { Node } from '@/types/database';
import { formatRelativeDate } from '@/utils/formatDate';
@@ -17,12 +17,6 @@ const SORT_LABELS: Record<SortOrder, string> = {
const PAGE_SIZE = 50;
interface DimensionSummary {
dimension: string;
count: number;
isPriority: boolean;
}
interface DatabaseTableViewProps {
onNodeClick: (nodeId: number) => void;
refreshToken?: number;
@@ -46,41 +40,11 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
const [sortOrder, setSortOrder] = useState<SortOrder>('updated');
const [searchQuery, setSearchQuery] = useState('');
const [activeSearch, setActiveSearch] = useState('');
const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
const [dimensions, setDimensions] = useState<DimensionSummary[]>([]);
const [showFilterPicker, setShowFilterPicker] = useState(false);
const [filterSearchQuery, setFilterSearchQuery] = useState('');
const [showSortDropdown, setShowSortDropdown] = useState(false);
const [hoveredRow, setHoveredRow] = useState<number | null>(null);
const filterPickerRef = useRef<HTMLDivElement>(null);
const sortDropdownRef = useRef<HTMLDivElement>(null);
const sortedDimensions = useMemo(() => {
return [...dimensions].sort((a, b) => {
if (a.isPriority && !b.isPriority) return -1;
if (!a.isPriority && b.isPriority) return 1;
return a.dimension.localeCompare(b.dimension);
});
}, [dimensions]);
const filterPickerDimensions = sortedDimensions.filter(d =>
d.dimension.toLowerCase().includes(filterSearchQuery.toLowerCase())
);
// Fetch dimensions
useEffect(() => {
(async () => {
try {
const res = await fetch('/api/dimensions');
const data = await res.json();
if (data.success) setDimensions(data.data || []);
} catch (e) {
console.error('Error fetching dimensions:', e);
}
})();
}, [refreshToken]);
// Fetch nodes
const fetchNodes = useCallback(async () => {
setLoading(true);
@@ -91,10 +55,6 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
sortBy: sortOrder,
});
if (activeSearch) params.set('search', activeSearch);
if (selectedFilters.length > 0) {
params.set('dimensions', selectedFilters.join(','));
params.set('dimensionsMatch', 'all');
}
const res = await fetch(`/api/nodes?${params}`);
const data = await res.json();
@@ -107,32 +67,27 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
} finally {
setLoading(false);
}
}, [page, sortOrder, activeSearch, selectedFilters]);
}, [page, sortOrder, activeSearch]);
useEffect(() => {
fetchNodes();
}, [fetchNodes, refreshToken]);
// Reset to page 1 when filters/sort/search change
const filtersKey = selectedFilters.join(',');
useEffect(() => {
setPage(1);
}, [sortOrder, activeSearch, filtersKey]);
}, [sortOrder, activeSearch]);
// Close dropdowns on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (showFilterPicker && filterPickerRef.current && !filterPickerRef.current.contains(e.target as HTMLElement)) {
setShowFilterPicker(false);
setFilterSearchQuery('');
}
if (showSortDropdown && sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as HTMLElement)) {
setShowSortDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showFilterPicker, showSortDropdown]);
}, [showSortDropdown]);
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -189,108 +144,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
</div>
</form>
{/* Filter chips + button */}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flex: 1, flexWrap: 'wrap' }}>
{selectedFilters.map(f => (
<div key={f} style={{
display: 'flex', alignItems: 'center', gap: '4px',
padding: '2px 7px',
background: 'rgba(34, 197, 94, 0.06)',
border: '1px solid rgba(34, 197, 94, 0.12)',
borderRadius: '4px', fontSize: '11px', color: '#5a9'
}}>
{f}
<button
onClick={() => setSelectedFilters(selectedFilters.filter(x => x !== f))}
style={{ background: 'transparent', border: 'none', color: '#5a9', cursor: 'pointer', padding: 0, display: 'flex' }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#5a9'; }}
>
<X size={10} />
</button>
</div>
))}
<div style={{ position: 'relative' }} ref={filterPickerRef}>
<button
onClick={() => setShowFilterPicker(!showFilterPicker)}
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',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<Filter size={11} />
Filter
</button>
{showFilterPicker && (
<div style={{
position: 'absolute', top: '100%', left: 0, marginTop: '4px',
background: 'var(--rah-bg-panel)', border: '1px solid var(--rah-border)', borderRadius: '10px',
padding: '6px', minWidth: '220px', maxHeight: '320px', overflowY: 'auto',
zIndex: 1000, boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
}}>
<input
type="text"
value={filterSearchQuery}
onChange={(e) => setFilterSearchQuery(e.target.value)}
placeholder="Search dimensions..."
autoFocus
style={{
width: '100%', padding: '7px 10px', background: 'var(--rah-bg-base)',
border: '1px solid transparent', borderRadius: '6px',
color: 'var(--rah-text-active)', fontSize: '12px', marginBottom: '4px', outline: 'none',
}}
/>
{filterPickerDimensions.length === 0 ? (
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
No matching dimensions
</div>
) : (
filterPickerDimensions.map(d => (
<button
key={d.dimension}
onClick={() => {
if (!selectedFilters.includes(d.dimension)) {
setSelectedFilters([...selectedFilters, d.dimension]);
}
setShowFilterPicker(false);
setFilterSearchQuery('');
}}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
width: '100%', padding: '7px 10px', background: 'transparent',
border: 'none', borderRadius: '5px', color: 'var(--rah-text-secondary)',
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<span>{d.dimension}</span>
<span style={{ color: 'var(--rah-text-muted)', fontSize: '10px', background: 'var(--rah-bg-active)', padding: '1px 6px', borderRadius: '10px' }}>
{d.count}
</span>
</button>
))
)}
</div>
)}
</div>
{selectedFilters.length > 0 && (
<button
onClick={() => setSelectedFilters([])}
style={{ padding: '4px 6px', background: 'transparent', border: 'none', color: 'var(--rah-text-muted)', fontSize: '11px', cursor: 'pointer' }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
>
Clear
</button>
)}
</div>
<div style={{ flex: 1 }} />
{/* Sort dropdown */}
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
@@ -393,7 +247,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
<div style={{ padding: '40px', color: 'var(--rah-text-muted)', textAlign: 'center', fontSize: '13px' }}>Loading...</div>
) : nodes.length === 0 ? (
<div style={{ padding: '40px', color: 'var(--rah-text-muted)', textAlign: 'center', fontSize: '13px' }}>
{activeSearch || selectedFilters.length > 0 ? 'No nodes match your filters.' : 'No nodes yet.'}
{activeSearch ? 'No nodes match your search.' : 'No nodes yet.'}
</div>
) : (
<table style={{ minWidth: '1600px', width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
@@ -402,9 +256,9 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
<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' })}>NOTES</th>
<th style={thStyle({ width: '160px' })}>SOURCE</th>
<th style={thStyle({ width: '180px' })}>LINK</th>
<th style={thStyle({ width: '160px' })}>DIMENSIONS</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>
@@ -488,26 +342,19 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
</div>
</td>
{/* Dimensions */}
{/* Context */}
<td style={tdStyle()}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px', overflow: 'hidden', maxHeight: '36px' }}>
{node.dimensions && node.dimensions.length > 0 ? (
{node.context?.name ? (
<>
{node.dimensions.slice(0, 3).map(d => (
<span key={d} 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',
}}>
{d}
</span>
))}
{node.dimensions.length > 3 && (
<span style={{ fontSize: '9px', color: 'var(--rah-text-muted)' }}>
+{node.dimensions.length - 3}
</span>
)}
<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>
+12 -26
View File
@@ -2,7 +2,6 @@
import { Node } from '@/types/database';
import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
import { getNodeProcessedState } from '@/services/nodes/metadata';
interface GridViewProps {
@@ -11,7 +10,6 @@ interface GridViewProps {
}
export default function GridView({ nodes, onNodeClick }: GridViewProps) {
const { dimensionIcons } = useDimensionIcons();
const truncateContent = (content?: string, maxLength: number = 120) => {
if (!content) return '';
if (content.length <= maxLength) return content;
@@ -93,7 +91,7 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
borderRadius: '6px',
flexShrink: 0
}}>
{getNodeIcon(node, dimensionIcons, 14)}
{getNodeIcon(node, 14)}
</div>
<div style={{
fontSize: '13px',
@@ -135,37 +133,25 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
</div>
)}
{/* Footer with Dimensions */}
{node.dimensions && node.dimensions.length > 0 && (
{/* Footer with Context */}
{node.context?.name && (
<div style={{
display: 'flex',
gap: '4px',
flexWrap: 'wrap',
marginTop: 'auto'
}}>
{node.dimensions.slice(0, 3).map(dim => (
<span
key={dim}
style={{
padding: '2px 6px',
background: '#1a1a1a',
borderRadius: '3px',
fontSize: '10px',
color: '#888'
}}
>
{dim}
</span>
))}
{node.dimensions.length > 3 && (
<span style={{
<span
style={{
padding: '2px 6px',
background: '#1a1a1a',
borderRadius: '3px',
fontSize: '10px',
color: '#555'
}}>
+{node.dimensions.length - 3}
</span>
)}
color: '#888'
}}
>
{node.context.name}
</span>
</div>
)}
</button>
-536
View File
@@ -1,536 +0,0 @@
"use client";
import { useState, DragEvent } from 'react';
import { Plus, GripVertical, X } from 'lucide-react';
import { Node } from '@/types/database';
import { KanbanColumn } from '@/types/views';
interface KanbanViewProps {
nodes: Node[];
columns: KanbanColumn[];
dimensions: string[];
onNodeClick: (nodeId: number) => void;
onColumnChange: (columns: KanbanColumn[]) => void;
onNodeDimensionUpdate: (nodeId: number, newDimension: string, oldDimension?: string) => void;
}
export default function KanbanView({
nodes,
columns,
dimensions,
onNodeClick,
onColumnChange,
onNodeDimensionUpdate
}: KanbanViewProps) {
const [showColumnPicker, setShowColumnPicker] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [draggedNodeId, setDraggedNodeId] = useState<number | null>(null);
const [draggedFromColumn, setDraggedFromColumn] = useState<string | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<string | null>(null);
const [draggingColumnId, setDraggingColumnId] = useState<string | null>(null);
const [dragOverColumnId, setDragOverColumnId] = useState<string | null>(null);
// Get nodes for a specific column (dimension)
const getNodesForColumn = (dimension: string) => {
return nodes.filter(node => node.dimensions?.includes(dimension));
};
// Get uncategorized nodes (not in any column)
const getUncategorizedNodes = () => {
const columnDimensions = columns.map(c => c.dimension);
return nodes.filter(node =>
!node.dimensions || !node.dimensions.some(d => columnDimensions.includes(d))
);
};
const handleAddColumn = (dimension: string) => {
const newColumn: KanbanColumn = {
id: `col-${Date.now()}`,
dimension,
order: columns.length
};
onColumnChange([...columns, newColumn]);
setShowColumnPicker(false);
setSearchQuery('');
};
const handleRemoveColumn = (columnId: string) => {
onColumnChange(columns.filter(c => c.id !== columnId));
};
// Node drag handlers
const handleNodeDragStart = (e: DragEvent, nodeId: number, fromColumn: string) => {
setDraggedNodeId(nodeId);
setDraggedFromColumn(fromColumn);
e.dataTransfer.effectAllowed = 'copyMove';
// Find the node to get its title for chat drop
const node = nodes.find(n => n.id === nodeId);
const title = node?.title || 'Untitled';
// Set MIME types for chat input drops
e.dataTransfer.setData('application/x-rah-node', JSON.stringify({ id: nodeId, title }));
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: nodeId, title, dimensions: node?.dimensions || [] }));
e.dataTransfer.setData('text/plain', `[NODE:${nodeId}:"${title}"]`);
};
const handleNodeDragEnd = () => {
setDraggedNodeId(null);
setDraggedFromColumn(null);
setDragOverColumn(null);
};
const handleColumnDragOver = (e: DragEvent, columnDimension: string) => {
e.preventDefault();
if (draggedNodeId !== null) {
setDragOverColumn(columnDimension);
}
};
const handleColumnDragLeave = () => {
setDragOverColumn(null);
};
const handleColumnDrop = (e: DragEvent, targetDimension: string) => {
e.preventDefault();
if (draggedNodeId !== null && draggedFromColumn !== targetDimension) {
onNodeDimensionUpdate(
draggedNodeId,
targetDimension,
draggedFromColumn || undefined
);
}
handleNodeDragEnd();
};
// Column reorder drag handlers
const handleColumnDragStart = (e: DragEvent, columnId: string) => {
setDraggingColumnId(columnId);
e.dataTransfer.effectAllowed = 'move';
};
const handleColumnDragEnd = () => {
setDraggingColumnId(null);
setDragOverColumnId(null);
};
const handleColumnReorderDragOver = (e: DragEvent, columnId: string) => {
e.preventDefault();
if (draggingColumnId && draggingColumnId !== columnId) {
setDragOverColumnId(columnId);
}
};
const handleColumnReorderDrop = (e: DragEvent, targetColumnId: string) => {
e.preventDefault();
if (!draggingColumnId || draggingColumnId === targetColumnId) return;
const newColumns = [...columns];
const dragIndex = newColumns.findIndex(c => c.id === draggingColumnId);
const dropIndex = newColumns.findIndex(c => c.id === targetColumnId);
if (dragIndex !== -1 && dropIndex !== -1) {
const [removed] = newColumns.splice(dragIndex, 1);
newColumns.splice(dropIndex, 0, removed);
// Update order values
newColumns.forEach((col, idx) => { col.order = idx; });
onColumnChange(newColumns);
}
handleColumnDragEnd();
};
const filteredDimensions = dimensions.filter(d =>
d.toLowerCase().includes(searchQuery.toLowerCase()) &&
!columns.some(c => c.dimension === d)
);
const sortedColumns = [...columns].sort((a, b) => a.order - b.order);
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
background: '#000'
}}>
{/* Column Setup Bar */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
borderBottom: '1px solid #222',
background: '#0a0a0a',
flexShrink: 0
}}>
<span style={{ fontSize: '11px', color: '#666', fontWeight: 500 }}>
Columns:
</span>
{columns.length === 0 && (
<span style={{ fontSize: '11px', color: '#555', fontStyle: 'italic' }}>
Add dimensions to create columns
</span>
)}
{/* Add Column Button */}
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowColumnPicker(!showColumnPicker)}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '4px',
fontSize: '11px',
color: '#888',
cursor: 'pointer'
}}
>
<Plus size={12} />
Add Column
</button>
{/* Dimension Picker */}
{showColumnPicker && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
width: '200px',
maxHeight: '300px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px',
overflow: 'hidden',
zIndex: 100,
boxShadow: '0 4px 12px rgba(0,0,0,0.5)'
}}>
<input
type="text"
placeholder="Search dimensions..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
background: '#0a0a0a',
border: 'none',
borderBottom: '1px solid #333',
color: '#fff',
fontSize: '12px',
outline: 'none'
}}
autoFocus
/>
<div style={{ maxHeight: '250px', overflowY: 'auto' }}>
{filteredDimensions.length === 0 ? (
<div style={{
padding: '12px',
fontSize: '12px',
color: '#666',
textAlign: 'center'
}}>
No dimensions available
</div>
) : (
filteredDimensions.map(dim => (
<button
key={dim}
onClick={() => handleAddColumn(dim)}
style={{
width: '100%',
padding: '8px 12px',
background: 'transparent',
border: 'none',
color: '#ccc',
fontSize: '12px',
textAlign: 'left',
cursor: 'pointer'
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#2a2a2a'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
{dim}
</button>
))
)}
</div>
</div>
)}
</div>
{/* Click outside to close */}
{showColumnPicker && (
<div
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
onClick={() => setShowColumnPicker(false)}
/>
)}
</div>
{/* Kanban Board */}
<div style={{
flex: 1,
display: 'flex',
gap: '12px',
padding: '12px',
overflowX: 'auto',
overflowY: 'hidden'
}}>
{sortedColumns.map(column => {
const columnNodes = getNodesForColumn(column.dimension);
const isDropTarget = dragOverColumn === column.dimension && draggedFromColumn !== column.dimension;
const isReorderTarget = dragOverColumnId === column.id;
return (
<div
key={column.id}
style={{
width: '280px',
minWidth: '280px',
display: 'flex',
flexDirection: 'column',
background: isDropTarget ? '#0f2417' : '#0a0a0a',
border: isReorderTarget ? '2px dashed #22c55e' : '1px solid #1a1a1a',
borderRadius: '8px',
transition: 'all 0.2s'
}}
onDragOver={(e) => handleColumnDragOver(e, column.dimension)}
onDragLeave={handleColumnDragLeave}
onDrop={(e) => handleColumnDrop(e, column.dimension)}
>
{/* Column Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 12px',
borderBottom: '1px solid #1a1a1a',
cursor: 'grab'
}}
draggable
onDragStart={(e) => handleColumnDragStart(e, column.id)}
onDragEnd={handleColumnDragEnd}
onDragOver={(e) => handleColumnReorderDragOver(e, column.id)}
onDrop={(e) => handleColumnReorderDrop(e, column.id)}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<GripVertical size={14} color="#444" />
<span style={{
fontSize: '12px',
fontWeight: 600,
color: '#e5e5e5',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
{column.dimension}
</span>
<span style={{
fontSize: '11px',
color: '#666',
background: '#1a1a1a',
padding: '2px 6px',
borderRadius: '10px'
}}>
{columnNodes.length}
</span>
</div>
<button
onClick={() => handleRemoveColumn(column.id)}
style={{
background: 'none',
border: 'none',
padding: '4px',
cursor: 'pointer',
color: '#666',
display: 'flex',
alignItems: 'center'
}}
>
<X size={14} />
</button>
</div>
{/* Column Content */}
<div style={{
flex: 1,
overflowY: 'auto',
padding: '8px'
}}>
{columnNodes.map(node => (
<div
key={node.id}
draggable
onDragStart={(e) => handleNodeDragStart(e, node.id, column.dimension)}
onDragEnd={handleNodeDragEnd}
onClick={() => onNodeClick(node.id)}
style={{
padding: '10px',
marginBottom: '6px',
background: draggedNodeId === node.id ? '#1a1a1a' : '#111',
border: '1px solid #222',
borderRadius: '6px',
cursor: 'pointer',
opacity: draggedNodeId === node.id ? 0.5 : 1,
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
if (draggedNodeId !== node.id) {
e.currentTarget.style.background = '#1a1a1a';
e.currentTarget.style.borderColor = '#333';
}
}}
onMouseLeave={(e) => {
if (draggedNodeId !== node.id) {
e.currentTarget.style.background = '#111';
e.currentTarget.style.borderColor = '#222';
}
}}
>
<div style={{
fontSize: '12px',
fontWeight: 500,
color: '#e5e5e5',
marginBottom: '4px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{node.title || 'Untitled'}
</div>
{node.dimensions && node.dimensions.length > 1 && (
<div style={{
display: 'flex',
gap: '4px',
flexWrap: 'wrap',
marginTop: '6px'
}}>
{node.dimensions
.filter(d => d !== column.dimension)
.slice(0, 2)
.map(dim => (
<span
key={dim}
style={{
padding: '2px 6px',
background: '#1a1a1a',
borderRadius: '3px',
fontSize: '10px',
color: '#666'
}}
>
{dim}
</span>
))}
</div>
)}
</div>
))}
{columnNodes.length === 0 && (
<div style={{
padding: '20px',
textAlign: 'center',
color: '#444',
fontSize: '11px'
}}>
Drop nodes here
</div>
)}
</div>
</div>
);
})}
{/* Uncategorized Column (if nodes exist without column dimensions) */}
{columns.length > 0 && getUncategorizedNodes().length > 0 && (
<div
style={{
width: '280px',
minWidth: '280px',
display: 'flex',
flexDirection: 'column',
background: '#0a0a0a',
border: '1px dashed #333',
borderRadius: '8px'
}}
>
<div style={{
padding: '10px 12px',
borderBottom: '1px solid #1a1a1a'
}}>
<span style={{
fontSize: '12px',
fontWeight: 600,
color: '#666',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
Uncategorized ({getUncategorizedNodes().length})
</span>
</div>
<div style={{
flex: 1,
overflowY: 'auto',
padding: '8px'
}}>
{getUncategorizedNodes().map(node => (
<div
key={node.id}
draggable
onDragStart={(e) => handleNodeDragStart(e, node.id, '__uncategorized__')}
onDragEnd={handleNodeDragEnd}
onClick={() => onNodeClick(node.id)}
style={{
padding: '10px',
marginBottom: '6px',
background: '#111',
border: '1px solid #222',
borderRadius: '6px',
cursor: 'pointer'
}}
>
<div style={{
fontSize: '12px',
fontWeight: 500,
color: '#888',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{node.title || 'Untitled'}
</div>
</div>
))}
</div>
</div>
)}
{/* Empty State */}
{columns.length === 0 && (
<div style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#666',
fontSize: '13px'
}}>
Add dimension columns to organize your nodes
</div>
)}
</div>
</div>
);
}
+14 -35
View File
@@ -3,7 +3,6 @@
import { Inbox } from 'lucide-react';
import { Node } from '@/types/database';
import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
import { formatRelativeDate } from '@/utils/formatDate';
import { getNodeProcessedState } from '@/services/nodes/metadata';
@@ -13,7 +12,6 @@ interface ListViewProps {
}
export default function ListView({ nodes, onNodeClick }: ListViewProps) {
const { dimensionIcons } = useDimensionIcons();
const truncateContent = (content?: string, maxLength: number = 100) => {
if (!content) return '';
@@ -97,7 +95,7 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
borderRadius: '6px',
flexShrink: 0
}}>
{getNodeIcon(node, dimensionIcons, 16)}
{getNodeIcon(node, 16)}
</div>
{/* Content */}
@@ -148,38 +146,19 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
}}>
{processedState}
</span>
{/* Dimensions */}
{node.dimensions && node.dimensions.length > 0 && (
<div style={{
display: 'flex',
gap: '4px',
flexWrap: 'wrap'
}}>
{node.dimensions.slice(0, 3).map(dim => (
<span
key={dim}
style={{
padding: '2px 8px',
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-strong)',
borderRadius: '8px',
fontSize: '11px',
color: 'var(--rah-text-base)'
}}
>
{dim}
</span>
))}
{node.dimensions.length > 3 && (
<span style={{
padding: '2px 6px',
fontSize: '11px',
color: 'var(--rah-text-muted)'
}}>
+{node.dimensions.length - 3}
</span>
)}
</div>
{node.context?.name && (
<span
style={{
padding: '2px 8px',
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-strong)',
borderRadius: '8px',
fontSize: '11px',
color: 'var(--rah-text-base)'
}}
>
{node.context.name}
</span>
)}
{/* Date */}
-242
View File
@@ -1,242 +0,0 @@
"use client";
import { useState } from 'react';
import { X, Plus, ChevronDown } from 'lucide-react';
import { ViewFilter } from '@/types/views';
interface ViewFiltersProps {
filters: ViewFilter[];
filterLogic: 'and' | 'or';
dimensions: string[];
onFilterChange: (filters: ViewFilter[]) => void;
onFilterLogicChange: (logic: 'and' | 'or') => void;
}
export default function ViewFilters({
filters,
filterLogic,
dimensions,
onFilterChange,
onFilterLogicChange
}: ViewFiltersProps) {
const [showDimensionPicker, setShowDimensionPicker] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const handleAddFilter = (dimension: string) => {
if (!filters.some(f => f.dimension === dimension)) {
onFilterChange([...filters, { dimension, operator: 'includes' }]);
}
setShowDimensionPicker(false);
setSearchQuery('');
};
const handleRemoveFilter = (dimension: string) => {
onFilterChange(filters.filter(f => f.dimension !== dimension));
};
const handleToggleOperator = (dimension: string) => {
onFilterChange(filters.map(f =>
f.dimension === dimension
? { ...f, operator: f.operator === 'includes' ? 'excludes' : 'includes' }
: f
));
};
const filteredDimensions = dimensions.filter(d =>
d.toLowerCase().includes(searchQuery.toLowerCase()) &&
!filters.some(f => f.dimension === d)
);
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
borderBottom: '1px solid #222',
background: '#0a0a0a',
flexWrap: 'wrap',
minHeight: '40px'
}}>
<span style={{ fontSize: '11px', color: '#666', fontWeight: 500 }}>
Filters:
</span>
{/* Filter Chips */}
{filters.map(filter => (
<div
key={filter.dimension}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: filter.operator === 'includes' ? '#0f2417' : '#2a1515',
border: `1px solid ${filter.operator === 'includes' ? '#166534' : '#7f1d1d'}`,
borderRadius: '4px',
fontSize: '11px',
color: filter.operator === 'includes' ? '#22c55e' : '#ef4444'
}}
>
<button
onClick={() => handleToggleOperator(filter.dimension)}
style={{
background: 'none',
border: 'none',
padding: '0 2px',
cursor: 'pointer',
color: 'inherit',
fontSize: '10px',
opacity: 0.7
}}
title={filter.operator === 'includes' ? 'Click to exclude' : 'Click to include'}
>
{filter.operator === 'includes' ? '+' : '-'}
</button>
<span>{filter.dimension}</span>
<button
onClick={() => handleRemoveFilter(filter.dimension)}
style={{
background: 'none',
border: 'none',
padding: '0',
cursor: 'pointer',
color: 'inherit',
display: 'flex',
alignItems: 'center'
}}
>
<X size={12} />
</button>
</div>
))}
{/* Add Filter Button */}
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowDimensionPicker(!showDimensionPicker)}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '4px',
fontSize: '11px',
color: '#888',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
<Plus size={12} />
Add
</button>
{/* Dimension Picker Dropdown */}
{showDimensionPicker && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
width: '200px',
maxHeight: '300px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px',
overflow: 'hidden',
zIndex: 100,
boxShadow: '0 4px 12px rgba(0,0,0,0.5)'
}}>
<input
type="text"
placeholder="Search dimensions..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
background: '#0a0a0a',
border: 'none',
borderBottom: '1px solid #333',
color: '#fff',
fontSize: '12px',
outline: 'none'
}}
autoFocus
/>
<div style={{ maxHeight: '250px', overflowY: 'auto' }}>
{filteredDimensions.length === 0 ? (
<div style={{
padding: '12px',
fontSize: '12px',
color: '#666',
textAlign: 'center'
}}>
No dimensions found
</div>
) : (
filteredDimensions.map(dim => (
<button
key={dim}
onClick={() => handleAddFilter(dim)}
style={{
width: '100%',
padding: '8px 12px',
background: 'transparent',
border: 'none',
color: '#ccc',
fontSize: '12px',
textAlign: 'left',
cursor: 'pointer',
transition: 'background 0.2s'
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#2a2a2a'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
{dim}
</button>
))
)}
</div>
</div>
)}
</div>
{/* Logic Toggle */}
{filters.length > 1 && (
<button
onClick={() => onFilterLogicChange(filterLogic === 'and' ? 'or' : 'and')}
style={{
padding: '4px 8px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '4px',
fontSize: '10px',
fontWeight: 600,
color: filterLogic === 'and' ? '#22c55e' : '#3b82f6',
cursor: 'pointer',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}
title={filterLogic === 'and' ? 'Match ALL filters' : 'Match ANY filter'}
>
{filterLogic}
</button>
)}
{/* Click outside to close */}
{showDimensionPicker && (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 99
}}
onClick={() => setShowDimensionPicker(false)}
/>
)}
</div>
);
}
+155 -507
View File
@@ -1,11 +1,10 @@
"use client";
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { useEffect, useState, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Filter, ChevronDown, X, ArrowUpDown, GripVertical, Inbox, Check } from 'lucide-react';
import type { ContextSummary, Node } from '@/types/database';
import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
import { usePersistentState } from '@/hooks/usePersistentState';
import type { PendingNode } from '@/components/layout/ThreePanelLayout';
import { getNodeProcessedState } from '@/services/nodes/metadata';
@@ -24,41 +23,6 @@ const SORT_LABELS: Record<SortOrder, string> = {
const DOCUMENT_MAX_WIDTH = '980px';
const pickerRowStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '7px 10px',
background: 'transparent',
border: 'none',
borderRadius: '5px',
color: 'var(--rah-text-secondary)',
fontSize: '12px',
cursor: 'pointer',
textAlign: 'left',
};
const pickerCountStyle: React.CSSProperties = {
color: 'var(--rah-text-muted)',
fontSize: '10px',
background: 'var(--rah-bg-active)',
padding: '1px 6px',
borderRadius: '10px',
};
interface ColumnFilter {
id: string;
dimension: string;
}
interface DimensionSummary {
dimension: string;
count: number;
isPriority: boolean;
description?: string | null;
}
interface ViewsOverlayProps {
onNodeClick: (nodeId: number) => void;
onNodeOpenInOtherPane?: (nodeId: number) => void;
@@ -66,10 +30,8 @@ interface ViewsOverlayProps {
pendingNodes?: PendingNode[];
onDismissPending?: (id: string) => void;
externalContextFilterId?: number | null;
externalDimensionFilter?: string | null;
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
onClearExternalContextFilter?: () => void;
onClearExternalDimensionFilter?: () => void;
toolbarHost?: HTMLDivElement | null;
}
@@ -225,6 +187,29 @@ function SkeletonCard() {
);
}
const pickerRowStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '7px 10px',
background: 'transparent',
border: 'none',
borderRadius: '5px',
color: 'var(--rah-text-secondary)',
fontSize: '12px',
cursor: 'pointer',
textAlign: 'left',
};
const pickerCountStyle: React.CSSProperties = {
color: 'var(--rah-text-muted)',
fontSize: '10px',
background: 'var(--rah-bg-active)',
padding: '1px 6px',
borderRadius: '10px',
};
export default function ViewsOverlay({
onNodeClick,
onNodeOpenInOtherPane,
@@ -232,17 +217,11 @@ export default function ViewsOverlay({
pendingNodes,
onDismissPending,
externalContextFilterId = null,
externalDimensionFilter = null,
onContextFilterSelect,
onClearExternalContextFilter,
onClearExternalDimensionFilter,
toolbarHost,
}: ViewsOverlayProps) {
const { dimensionIcons } = useDimensionIcons();
// Dimensions for filter picker
const [dimensions, setDimensions] = useState<DimensionSummary[]>([]);
const [dimensionsLoading, setDimensionsLoading] = useState(true);
const [contexts, setContexts] = useState<ContextSummary[]>([]);
const [contextsLoading, setContextsLoading] = useState(true);
@@ -256,13 +235,8 @@ export default function ViewsOverlay({
const [reorderDragIndex, setReorderDragIndex] = useState<number | null>(null);
const [reorderDropIndex, setReorderDropIndex] = useState<number | null>(null);
// Filter system state
const [columns, setColumns] = useState<ColumnFilter[]>([]);
const [filteredNodes, setFilteredNodes] = useState<Node[]>([]);
const [filteredNodesLoading, setFilteredNodesLoading] = useState(false);
const [showFilterPicker, setShowFilterPicker] = useState(false);
const [showDimensionPicker, setShowDimensionPicker] = useState(false);
const [filterSearchQuery, setFilterSearchQuery] = useState('');
const [showSortDropdown, setShowSortDropdown] = useState(false);
const processedFilter: ProcessedFilter = sortOrder === 'processed'
@@ -271,41 +245,6 @@ export default function ViewsOverlay({
? 'not_processed'
: 'all';
// Derive selectedFilters for backward compatibility (unique dimensions)
const selectedFilters = useMemo(() => {
if (externalDimensionFilter) {
return [externalDimensionFilter];
}
return [...new Set(columns.map(c => c.dimension))];
}, [columns, externalDimensionFilter]);
// Sorted dimensions (locked first)
const sortedDimensions = useMemo(() => {
return [...dimensions].sort((a, b) => {
if (a.isPriority && !b.isPriority) return -1;
if (!a.isPriority && b.isPriority) return 1;
return a.dimension.localeCompare(b.dimension);
});
}, [dimensions]);
// Fetch functions
const fetchDimensions = useCallback(async () => {
setDimensionsLoading(true);
try {
const response = await fetch('/api/dimensions');
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to fetch dimensions');
}
setDimensions(data.data || []);
} catch (error) {
console.error('Error fetching dimensions:', error);
} finally {
setDimensionsLoading(false);
}
}, []);
const fetchContexts = useCallback(async () => {
setContextsLoading(true);
try {
@@ -364,135 +303,35 @@ export default function ViewsOverlay({
}
}, [sortOrder, customOrder, applyProcessedFilter, externalContextFilterId]);
const fetchFilteredNodes = useCallback(async (filters: string[]) => {
if (filters.length === 0) {
fetchAllNodes();
return;
}
setFilteredNodesLoading(true);
try {
const apiSort = sortOrder === 'custom' || sortOrder === 'processed' || sortOrder === 'not_processed'
? 'updated'
: sortOrder;
const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}&dimensions=${encodeURIComponent(filters.join(','))}&dimensionsMatch=all${externalContextFilterId ? `&contextId=${externalContextFilterId}` : ''}`);
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to fetch nodes');
}
const nodes: Node[] = data.data || [];
if (sortOrder === 'custom' && customOrder.length > 0) {
const orderMap = new Map(customOrder.map((id, idx) => [id, idx]));
const ordered: Node[] = [];
const unordered: Node[] = [];
for (const node of nodes) {
if (orderMap.has(node.id)) {
ordered.push(node);
} else {
unordered.push(node);
}
}
ordered.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
setFilteredNodes(applyProcessedFilter([...ordered, ...unordered]));
} else {
setFilteredNodes(applyProcessedFilter(nodes));
}
} catch (error) {
console.error('Error fetching filtered nodes:', error);
} finally {
setFilteredNodesLoading(false);
}
}, [fetchAllNodes, sortOrder, customOrder, applyProcessedFilter, externalContextFilterId]);
// Stringify filters for stable dependency
const filtersKey = selectedFilters.join(',');
// Fetch dimensions on mount
useEffect(() => {
fetchDimensions();
}, [fetchDimensions]);
// Fetch contexts on mount
useEffect(() => {
fetchContexts();
}, [fetchContexts]);
// Fetch nodes on mount and when filters/sort/refreshToken change
// Fetch nodes on mount and when sort/refreshToken/context filter change
useEffect(() => {
if (refreshToken > 0) {
console.log('🔄 Feed refreshing due to SSE event (refreshToken:', refreshToken, ')');
}
if (selectedFilters.length > 0) {
fetchFilteredNodes(selectedFilters);
} else {
fetchAllNodes();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filtersKey, sortOrder, refreshToken, externalContextFilterId]);
fetchAllNodes();
}, [fetchAllNodes, refreshToken, sortOrder, externalContextFilterId]);
// Also refresh dimensions when data changes (for filter picker counts)
// Refresh contexts when data changes
useEffect(() => {
if (refreshToken > 0) {
fetchDimensions();
fetchContexts();
}
}, [refreshToken, fetchDimensions, fetchContexts]);
// Column management
const addColumn = (dimension: string) => {
if (externalDimensionFilter) {
return;
}
const newColumn: ColumnFilter = {
id: `col-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
dimension
};
setColumns([...columns, newColumn]);
setShowFilterPicker(false);
setFilterSearchQuery('');
};
const removeFilter = (dimension: string) => {
if (externalDimensionFilter && dimension === externalDimensionFilter) {
onClearExternalDimensionFilter?.();
return;
}
const idx = columns.findIndex(c => c.dimension === dimension);
if (idx !== -1) {
setColumns(columns.filter((_, i) => i !== idx));
}
};
const clearFilters = () => {
if (externalContextFilterId) {
onClearExternalContextFilter?.();
}
if (externalDimensionFilter) {
onClearExternalDimensionFilter?.();
}
setColumns([]);
};
// Filter dimensions for picker
const filterPickerDimensions = sortedDimensions.filter(d =>
d.dimension.toLowerCase().includes(filterSearchQuery.toLowerCase())
);
}, [refreshToken, fetchContexts]);
// Close dropdowns on outside click
const filterPickerRef = useRef<HTMLDivElement>(null);
const dimensionPickerRef = useRef<HTMLDivElement>(null);
const contextPickerRef = useRef<HTMLDivElement>(null);
const sortDropdownRef = useRef<HTMLDivElement>(null);
const [showContextPicker, setShowContextPicker] = useState(false);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (showFilterPicker && filterPickerRef.current && !filterPickerRef.current.contains(e.target as HTMLElement)) {
setShowFilterPicker(false);
setFilterSearchQuery('');
}
if (showDimensionPicker && dimensionPickerRef.current && !dimensionPickerRef.current.contains(e.target as HTMLElement)) {
setShowDimensionPicker(false);
if (showContextPicker && contextPickerRef.current && !contextPickerRef.current.contains(e.target as HTMLElement)) {
setShowContextPicker(false);
}
if (showSortDropdown && sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as HTMLElement)) {
setShowSortDropdown(false);
@@ -500,7 +339,7 @@ export default function ViewsOverlay({
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showDimensionPicker, showFilterPicker, showSortDropdown]);
}, [showContextPicker, showSortDropdown]);
// Reorder handlers
const handleReorderDrop = useCallback((dropIdx: number) => {
@@ -557,17 +396,13 @@ export default function ViewsOverlay({
}
} catch (error) {
console.error('Error updating processed state from feed:', error);
if (selectedFilters.length > 0) {
void fetchFilteredNodes(selectedFilters);
} else {
void fetchAllNodes();
}
void fetchAllNodes();
}
}, [fetchAllNodes, fetchFilteredNodes, selectedFilters]);
}, [fetchAllNodes]);
// Render node card
const renderNodeCard = (node: Node, index: number) => {
const nodeIcon = getNodeIcon(node, dimensionIcons, 14);
const nodeIcon = getNodeIcon(node, 14);
const isCustomSort = sortOrder === 'custom';
const isDragSource = reorderDragIndex === index;
const isDropTarget = reorderDropIndex === index;
@@ -587,7 +422,7 @@ export default function ViewsOverlay({
onDragStart={(e) => {
const title = node.title || 'Untitled';
e.dataTransfer.setData('application/x-rah-node', JSON.stringify({ id: node.id, title }));
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: node.id, title, dimensions: node.dimensions || [] }));
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: node.id, title }));
e.dataTransfer.setData('text/plain', `[NODE:${node.id}:"${title}"]`);
}}
onDragOver={(e) => {
@@ -636,8 +471,7 @@ export default function ViewsOverlay({
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', minWidth: 0 }}>
{/* Grip handle — only in custom sort mode */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', minWidth: 0 }}>
{isCustomSort && (
<div
draggable
@@ -695,137 +529,112 @@ export default function ViewsOverlay({
</button>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
flexDirection: 'column',
gap: '4px',
minWidth: 0,
flex: 1,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
<span style={{
fontSize: '13px',
fontWeight: 500,
fontWeight: 600,
color: 'var(--rah-text-active)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
flex: 1,
minWidth: 0,
flex: 1,
}}>
{node.title || 'Untitled'}
</span>
{node.context?.name ? (
<span style={{
fontSize: '10px',
lineHeight: 1.2,
padding: '3px 7px',
borderRadius: '999px',
background: 'rgba(34, 197, 94, 0.08)',
border: '1px solid rgba(34, 197, 94, 0.18)',
color: 'var(--rah-accent-green)',
flexShrink: 0,
maxWidth: '40%',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
<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={{
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)',
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
<div style={{
fontSize: '12px',
color: 'var(--rah-text-muted)',
lineHeight: '1.35',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}>
{descPreview || 'No description'}
</div>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
minWidth: 0,
overflow: 'hidden',
flexShrink: 0,
fontSize: '11px',
fontWeight: 600,
}}>
{node.edge_count}
</span>
)}
</div>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
minWidth: 0,
}}>
<div style={{
fontSize: '12px',
color: 'var(--rah-text-muted)',
lineHeight: '1.35',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}>
{descPreview || 'No description'}
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '3px',
minWidth: 0,
maxWidth: '46%',
overflow: 'hidden',
flexShrink: 1,
whiteSpace: 'nowrap',
}}>
{node.dimensions && node.dimensions.length > 0 ? (
<>
{node.dimensions.map(d => (
<span
key={d}
style={{
fontSize: '10px',
padding: '2px 7px',
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-strong)',
color: 'var(--rah-text-base)',
borderRadius: '999px',
flexShrink: 0,
}}
>
{d}
<div style={{
width: '20px',
height: '20px',
borderRadius: '6px',
background: 'var(--rah-bg-panel)',
border: '1px solid var(--rah-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}>
{nodeIcon}
</div>
<span style={{
fontSize: '10px',
color: 'var(--rah-text-muted)',
background: 'var(--rah-bg-panel)',
padding: '2px 6px',
borderRadius: '999px',
fontFamily: 'monospace',
flexShrink: 0,
lineHeight: 1.2,
}}>
#{node.id}
</span>
{node.edge_count != null && node.edge_count > 0 ? (
<span style={{
minWidth: '18px',
height: '18px',
padding: '0 5px',
borderRadius: '999px',
background: 'var(--rah-accent-green-soft)',
border: '1px solid var(--rah-accent-green-soft-strong)',
color: 'var(--rah-accent-green)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
fontSize: '11px',
fontWeight: 600,
}}>
{node.edge_count}
</span>
))}
</>
) : null}
) : null}
</div>
</div>
</div>
</div>
</div>
@@ -843,87 +652,6 @@ export default function ViewsOverlay({
flexWrap: 'wrap'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', flex: 1 }}>
<div style={{ position: 'relative' }} ref={filterPickerRef}>
<button
onClick={() => setShowFilterPicker(!showFilterPicker)}
title="Context"
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: 'transparent',
border: '1px solid var(--rah-border)',
borderRadius: '5px',
color: 'var(--rah-text-soft)',
fontSize: '11px',
cursor: 'pointer',
transition: 'all 0.15s ease'
}}
>
<Filter size={11} />
{externalContextFilterId
? (contexts.find((context) => context.id === externalContextFilterId)?.name || 'Context')
: 'Context'}
<ChevronDown size={10} />
</button>
{showFilterPicker && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
background: 'var(--rah-bg-panel)',
border: '1px solid var(--rah-border)',
borderRadius: '10px',
padding: '6px',
minWidth: '220px',
maxHeight: '320px',
overflowY: 'auto',
zIndex: 1000,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)'
}}>
{contextsLoading ? (
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
Loading contexts...
</div>
) : (
<>
<button
onClick={() => {
onContextFilterSelect?.(null, null);
setShowFilterPicker(false);
}}
style={pickerRowStyle}
>
<span>All contexts</span>
</button>
{contexts.map((context) => (
<button
key={context.id}
onClick={() => {
onContextFilterSelect?.(context.id, context.name);
setShowFilterPicker(false);
}}
style={{
...pickerRowStyle,
background: externalContextFilterId === context.id ? 'rgba(255,255,255,0.04)' : 'transparent',
color: externalContextFilterId === context.id ? 'var(--rah-text-active)' : 'var(--rah-text-secondary)',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', paddingRight: '8px' }}>
{context.name}
</span>
<span style={pickerCountStyle}>{context.count}</span>
</button>
))}
</>
)}
</div>
)}
</div>
{externalContextFilterId ? (
<div
style={{
@@ -938,7 +666,7 @@ export default function ViewsOverlay({
color: '#5a9'
}}
>
{contexts.find((context) => context.id === externalContextFilterId)?.name || 'Context'}
{contexts.find((context) => context.id === externalContextFilterId)?.name ?? 'Context'}
<button
onClick={() => onClearExternalContextFilter?.()}
style={{
@@ -956,53 +684,16 @@ export default function ViewsOverlay({
</div>
) : null}
{selectedFilters.map(filter => (
<div
key={filter}
style={{
display: 'flex',
alignItems: 'center',
gap: '5px',
padding: '3px 8px',
background: 'rgba(34, 197, 94, 0.06)',
border: '1px solid rgba(34, 197, 94, 0.12)',
borderRadius: '5px',
fontSize: '11px',
color: '#5a9'
}}
>
{filter}
<button
onClick={() => removeFilter(filter)}
style={{
background: 'transparent',
border: 'none',
color: '#5a9',
cursor: 'pointer',
padding: '0',
display: 'flex',
alignItems: 'center'
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#5a9'; }}
>
<X size={11} />
</button>
</div>
))}
<div style={{ position: 'relative' }} ref={dimensionPickerRef}>
<div style={{ position: 'relative' }} ref={contextPickerRef}>
<button
onClick={() => {
setShowDimensionPicker(!showDimensionPicker);
}}
title="Dimensions"
onClick={() => setShowContextPicker(!showContextPicker)}
title="Context filter"
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: 'transparent',
background: externalContextFilterId ? 'rgba(34, 197, 94, 0.06)' : 'transparent',
border: '1px solid var(--rah-border)',
borderRadius: '5px',
color: 'var(--rah-text-soft)',
@@ -1010,21 +701,12 @@ export default function ViewsOverlay({
cursor: 'pointer',
transition: 'all 0.15s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.04)';
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.borderColor = 'var(--rah-border)';
}}
>
<Filter size={11} />
Dimension
<ChevronDown size={10} />
Context
</button>
{showDimensionPicker && (
{showContextPicker && (
<div style={{
position: 'absolute',
top: '100%',
@@ -1040,71 +722,37 @@ export default function ViewsOverlay({
zIndex: 1000,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)'
}}>
<input
type="text"
value={filterSearchQuery}
onChange={(e) => setFilterSearchQuery(e.target.value)}
placeholder="Search dimensions..."
autoFocus
style={{
width: '100%',
padding: '7px 10px',
background: 'var(--rah-bg-base)',
border: '1px solid transparent',
borderRadius: '6px',
color: 'var(--rah-text-active)',
fontSize: '12px',
marginBottom: '4px',
outline: 'none',
<button
onClick={() => {
onClearExternalContextFilter?.();
setShowContextPicker(false);
}}
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--rah-border-strong)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
/>
{dimensionsLoading ? (
style={pickerRowStyle}
>
All contexts
</button>
{contextsLoading ? (
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
Loading dimensions...
Loading contexts...
</div>
) : filterPickerDimensions.length === 0 ? (
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
{filterSearchQuery ? 'No matching dimensions' : 'No dimensions available'}
</div>
) : (
filterPickerDimensions.map(d => (
<button
key={d.dimension}
onClick={() => addColumn(d.dimension)}
style={pickerRowStyle}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<span>{d.dimension}</span>
<span style={pickerCountStyle}>{d.count}</span>
</button>
))
)}
) : contexts.map((context) => (
<button
key={context.id}
onClick={() => {
onContextFilterSelect?.(context.id, context.name);
setShowContextPicker(false);
}}
style={pickerRowStyle}
>
<span>{context.name}</span>
<span style={pickerCountStyle}>{context.count}</span>
</button>
))}
</div>
)}
</div>
</div>
{(selectedFilters.length > 0 || externalContextFilterId) && (
<button
onClick={clearFilters}
style={{
padding: '4px 8px',
background: 'transparent',
border: 'none',
color: 'var(--rah-text-muted)',
fontSize: '11px',
cursor: 'pointer'
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
>
Clear all
</button>
)}
{/* Sort dropdown */}
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
<button
@@ -1234,10 +882,10 @@ export default function ViewsOverlay({
<div style={{ width: '100%', maxWidth: DOCUMENT_MAX_WIDTH, margin: '0 auto', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<Inbox size={28} strokeWidth={1.5} style={{ opacity: 0.4 }} />
<span style={{ fontSize: '14px', color: 'var(--rah-text-secondary)' }}>
{selectedFilters.length > 0 ? 'Nothing matches these filters' : 'Nothing here yet'}
Nothing here yet
</span>
<span style={{ fontSize: '12px', opacity: 0.7 }}>
{selectedFilters.length > 0 ? 'Try adjusting your filters, or add a node with ⌘N' : 'Add a node with ⌘N to get started'}
Add a node with N to get started
</span>
</div>
</div>