"use client"; import { useEffect, useMemo, 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'; type SortOrder = 'updated' | 'edges' | 'created' | 'custom' | 'processed' | 'not_processed'; type ProcessedFilter = 'all' | 'processed' | 'not_processed'; const SORT_LABELS: Record = { updated: 'Updated', edges: 'Edges', created: 'Created', custom: 'Custom', processed: 'Processed', not_processed: 'Not Processed', }; 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; refreshToken?: number; 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; } const INPUT_TYPE_LABELS: Record = { youtube: 'Extracting YouTube video...', website: 'Extracting webpage...', pdf: 'Processing PDF...', note: 'Creating note...', chat: 'Importing transcript...', }; function PendingNodeCard({ pending, onDismiss }: { pending: PendingNode; onDismiss?: () => void }) { const isError = pending.status === 'error'; const label = isError ? pending.error || 'Processing failed' : INPUT_TYPE_LABELS[pending.inputType] || 'Processing...'; // Truncate input for display const displayInput = pending.input.length > 80 ? pending.input.slice(0, 77) + '...' : pending.input; return (
{isError ? ( ) : ( )}
{displayInput} {isError && onDismiss && ( )}
{label}
); } function SkeletonCard() { return (
); } export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refreshToken = 0, pendingNodes, onDismissPending, externalContextFilterId = null, externalDimensionFilter = null, onContextFilterSelect, onClearExternalContextFilter, onClearExternalDimensionFilter, toolbarHost, }: ViewsOverlayProps) { const { dimensionIcons } = useDimensionIcons(); // Dimensions for filter picker const [dimensions, setDimensions] = useState([]); const [dimensionsLoading, setDimensionsLoading] = useState(true); const [contexts, setContexts] = useState([]); const [contextsLoading, setContextsLoading] = useState(true); // Sort order (persisted) const [sortOrder, setSortOrder] = usePersistentState('ui.feedSortOrder', 'updated'); // Custom order (persisted) — stores node IDs in user-defined order const [customOrder, setCustomOrder] = usePersistentState('ui.feedCustomOrder', []); // Drag-to-reorder state const [reorderDragIndex, setReorderDragIndex] = useState(null); const [reorderDropIndex, setReorderDropIndex] = useState(null); // Filter system state const [columns, setColumns] = useState([]); const [filteredNodes, setFilteredNodes] = useState([]); 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' ? 'processed' : sortOrder === 'not_processed' ? '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 { const response = await fetch('/api/contexts'); const data = await response.json(); if (!response.ok || !data.success) { throw new Error(data.error || 'Failed to fetch contexts'); } setContexts(data.data || []); } catch (error) { console.error('Error fetching contexts:', error); } finally { setContextsLoading(false); } }, []); const applyProcessedFilter = useCallback((nodes: Node[]) => { if (processedFilter === 'all') return nodes; return nodes.filter((node) => getNodeProcessedState(node.metadata) === processedFilter); }, [processedFilter]); const fetchAllNodes = useCallback(async () => { setFilteredNodesLoading(true); try { // Custom sort fetches with 'updated' then reorders client-side const apiSort = sortOrder === 'custom' || sortOrder === 'processed' || sortOrder === 'not_processed' ? 'updated' : sortOrder; const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}${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) { // Reorder nodes based on saved custom order 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); // New nodes not in custom order — append at bottom } } 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 nodes:', error); } finally { setFilteredNodesLoading(false); } }, [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]); useEffect(() => { fetchContexts(); }, [fetchContexts]); // Fetch nodes on mount and when filters/sort/refreshToken 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]); // Also refresh dimensions when data changes (for filter picker counts) 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()) ); // Close dropdowns on outside click const filterPickerRef = useRef(null); const dimensionPickerRef = 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 (showDimensionPicker && dimensionPickerRef.current && !dimensionPickerRef.current.contains(e.target as HTMLElement)) { setShowDimensionPicker(false); } if (showSortDropdown && sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as HTMLElement)) { setShowSortDropdown(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [showDimensionPicker, showFilterPicker, showSortDropdown]); // Reorder handlers const handleReorderDrop = useCallback((dropIdx: number) => { if (reorderDragIndex === null || reorderDragIndex === dropIdx) { setReorderDragIndex(null); setReorderDropIndex(null); return; } // Reorder filteredNodes and persist const newNodes = [...filteredNodes]; const [moved] = newNodes.splice(reorderDragIndex, 1); newNodes.splice(dropIdx > reorderDragIndex ? dropIdx - 1 : dropIdx, 0, moved); setFilteredNodes(newNodes); setCustomOrder(newNodes.map(n => n.id)); setReorderDragIndex(null); setReorderDropIndex(null); }, [reorderDragIndex, filteredNodes, setCustomOrder]); const toggleNodeProcessed = useCallback(async (node: Node) => { const nextState = getNodeProcessedState(node.metadata) === 'processed' ? 'not_processed' : 'processed'; setFilteredNodes((prev) => prev.map((candidate) => ( candidate.id === node.id ? { ...candidate, metadata: { ...(candidate.metadata && typeof candidate.metadata === 'object' ? candidate.metadata : {}), state: nextState, }, } : candidate ))); try { const response = await fetch(`/api/nodes/${node.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ metadata: { state: nextState, }, }), }); if (!response.ok) { throw new Error('Failed to update processed state'); } const result = await response.json(); if (result.node) { setFilteredNodes((prev) => prev.map((candidate) => ( candidate.id === node.id ? result.node as Node : candidate ))); } } catch (error) { console.error('Error updating processed state from feed:', error); if (selectedFilters.length > 0) { void fetchFilteredNodes(selectedFilters); } else { void fetchAllNodes(); } } }, [fetchAllNodes, fetchFilteredNodes, selectedFilters]); // Render node card const renderNodeCard = (node: Node, index: number) => { const nodeIcon = getNodeIcon(node, dimensionIcons, 14); const isCustomSort = sortOrder === 'custom'; const isDragSource = reorderDragIndex === index; const isDropTarget = reorderDropIndex === index; const processedState = getNodeProcessedState(node.metadata); const isProcessed = processedState === 'processed'; // 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) => { 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}"]`); }} onDragOver={(e) => { if (!isCustomSort || reorderDragIndex === null) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setReorderDropIndex(index); }} onDragLeave={() => { if (!isCustomSort) return; setReorderDropIndex(null); }} onDrop={(e) => { if (!isCustomSort || reorderDragIndex === null) return; e.preventDefault(); handleReorderDrop(index); }} style={{ padding: '10px 12px', background: isProcessed ? 'rgba(74, 222, 128, 0.045)' : 'var(--rah-bg-base)', cursor: 'pointer', transition: 'all 0.15s ease', border: '1px solid var(--rah-border)', borderLeft: `2px solid ${isProcessed ? 'rgba(74, 222, 128, 0.6)' : 'var(--rah-border-stronger)'}`, borderRadius: '10px', opacity: isDragSource ? 0.4 : 1, borderTop: isDropTarget ? '2px solid var(--rah-accent-green)' : '2px solid transparent', }} onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--rah-bg-surface)'; e.currentTarget.style.borderColor = 'var(--rah-border-strong)'; e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = 'var(--rah-shadow-floating)'; }} onMouseLeave={(e) => { e.currentTarget.style.background = 'var(--rah-bg-base)'; e.currentTarget.style.borderColor = 'var(--rah-border)'; e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = 'none'; }} onContextMenu={(e) => { e.preventDefault(); if (onNodeOpenInOtherPane) { onNodeOpenInOtherPane(node.id); } }} >
{/* Grip handle — only in custom sort mode */}
{isCustomSort && (
{ e.stopPropagation(); setReorderDragIndex(index); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('application/x-rah-reorder', String(index)); }} onDragEnd={() => { setReorderDragIndex(null); setReorderDropIndex(null); }} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '16px', cursor: 'grab', color: 'var(--rah-text-muted)', flexShrink: 0, transition: 'color 0.15s ease', }} onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--rah-text-soft)'; }} onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }} onClick={(e) => e.stopPropagation()} >
)}
{node.title || 'Untitled'} {node.context?.name ? ( {node.context.name} ) : null}
{nodeIcon}
#{node.id} {node.edge_count != null && node.edge_count > 0 && ( {node.edge_count} )}
{descPreview || 'No description'}
{node.dimensions && node.dimensions.length > 0 ? ( <> {node.dimensions.map(d => ( {d} ))} ) : null}
); }; const toolbar = (
{showFilterPicker && (
{contextsLoading ? (
Loading contexts...
) : ( <> {contexts.map((context) => ( ))} )}
)}
{externalContextFilterId ? (
{contexts.find((context) => context.id === externalContextFilterId)?.name || 'Context'}
) : null} {selectedFilters.map(filter => (
{filter}
))}
{showDimensionPicker && (
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', }} onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--rah-border-strong)'; }} onBlur={(e) => { e.currentTarget.style.borderColor = 'transparent'; }} /> {dimensionsLoading ? (
Loading dimensions...
) : filterPickerDimensions.length === 0 ? (
{filterSearchQuery ? 'No matching dimensions' : 'No dimensions available'}
) : ( filterPickerDimensions.map(d => ( )) )}
)}
{(selectedFilters.length > 0 || externalContextFilterId) && ( )} {/* Sort dropdown */}
{showSortDropdown && (
{(Object.keys(SORT_LABELS) as SortOrder[]).map(key => ( ))}
)}
); return (
{toolbarHost ? createPortal(toolbar, toolbarHost) : (
{toolbar}
)} {/* Content area — list view */} {filteredNodesLoading ? (
{Array.from({ length: 6 }, (_, index) => ( ))}
) : filteredNodes.length === 0 ? (
{selectedFilters.length > 0 ? 'Nothing matches these filters' : 'Nothing here yet'} {selectedFilters.length > 0 ? 'Try adjusting your filters, or add a node with ⌘N' : 'Add a node with ⌘N to get started'}
) : (
{pendingNodes && pendingNodes.length > 0 && pendingNodes.map(p => ( onDismissPending(p.id) : undefined} /> ))} {filteredNodes.map((node, index) => renderNodeCard(node, index))}
)}
); }