Replace dark hex values with CSS custom properties across all panel, card, modal, and focus components so light mode renders correctly. Backgrounds, borders, and text colors now respond to data-theme attribute. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1911 lines
65 KiB
TypeScript
1911 lines
65 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState, useRef, type DragEvent } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { Check, X, ArrowLeft, Plus, Trash2, Edit2, Lock } 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 { useDimensionIcons } from '@/context/DimensionIconsContext';
|
|
import { usePersistentState } from '@/hooks/usePersistentState';
|
|
|
|
interface DimensionSummary {
|
|
dimension: string;
|
|
count: number;
|
|
isPriority: boolean;
|
|
description?: string | null;
|
|
}
|
|
|
|
interface FolderViewOverlayProps {
|
|
onClose: () => void;
|
|
onNodeOpen: (nodeId: number) => void;
|
|
refreshToken: number;
|
|
onDataChanged?: () => void;
|
|
onDimensionSelect?: (dimensionName: string | null) => void;
|
|
toolbarHost?: HTMLDivElement | null;
|
|
}
|
|
|
|
const PAGE_SIZE = 100;
|
|
|
|
export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, onDataChanged, onDimensionSelect, toolbarHost }: FolderViewOverlayProps) {
|
|
const [view, setView] = useState<'dimensions' | 'nodes'>('dimensions');
|
|
const [dimensions, setDimensions] = useState<DimensionSummary[]>([]);
|
|
const [dimensionsLoading, setDimensionsLoading] = useState(true);
|
|
const [dimensionsError, setDimensionsError] = useState<string | null>(null);
|
|
const [selectedDimension, setSelectedDimension] = useState<DimensionSummary | null>(null);
|
|
const [nodes, setNodes] = useState<Node[]>([]);
|
|
const [nodesLoading, setNodesLoading] = useState(false);
|
|
const [nodesError, setNodesError] = useState<string | null>(null);
|
|
const [hasMoreNodes, setHasMoreNodes] = useState(false);
|
|
const [nodeOffset, setNodeOffset] = useState(0);
|
|
const [deletingDimension, setDeletingDimension] = useState<string | null>(null);
|
|
const [dimensionPendingDelete, setDimensionPendingDelete] = useState<string | null>(null);
|
|
const [dragHoverDimension, setDragHoverDimension] = useState<string | null>(null);
|
|
const [editingDescription, setEditingDescription] = useState<boolean>(false);
|
|
const [editDescriptionText, setEditDescriptionText] = useState('');
|
|
const [editingDimensionName, setEditingDimensionName] = useState<boolean>(false);
|
|
const [editDimensionNameText, setEditDimensionNameText] = useState('');
|
|
const [showAddDimensionDialog, setShowAddDimensionDialog] = useState(false);
|
|
const draggedNodeRef = useRef<{ id: number; title?: string; dimensions?: string[] } | null>(null);
|
|
|
|
// Kanban columns state (global, persisted)
|
|
const [kanbanColumns, setKanbanColumns] = usePersistentState<{ dimension: string; order: number }[]>(
|
|
'ui.kanbanColumns.global',
|
|
[]
|
|
);
|
|
|
|
// Kanban-specific drag states
|
|
const [draggedNodeId, setDraggedNodeId] = useState<number | null>(null);
|
|
const [draggedFromColumn, setDraggedFromColumn] = useState<string | null>(null);
|
|
const [dragOverColumn, setDragOverColumn] = useState<string | null>(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<string | null>(null);
|
|
|
|
// Kanban column reordering state
|
|
const [draggedColumn, setDraggedColumn] = useState<string | null>(null);
|
|
const [columnDropTarget, setColumnDropTarget] = useState<string | null>(null);
|
|
|
|
// Node priority ordering within dimensions (persisted)
|
|
const [dimensionOrders, setDimensionOrders] = usePersistentState<Record<string, number[]>>('ui.dimensionOrders', {});
|
|
|
|
// Dimension icons from shared context
|
|
const { dimensionIcons, setDimensionIcons } = useDimensionIcons();
|
|
|
|
// Dimension edit modal state
|
|
const [editingDimensionModal, setEditingDimensionModal] = useState<DimensionSummary | null>(null);
|
|
const [editModalName, setEditModalName] = useState('');
|
|
const [editModalDescription, setEditModalDescription] = useState('');
|
|
const [editModalIcon, setEditModalIcon] = useState('Folder');
|
|
const [savingDimensionEdit, setSavingDimensionEdit] = useState(false);
|
|
const [editModalNameError, setEditModalNameError] = useState('');
|
|
|
|
// Within-dimension reorder drag state
|
|
const [reorderDrag, setReorderDrag] = useState<{ nodeId: number; dimension: string; index: number } | null>(null);
|
|
const [reorderDropIndex, setReorderDropIndex] = useState<number | 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]);
|
|
|
|
// 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]);
|
|
|
|
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) => {
|
|
onDimensionSelect?.(dimension.dimension);
|
|
};
|
|
|
|
const handleBackToDimensions = () => {
|
|
setView('dimensions');
|
|
setSelectedDimension(null);
|
|
setNodes([]);
|
|
setNodeOffset(0);
|
|
setHasMoreNodes(false);
|
|
onDimensionSelect?.(null);
|
|
};
|
|
|
|
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<HTMLDivElement>, node: Node) => {
|
|
event.dataTransfer.effectAllowed = 'copyMove';
|
|
const nodeData = {
|
|
id: node.id,
|
|
title: node.title || 'Untitled',
|
|
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';
|
|
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<HTMLElement>) => {
|
|
if (event.dataTransfer.types.includes('application/node-info') || event.dataTransfer.types.includes('text/plain')) {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = 'copy';
|
|
}
|
|
};
|
|
|
|
const handleDimensionDragEnter = (event: DragEvent<HTMLElement>, dimension: string) => {
|
|
if (event.dataTransfer.types.includes('application/node-info') || event.dataTransfer.types.includes('text/plain')) {
|
|
setDragHoverDimension(dimension);
|
|
}
|
|
};
|
|
|
|
const handleDimensionDragLeave = (event: DragEvent<HTMLElement>, dimension: string) => {
|
|
if (dragHoverDimension === dimension) {
|
|
setDragHoverDimension(null);
|
|
}
|
|
};
|
|
|
|
const handleNodeDropOnDimension = async (event: DragEvent<HTMLElement>, 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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('');
|
|
};
|
|
|
|
// Dimension edit modal handlers (from folder cards)
|
|
const openDimensionEditModal = (dimension: DimensionSummary) => {
|
|
setEditingDimensionModal(dimension);
|
|
setEditModalName(dimension.dimension);
|
|
setEditModalDescription(dimension.description || '');
|
|
setEditModalIcon(dimensionIcons[dimension.dimension] || 'Folder');
|
|
setEditModalNameError('');
|
|
};
|
|
|
|
const closeDimensionEditModal = () => {
|
|
setEditingDimensionModal(null);
|
|
setEditModalName('');
|
|
setEditModalDescription('');
|
|
setEditModalIcon('Folder');
|
|
setSavingDimensionEdit(false);
|
|
setEditModalNameError('');
|
|
};
|
|
|
|
const saveDimensionEdit = async () => {
|
|
if (!editingDimensionModal) return;
|
|
|
|
const trimmedName = editModalName.trim();
|
|
if (!trimmedName) {
|
|
setEditModalNameError('Name cannot be empty');
|
|
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;
|
|
}
|
|
}
|
|
|
|
setSavingDimensionEdit(true);
|
|
setEditModalNameError('');
|
|
try {
|
|
// Save description (and optionally rename) via API
|
|
const body: Record<string, string> = {
|
|
description: editModalDescription.trim()
|
|
};
|
|
if (isRenamed) {
|
|
body.currentName = editingDimensionModal.dimension;
|
|
body.newName = trimmedName;
|
|
} else {
|
|
body.name = editingDimensionModal.dimension;
|
|
}
|
|
|
|
const response = await fetch('/api/dimensions', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
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];
|
|
return next;
|
|
});
|
|
|
|
await fetchDimensions();
|
|
onDataChanged?.();
|
|
closeDimensionEditModal();
|
|
} catch (error) {
|
|
console.error('Error saving dimension:', error);
|
|
alert('Failed to save dimension. Please try again.');
|
|
setSavingDimensionEdit(false);
|
|
}
|
|
};
|
|
|
|
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 (
|
|
<div style={{ padding: '40px', color: 'var(var(--rah-text-muted))', textAlign: 'center' }}>
|
|
Loading dimensions...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (dimensionsError) {
|
|
return (
|
|
<div style={{ padding: '40px', color: '#f87171', textAlign: 'center' }}>
|
|
{dimensionsError}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (sortedDimensions.length === 0) {
|
|
return (
|
|
<div style={{ padding: '40px', color: 'var(var(--rah-text-muted))', textAlign: 'center' }}>
|
|
No dimensions yet. Create one to get started.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
overflowY: 'auto',
|
|
padding: '24px',
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
|
gap: '8px',
|
|
alignContent: 'start'
|
|
}}
|
|
>
|
|
{sortedDimensions.map((dimension) => {
|
|
const isLocked = dimension.isPriority;
|
|
const isDragTarget = dragHoverDimension === dimension.dimension;
|
|
|
|
return (
|
|
<div
|
|
key={dimension.dimension}
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => 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: isLocked ? '2px solid #22c55e' : '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 */}
|
|
<div style={{
|
|
width: '28px',
|
|
height: '28px',
|
|
borderRadius: '6px',
|
|
background: isLocked ? 'rgba(34, 197, 94, 0.08)' : 'rgba(255, 255, 255, 0.03)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
flexShrink: 0
|
|
}}>
|
|
<DynamicIcon
|
|
name={dimensionIcons[dimension.dimension] || 'Folder'}
|
|
size={14}
|
|
style={{ color: isLocked ? '#22c55e' : '#555' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Name and description */}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{
|
|
fontSize: '13px',
|
|
fontWeight: 500,
|
|
color: isLocked ? '#f0f0f0' : '#999',
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
letterSpacing: '-0.01em'
|
|
}}>
|
|
{dimension.dimension}
|
|
</div>
|
|
{dimension.description && (
|
|
<div style={{
|
|
fontSize: '11px',
|
|
color: 'var(var(--rah-text-muted))',
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
marginTop: '1px'
|
|
}}>
|
|
{dimension.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Count - minimal */}
|
|
<span style={{
|
|
fontSize: '11px',
|
|
fontWeight: 500,
|
|
color: isLocked ? '#22c55e' : '#444',
|
|
fontFamily: 'monospace',
|
|
flexShrink: 0
|
|
}}>
|
|
{dimension.count}
|
|
</span>
|
|
|
|
{/* Action buttons - subtle */}
|
|
<div style={{ display: 'flex', gap: '2px', flexShrink: 0, opacity: 0.4 }}
|
|
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.4'; }}
|
|
>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
openDimensionEditModal(dimension);
|
|
}}
|
|
title="Edit"
|
|
style={{
|
|
background: 'transparent',
|
|
border: 'none',
|
|
borderRadius: '4px',
|
|
width: '22px',
|
|
height: '22px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
cursor: 'pointer',
|
|
color: 'var(var(--rah-text-muted))',
|
|
transition: 'color 0.1s ease'
|
|
}}
|
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#999'; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
|
>
|
|
<Edit2 size={12} />
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleToggleLock(dimension.dimension);
|
|
}}
|
|
title={isLocked ? 'Unlock' : 'Lock'}
|
|
style={{
|
|
background: 'transparent',
|
|
border: 'none',
|
|
borderRadius: '4px',
|
|
width: '22px',
|
|
height: '22px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
cursor: 'pointer',
|
|
color: isLocked ? '#22c55e' : '#666',
|
|
transition: 'color 0.1s ease'
|
|
}}
|
|
onMouseEnter={(e) => { if (!isLocked) e.currentTarget.style.color = '#22c55e'; }}
|
|
onMouseLeave={(e) => { if (!isLocked) e.currentTarget.style.color = '#666'; }}
|
|
>
|
|
{isLocked ? <Check size={12} /> : <Lock size={12} />}
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setDimensionPendingDelete(dimension.dimension);
|
|
}}
|
|
title="Delete"
|
|
style={{
|
|
background: 'transparent',
|
|
border: 'none',
|
|
borderRadius: '4px',
|
|
width: '22px',
|
|
height: '22px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
cursor: deletingDimension === dimension.dimension ? 'not-allowed' : 'pointer',
|
|
color: 'var(var(--rah-text-muted))',
|
|
opacity: deletingDimension === dimension.dimension ? 0.3 : 1,
|
|
transition: 'color 0.1s ease'
|
|
}}
|
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
|
disabled={deletingDimension === dimension.dimension}
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 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<HTMLDivElement>, 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<HTMLDivElement>, columnDimension: string) => {
|
|
e.preventDefault();
|
|
if (draggedNodeId !== null) {
|
|
setDragOverColumn(columnDimension);
|
|
}
|
|
};
|
|
|
|
const handleKanbanColumnDragLeave = () => {
|
|
setDragOverColumn(null);
|
|
};
|
|
|
|
const handleKanbanNodeDrop = async (e: DragEvent<HTMLDivElement>, 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 = () => (
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
overflowY: 'auto',
|
|
padding: '0 24px 24px',
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
|
gap: '16px',
|
|
alignContent: 'start'
|
|
}}
|
|
>
|
|
{nodes.map((node) => (
|
|
<div
|
|
key={node.id}
|
|
draggable
|
|
onDragStart={(event) => 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 */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
<span style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
background: '#22c55e',
|
|
color: '#0a0a0a',
|
|
fontSize: '9px',
|
|
fontWeight: 600,
|
|
padding: '1px 5px',
|
|
borderRadius: '3px',
|
|
flexShrink: 0,
|
|
fontFamily: 'monospace',
|
|
lineHeight: 1,
|
|
height: '16px'
|
|
}}>
|
|
#{node.id}
|
|
</span>
|
|
<div style={{
|
|
fontSize: '14px',
|
|
fontWeight: 600,
|
|
color: '#f8fafc',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
lineHeight: '1.2',
|
|
flex: 1
|
|
}}>
|
|
{node.title || 'Untitled'}
|
|
</div>
|
|
{node.link && (
|
|
<span style={{ flexShrink: 0 }}>
|
|
{getNodeIcon(node, dimensionIcons)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{node.notes && (
|
|
<div style={{
|
|
fontSize: '12px',
|
|
color: '#94a3b8',
|
|
lineHeight: '1.4',
|
|
overflow: 'hidden',
|
|
display: '-webkit-box',
|
|
WebkitLineClamp: 2,
|
|
WebkitBoxOrient: 'vertical',
|
|
fontWeight: 400
|
|
}}>
|
|
{getContentPreview(node.notes)}
|
|
</div>
|
|
)}
|
|
{node.dimensions && node.dimensions.length > 0 && (
|
|
<div style={{ display: 'flex', gap: '6px', overflow: 'hidden', flexWrap: 'nowrap' }}>
|
|
{node.dimensions.slice(0, 3).map((dimension, index) => {
|
|
const isCurrentDimension = dimension === selectedDimension?.dimension;
|
|
return (
|
|
<span
|
|
key={`${dimension}-${index}`}
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
padding: '3px 8px',
|
|
fontSize: '10px',
|
|
fontWeight: 500,
|
|
color: isCurrentDimension ? '#7de8a5' : '#cbd5e1',
|
|
background: isCurrentDimension ? 'rgba(125, 232, 165, 0.15)' : 'rgba(148, 163, 184, 0.1)',
|
|
border: isCurrentDimension ? '1px solid rgba(125, 232, 165, 0.3)' : '1px solid rgba(148, 163, 184, 0.2)',
|
|
borderRadius: '8px',
|
|
whiteSpace: 'nowrap',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.025em',
|
|
flexShrink: 0,
|
|
maxWidth: '100px',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis'
|
|
}}
|
|
>
|
|
{dimension}
|
|
</span>
|
|
);
|
|
})}
|
|
{node.dimensions.length > 3 && (
|
|
<span style={{
|
|
fontSize: '10px',
|
|
color: '#64748b',
|
|
fontWeight: 500,
|
|
padding: '3px 6px',
|
|
background: 'rgba(100, 116, 139, 0.1)',
|
|
borderRadius: '6px',
|
|
flexShrink: 0
|
|
}}>
|
|
+{node.dimensions.length - 3}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
{nodesLoading && (
|
|
<div style={{ padding: '20px', color: 'var(var(--rah-text-muted))' }}>Loading...</div>
|
|
)}
|
|
{!nodesLoading && nodes.length === 0 && (
|
|
<div style={{ gridColumn: '1 / -1', textAlign: 'center', color: 'var(var(--rah-text-muted))', paddingTop: '40px' }}>
|
|
No nodes in this dimension yet.
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const renderListContent = () => (
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
overflowY: 'auto',
|
|
padding: '0 24px 24px'
|
|
}}
|
|
>
|
|
{nodes.map((node) => (
|
|
<button
|
|
key={node.id}
|
|
onClick={() => {
|
|
onNodeOpen(node.id);
|
|
onClose();
|
|
}}
|
|
style={{
|
|
width: '100%',
|
|
display: 'flex',
|
|
alignItems: 'flex-start',
|
|
gap: '12px',
|
|
padding: '12px 16px',
|
|
marginBottom: '4px',
|
|
background: 'var(var(--rah-bg-base))',
|
|
border: '1px solid #161616',
|
|
borderRadius: '10px',
|
|
cursor: 'pointer',
|
|
textAlign: 'left',
|
|
transition: 'all 0.2s'
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.background = '#111';
|
|
e.currentTarget.style.borderColor = '#222';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.background = '#0a0a0a';
|
|
e.currentTarget.style.borderColor = '#161616';
|
|
}}
|
|
>
|
|
<div style={{
|
|
width: '32px',
|
|
height: '32px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
background: 'var(var(--rah-bg-active))',
|
|
borderRadius: '8px',
|
|
flexShrink: 0
|
|
}}>
|
|
{getNodeIcon(node, dimensionIcons)}
|
|
</div>
|
|
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{
|
|
fontSize: '13px',
|
|
fontWeight: 500,
|
|
color: '#f8fafc',
|
|
marginBottom: '4px',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap'
|
|
}}>
|
|
{node.title || 'Untitled'}
|
|
</div>
|
|
|
|
{node.notes && (
|
|
<div style={{
|
|
fontSize: '12px',
|
|
color: '#94a3b8',
|
|
marginBottom: '8px',
|
|
lineHeight: '1.4',
|
|
display: '-webkit-box',
|
|
WebkitLineClamp: 2,
|
|
WebkitBoxOrient: 'vertical',
|
|
overflow: 'hidden'
|
|
}}>
|
|
{getContentPreview(node.notes)}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '12px',
|
|
flexWrap: 'wrap'
|
|
}}>
|
|
{node.dimensions && node.dimensions.length > 0 && (
|
|
<div style={{
|
|
display: 'flex',
|
|
gap: '4px',
|
|
flexWrap: 'wrap'
|
|
}}>
|
|
{node.dimensions.slice(0, 3).map(dim => {
|
|
const isCurrentDimension = dim === selectedDimension?.dimension;
|
|
return (
|
|
<span
|
|
key={dim}
|
|
style={{
|
|
padding: '2px 6px',
|
|
background: isCurrentDimension ? 'rgba(125, 232, 165, 0.15)' : '#1a1a1a',
|
|
borderRadius: '4px',
|
|
fontSize: '10px',
|
|
color: isCurrentDimension ? '#7de8a5' : '#888',
|
|
textTransform: 'uppercase'
|
|
}}
|
|
>
|
|
{dim}
|
|
</span>
|
|
);
|
|
})}
|
|
{node.dimensions.length > 3 && (
|
|
<span style={{
|
|
padding: '2px 6px',
|
|
fontSize: '10px',
|
|
color: 'var(var(--rah-text-muted))'
|
|
}}>
|
|
+{node.dimensions.length - 3}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<span style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
background: '#22c55e',
|
|
color: '#0a0a0a',
|
|
fontSize: '9px',
|
|
fontWeight: 600,
|
|
padding: '1px 5px',
|
|
borderRadius: '3px',
|
|
flexShrink: 0,
|
|
fontFamily: 'monospace',
|
|
lineHeight: 1,
|
|
height: '16px'
|
|
}}>
|
|
#{node.id}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
{nodesLoading && (
|
|
<div style={{ padding: '20px', color: 'var(var(--rah-text-muted))' }}>Loading...</div>
|
|
)}
|
|
{!nodesLoading && nodes.length === 0 && (
|
|
<div style={{ textAlign: 'center', color: 'var(var(--rah-text-muted))', paddingTop: '40px' }}>
|
|
No nodes in this dimension yet.
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const renderKanbanContent = () => (
|
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
{/* Kanban Column Setup Bar */}
|
|
<div style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px',
|
|
padding: '8px 24px',
|
|
borderBottom: '1px solid var(--rah-border)',
|
|
background: 'var(var(--rah-bg-base))',
|
|
flexShrink: 0
|
|
}}>
|
|
<span style={{ fontSize: '11px', color: '#64748b', fontWeight: 500 }}>
|
|
Group by:
|
|
</span>
|
|
|
|
{kanbanColumns.length === 0 && (
|
|
<span style={{ fontSize: '11px', color: 'var(var(--rah-text-muted))', fontStyle: 'italic' }}>
|
|
Add dimensions to create columns
|
|
</span>
|
|
)}
|
|
|
|
<div style={{ position: 'relative' }}>
|
|
<button
|
|
onClick={() => setShowKanbanColumnPicker(!showKanbanColumnPicker)}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '4px',
|
|
padding: '4px 8px',
|
|
background: 'var(var(--rah-bg-active))',
|
|
border: '1px solid #333',
|
|
borderRadius: '6px',
|
|
fontSize: '11px',
|
|
color: 'var(var(--rah-text-muted))',
|
|
cursor: 'pointer'
|
|
}}
|
|
>
|
|
<Plus size={12} />
|
|
Add Column
|
|
</button>
|
|
|
|
{showKanbanColumnPicker && (
|
|
<div style={{
|
|
position: 'absolute',
|
|
top: '100%',
|
|
left: 0,
|
|
marginTop: '4px',
|
|
width: '200px',
|
|
maxHeight: '300px',
|
|
background: 'var(var(--rah-bg-active))',
|
|
border: '1px solid #333',
|
|
borderRadius: '8px',
|
|
overflow: 'hidden',
|
|
zIndex: 100,
|
|
boxShadow: '0 4px 12px rgba(0,0,0,0.5)'
|
|
}}>
|
|
<input
|
|
type="text"
|
|
placeholder="Search dimensions..."
|
|
value={kanbanSearchQuery}
|
|
onChange={(e) => 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
|
|
/>
|
|
<div style={{ maxHeight: '250px', overflowY: 'auto' }}>
|
|
{filteredKanbanDimensions.length === 0 ? (
|
|
<div style={{
|
|
padding: '12px',
|
|
fontSize: '12px',
|
|
color: 'var(var(--rah-text-muted))',
|
|
textAlign: 'center'
|
|
}}>
|
|
No dimensions available
|
|
</div>
|
|
) : (
|
|
filteredKanbanDimensions.map(dim => (
|
|
<button
|
|
key={dim.dimension}
|
|
onClick={() => handleAddKanbanColumn(dim.dimension)}
|
|
style={{
|
|
width: '100%',
|
|
padding: '8px 12px',
|
|
background: 'transparent',
|
|
border: 'none',
|
|
color: 'var(var(--rah-text-secondary))',
|
|
fontSize: '12px',
|
|
textAlign: 'left',
|
|
cursor: 'pointer'
|
|
}}
|
|
onMouseEnter={(e) => { e.currentTarget.style.background = '#2a2a2a'; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
|
>
|
|
{dim.dimension}
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{showKanbanColumnPicker && (
|
|
<div
|
|
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
|
|
onClick={() => setShowKanbanColumnPicker(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Kanban Board */}
|
|
<div style={{
|
|
flex: 1,
|
|
display: 'flex',
|
|
gap: '16px',
|
|
padding: '16px 24px',
|
|
overflowX: 'auto',
|
|
overflowY: 'hidden'
|
|
}}>
|
|
{sortedKanbanColumns.map(column => {
|
|
const columnNodes = getNodesForKanbanColumn(column.dimension);
|
|
const isDropTarget = dragOverColumn === column.dimension && draggedFromColumn !== column.dimension;
|
|
|
|
return (
|
|
<div
|
|
key={column.dimension}
|
|
style={{
|
|
width: '280px',
|
|
minWidth: '280px',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
background: isDropTarget ? '#0f2417' : '#0a0a0a',
|
|
border: '1px solid var(--rah-border)',
|
|
borderRadius: '12px',
|
|
transition: 'all 0.2s'
|
|
}}
|
|
onDragOver={(e) => handleKanbanColumnDragOver(e, column.dimension)}
|
|
onDragLeave={handleKanbanColumnDragLeave}
|
|
onDrop={(e) => handleKanbanNodeDrop(e, column.dimension)}
|
|
>
|
|
{/* Column Header */}
|
|
<div style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
padding: '12px',
|
|
borderBottom: '1px solid var(--rah-border)'
|
|
}}>
|
|
<div style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px'
|
|
}}>
|
|
<span style={{
|
|
fontSize: '12px',
|
|
fontWeight: 600,
|
|
color: '#f8fafc',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.05em'
|
|
}}>
|
|
{column.dimension}
|
|
</span>
|
|
<span style={{
|
|
fontSize: '11px',
|
|
color: 'var(var(--rah-text-muted))',
|
|
background: 'var(var(--rah-bg-active))',
|
|
padding: '2px 6px',
|
|
borderRadius: '10px'
|
|
}}>
|
|
{columnNodes.length}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => handleRemoveKanbanColumn(column.dimension)}
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
padding: '4px',
|
|
cursor: 'pointer',
|
|
color: 'var(var(--rah-text-muted))',
|
|
display: 'flex',
|
|
alignItems: 'center'
|
|
}}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Column Content */}
|
|
<div style={{
|
|
flex: 1,
|
|
overflowY: 'auto',
|
|
padding: '8px'
|
|
}}>
|
|
{columnNodes.map(node => (
|
|
<div
|
|
key={node.id}
|
|
draggable
|
|
onDragStart={(e) => 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';
|
|
}
|
|
}}
|
|
>
|
|
<div style={{
|
|
fontSize: '12px',
|
|
fontWeight: 500,
|
|
color: '#f8fafc',
|
|
marginBottom: '4px',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap'
|
|
}}>
|
|
{node.title || 'Untitled'}
|
|
</div>
|
|
{node.dimensions && node.dimensions.length > 1 && (
|
|
<div style={{
|
|
display: 'flex',
|
|
gap: '4px',
|
|
flexWrap: 'wrap',
|
|
marginTop: '6px'
|
|
}}>
|
|
{node.dimensions
|
|
.filter(d => d !== column.dimension && d !== selectedDimension?.dimension)
|
|
.slice(0, 2)
|
|
.map(dim => (
|
|
<span
|
|
key={dim}
|
|
style={{
|
|
padding: '2px 6px',
|
|
background: 'var(var(--rah-bg-active))',
|
|
borderRadius: '4px',
|
|
fontSize: '10px',
|
|
color: 'var(var(--rah-text-muted))'
|
|
}}
|
|
>
|
|
{dim}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{columnNodes.length === 0 && (
|
|
<div style={{
|
|
padding: '20px',
|
|
textAlign: 'center',
|
|
color: '#444',
|
|
fontSize: '11px'
|
|
}}>
|
|
Drop nodes here
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Empty State */}
|
|
{kanbanColumns.length === 0 && (
|
|
<div style={{
|
|
flex: 1,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: 'var(var(--rah-text-muted))',
|
|
fontSize: '13px'
|
|
}}>
|
|
Add dimension columns to organize your nodes
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const renderNodeGrid = () => {
|
|
if (!selectedDimension) return null;
|
|
|
|
return (
|
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
<div style={{ padding: '0 24px 12px', color: '#94a3b8', fontSize: '12px', fontWeight: 500 }}>
|
|
Showing <strong style={{ color: '#f8fafc' }}>{nodes.length}</strong> nodes tagged with <strong style={{ color: '#7de8a5' }}>{selectedDimension.dimension.toUpperCase()}</strong>
|
|
</div>
|
|
|
|
{renderGridContent()}
|
|
|
|
{nodesError && (
|
|
<div style={{ padding: '12px 16px', color: '#f87171', fontSize: '12px' }}>
|
|
{nodesError}
|
|
</div>
|
|
)}
|
|
{hasMoreNodes && (
|
|
<div style={{ padding: '16px', textAlign: 'center' }}>
|
|
<button
|
|
onClick={() => fetchNodes(false)}
|
|
disabled={nodesLoading}
|
|
style={{
|
|
padding: '10px 18px',
|
|
borderRadius: '999px',
|
|
border: '1px solid var(--rah-bg-active)',
|
|
background: 'var(var(--rah-bg-surface))',
|
|
color: '#f1f5f9',
|
|
cursor: nodesLoading ? 'not-allowed' : 'pointer'
|
|
}}
|
|
>
|
|
{nodesLoading ? 'Loading...' : 'Load more'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const toolbar = (
|
|
<div style={{
|
|
width: '100%',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: '8px',
|
|
minWidth: 0
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
|
|
{view === 'nodes' && (
|
|
<button
|
|
onClick={handleBackToDimensions}
|
|
style={{
|
|
padding: '6px',
|
|
borderRadius: '6px',
|
|
border: '1px solid var(--rah-bg-active)',
|
|
background: 'transparent',
|
|
cursor: 'pointer',
|
|
color: '#cbd5f5'
|
|
}}
|
|
>
|
|
<ArrowLeft size={16} />
|
|
</button>
|
|
)}
|
|
{view === 'nodes' && (
|
|
<div style={{
|
|
fontSize: '12px',
|
|
color: '#7de8a5',
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis'
|
|
}}>
|
|
{selectedDimension?.dimension ?? ''}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
{view === 'dimensions' && (
|
|
<button
|
|
onClick={() => setShowAddDimensionDialog(true)}
|
|
title="Add dimension"
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: '28px',
|
|
height: '28px',
|
|
color: 'var(var(--rah-text-muted))',
|
|
background: 'transparent',
|
|
border: '1px solid var(--rah-bg-active)',
|
|
borderRadius: '6px',
|
|
cursor: 'pointer',
|
|
transition: 'all 0.15s'
|
|
}}
|
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#22c55e'; e.currentTarget.style.borderColor = '#22c55e'; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#555'; e.currentTarget.style.borderColor = '#1f1f1f'; }}
|
|
>
|
|
<Plus size={14} />
|
|
</button>
|
|
)}
|
|
|
|
{!toolbarHost && (
|
|
<button
|
|
onClick={onClose}
|
|
title="Close"
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: '28px',
|
|
height: '28px',
|
|
borderRadius: '6px',
|
|
border: '1px solid var(--rah-bg-active)',
|
|
background: 'transparent',
|
|
cursor: 'pointer',
|
|
color: 'var(var(--rah-text-muted))',
|
|
transition: 'all 0.15s'
|
|
}}
|
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#888'; e.currentTarget.style.background = '#1a1a1a'; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#555'; e.currentTarget.style.background = 'transparent'; }}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
background: 'transparent',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
zIndex: 5
|
|
}}
|
|
>
|
|
{toolbarHost ? createPortal(toolbar, toolbarHost) : (
|
|
<div style={{ borderBottom: '1px solid var(--rah-border)', padding: '12px 16px' }}>
|
|
{toolbar}
|
|
</div>
|
|
)}
|
|
|
|
{/* Content */}
|
|
{view === 'nodes' ? renderNodeGrid() : renderDimensionGrid()}
|
|
</div>
|
|
<ConfirmDialog
|
|
open={dimensionPendingDelete !== null}
|
|
title="Delete this dimension?"
|
|
message={`This will remove "${dimensionPendingDelete ?? ''}" from every node.`}
|
|
confirmLabel="Delete"
|
|
onConfirm={() => {
|
|
if (dimensionPendingDelete) {
|
|
handleDeleteDimension(dimensionPendingDelete);
|
|
}
|
|
}}
|
|
onCancel={() => setDimensionPendingDelete(null)}
|
|
/>
|
|
<InputDialog
|
|
open={showAddDimensionDialog}
|
|
title="Add New Dimension"
|
|
message="Enter a name for the new dimension:"
|
|
placeholder="e.g. Research, Work, Ideas"
|
|
confirmLabel="Create"
|
|
onConfirm={handleAddDimension}
|
|
onCancel={() => setShowAddDimensionDialog(false)}
|
|
/>
|
|
|
|
{/* Dimension Edit Modal */}
|
|
{editingDimensionModal && (
|
|
<div
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
background: 'rgba(0, 0, 0, 0.7)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
zIndex: 1000
|
|
}}
|
|
onClick={(e) => {
|
|
if (e.target === e.currentTarget) closeDimensionEditModal();
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
background: 'var(var(--rah-bg-base))',
|
|
border: '1px solid #333',
|
|
borderRadius: '12px',
|
|
width: '480px',
|
|
maxWidth: '90vw',
|
|
maxHeight: '90vh',
|
|
overflow: 'hidden',
|
|
display: 'flex',
|
|
flexDirection: 'column'
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<div style={{
|
|
padding: '16px 20px',
|
|
borderBottom: '1px solid var(--rah-border)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between'
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
<div style={{
|
|
width: '32px',
|
|
height: '32px',
|
|
borderRadius: '8px',
|
|
background: editingDimensionModal.isPriority ? 'rgba(34, 197, 94, 0.1)' : '#111',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center'
|
|
}}>
|
|
<DynamicIcon
|
|
name={editModalIcon}
|
|
size={16}
|
|
style={{ color: editingDimensionModal.isPriority ? '#22c55e' : '#888' }}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<div style={{
|
|
fontSize: '15px',
|
|
fontWeight: 600,
|
|
color: 'var(var(--rah-text-base))'
|
|
}}>
|
|
Edit Dimension
|
|
</div>
|
|
<div style={{
|
|
fontSize: '13px',
|
|
color: editingDimensionModal.isPriority ? '#22c55e' : '#888'
|
|
}}>
|
|
{editingDimensionModal.dimension}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={closeDimensionEditModal}
|
|
style={{
|
|
background: 'transparent',
|
|
border: 'none',
|
|
borderRadius: '6px',
|
|
width: '28px',
|
|
height: '28px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
cursor: 'pointer',
|
|
color: 'var(var(--rah-text-muted))'
|
|
}}
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div style={{
|
|
padding: '20px',
|
|
overflowY: 'auto',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '20px'
|
|
}}>
|
|
{/* Description */}
|
|
<div>
|
|
<label style={{
|
|
display: 'block',
|
|
fontSize: '12px',
|
|
fontWeight: 600,
|
|
color: 'var(var(--rah-text-muted))',
|
|
marginBottom: '8px',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.05em'
|
|
}}>
|
|
Description
|
|
</label>
|
|
<textarea
|
|
value={editModalDescription}
|
|
onChange={(e) => setEditModalDescription(e.target.value)}
|
|
placeholder="Describe what this dimension is for..."
|
|
maxLength={500}
|
|
style={{
|
|
width: '100%',
|
|
minHeight: '80px',
|
|
padding: '12px',
|
|
background: 'var(var(--rah-bg-surface))',
|
|
border: '1px solid #333',
|
|
borderRadius: '8px',
|
|
color: 'var(var(--rah-text-base))',
|
|
fontSize: '13px',
|
|
resize: 'vertical',
|
|
outline: 'none'
|
|
}}
|
|
onFocus={(e) => {
|
|
e.currentTarget.style.borderColor = '#22c55e';
|
|
}}
|
|
onBlur={(e) => {
|
|
e.currentTarget.style.borderColor = '#333';
|
|
}}
|
|
/>
|
|
<div style={{
|
|
marginTop: '4px',
|
|
fontSize: '11px',
|
|
color: 'var(var(--rah-text-muted))',
|
|
textAlign: 'right'
|
|
}}>
|
|
{editModalDescription.length}/500
|
|
</div>
|
|
</div>
|
|
|
|
{/* Icon Picker */}
|
|
<div>
|
|
<label style={{
|
|
display: 'block',
|
|
fontSize: '12px',
|
|
fontWeight: 600,
|
|
color: 'var(var(--rah-text-muted))',
|
|
marginBottom: '8px',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.05em'
|
|
}}>
|
|
Icon
|
|
</label>
|
|
<LucideIconPicker
|
|
selectedIcon={editModalIcon}
|
|
onSelect={setEditModalIcon}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div style={{
|
|
padding: '16px 20px',
|
|
borderTop: '1px solid var(--rah-border)',
|
|
display: 'flex',
|
|
justifyContent: 'flex-end',
|
|
gap: '8px'
|
|
}}>
|
|
<button
|
|
onClick={closeDimensionEditModal}
|
|
style={{
|
|
padding: '8px 16px',
|
|
background: 'transparent',
|
|
border: '1px solid #333',
|
|
borderRadius: '6px',
|
|
color: 'var(var(--rah-text-muted))',
|
|
fontSize: '13px',
|
|
fontWeight: 500,
|
|
cursor: 'pointer'
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={saveDimensionEdit}
|
|
disabled={savingDimensionEdit}
|
|
style={{
|
|
padding: '8px 16px',
|
|
background: '#22c55e',
|
|
border: 'none',
|
|
borderRadius: '6px',
|
|
color: '#000',
|
|
fontSize: '13px',
|
|
fontWeight: 600,
|
|
cursor: savingDimensionEdit ? 'not-allowed' : 'pointer',
|
|
opacity: savingDimensionEdit ? 0.6 : 1
|
|
}}
|
|
>
|
|
{savingDimensionEdit ? 'Saving...' : 'Save'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|