"use client"; import { useEffect, useMemo, useState, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { Filter, ChevronDown, X, ArrowUpDown, GripVertical } from 'lucide-react'; import type { Node } from '@/types/database'; import { getNodeIcon } from '@/utils/nodeIcons'; import { useDimensionIcons } from '@/context/DimensionIconsContext'; import { usePersistentState } from '@/hooks/usePersistentState'; import type { PendingNode } from '../layout/ThreePanelLayout'; type SortOrder = 'updated' | 'edges' | 'created' | 'custom'; const SORT_LABELS: Record = { updated: 'Updated', edges: 'Edges', created: 'Created', custom: 'Custom', }; interface ColumnFilter { id: string; dimension: string; } interface DimensionSummary { dimension: string; count: number; isPriority: boolean; description?: string | null; } const INPUT_TYPE_LABELS: Record = { youtube: 'Extracting YouTube video...', website: 'Extracting webpage...', pdf: 'Extracting PDF...', note: 'Creating note...', chat: 'Importing transcript...', }; function PendingNodeCard({ pending, onDismiss }: { pending: PendingNode; onDismiss?: () => void }) { const isError = pending.status === 'error'; return (
{isError ? ( ) : ( )}
{isError ? 'Failed' : (INPUT_TYPE_LABELS[pending.inputType] || 'Processing...')}
{isError && pending.error ? pending.error : pending.input}
{isError && onDismiss && ( )}
); } interface ViewsOverlayProps { onNodeClick: (nodeId: number) => void; onNodeOpenInOtherPane?: (nodeId: number) => void; refreshToken?: number; pendingNodes?: PendingNode[]; onDismissPending?: (id: string) => void; externalDimensionFilter?: string | null; onClearExternalDimensionFilter?: () => void; toolbarHost?: HTMLDivElement | null; } export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refreshToken = 0, pendingNodes, onDismissPending, externalDimensionFilter = null, onClearExternalDimensionFilter, toolbarHost, }: ViewsOverlayProps) { const { dimensionIcons } = useDimensionIcons(); // Dimensions for filter picker const [dimensions, setDimensions] = useState([]); const [dimensionsLoading, setDimensionsLoading] = 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 [filterSearchQuery, setFilterSearchQuery] = useState(''); const [showSortDropdown, setShowSortDropdown] = useState(false); // 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 fetchAllNodes = useCallback(async () => { setFilteredNodesLoading(true); try { // Custom sort fetches with 'updated' then reorders client-side const apiSort = sortOrder === 'custom' ? 'updated' : sortOrder; const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}`); 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([...ordered, ...unordered]); } else { setFilteredNodes(nodes); } } catch (error) { console.error('Error fetching nodes:', error); } finally { setFilteredNodesLoading(false); } }, [sortOrder, customOrder]); const fetchFilteredNodes = useCallback(async (filters: string[]) => { if (filters.length === 0) { fetchAllNodes(); return; } setFilteredNodesLoading(true); try { const apiSort = sortOrder === 'custom' ? 'updated' : sortOrder; const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}&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'); } 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([...ordered, ...unordered]); } else { setFilteredNodes(nodes); } } catch (error) { console.error('Error fetching filtered nodes:', error); } finally { setFilteredNodesLoading(false); } }, [fetchAllNodes, sortOrder, customOrder]); // Stringify filters for stable dependency const filtersKey = selectedFilters.join(','); // Fetch dimensions on mount useEffect(() => { fetchDimensions(); }, [fetchDimensions]); // 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]); // Also refresh dimensions when data changes (for filter picker counts) useEffect(() => { if (refreshToken > 0) { fetchDimensions(); } }, [refreshToken, fetchDimensions]); // 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 (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 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]); // 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]); // Render node card const renderNodeCard = (node: Node, index: number) => { const nodeIcon = getNodeIcon(node, dimensionIcons, 18); const isCustomSort = sortOrder === 'custom'; const isDragSource = reorderDragIndex === index; const isDropTarget = reorderDropIndex === index; // 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: 'transparent', cursor: 'pointer', transition: 'all 0.15s ease', borderBottom: '1px solid #141414', borderLeft: '3px solid transparent', opacity: isDragSource ? 0.4 : 1, borderTop: isDropTarget ? '2px solid #22c55e' : '2px solid transparent', }} onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.02)'; }} onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }} 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: '#444', flexShrink: 0, alignSelf: 'center', transition: 'color 0.15s ease', }} onMouseEnter={(e) => { e.currentTarget.style.color = '#888'; }} onMouseLeave={(e) => { e.currentTarget.style.color = '#444'; }} onClick={(e) => e.stopPropagation()} >
)}
{nodeIcon}
{node.title || 'Untitled'} {node.edge_count != null && node.edge_count > 0 && ( 🔗 {node.edge_count} )} #{node.id}
{descPreview && (
{descPreview}
)}
{node.dimensions && node.dimensions.length > 0 ? ( <> {node.dimensions.slice(0, 3).map(d => ( {d} ))} {node.dimensions.length > 3 && ( +{node.dimensions.length - 3} )} ) : null}
); }; const toolbar = (
{selectedFilters.map(filter => (
{filter}
))} {externalDimensionFilter && ( Sidebar filter )}
{/* Filter picker dropdown */} {showFilterPicker && !externalDimensionFilter && (
setFilterSearchQuery(e.target.value)} placeholder="Search dimensions..." autoFocus style={{ width: '100%', padding: '7px 10px', background: '#0d0d0d', border: '1px solid transparent', borderRadius: '6px', color: '#f0f0f0', fontSize: '12px', marginBottom: '4px', outline: 'none', }} onFocus={(e) => { e.currentTarget.style.borderColor = '#333'; }} 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 && ( )}
{/* Sort dropdown */}
{showSortDropdown && (
{(Object.keys(SORT_LABELS) as SortOrder[]).map(key => ( ))}
)}
); return (
{toolbarHost ? createPortal(toolbar, toolbarHost) : (
{toolbar}
)} {/* 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.'}
) : (
{pendingNodes && pendingNodes.length > 0 && pendingNodes.map(p => ( onDismissPending(p.id) : undefined} /> ))} {filteredNodes.map((node, index) => renderNodeCard(node, index))}
)}
); }