diff --git a/app/api/nodes/route.ts b/app/api/nodes/route.ts index 0890059..7baf8c5 100644 --- a/app/api/nodes/route.ts +++ b/app/api/nodes/route.ts @@ -25,12 +25,26 @@ export async function GET(request: NextRequest) { filters.dimensions = dimensionsParam.split(',').map(dim => dim.trim()).filter(Boolean); } - // Handle sortBy parameter + // Handle dimensionsMatch parameter (any|all) + const dimensionsMatchParam = searchParams.get('dimensionsMatch'); + if (dimensionsMatchParam === 'all') { + filters.dimensionsMatch = 'all'; + } + + // Handle sortBy parameter (sortBy=edges|updated|created) const sortByParam = searchParams.get('sortBy'); - if (sortByParam === 'edges' || sortByParam === 'updated') { + if (sortByParam === 'edges' || sortByParam === 'updated' || sortByParam === 'created') { filters.sortBy = sortByParam; } + // Also support sort=created_at|updated_at with order=asc|desc (used by feed) + const sortParam = searchParams.get('sort'); + if (sortParam === 'created_at') { + filters.sortBy = 'created'; + } else if (sortParam === 'updated_at') { + filters.sortBy = 'updated'; + } + const nodes = await nodeService.getNodes(filters); return NextResponse.json({ diff --git a/src/components/views/ViewsOverlay.tsx b/src/components/views/ViewsOverlay.tsx index d2488dc..6d04c9c 100644 --- a/src/components/views/ViewsOverlay.tsx +++ b/src/components/views/ViewsOverlay.tsx @@ -1,29 +1,23 @@ "use client"; -import { useEffect, useMemo, useState, useRef, useCallback, type DragEvent } from 'react'; -import { Plus, Trash2, LayoutGrid, List, Columns3, Save, Filter, ChevronDown, X } from 'lucide-react'; +import { useEffect, useMemo, useState, useRef, useCallback } from 'react'; +import { Filter, ChevronDown, X, ArrowUpDown } from 'lucide-react'; import type { Node } from '@/types/database'; -import InputDialog from '../common/InputDialog'; import { getNodeIcon } from '@/utils/nodeIcons'; import { useDimensionIcons } from '@/context/DimensionIconsContext'; +import { usePersistentState } from '@/hooks/usePersistentState'; -type ViewMode = 'grid' | 'list' | 'kanban'; +type SortOrder = 'updated' | 'edges' | 'created'; + +const SORT_LABELS: Record = { + updated: 'Updated', + edges: 'Edges', + created: 'Created', +}; -// Each column can have a primary dimension and optional secondary filter (AND logic) interface ColumnFilter { id: string; - dimension: string; // Primary dimension - filterBy?: string; // Optional secondary dimension (AND filter) -} - -interface SavedView { - id: string; - name: string; - filters: string[]; // Legacy: simple dimension names - columnFilters?: ColumnFilter[]; // NEW: columns with optional secondary filters - viewMode: ViewMode; - kanbanColumns?: { dimension: string; order: number }[]; - createdAt: string; + dimension: string; } interface DimensionSummary { @@ -41,32 +35,21 @@ interface ViewsOverlayProps { export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refreshToken = 0 }: ViewsOverlayProps) { const { dimensionIcons } = useDimensionIcons(); + // Dimensions for filter picker const [dimensions, setDimensions] = useState([]); const [dimensionsLoading, setDimensionsLoading] = useState(true); - // View mode state (local - simpler for now) - const [viewMode, setViewMode] = useState('list'); + // Sort order (persisted) + const [sortOrder, setSortOrder] = usePersistentState('ui.feedSortOrder', 'updated'); - // Filter/View system state (local - simpler for now) + // Filter system state const [columns, setColumns] = useState([]); const [filteredNodes, setFilteredNodes] = useState([]); const [filteredNodesLoading, setFilteredNodesLoading] = useState(false); const [showFilterPicker, setShowFilterPicker] = useState(false); const [filterSearchQuery, setFilterSearchQuery] = useState(''); - const [savedViews, setSavedViews] = useState([]); - const [activeViewId, setActiveViewId] = useState(null); - const [showSaveViewDialog, setShowSaveViewDialog] = useState(false); - const [showSavedViewsDropdown, setShowSavedViewsDropdown] = useState(false); - - // Which column is showing filter picker (by column ID) - const [showColumnFilterPicker, setShowColumnFilterPicker] = useState(null); - - // Node priority ordering within dimensions (local for now) - const [dimensionOrders, setDimensionOrders] = useState>({}); - - // Drag state - const draggedNodeRef = useRef<{ id: number; title?: string; dimensions?: string[] } | null>(null); + const [showSortDropdown, setShowSortDropdown] = useState(false); // Derive selectedFilters for backward compatibility (unique dimensions) const selectedFilters = useMemo(() => @@ -74,22 +57,6 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre [columns] ); - // Kanban drag states - const [draggedNodeId, setDraggedNodeId] = useState(null); - const [draggedFromColumn, setDraggedFromColumn] = useState(null); - const [dragOverColumn, setDragOverColumn] = useState(null); - - // Column reorder state - const [draggedColumn, setDraggedColumn] = useState(null); - const [columnDropTarget, setColumnDropTarget] = useState(null); - - // Reorder state within dimension - const [reorderDrag, setReorderDrag] = useState<{ nodeId: number; dimension: string; index: number } | null>(null); - const [reorderDropIndex, setReorderDropIndex] = useState(null); - - // Active view helper - const activeView = savedViews.find(v => v.id === activeViewId); - // Sorted dimensions (locked first) const sortedDimensions = useMemo(() => { return [...dimensions].sort((a, b) => { @@ -99,7 +66,7 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre }); }, [dimensions]); - // Fetch functions defined first (memoized) + // Fetch functions const fetchDimensions = useCallback(async () => { setDimensionsLoading(true); try { @@ -119,7 +86,7 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre const fetchAllNodes = useCallback(async () => { setFilteredNodesLoading(true); try { - const response = await fetch('/api/nodes?limit=500&sort=created_at&order=desc'); + const response = await fetch(`/api/nodes?limit=500&sortBy=${sortOrder}`); const data = await response.json(); if (!response.ok || !data.success) { throw new Error(data.error || 'Failed to fetch nodes'); @@ -130,7 +97,7 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre } finally { setFilteredNodesLoading(false); } - }, []); + }, [sortOrder]); const fetchFilteredNodes = useCallback(async (filters: string[]) => { if (filters.length === 0) { @@ -139,23 +106,18 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre } setFilteredNodesLoading(true); try { - const response = await fetch(`/api/nodes?limit=500&sort=created_at&order=desc`); + const response = await fetch(`/api/nodes?limit=500&sortBy=${sortOrder}&dimensions=${encodeURIComponent(filters.join(','))}&dimensionsMatch=all`); const data = await response.json(); if (!response.ok || !data.success) { throw new Error(data.error || 'Failed to fetch nodes'); } - // Filter client-side to get nodes matching selected dimensions - const allFetched: Node[] = data.data || []; - const filtered = allFetched.filter(node => - node.dimensions?.some(d => filters.includes(d)) - ); - setFilteredNodes(filtered); + setFilteredNodes(data.data || []); } catch (error) { console.error('Error fetching filtered nodes:', error); } finally { setFilteredNodesLoading(false); } - }, [fetchAllNodes]); + }, [fetchAllNodes, sortOrder]); // Stringify filters for stable dependency const filtersKey = selectedFilters.join(','); @@ -165,7 +127,7 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre fetchDimensions(); }, [fetchDimensions]); - // Fetch nodes on mount and when filters/refreshToken change + // Fetch nodes on mount and when filters/sort/refreshToken change useEffect(() => { if (refreshToken > 0) { console.log('🔄 Feed refreshing due to SSE event (refreshToken:', refreshToken, ')'); @@ -176,7 +138,7 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre fetchAllNodes(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filtersKey, refreshToken]); + }, [filtersKey, sortOrder, refreshToken]); // Also refresh dimensions when data changes (for filter picker counts) useEffect(() => { @@ -196,10 +158,6 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre setFilterSearchQuery(''); }; - const removeColumn = (columnId: string) => { - setColumns(columns.filter(c => c.id !== columnId)); - }; - const removeFilter = (dimension: string) => { const idx = columns.findIndex(c => c.dimension === dimension); if (idx !== -1) { @@ -209,191 +167,6 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre const clearFilters = () => { setColumns([]); - setActiveViewId(null); - }; - - // Column filter helpers - const getColumn = (columnId: string): ColumnFilter | undefined => { - return columns.find(c => c.id === columnId); - }; - - const setColumnSecondaryFilter = (columnId: string, filterBy: string | undefined) => { - setColumns(columns.map(c => - c.id === columnId ? { ...c, filterBy } : c - )); - setShowColumnFilterPicker(null); - }; - - const getColumnLabel = (column: ColumnFilter): string => { - return column.filterBy ? `${column.dimension} + ${column.filterBy}` : column.dimension; - }; - - const getNodesForColumnById = (columnId: string): Node[] => { - const column = getColumn(columnId); - if (!column) return []; - return filteredNodes.filter(node => { - const hasPrimary = node.dimensions?.includes(column.dimension); - if (!hasPrimary) return false; - if (column.filterBy) { - return node.dimensions?.includes(column.filterBy); - } - return true; - }); - }; - - // Column reorder handler - const handleColumnReorder = (draggedColumnId: string, targetColumnId: string) => { - if (draggedColumnId === targetColumnId) return; - - const currentColumns = [...columns]; - const draggedIndex = currentColumns.findIndex(c => c.id === draggedColumnId); - const targetIndex = currentColumns.findIndex(c => c.id === targetColumnId); - - if (draggedIndex === -1 || targetIndex === -1) return; - - const [draggedItem] = currentColumns.splice(draggedIndex, 1); - currentColumns.splice(targetIndex, 0, draggedItem); - - setColumns(currentColumns); - }; - - // Kanban drag handlers - const handleKanbanDrop = async (nodeId: number, fromDimension: string, toDimension: string) => { - if (fromDimension === toDimension) return; - - const node = filteredNodes.find(n => n.id === nodeId); - if (!node) return; - - const newDimensions = [...(node.dimensions || [])].filter(d => d !== fromDimension); - if (!newDimensions.includes(toDimension)) { - newDimensions.push(toDimension); - } - - // Optimistic update - setFilteredNodes(prev => prev.map(n => - n.id === nodeId ? { ...n, dimensions: newDimensions } : n - )); - - try { - const response = await fetch(`/api/nodes/${nodeId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ dimensions: newDimensions }) - }); - - if (!response.ok) { - // Revert on failure - setFilteredNodes(prev => prev.map(n => - n.id === nodeId ? { ...n, dimensions: node.dimensions } : n - )); - } - } catch (error) { - console.error('Error updating node dimensions:', error); - setFilteredNodes(prev => prev.map(n => - n.id === nodeId ? { ...n, dimensions: node.dimensions } : n - )); - } - }; - - const handleKanbanNodeDragStart = (e: DragEvent, nodeId: number, fromColumn: string) => { - setDraggedNodeId(nodeId); - setDraggedFromColumn(fromColumn); - e.dataTransfer.effectAllowed = 'copyMove'; - const node = filteredNodes.find(n => n.id === nodeId); - const title = node?.title || 'Untitled'; - 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}"]`); - draggedNodeRef.current = { id: nodeId, title, dimensions: node?.dimensions || [] }; - }; - - const handleKanbanNodeDragEnd = () => { - setDraggedNodeId(null); - setDraggedFromColumn(null); - setDragOverColumn(null); - draggedNodeRef.current = null; - }; - - const handleKanbanColumnDragOver = (e: DragEvent, columnDimension: string) => { - e.preventDefault(); - if (draggedNodeId !== null) { - setDragOverColumn(columnDimension); - } - }; - - const handleKanbanColumnDrop = (e: DragEvent, targetDimension: string) => { - e.preventDefault(); - if (draggedNodeId !== null && draggedFromColumn !== null) { - handleKanbanDrop(draggedNodeId, draggedFromColumn, targetDimension); - } - setDragOverColumn(null); - }; - - // Node ordering - const getOrderedNodes = (dimension: string, nodesInDimension: Node[]): Node[] => { - const order = dimensionOrders[dimension]; - if (!order || order.length === 0) return nodesInDimension; - - return [...nodesInDimension].sort((a, b) => { - const aIndex = order.indexOf(a.id); - const bIndex = order.indexOf(b.id); - if (aIndex === -1 && bIndex === -1) return 0; - if (aIndex === -1) return 1; - if (bIndex === -1) return -1; - return aIndex - bIndex; - }); - }; - - const handleNodeReorder = (dimension: string, nodeId: number, newIndex: number, nodesInDimension: Node[]) => { - const currentOrder = dimensionOrders[dimension] || nodesInDimension.map(n => n.id); - const nodeIndex = currentOrder.indexOf(nodeId); - if (nodeIndex === -1) return; - - const newOrder = [...currentOrder]; - newOrder.splice(nodeIndex, 1); - newOrder.splice(newIndex, 0, nodeId); - - setDimensionOrders(prev => ({ - ...prev, - [dimension]: newOrder - })); - }; - - // Saved views - const handleSaveView = (name: string) => { - const newView: SavedView = { - id: `view-${Date.now()}`, - name, - filters: selectedFilters, - columnFilters: columns, - viewMode, - createdAt: new Date().toISOString() - }; - setSavedViews([...savedViews, newView]); - setActiveViewId(newView.id); - setShowSaveViewDialog(false); - }; - - const handleLoadView = (view: SavedView) => { - if (view.columnFilters) { - setColumns(view.columnFilters); - } else { - // Legacy: convert filters to columns - setColumns(view.filters.map((f, i) => ({ - id: `col-legacy-${i}`, - dimension: f - }))); - } - setViewMode(view.viewMode); - setActiveViewId(view.id); - setShowSavedViewsDropdown(false); - }; - - const handleDeleteView = (viewId: string) => { - setSavedViews(savedViews.filter(v => v.id !== viewId)); - if (activeViewId === viewId) { - setActiveViewId(null); - } }; // Filter dimensions for picker @@ -401,9 +174,32 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre d.dimension.toLowerCase().includes(filterSearchQuery.toLowerCase()) ); + // Close dropdowns on outside click + const filterPickerRef = useRef(null); + const sortDropdownRef = useRef(null); + + 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]); + // Render node card - const renderNodeCard = (node: Node, columnId?: string, dimension?: string) => { - const nodeIcon = getNodeIcon(node, dimensionIcons); + const renderNodeCard = (node: Node) => { + const nodeIcon = getNodeIcon(node, dimensionIcons, 18); + + // Description preview — first meaningful line, truncated + const descPreview = node.description && node.description.length > 10 + ? node.description.slice(0, 120) + (node.description.length > 120 ? '...' : '') + : null; return (
onNodeClick(node.id)} draggable onDragStart={(e) => { - if (dimension) { - handleKanbanNodeDragStart(e, node.id, dimension); - } else { - 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('text/plain', `[NODE:${node.id}:"${title}"]`); - } + 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('text/plain', `[NODE:${node.id}:"${title}"]`); }} - onDragEnd={handleKanbanNodeDragEnd} style={{ - padding: '12px 0 14px 0', + padding: '10px 12px', background: 'transparent', cursor: 'pointer', transition: 'all 0.15s ease', - borderBottom: '1px solid #1a1a1a', + borderBottom: '1px solid #141414', + borderLeft: '3px solid transparent', }} onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.02)'; @@ -443,10 +235,11 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre >
{node.title || 'Untitled'} + {node.edge_count != null && node.edge_count > 0 && ( + + 🔗 + {node.edge_count} + + )}
+ {descPreview && ( +
+ {descPreview} +
+ )}
{node.dimensions && node.dimensions.length > 0 ? ( <> @@ -497,18 +315,18 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre {d} ))} {node.dimensions.length > 3 && ( - + +{node.dimensions.length - 3} )} @@ -521,216 +339,6 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre ); }; - // Render list view - const renderListView = () => { - if (filteredNodesLoading) { - return ( -
- Loading... -
- ); - } - - if (filteredNodes.length === 0) { - return ( -
- {selectedFilters.length > 0 ? 'No nodes match the selected filters.' : 'No nodes yet. Add some content to get started.'} -
- ); - } - - return ( -
- {filteredNodes.map(node => renderNodeCard(node))} -
- ); - }; - - // Render grid view - const renderGridView = () => { - if (filteredNodesLoading) { - return ( -
- Loading... -
- ); - } - - if (filteredNodes.length === 0) { - return ( -
- {selectedFilters.length > 0 ? 'No nodes match the selected filters.' : 'No nodes yet. Add some content to get started.'} -
- ); - } - - return ( -
- {filteredNodes.map(node => renderNodeCard(node))} -
- ); - }; - - // Render kanban view - const renderKanbanView = () => { - if (columns.length === 0) { - return ( -
- Add dimension filters to see kanban columns. -
- ); - } - - return ( -
- {columns.map((column) => { - const nodesInColumn = getNodesForColumnById(column.id); - const orderedNodes = getOrderedNodes(column.dimension, nodesInColumn); - const isDropTarget = dragOverColumn === column.dimension; - - return ( -
{ - setDraggedColumn(column.id); - e.dataTransfer.effectAllowed = 'move'; - }} - onDragEnd={() => { - setDraggedColumn(null); - setColumnDropTarget(null); - }} - onDragOver={(e) => { - e.preventDefault(); - if (draggedColumn && draggedColumn !== column.id) { - setColumnDropTarget(column.id); - } - handleKanbanColumnDragOver(e, column.dimension); - }} - onDragLeave={() => { - setColumnDropTarget(null); - }} - onDrop={(e) => { - if (draggedColumn && draggedColumn !== column.id) { - handleColumnReorder(draggedColumn, column.id); - setColumnDropTarget(null); - } - handleKanbanColumnDrop(e, column.dimension); - }} - style={{ - minWidth: '280px', - maxWidth: '320px', - background: columnDropTarget === column.id ? 'rgba(34, 197, 94, 0.05)' : '#0c0c0c', - borderRadius: '10px', - border: isDropTarget ? '2px dashed #22c55e' : '1px solid #1a1a1a', - display: 'flex', - flexDirection: 'column', - maxHeight: '100%', - transition: 'border-color 0.15s ease' - }} - > - {/* Column header */} -
-
- {getColumnLabel(column)} -
-
- - {nodesInColumn.length} - - -
-
- - {/* Column content */} -
- {orderedNodes.map((node, index) => ( -
- {renderNodeCard(node, column.id, column.dimension)} -
- ))} - {nodesInColumn.length === 0 && ( -
- Drop nodes here -
- )} -
-
- ); - })} -
- ); - }; - return (
- {/* Header with filters */} + {/* Header with filters + sort */}
- {/* Filter chips */} -
+ {/* Filter chips + add filter button */} +
{selectedFilters.map(filter => (
{filter} @@ -770,45 +378,47 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre style={{ background: 'transparent', border: 'none', - color: '#22c55e', + color: '#5a9', cursor: 'pointer', padding: '0', display: 'flex', alignItems: 'center' }} + onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }} + onMouseLeave={(e) => { e.currentTarget.style.color = '#5a9'; }} > - +
))} {/* Add filter button */} -
+
@@ -819,15 +429,15 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre top: '100%', left: 0, marginTop: '4px', - background: '#1a1a1a', - border: '1px solid #2a2a2a', - borderRadius: '8px', - padding: '8px', - minWidth: '200px', - maxHeight: '300px', + background: '#141414', + border: '1px solid #222', + borderRadius: '10px', + padding: '6px', + minWidth: '220px', + maxHeight: '320px', overflowY: 'auto', zIndex: 1000, - boxShadow: '0 4px 12px rgba(0,0,0,0.3)' + boxShadow: '0 8px 24px rgba(0,0,0,0.4)' }}> { e.currentTarget.style.borderColor = '#333'; }} + onBlur={(e) => { e.currentTarget.style.borderColor = 'transparent'; }} /> {dimensionsLoading ? (
@@ -864,20 +477,26 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre alignItems: 'center', justifyContent: 'space-between', width: '100%', - padding: '8px', + padding: '7px 10px', background: 'transparent', border: 'none', - borderRadius: '4px', + borderRadius: '5px', color: '#ccc', fontSize: '12px', cursor: 'pointer', textAlign: 'left' }} - onMouseEnter={(e) => { e.currentTarget.style.background = '#2a2a2a'; }} + onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }} onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }} > {d.dimension} - + {d.count} @@ -906,197 +525,106 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre )}
- {/* View mode toggle */} -
+ {/* Sort dropdown */} +
- - -
- - {/* Saved views dropdown */} -
- - {showSavedViewsDropdown && ( + {showSortDropdown && (
- {savedViews.length === 0 ? ( -
- No saved views yet -
- ) : ( - savedViews.map(view => ( -
handleLoadView(view)} - onMouseEnter={(e) => { - if (activeViewId !== view.id) { - e.currentTarget.style.background = '#222'; - } - }} - onMouseLeave={(e) => { - if (activeViewId !== view.id) { - e.currentTarget.style.background = 'transparent'; - } - }} - > - {view.name} - -
- )) - )} -
+ {(Object.keys(SORT_LABELS) as SortOrder[]).map(key => ( -
+ ))}
)}
- {/* Content area */} - {viewMode === 'list' && renderListView()} - {viewMode === 'grid' && renderGridView()} - {viewMode === 'kanban' && renderKanbanView()} - - {/* Save view dialog */} - setShowSaveViewDialog(false)} - onConfirm={handleSaveView} - title="Save View" - message="Enter a name for this view" - placeholder="View name" - confirmLabel="Save" - /> + {/* Content area — list view */} + {filteredNodesLoading ? ( +
+ Loading... +
+ ) : filteredNodes.length === 0 ? ( +
+ {selectedFilters.length > 0 ? 'No nodes match the selected filters.' : 'No nodes yet. Add some content to get started.'} +
+ ) : ( +
+ {filteredNodes.map(node => renderNodeCard(node))} +
+ )}
); } diff --git a/src/services/database/nodes.ts b/src/services/database/nodes.ts index 68909d7..b11b2d3 100644 --- a/src/services/database/nodes.ts +++ b/src/services/database/nodes.ts @@ -10,7 +10,7 @@ export class NodeService { // PostgreSQL path removed in SQLite-only consolidation private async getNodesSQLite(filters: NodeFilters = {}): Promise { - const { dimensions, search, limit = 100, offset = 0, sortBy } = filters; + const { dimensions, search, limit = 100, offset = 0, sortBy, dimensionsMatch = 'any' } = filters; const sqlite = getSQLiteClient(); // Use nodes_v view for array-like dimensions behavior (exclude embedding BLOB for performance) @@ -28,12 +28,23 @@ export class NodeService { // Filter by dimensions (SQLite JOIN with node_dimensions) if (dimensions && dimensions.length > 0) { - query += ` AND EXISTS ( - SELECT 1 FROM node_dimensions nd - WHERE nd.node_id = n.id - AND nd.dimension IN (${dimensions.map(() => '?').join(',')}) - )`; - params.push(...dimensions); + if (dimensionsMatch === 'all' && dimensions.length > 1) { + // AND logic: node must have ALL specified dimensions + query += ` AND ( + SELECT COUNT(DISTINCT nd.dimension) FROM node_dimensions nd + WHERE nd.node_id = n.id + AND nd.dimension IN (${dimensions.map(() => '?').join(',')}) + ) = ?`; + params.push(...dimensions, dimensions.length); + } else { + // OR logic: node must have at least one of the specified dimensions + query += ` AND EXISTS ( + SELECT 1 FROM node_dimensions nd + WHERE nd.node_id = n.id + AND nd.dimension IN (${dimensions.map(() => '?').join(',')}) + )`; + params.push(...dimensions); + } } // Text search in title, description, and content (SQLite LIKE with COLLATE NOCASE) @@ -62,6 +73,8 @@ export class NodeService { } else if (sortBy === 'edges') { // Sort by edge count (most connected first) query += ' ORDER BY edge_count DESC, n.updated_at DESC'; + } else if (sortBy === 'created') { + query += ' ORDER BY n.created_at DESC'; } else { query += ' ORDER BY n.updated_at DESC'; } diff --git a/src/types/database.ts b/src/types/database.ts index 5b9a099..db8af22 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -70,7 +70,8 @@ export interface NodeFilters { search?: string; // Text search in title/content limit?: number; offset?: number; - sortBy?: 'updated' | 'edges'; // Sort by updated_at or edge count + sortBy?: 'updated' | 'edges' | 'created'; // Sort by updated_at, edge count, or created_at + dimensionsMatch?: 'any' | 'all'; // 'any' = OR (default), 'all' = AND } export interface ChunkData {