diff --git a/app/globals.css b/app/globals.css index 92a8651..aabc354 100644 --- a/app/globals.css +++ b/app/globals.css @@ -47,6 +47,7 @@ html[data-theme="dark"] { --rah-bg-elevated: #161616; --rah-bg-active: #1a1a1a; --rah-bg-modal: #141414; + --rah-bg-folder: #222222; --rah-border: #1a1a1a; --rah-border-strong: #2a2a2a; --rah-border-stronger: #3a3a3a; @@ -77,6 +78,7 @@ html[data-theme="light"] { --rah-bg-elevated: #ececec; --rah-bg-active: #e8e8e8; --rah-bg-modal: #ffffff; + --rah-bg-folder: #dfdfdf; --rah-border: #e5e5e5; --rah-border-strong: #d4d4d4; --rah-border-stronger: #bdbdbd; diff --git a/src/components/nodes/FolderViewOverlay.tsx b/src/components/nodes/FolderViewOverlay.tsx index a3617cb..0a76059 100644 --- a/src/components/nodes/FolderViewOverlay.tsx +++ b/src/components/nodes/FolderViewOverlay.tsx @@ -1,21 +1,21 @@ "use client"; -import { useEffect, useMemo, useState, useRef, type DragEvent } from 'react'; +import { useEffect, useMemo, useRef, useState, type DragEvent } from 'react'; import { createPortal } from 'react-dom'; -import { X, ArrowLeft, Plus, Trash2, Edit2 } from 'lucide-react'; +import { ArrowLeft, Edit2, FolderPlus, Plus, Trash2, X } from 'lucide-react'; import type { Node } from '@/types/database'; import ConfirmDialog from '../common/ConfirmDialog'; import InputDialog from '../common/InputDialog'; -import { getNodeIcon } from '@/utils/nodeIcons'; import LucideIconPicker, { DynamicIcon } from '../common/LucideIconPicker'; +import { getNodeIcon } from '@/utils/nodeIcons'; import { useDimensionIcons } from '@/context/DimensionIconsContext'; -import { usePersistentState } from '@/hooks/usePersistentState'; interface DimensionSummary { dimension: string; count: number; isPriority: boolean; description?: string | null; + icon?: string | null; } interface FolderViewOverlayProps { @@ -27,9 +27,41 @@ interface FolderViewOverlayProps { toolbarHost?: HTMLDivElement | null; } -const PAGE_SIZE = 100; +const PAGE_SIZE = 50; +const CARD_BG = 'var(--rah-bg-card)'; +const CARD_TEXT = 'var(--rah-text-primary)'; -export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, onDataChanged, onDimensionSelect, toolbarHost }: FolderViewOverlayProps) { +function formatRelativeTime(dateStr: string | null | undefined): string { + if (!dateStr) return 'now'; + + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diff = now - then; + + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return 'now'; + if (minutes < 60) return `${minutes}m`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d`; + + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo`; + + return `${Math.floor(months / 12)}y`; +} + +export default function FolderViewOverlay({ + onClose, + onNodeOpen, + refreshToken, + onDataChanged, + onDimensionSelect, + toolbarHost, +}: FolderViewOverlayProps) { const [view, setView] = useState<'dimensions' | 'nodes'>('dimensions'); const [dimensions, setDimensions] = useState([]); const [dimensionsLoading, setDimensionsLoading] = useState(true); @@ -40,138 +72,64 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o 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; title?: string; dimensions?: string[] } | null>(null); + const [dimensionPendingDelete, setDimensionPendingDelete] = useState(null); + const [deletingDimension, setDeletingDimension] = useState(null); + const [dragHoverDimension, setDragHoverDimension] = useState(null); + const [editingDescription, setEditingDescription] = useState(false); + const [editDescriptionText, setEditDescriptionText] = useState(''); - // Kanban columns state (global, persisted) - const [kanbanColumns, setKanbanColumns] = usePersistentState<{ dimension: string; order: number }[]>( - 'ui.kanbanColumns.global', - [] - ); + const [hoveredDimension, setHoveredDimension] = useState(null); - // Kanban-specific drag states - const [draggedNodeId, setDraggedNodeId] = useState(null); - const [draggedFromColumn, setDraggedFromColumn] = useState(null); - const [dragOverColumn, setDragOverColumn] = useState(null); - const [showKanbanColumnPicker, setShowKanbanColumnPicker] = useState(false); - const [kanbanSearchQuery, setKanbanSearchQuery] = useState(''); - - // Kanban drag-and-drop state - const [draggedNode, setDraggedNode] = useState<{ id: number; fromDimension: string } | null>(null); - const [dropTargetDimension, setDropTargetDimension] = useState(null); - - // Kanban column reordering state - const [draggedColumn, setDraggedColumn] = useState(null); - const [columnDropTarget, setColumnDropTarget] = useState(null); - - // Node priority ordering within dimensions (persisted) - const [dimensionOrders, setDimensionOrders] = usePersistentState>('ui.dimensionOrders', {}); - - // Dimension icons from shared context - const { dimensionIcons, setDimensionIcons } = useDimensionIcons(); - - // Dimension edit modal state const [editingDimensionModal, setEditingDimensionModal] = useState(null); const [editModalName, setEditModalName] = useState(''); const [editModalDescription, setEditModalDescription] = useState(''); const [editModalIcon, setEditModalIcon] = useState('Folder'); - const [savingDimensionEdit, setSavingDimensionEdit] = useState(false); const [editModalNameError, setEditModalNameError] = useState(''); + const [savingDimensionEdit, setSavingDimensionEdit] = useState(false); - // Within-dimension reorder drag state - const [reorderDrag, setReorderDrag] = useState<{ nodeId: number; dimension: string; index: number } | null>(null); - const [reorderDropIndex, setReorderDropIndex] = useState(null); + const draggedNodeRef = useRef<{ id: number; title?: string; dimensions?: string[] } | null>(null); + const { dimensionIcons, setDimensionIcons } = useDimensionIcons(); + + const sortedDimensions = useMemo( + () => [...dimensions].sort((a, b) => a.dimension.localeCompare(b.dimension)), + [dimensions], + ); useEffect(() => { - fetchDimensions(); - // eslint-disable-next-line react-hooks/exhaustive-deps + void fetchDimensions(); }, []); useEffect(() => { if (view === 'dimensions') { - fetchDimensions(); - } else if (selectedDimension) { - fetchNodes(true); + void fetchDimensions(); + return; } - // 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]); - - // Sort nodes by their priority order within a dimension - const sortNodesByDimensionOrder = (nodes: Node[], dimension: string): Node[] => { - const order = dimensionOrders[dimension] || []; - return [...nodes].sort((a, b) => { - const aIndex = order.indexOf(a.id); - const bIndex = order.indexOf(b.id); - // Nodes in order array come first, sorted by their position - // Nodes not in order array go to end, sorted by ID - if (aIndex === -1 && bIndex === -1) return a.id - b.id; - if (aIndex === -1) return 1; - if (bIndex === -1) return -1; - return aIndex - bIndex; - }); - }; - - // Handle reordering a node within a dimension - const handleReorderDrop = (dimension: string, fromIndex: number, toIndex: number, nodes: Node[]) => { - if (fromIndex === toIndex) return; - - // Get current order or create from current node order - const currentOrder = dimensionOrders[dimension] || nodes.map(n => n.id); - const nodeIds = [...currentOrder]; - - // Ensure all nodes are in the order array - nodes.forEach(n => { - if (!nodeIds.includes(n.id)) { - nodeIds.push(n.id); - } - }); - - // Move the node from fromIndex to toIndex - const [movedId] = nodeIds.splice(fromIndex, 1); - nodeIds.splice(toIndex, 0, movedId); - - // Update the dimension orders - setDimensionOrders({ - ...dimensionOrders, - [dimension]: nodeIds - }); - - setReorderDrag(null); - setReorderDropIndex(null); - }; - - 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]); + if (selectedDimension) { + void fetchNodes(selectedDimension.dimension, true); + void fetchDimensions(); + } + }, [refreshToken]); // eslint-disable-line react-hooks/exhaustive-deps const fetchDimensions = async () => { setDimensionsLoading(true); setDimensionsError(null); + try { - const response = await fetch('/api/dimensions/popular'); + 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 || []); + setSelectedDimension((current) => { + if (!current) return current; + const updated = (data.data || []).find((dim: DimensionSummary) => dim.dimension === current.dimension); + return updated ?? current; + }); } catch (error) { console.error('Error fetching dimensions:', error); setDimensionsError('Failed to load dimensions'); @@ -180,21 +138,30 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o } }; - const fetchNodes = async (reset = false) => { - if (!selectedDimension) return; + const fetchNodes = async (dimensionName: string, reset = false) => { 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 params = new URLSearchParams({ + dimensions: dimensionName, + limit: String(PAGE_SIZE), + offset: String(offset), + sortBy: 'updated', + }); + + const response = await fetch(`/api/nodes?${params.toString()}`); 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); + setNodes((prev) => (reset ? fetchedNodes : [...prev, ...fetchedNodes])); setNodeOffset(offset + fetchedNodes.length); + setHasMoreNodes(fetchedNodes.length === PAGE_SIZE); } catch (error) { console.error('Error fetching nodes:', error); setNodesError('Failed to load nodes'); @@ -204,7 +171,15 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o }; const handleSelectDimension = (dimension: DimensionSummary) => { + setSelectedDimension(dimension); + setView('nodes'); + setNodes([]); + setNodeOffset(0); + setHasMoreNodes(false); + setEditingDescription(false); + setEditDescriptionText(''); onDimensionSelect?.(dimension.dimension); + void fetchNodes(dimension.dimension, true); }; const handleBackToDimensions = () => { @@ -213,44 +188,52 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o setNodes([]); setNodeOffset(0); setHasMoreNodes(false); + setEditingDescription(false); + setEditDescriptionText(''); onDimensionSelect?.(null); }; const handleAddDimension = async (name: string) => { - if (!name || !name.trim()) return; + if (!name.trim()) return; + try { const response = await fetch('/api/dimensions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: name.trim() }) + 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); + console.error('Error creating dimension:', error); alert('Failed to create dimension. Please try again.'); } }; - const handleDeleteDimension = async (dimension: string) => { setDeletingDimension(dimension); + try { const response = await fetch(`/api/dimensions?name=${encodeURIComponent(dimension)}`, { - method: 'DELETE' + 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) { @@ -262,23 +245,19 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o } }; - const handleNodeTileDragStart = (event: DragEvent, node: Node) => { + const handleNodeTileDragStart = (event: DragEvent, node: Node) => { event.dataTransfer.effectAllowed = 'copyMove'; const nodeData = { id: node.id, title: node.title || 'Untitled', - dimensions: node.dimensions || [] + dimensions: node.dimensions || [], }; - // Store in ref for webview compatibility (dataTransfer.getData can fail in Electron/Tauri) + draggedNodeRef.current = nodeData; - // Set multiple MIME types for different drop targets event.dataTransfer.setData('application/node-info', JSON.stringify(nodeData)); - // For chat input drops - includes title for [NODE:id:"title"] token event.dataTransfer.setData('application/x-rah-node', JSON.stringify({ id: node.id, title: node.title || 'Untitled' })); - // Fallback for browsers/webviews that only support text/plain event.dataTransfer.setData('text/plain', `[NODE:${node.id}:"${node.title || 'Untitled'}"]`); - // 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'; @@ -293,15 +272,13 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o preview.style.border = '1px solid #1f1f1f'; document.body.appendChild(preview); event.dataTransfer.setDragImage(preview, 6, 6); + setTimeout(() => { - if (preview.parentNode) { - preview.parentNode.removeChild(preview); - } + preview.parentNode?.removeChild(preview); }, 0); }; const handleNodeTileDragEnd = () => { - // Clear ref if drag ends without a drop draggedNodeRef.current = null; }; @@ -318,7 +295,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o } }; - const handleDimensionDragLeave = (event: DragEvent, dimension: string) => { + const handleDimensionDragLeave = (_event: DragEvent, dimension: string) => { if (dragHoverDimension === dimension) { setDragHoverDimension(null); } @@ -327,27 +304,24 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o 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') || event.dataTransfer.getData('text/plain'); if (raw) { try { payload = JSON.parse(raw); - } catch (e) { - console.error('Failed to parse drag data:', e); + } catch (error) { + console.error('Failed to parse drag data:', error); } } } - - // Clear the ref + draggedNodeRef.current = null; - + if (!payload?.id) { - console.warn('No valid node data in drop event'); + setDragHoverDimension(null); return; } @@ -362,7 +336,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o const response = await fetch(`/api/nodes/${payload.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ dimensions: updatedDimensions }) + body: JSON.stringify({ dimensions: updatedDimensions }), }); if (!response.ok) { @@ -370,73 +344,71 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o } if (selectedDimension?.dimension === dimension) { - fetchNodes(true); + await fetchNodes(dimension, true); } - fetchDimensions(); + + await fetchDimensions(); onDataChanged?.(); } catch (error) { - console.error('Error handling node drop:', error); + console.error('Error assigning node to dimension:', 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 = () => { + const startDescriptionEdit = () => { if (!selectedDimension) return; setEditingDescription(true); setEditDescriptionText(selectedDimension.description || ''); }; - const handleSaveDescription = async () => { + const cancelDescriptionEdit = () => { + setEditingDescription(false); + setEditDescriptionText(''); + }; + + const saveDescription = 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() - }) + 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?.(); + const nextDescription = editDescriptionText.trim(); + setSelectedDimension((current) => (current ? { ...current, description: nextDescription } : null)); + setDimensions((current) => + current.map((dimension) => + dimension.dimension === selectedDimension.dimension + ? { ...dimension, description: nextDescription } + : dimension, + ), + ); setEditingDescription(false); setEditDescriptionText(''); + onDataChanged?.(); } catch (error) { - console.error('Error updating description:', error); + console.error('Error updating dimension description:', error); alert('Failed to update description. Please try again.'); } }; - const handleCancelDescription = () => { - setEditingDescription(false); - setEditDescriptionText(''); - }; - - // Dimension edit modal handlers (from folder cards) const openDimensionEditModal = (dimension: DimensionSummary) => { setEditingDimensionModal(dimension); setEditModalName(dimension.dimension); setEditModalDescription(dimension.description || ''); - setEditModalIcon(dimensionIcons[dimension.dimension] || 'Folder'); + setEditModalIcon(dimensionIcons[dimension.dimension] || dimension.icon || 'Folder'); setEditModalNameError(''); }; @@ -445,8 +417,8 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o setEditModalName(''); setEditModalDescription(''); setEditModalIcon('Folder'); - setSavingDimensionEdit(false); setEditModalNameError(''); + setSavingDimensionEdit(false); }; const saveDimensionEdit = async () => { @@ -458,112 +430,295 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o return; } - const isRenamed = trimmedName !== editingDimensionModal.dimension; - if (isRenamed) { - const duplicate = dimensions.some(d => d.dimension.toLowerCase() === trimmedName.toLowerCase() && d.dimension !== editingDimensionModal.dimension); - if (duplicate) { - setEditModalNameError('A dimension with this name already exists'); - return; - } + const duplicate = dimensions.some( + (dimension) => + dimension.dimension.toLowerCase() === trimmedName.toLowerCase() + && dimension.dimension !== editingDimensionModal.dimension, + ); + + if (duplicate) { + setEditModalNameError('A dimension with this name already exists'); + return; } setSavingDimensionEdit(true); - setEditModalNameError(''); - try { - // Save description (and optionally rename) via API - const body: Record = { - description: editModalDescription.trim() - }; - if (isRenamed) { - body.currentName = editingDimensionModal.dimension; - body.newName = trimmedName; - } else { - body.name = editingDimensionModal.dimension; - } + try { + const isRenamed = trimmedName !== editingDimensionModal.dimension; const response = await fetch('/api/dimensions', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) + body: JSON.stringify( + isRenamed + ? { + currentName: editingDimensionModal.dimension, + newName: trimmedName, + description: editModalDescription.trim(), + icon: editModalIcon, + } + : { + name: editingDimensionModal.dimension, + description: editModalDescription.trim(), + icon: editModalIcon, + }, + ), }); - const data = await response.json(); + if (!response.ok || !data.success) { throw new Error(data.error || 'Failed to update dimension'); } - // Save icon — use new name if renamed - const iconKey = isRenamed ? trimmedName : editingDimensionModal.dimension; - setDimensionIcons(prev => { - const next = { ...prev, [iconKey]: editModalIcon }; - if (isRenamed) delete next[editingDimensionModal.dimension]; + setDimensionIcons((current) => { + const next = { ...current, [trimmedName]: editModalIcon }; + if (isRenamed) { + delete next[editingDimensionModal.dimension]; + } return next; }); + if (selectedDimension?.dimension === editingDimensionModal.dimension) { + const nextDimension = { + ...selectedDimension, + dimension: trimmedName, + description: editModalDescription.trim(), + icon: editModalIcon, + }; + setSelectedDimension(nextDimension); + onDimensionSelect?.(trimmedName); + } + await fetchDimensions(); onDataChanged?.(); closeDimensionEditModal(); } catch (error) { - console.error('Error saving dimension:', error); + console.error('Error saving dimension edit:', error); alert('Failed to save dimension. Please try again.'); setSavingDimensionEdit(false); } }; - const handleEditDimensionName = () => { - if (!selectedDimension) return; - setEditingDimensionName(true); - setEditDimensionNameText(selectedDimension.dimension); + const renderDimensionCard = (dimension: DimensionSummary) => { + const isDragTarget = dragHoverDimension === dimension.dimension; + const isHovered = hoveredDimension === dimension.dimension; + const iconName = dimensionIcons[dimension.dimension] || dimension.icon || 'Folder'; + const hasCustomIcon = iconName !== 'Folder'; + + return ( + + + + + {/* Body — empty dark space */} +
+ + {/* Footer */} +
+
+
+ + + {dimension.dimension} + +
+ + {dimension.count > 0 ? dimension.count : 'New'} + +
+ {dimension.description?.trim() && ( + + {dimension.description.trim()} + + )} +
+ + ); }; - const handleSaveDimensionName = async () => { - if (!selectedDimension || !editDimensionNameText.trim()) return; + const renderNewDimensionCard = () => ( + + ); const renderDimensionGrid = () => { if (dimensionsLoading) { return ( -
+
Loading dimensions...
); @@ -571,944 +726,286 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o if (dimensionsError) { return ( -
+
{dimensionsError}
); } - if (sortedDimensions.length === 0) { - return ( -
- No dimensions yet. Create one to get started. -
- ); - } - return (
- {sortedDimensions.map((dimension) => { - const isDragTarget = dragHoverDimension === dimension.dimension; - - return ( -
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: isDragTarget ? 'rgba(34, 197, 94, 0.05)' : 'transparent', - borderLeft: '2px solid transparent', - borderRadius: '6px', - padding: '12px 14px', - textAlign: 'left', - display: 'flex', - alignItems: 'center', - gap: '12px', - cursor: 'pointer', - transition: 'all 0.12s ease', - position: 'relative' - }} - onMouseEnter={(e) => { - if (!isDragTarget) { - e.currentTarget.style.background = 'rgba(255, 255, 255, 0.02)'; - } - }} - onMouseLeave={(e) => { - if (!isDragTarget) { - e.currentTarget.style.background = 'transparent'; - } - }} - > - {/* Dimension icon */} -
- -
- - {/* Name and description */} -
-
- {dimension.dimension} -
- {dimension.description && ( -
- {dimension.description} -
- )} -
- - {/* Count - minimal */} - - {dimension.count} - - - {/* Action buttons - subtle */} -
{ e.currentTarget.style.opacity = '1'; }} - onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.4'; }} - > - - -
-
- ); - })} + {renderNewDimensionCard()} + {sortedDimensions.map(renderDimensionCard)}
); }; - // Kanban helper functions - const getNodesForKanbanColumn = (dimension: string) => { - return nodes.filter(node => node.dimensions?.includes(dimension)); - }; - - const handleAddKanbanColumn = (dimension: string) => { - const newColumn = { - dimension, - order: kanbanColumns.length - }; - setKanbanColumns([...kanbanColumns, newColumn]); - setShowKanbanColumnPicker(false); - setKanbanSearchQuery(''); - }; - - const handleRemoveKanbanColumn = (dimension: string) => { - setKanbanColumns(kanbanColumns.filter(c => c.dimension !== dimension)); - }; - - const handleKanbanNodeDragStart = (e: DragEvent, nodeId: number, fromColumn: string) => { - setDraggedNodeId(nodeId); - setDraggedFromColumn(fromColumn); - e.dataTransfer.effectAllowed = 'copyMove'; - // Find the node to get its title for chat drops - const node = nodes.find(n => n.id === nodeId); - const title = node?.title || 'Untitled'; - // Set MIME types for chat input and folder 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}"]`); - // Store in ref for webview compatibility - 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 handleKanbanColumnDragLeave = () => { - setDragOverColumn(null); - }; - - const handleKanbanNodeDrop = async (e: DragEvent, targetDimension: string) => { - e.preventDefault(); - if (draggedNodeId === null || draggedFromColumn === targetDimension) { - handleKanbanNodeDragEnd(); - return; - } - - try { - const node = nodes.find(n => n.id === draggedNodeId); - if (!node) return; - - const currentDimensions = node.dimensions || []; - let updatedDimensions: string[]; - - if (draggedFromColumn === '__uncategorized__') { - // Adding to a new dimension - updatedDimensions = [...currentDimensions, targetDimension]; - } else { - // Replace old dimension with new one - updatedDimensions = currentDimensions.map(d => - d === draggedFromColumn ? targetDimension : d - ); - } - - const response = await fetch(`/api/nodes/${draggedNodeId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ dimensions: updatedDimensions }) - }); - - if (!response.ok) { - throw new Error('Failed to update node dimensions'); - } - - fetchNodes(true); - onDataChanged?.(); - } catch (error) { - console.error('Error updating node dimension:', error); - alert('Failed to move node. Please try again.'); - } finally { - handleKanbanNodeDragEnd(); - } - }; - - const filteredKanbanDimensions = dimensions.filter(d => - d.dimension.toLowerCase().includes(kanbanSearchQuery.toLowerCase()) && - !kanbanColumns.some(c => c.dimension === d.dimension) && - d.dimension !== selectedDimension?.dimension - ); - - const sortedKanbanColumns = [...kanbanColumns].sort((a, b) => a.order - b.order); - - // Render functions for each view mode - const renderGridContent = () => ( -
- {nodes.map((node) => ( -
handleNodeTileDragStart(event, node)} - onDragEnd={handleNodeTileDragEnd} - onClick={() => { - onNodeOpen(node.id); - onClose(); - }} - style={{ - background: 'var(var(--rah-bg-base))', - border: '1px solid #161616', - borderRadius: '12px', - padding: '14px', - display: 'flex', - flexDirection: 'column', - gap: '6px', - cursor: 'pointer', - transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', - position: 'relative', - minHeight: '120px', - 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)'; - }} - > - {/* Header: ID | Title | Icon */} -
- - #{node.id} - -
- {node.title || 'Untitled'} -
- {node.link && ( - - {getNodeIcon(node, dimensionIcons)} - - )} -
- {node.notes && ( -
- {getContentPreview(node.notes)} -
- )} - {node.dimensions && node.dimensions.length > 0 && ( -
- {node.dimensions.slice(0, 3).map((dimension, index) => { - const isCurrentDimension = dimension === selectedDimension?.dimension; - return ( - - {dimension} - - ); - })} - {node.dimensions.length > 3 && ( - - +{node.dimensions.length - 3} - - )} -
- )} -
- ))} - {nodesLoading && ( -
Loading...
- )} - {!nodesLoading && nodes.length === 0 && ( -
- No nodes in this dimension yet. -
- )} -
- ); - - const renderListContent = () => ( -
- {nodes.map((node) => ( - - ))} - {nodesLoading && ( -
Loading...
- )} - {!nodesLoading && nodes.length === 0 && ( -
- No nodes in this dimension yet. -
- )} -
- ); - - const renderKanbanContent = () => ( -
- {/* Kanban Column Setup Bar */} -
- - Group by: - - - {kanbanColumns.length === 0 && ( - - Add dimensions to create columns - - )} - -
- - - {showKanbanColumnPicker && ( -
- setKanbanSearchQuery(e.target.value)} - style={{ - width: '100%', - padding: '8px 12px', - background: 'var(var(--rah-bg-base))', - border: 'none', - borderBottom: '1px solid #333', - color: 'var(var(--rah-text-active))', - fontSize: '12px', - outline: 'none' - }} - autoFocus - /> -
- {filteredKanbanDimensions.length === 0 ? ( -
- No dimensions available -
- ) : ( - filteredKanbanDimensions.map(dim => ( - - )) - )} -
-
- )} -
- - {showKanbanColumnPicker && ( -
setShowKanbanColumnPicker(false)} - /> - )} -
- - {/* Kanban Board */} -
- {sortedKanbanColumns.map(column => { - const columnNodes = getNodesForKanbanColumn(column.dimension); - const isDropTarget = dragOverColumn === column.dimension && draggedFromColumn !== column.dimension; - - return ( -
handleKanbanColumnDragOver(e, column.dimension)} - onDragLeave={handleKanbanColumnDragLeave} - onDrop={(e) => handleKanbanNodeDrop(e, column.dimension)} - > - {/* Column Header */} -
-
- - {column.dimension} - - - {columnNodes.length} - -
- -
- - {/* Column Content */} -
- {columnNodes.map(node => ( -
handleKanbanNodeDragStart(e, node.id, column.dimension)} - onDragEnd={handleKanbanNodeDragEnd} - onClick={() => { - onNodeOpen(node.id); - onClose(); - }} - style={{ - padding: '10px', - marginBottom: '6px', - background: draggedNodeId === node.id ? '#1a1a1a' : '#111', - border: '1px solid var(--rah-border-strong)', - borderRadius: '8px', - 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'; - } - }} - > -
- {node.title || 'Untitled'} -
- {node.dimensions && node.dimensions.length > 1 && ( -
- {node.dimensions - .filter(d => d !== column.dimension && d !== selectedDimension?.dimension) - .slice(0, 2) - .map(dim => ( - - {dim} - - ))} -
- )} -
- ))} - - {columnNodes.length === 0 && ( -
- Drop nodes here -
- )} -
-
- ); - })} - - {/* Empty State */} - {kanbanColumns.length === 0 && ( -
- Add dimension columns to organize your nodes -
- )} -
-
- ); - const renderNodeGrid = () => { if (!selectedDimension) return null; return ( -
-
- Showing {nodes.length} nodes tagged with {selectedDimension.dimension.toUpperCase()} +
+
+ + + {editingDescription ? ( +
+