feat: port holistic node refinement contract
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user