"use client"; import { useEffect, useMemo, useState, useRef, type DragEvent } from 'react'; import { Folder, Check, X, ArrowLeft, Plus, Trash2, Edit2 } from 'lucide-react'; import type { Node } from '@/types/database'; import ConfirmDialog from '../common/ConfirmDialog'; import InputDialog from '../common/InputDialog'; import { getNodeIcon } from '@/utils/nodeIcons'; interface DimensionSummary { dimension: string; count: number; isPriority: boolean; description?: string | null; } interface FolderViewOverlayProps { onClose: () => void; onNodeOpen: (nodeId: number) => void; refreshToken: number; onDataChanged?: () => void; } const PAGE_SIZE = 100; export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, onDataChanged }: FolderViewOverlayProps) { const [view, setView] = useState<'dimensions' | 'nodes'>('dimensions'); const [dimensions, setDimensions] = useState([]); const [dimensionsLoading, setDimensionsLoading] = useState(true); const [dimensionsError, setDimensionsError] = useState(null); const [selectedDimension, setSelectedDimension] = useState(null); const [nodes, setNodes] = useState([]); const [nodesLoading, setNodesLoading] = useState(false); const [nodesError, setNodesError] = useState(null); const [hasMoreNodes, setHasMoreNodes] = useState(false); const [nodeOffset, setNodeOffset] = useState(0); const [deletingDimension, setDeletingDimension] = useState(null); const [dimensionPendingDelete, setDimensionPendingDelete] = useState(null); const [dragHoverDimension, setDragHoverDimension] = useState(null); const [editingDescription, setEditingDescription] = useState(false); const [editDescriptionText, setEditDescriptionText] = useState(''); const [editingDimensionName, setEditingDimensionName] = useState(false); const [editDimensionNameText, setEditDimensionNameText] = useState(''); const [showAddDimensionDialog, setShowAddDimensionDialog] = useState(false); const draggedNodeRef = useRef<{ id: number; dimensions?: string[] } | null>(null); useEffect(() => { fetchDimensions(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (view === 'dimensions') { fetchDimensions(); } else if (selectedDimension) { fetchNodes(true); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [refreshToken]); useEffect(() => { if (!selectedDimension) return; fetchNodes(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedDimension?.dimension]); const sortedDimensions = useMemo(() => { return [...dimensions].sort((a, b) => { if (a.isPriority !== b.isPriority) { return a.isPriority ? -1 : 1; } return a.dimension.localeCompare(b.dimension); }); }, [dimensions]); const fetchDimensions = async () => { setDimensionsLoading(true); setDimensionsError(null); try { const response = await fetch('/api/dimensions/popular'); 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); setDimensionsError('Failed to load dimensions'); } finally { setDimensionsLoading(false); } }; const fetchNodes = async (reset = false) => { if (!selectedDimension) return; setNodesLoading(true); setNodesError(null); try { const offset = reset ? 0 : nodeOffset; const response = await fetch(`/api/nodes?dimensions=${encodeURIComponent(selectedDimension.dimension)}&limit=${PAGE_SIZE}&offset=${offset}`); const data = await response.json(); if (!response.ok || !data.success) { throw new Error(data.error || 'Failed to fetch nodes'); } const fetchedNodes: Node[] = data.data || []; setNodes((prev) => reset ? fetchedNodes : [...prev, ...fetchedNodes]); setHasMoreNodes(fetchedNodes.length === PAGE_SIZE); setNodeOffset(offset + fetchedNodes.length); } catch (error) { console.error('Error fetching nodes:', error); setNodesError('Failed to load nodes'); } finally { setNodesLoading(false); } }; const handleSelectDimension = (dimension: DimensionSummary) => { setSelectedDimension(dimension); setNodes([]); setNodeOffset(0); setHasMoreNodes(false); setView('nodes'); }; const handleBackToDimensions = () => { setView('dimensions'); setSelectedDimension(null); setNodes([]); setNodeOffset(0); setHasMoreNodes(false); }; const handleAddDimension = async (name: string) => { if (!name || !name.trim()) return; try { const response = await fetch('/api/dimensions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name.trim() }) }); const data = await response.json(); if (!response.ok || !data.success) { throw new Error(data.error || 'Failed to create dimension'); } await fetchDimensions(); onDataChanged?.(); setShowAddDimensionDialog(false); } catch (error) { console.error('Error adding dimension:', error); alert('Failed to create dimension. Please try again.'); } }; const handleToggleLock = async (dimension: string) => { try { const response = await fetch('/api/dimensions/popular', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dimension }) }); const data = await response.json(); if (!response.ok || !data.success) { throw new Error(data.error || 'Failed to toggle dimension'); } await fetchDimensions(); onDataChanged?.(); } catch (error) { console.error('Error toggling lock:', error); alert('Failed to update lock state.'); } }; const handleDeleteDimension = async (dimension: string) => { setDeletingDimension(dimension); try { const response = await fetch(`/api/dimensions?name=${encodeURIComponent(dimension)}`, { method: 'DELETE' }); const data = await response.json(); if (!response.ok || !data.success) { throw new Error(data.error || 'Failed to delete dimension'); } if (selectedDimension?.dimension === dimension) { handleBackToDimensions(); } await fetchDimensions(); onDataChanged?.(); } catch (error) { console.error('Error deleting dimension:', error); alert('Failed to delete dimension. Please try again.'); } finally { setDeletingDimension((current) => (current === dimension ? null : current)); setDimensionPendingDelete((current) => (current === dimension ? null : current)); } }; const handleNodeTileDragStart = (event: DragEvent, node: Node) => { event.dataTransfer.effectAllowed = 'copy'; const nodeData = { id: node.id, dimensions: node.dimensions || [] }; // Store in ref for webview compatibility (dataTransfer.getData can fail in Electron/Tauri) draggedNodeRef.current = nodeData; // Also set in dataTransfer for browser compatibility event.dataTransfer.setData('application/node-info', JSON.stringify(nodeData)); // Provide a compact drag preview so drop targets stay visible const preview = document.createElement('div'); preview.textContent = node.title || `Node #${node.id}`; preview.style.position = 'fixed'; preview.style.top = '-1000px'; preview.style.left = '-1000px'; preview.style.padding = '4px 8px'; preview.style.background = '#0f0f0f'; preview.style.color = '#f8fafc'; preview.style.fontSize = '11px'; preview.style.fontWeight = '600'; preview.style.borderRadius = '6px'; preview.style.border = '1px solid #1f1f1f'; document.body.appendChild(preview); event.dataTransfer.setDragImage(preview, 6, 6); setTimeout(() => { if (preview.parentNode) { preview.parentNode.removeChild(preview); } }, 0); }; const handleNodeTileDragEnd = () => { // Clear ref if drag ends without a drop draggedNodeRef.current = null; }; const handleDimensionDragOver = (event: DragEvent) => { if (event.dataTransfer.types.includes('application/node-info')) { event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; } }; const handleDimensionDragEnter = (event: DragEvent, dimension: string) => { if (event.dataTransfer.types.includes('application/node-info')) { setDragHoverDimension(dimension); } }; const handleDimensionDragLeave = (event: DragEvent, dimension: string) => { if (dragHoverDimension === dimension) { setDragHoverDimension(null); } }; const handleNodeDropOnDimension = async (event: DragEvent, dimension: string) => { event.preventDefault(); event.stopPropagation(); // Try to get data from ref first (works in Electron/Tauri webviews) let payload: { id: number; dimensions?: string[] } | null = draggedNodeRef.current; // Fallback to dataTransfer for browser compatibility if (!payload) { const raw = event.dataTransfer.getData('application/node-info'); if (raw) { try { payload = JSON.parse(raw); } catch (e) { console.error('Failed to parse drag data:', e); } } } // Clear the ref draggedNodeRef.current = null; if (!payload?.id) { console.warn('No valid node data in drop event'); return; } try { const currentDimensions = payload.dimensions || []; if (currentDimensions.some((dim) => dim.toLowerCase() === dimension.toLowerCase())) { setDragHoverDimension(null); return; } const updatedDimensions = Array.from(new Set([...currentDimensions, dimension])); const response = await fetch(`/api/nodes/${payload.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dimensions: updatedDimensions }) }); if (!response.ok) { throw new Error('Failed to update node dimensions'); } if (selectedDimension?.dimension === dimension) { fetchNodes(true); } fetchDimensions(); onDataChanged?.(); } catch (error) { console.error('Error handling node drop:', error); alert('Failed to add dimension to node. Please try again.'); } finally { setDragHoverDimension(null); } }; const getContentPreview = (value?: string | null): string => { if (!value) return ''; const trimmed = value.trim(); if (trimmed.length <= 160) return trimmed; return `${trimmed.slice(0, 160)}…`; }; const handleEditDescription = () => { if (!selectedDimension) return; setEditingDescription(true); setEditDescriptionText(selectedDimension.description || ''); }; const handleSaveDescription = async () => { if (!selectedDimension) return; try { const response = await fetch('/api/dimensions', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: selectedDimension.dimension, description: editDescriptionText.trim() }) }); const data = await response.json(); if (!response.ok || !data.success) { throw new Error(data.error || 'Failed to update description'); } // Update the local selectedDimension with new description setSelectedDimension(prev => prev ? { ...prev, description: editDescriptionText.trim() } : null); await fetchDimensions(); onDataChanged?.(); setEditingDescription(false); setEditDescriptionText(''); } catch (error) { console.error('Error updating description:', error); alert('Failed to update description. Please try again.'); } }; const handleCancelDescription = () => { setEditingDescription(false); setEditDescriptionText(''); }; const handleEditDimensionName = () => { if (!selectedDimension) return; setEditingDimensionName(true); setEditDimensionNameText(selectedDimension.dimension); }; const handleSaveDimensionName = async () => { if (!selectedDimension || !editDimensionNameText.trim()) return; const newName = editDimensionNameText.trim(); if (newName === selectedDimension.dimension) { // No change, just cancel handleCancelDimensionName(); return; } try { const response = await fetch('/api/dimensions', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentName: selectedDimension.dimension, newName: newName, description: selectedDimension.description }) }); const data = await response.json(); if (!response.ok || !data.success) { throw new Error(data.error || 'Failed to update dimension name'); } // Update the local selectedDimension with new name setSelectedDimension(prev => prev ? { ...prev, dimension: newName } : null); await fetchDimensions(); onDataChanged?.(); setEditingDimensionName(false); setEditDimensionNameText(''); } catch (error) { console.error('Error updating dimension name:', error); alert('Failed to update dimension name. Please try again.'); } }; const handleCancelDimensionName = () => { setEditingDimensionName(false); setEditDimensionNameText(''); }; const renderDimensionGrid = () => { if (dimensionsLoading) { return (
Loading dimensions...
); } if (dimensionsError) { return (
{dimensionsError}
); } if (sortedDimensions.length === 0) { return (
No dimensions yet. Create one to get started.
); } return (
{sortedDimensions.map((dimension) => (
handleSelectDimension(dimension)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); handleSelectDimension(dimension); } }} onDragOver={(event) => handleDimensionDragOver(event)} onDragEnter={(event) => handleDimensionDragEnter(event, dimension.dimension)} onDragLeave={(event) => handleDimensionDragLeave(event, dimension.dimension)} onDrop={(event) => handleNodeDropOnDimension(event, dimension.dimension)} style={{ background: dragHoverDimension === dimension.dimension ? '#152214' : (dimension.isPriority ? '#0a0e0b' : '#0a0a0a'), border: dimension.isPriority ? '1px solid #1c3f28' : '1px solid #161616', borderRadius: '16px', padding: '20px', textAlign: 'left', display: 'flex', flexDirection: 'column', gap: '10px', cursor: 'pointer', transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', position: 'relative', boxShadow: dimension.isPriority ? '0 0 0 1px rgba(125, 232, 165, 0.1), 0 2px 8px rgba(0, 0, 0, 0.3)' : '0 1px 4px rgba(0, 0, 0, 0.2)' }} onMouseEnter={(e) => { if (dragHoverDimension !== dimension.dimension) { e.currentTarget.style.background = dimension.isPriority ? '#0f140f' : '#111111'; e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = dimension.isPriority ? '0 0 0 1px rgba(125, 232, 165, 0.2), 0 8px 25px rgba(0, 0, 0, 0.4)' : '0 4px 12px rgba(0, 0, 0, 0.3)'; } }} onMouseLeave={(e) => { if (dragHoverDimension !== dimension.dimension) { e.currentTarget.style.background = dimension.isPriority ? '#0a0e0b' : '#0a0a0a'; e.currentTarget.style.transform = 'translateY(0px)'; e.currentTarget.style.boxShadow = dimension.isPriority ? '0 0 0 1px rgba(125, 232, 165, 0.1), 0 2px 8px rgba(0, 0, 0, 0.3)' : '0 1px 4px rgba(0, 0, 0, 0.2)'; } }} >
{dimension.count}
{/* Dimension name */}
{dimension.dimension}
{/* Description preview (read-only) */} {dimension.description ? (
{dimension.description}
) : (
No description
)}
))}
); }; const renderNodeGrid = () => { if (!selectedDimension) return null; return (
Showing {nodes.length} nodes tagged with {selectedDimension.dimension.toUpperCase()}
{nodes.map((node) => (
handleNodeTileDragStart(event, node)} onDragEnd={handleNodeTileDragEnd} onClick={() => { onNodeOpen(node.id); onClose(); }} style={{ background: '#0a0a0a', border: '1px solid #161616', borderRadius: '16px', padding: '20px', display: 'flex', flexDirection: 'column', gap: '12px', cursor: 'pointer', transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', position: 'relative', minHeight: '200px', maxHeight: '200px', overflow: 'hidden', boxShadow: '0 1px 4px rgba(0, 0, 0, 0.2)' }} onMouseEnter={(e) => { e.currentTarget.style.background = '#111111'; e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.3)'; }} onMouseLeave={(e) => { e.currentTarget.style.background = '#0a0a0a'; e.currentTarget.style.transform = 'translateY(0px)'; e.currentTarget.style.boxShadow = '0 1px 4px rgba(0, 0, 0, 0.2)'; }} >
{getNodeIcon(node)}
{node.title || 'Untitled'}
#{node.id}
{node.content && (
{getContentPreview(node.content)}
)} {node.link && (
{node.link}
)} {node.dimensions && node.dimensions.length > 0 && (
{node.dimensions.slice(0, 4).map((dimension, index) => { const isCurrentDimension = dimension === selectedDimension?.dimension; return ( {dimension} ); })} {node.dimensions.length > 4 && ( +{node.dimensions.length - 4} )}
)}
))} {nodesLoading && (
Loading...
)} {!nodesLoading && nodes.length === 0 && (
No nodes in this dimension yet.
)}
{nodesError && (
{nodesError}
)} {hasMoreNodes && (
)}
); }; return ( <>
{view === 'nodes' && ( )}
{view === 'dimensions' ? ( 'Dimensions' ) : editingDimensionName ? (
Nodes – setEditDimensionNameText(e.target.value)} autoFocus style={{ padding: '4px 8px', fontSize: '13px', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', background: '#1a1a1a', border: '1px solid #333', borderRadius: '4px', color: '#f8fafc', outline: 'none', minWidth: '120px' }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSaveDimensionName(); } else if (e.key === 'Escape') { e.preventDefault(); handleCancelDimensionName(); } }} />
) : (
Nodes – { e.currentTarget.style.borderColor = '#333'; e.currentTarget.style.background = '#1a1a1a'; }} onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; e.currentTarget.style.background = 'transparent'; }} > {selectedDimension?.dimension ?? ''}
)}
{view === 'nodes' && selectedDimension && (
{editingDescription ? (
setEditDescriptionText(e.target.value)} placeholder="Add a description to help with auto-assignment..." autoFocus style={{ width: '100%', padding: '8px 12px', fontSize: '12px', background: '#0f0f0f', border: '1px solid #22c55e', borderRadius: '8px', color: '#f8fafc', outline: 'none', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSaveDescription(); } else if (e.key === 'Escape') { e.preventDefault(); handleCancelDescription(); } }} />
) : (
{ e.currentTarget.style.borderColor = '#333'; e.currentTarget.style.background = '#0f0f0f'; }} onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; e.currentTarget.style.background = 'transparent'; }} > {selectedDimension.description || 'Add description...'}
)}
)}
{view === 'dimensions' && ( )}
{view === 'dimensions' ? renderDimensionGrid() : renderNodeGrid()}
{ if (dimensionPendingDelete) { handleDeleteDimension(dimensionPendingDelete); } }} onCancel={() => setDimensionPendingDelete(null)} /> setShowAddDimensionDialog(false)} /> ); }