sync: Drag-to-chat + views system from private repo

- Added drag-to-chat: drag any node into chat input to insert [NODE🆔"title"]
- Added application/x-rah-node MIME type to all drag sources
- New views folder: GridView, KanbanView, ListView, ViewFilters, ViewPanel
- UI improvements: green pill node IDs, grid card fixes, dimension folder redesign
- Drop handler in TerminalInput with visual feedback

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
“BeeRad”
2025-12-25 19:36:34 +11:00
co-authored by Claude
parent c9d85998c1
commit e15f223ed8
9 changed files with 1860 additions and 268 deletions
+91 -4
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect, type DragEvent } from 'react';
import { Mic, MicOff } from 'lucide-react'; import { Mic, MicOff } from 'lucide-react';
interface TerminalInputProps { interface TerminalInputProps {
@@ -34,6 +34,7 @@ export default function TerminalInput({
const [prompts, setPrompts] = useState<Array<{ id: string; name: string; content: string }>>([]); const [prompts, setPrompts] = useState<Array<{ id: string; name: string; content: string }>>([]);
const [showSlashMenu, setShowSlashMenu] = useState(false); const [showSlashMenu, setShowSlashMenu] = useState(false);
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [isDragOver, setIsDragOver] = useState(false);
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
@@ -151,6 +152,89 @@ export default function TerminalInput({
const amplitudeBars = Array.from({ length: 8 }); const amplitudeBars = Array.from({ length: 8 });
// Handle node drag over chat input
const handleDragOver = (e: DragEvent<HTMLTextAreaElement>) => {
// Check if it's a node being dragged (either custom MIME or text/plain fallback)
if (e.dataTransfer.types.includes('application/x-rah-node') ||
e.dataTransfer.types.includes('application/node-info') ||
e.dataTransfer.types.includes('text/plain')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
setIsDragOver(true);
}
};
const handleDragLeave = (e: DragEvent<HTMLTextAreaElement>) => {
// Only reset if actually leaving the textarea (not entering a child)
if (e.currentTarget === e.target) {
setIsDragOver(false);
}
};
const handleDrop = (e: DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
setIsDragOver(false);
// Try application/x-rah-node first (structured data with id + title)
let nodeData = e.dataTransfer.getData('application/x-rah-node');
if (nodeData) {
try {
const { id, title } = JSON.parse(nodeData);
const token = `[NODE:${id}:"${title}"]`;
insertAtCursor(token);
return;
} catch (err) {
console.error('Failed to parse x-rah-node data:', err);
}
}
// Fallback: try application/node-info (from NodesPanel)
nodeData = e.dataTransfer.getData('application/node-info');
if (nodeData) {
try {
const { id, title } = JSON.parse(nodeData);
const token = `[NODE:${id}:"${title || 'Untitled'}"]`;
insertAtCursor(token);
return;
} catch (err) {
console.error('Failed to parse node-info data:', err);
}
}
// Last resort: use text/plain (might already be formatted as [NODE:id:"title"])
const plainText = e.dataTransfer.getData('text/plain');
if (plainText && plainText.startsWith('[NODE:')) {
insertAtCursor(plainText);
}
};
const insertAtCursor = (text: string) => {
const textarea = textareaRef.current;
if (!textarea) {
setInput(prev => prev + text + ' ');
return;
}
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || 0;
const before = input.slice(0, start);
const after = input.slice(end);
// Add space before if there's text and it doesn't end with space
const needsSpaceBefore = before.length > 0 && !before.endsWith(' ') && !before.endsWith('\n');
// Add space after
const newText = (needsSpaceBefore ? ' ' : '') + text + ' ';
setInput(before + newText + after);
// Set cursor position after the inserted text
setTimeout(() => {
const newPos = start + newText.length;
textarea.setSelectionRange(newPos, newPos);
textarea.focus();
}, 0);
};
return ( return (
<> <>
@@ -247,14 +331,17 @@ export default function TerminalInput({
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
disabled={isProcessing || disabledExternally} disabled={isProcessing || disabledExternally}
placeholder={placeholder || `ask ra-h...`} placeholder={placeholder || `ask ra-h...`}
rows={rows} rows={rows}
style={{ style={{
flex: 1, flex: 1,
background: 'transparent', background: isDragOver ? 'rgba(34, 197, 94, 0.08)' : 'transparent',
border: 'none', border: isDragOver ? '1px dashed #22c55e' : 'none',
borderRadius: '0', borderRadius: isDragOver ? '4px' : '0',
color: '#e5e5e5', color: '#e5e5e5',
fontSize: '16px', fontSize: '16px',
fontFamily: 'inherit', fontFamily: 'inherit',
+17 -5
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef, type DragEvent } from 'react';
import { Eye, Trash2, Link, Loader, Database, Check } from 'lucide-react'; import { Eye, Trash2, Link, Loader, Database, Check } from 'lucide-react';
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer'; import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
import { parseNodeMarkers } from '@/tools/infrastructure/nodeFormatter'; import { parseNodeMarkers } from '@/tools/infrastructure/nodeFormatter';
@@ -1461,8 +1461,17 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
{/* Title Row with ID and Trash */} {/* Title Row with ID and Trash */}
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}> <div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
{/* Node ID */} {/* Node ID - Draggable */}
<span style={{ <span
draggable
onDragStart={(e: DragEvent<HTMLSpanElement>) => {
const title = nodesData[activeTab]?.title || 'Untitled';
e.dataTransfer.effectAllowed = 'copyMove';
e.dataTransfer.setData('application/x-rah-node', JSON.stringify({ id: activeTab, title }));
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: activeTab, title, dimensions: nodesData[activeTab]?.dimensions || [] }));
e.dataTransfer.setData('text/plain', `[NODE:${activeTab}:"${title}"]`);
}}
style={{
display: 'inline-block', display: 'inline-block',
background: '#22c55e', background: '#22c55e',
color: '#0a0a0a', color: '#0a0a0a',
@@ -1470,8 +1479,11 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
fontWeight: 600, fontWeight: 600,
padding: '2px 6px', padding: '2px 6px',
borderRadius: '4px', borderRadius: '4px',
flexShrink: 0 flexShrink: 0,
}}> cursor: 'grab'
}}
title="Drag to chat to reference this node"
>
#{activeTab} #{activeTab}
</span> </span>
+216 -134
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useState, useRef, type DragEvent } from 'react'; import { useEffect, useMemo, useState, useRef, type DragEvent } from 'react';
import { Check, X, ArrowLeft, Plus, Trash2, Edit2, LayoutGrid, List, Columns3, Save, Filter, ChevronDown } from 'lucide-react'; import { Check, X, ArrowLeft, Plus, Trash2, Edit2, LayoutGrid, List, Columns3, Save, Filter, ChevronDown, Lock } from 'lucide-react';
import type { Node } from '@/types/database'; import type { Node } from '@/types/database';
import ConfirmDialog from '../common/ConfirmDialog'; import ConfirmDialog from '../common/ConfirmDialog';
import InputDialog from '../common/InputDialog'; import InputDialog from '../common/InputDialog';
@@ -64,7 +64,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
const [editingDimensionName, setEditingDimensionName] = useState<boolean>(false); const [editingDimensionName, setEditingDimensionName] = useState<boolean>(false);
const [editDimensionNameText, setEditDimensionNameText] = useState(''); const [editDimensionNameText, setEditDimensionNameText] = useState('');
const [showAddDimensionDialog, setShowAddDimensionDialog] = useState(false); const [showAddDimensionDialog, setShowAddDimensionDialog] = useState(false);
const draggedNodeRef = useRef<{ id: number; dimensions?: string[] } | null>(null); const draggedNodeRef = useRef<{ id: number; title?: string; dimensions?: string[] } | null>(null);
// View mode state (persisted) // View mode state (persisted)
const [viewMode, setViewMode] = usePersistentState<DimensionViewMode>('ui.dimensionViewMode', 'grid'); const [viewMode, setViewMode] = usePersistentState<DimensionViewMode>('ui.dimensionViewMode', 'grid');
@@ -557,16 +557,20 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
}; };
const handleNodeTileDragStart = (event: DragEvent<HTMLDivElement>, node: Node) => { const handleNodeTileDragStart = (event: DragEvent<HTMLDivElement>, node: Node) => {
event.dataTransfer.effectAllowed = 'copy'; event.dataTransfer.effectAllowed = 'copyMove';
const nodeData = { const nodeData = {
id: node.id, 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) // Store in ref for webview compatibility (dataTransfer.getData can fail in Electron/Tauri)
draggedNodeRef.current = nodeData; draggedNodeRef.current = nodeData;
// Also set in dataTransfer for browser compatibility // Set multiple MIME types for different drop targets
event.dataTransfer.setData('application/node-info', JSON.stringify(nodeData)); event.dataTransfer.setData('application/node-info', JSON.stringify(nodeData));
event.dataTransfer.setData('text/plain', 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 // Provide a compact drag preview so drop targets stay visible
const preview = document.createElement('div'); const preview = document.createElement('div');
@@ -851,14 +855,18 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
style={{ style={{
flex: 1, flex: 1,
overflowY: 'auto', overflowY: 'auto',
padding: '20px', padding: '24px',
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '10px', gap: '8px',
alignContent: 'start' alignContent: 'start'
}} }}
> >
{sortedDimensions.map((dimension) => ( {sortedDimensions.map((dimension) => {
const isLocked = dimension.isPriority;
const isDragTarget = dragHoverDimension === dimension.dimension;
return (
<div <div
key={dimension.dimension} key={dimension.dimension}
role="button" role="button"
@@ -875,36 +883,35 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
onDragLeave={(event) => handleDimensionDragLeave(event, dimension.dimension)} onDragLeave={(event) => handleDimensionDragLeave(event, dimension.dimension)}
onDrop={(event) => handleNodeDropOnDimension(event, dimension.dimension)} onDrop={(event) => handleNodeDropOnDimension(event, dimension.dimension)}
style={{ style={{
background: dragHoverDimension === dimension.dimension ? '#0d1a12' : '#0a0a0a', background: isDragTarget ? 'rgba(34, 197, 94, 0.05)' : 'transparent',
border: dimension.isPriority ? '1px solid #1a3a25' : '1px solid #1a1a1a', borderLeft: isLocked ? '2px solid #22c55e' : '2px solid transparent',
borderRadius: '10px', borderRadius: '6px',
padding: '14px 16px', padding: '12px 14px',
textAlign: 'left', textAlign: 'left',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '12px', gap: '12px',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.15s ease' transition: 'all 0.12s ease',
position: 'relative'
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (dragHoverDimension !== dimension.dimension) { if (!isDragTarget) {
e.currentTarget.style.background = '#111'; e.currentTarget.style.background = 'rgba(255, 255, 255, 0.02)';
e.currentTarget.style.borderColor = dimension.isPriority ? '#22c55e' : '#333';
} }
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
if (dragHoverDimension !== dimension.dimension) { if (!isDragTarget) {
e.currentTarget.style.background = '#0a0a0a'; e.currentTarget.style.background = 'transparent';
e.currentTarget.style.borderColor = dimension.isPriority ? '#1a3a25' : '#1a1a1a';
} }
}} }}
> >
{/* Dimension icon */} {/* Dimension icon */}
<div style={{ <div style={{
width: '32px', width: '28px',
height: '32px', height: '28px',
borderRadius: '8px', borderRadius: '6px',
background: dimension.isPriority ? 'rgba(34, 197, 94, 0.1)' : '#111', background: isLocked ? 'rgba(34, 197, 94, 0.08)' : 'rgba(255, 255, 255, 0.03)',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -912,52 +919,54 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
}}> }}>
<DynamicIcon <DynamicIcon
name={dimensionIcons[dimension.dimension] || 'Folder'} name={dimensionIcons[dimension.dimension] || 'Folder'}
size={16} size={14}
style={{ color: dimension.isPriority ? '#22c55e' : '#666' }} style={{ color: isLocked ? '#22c55e' : '#555' }}
/> />
</div> </div>
{/* Name and count */} {/* Name and description */}
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ <div style={{
fontSize: '13px', fontSize: '13px',
fontWeight: 600, fontWeight: 500,
color: dimension.isPriority ? '#22c55e' : '#e5e5e5', color: isLocked ? '#f0f0f0' : '#999',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis' textOverflow: 'ellipsis',
letterSpacing: '-0.01em'
}}> }}>
{dimension.dimension} {dimension.dimension}
</div> </div>
{dimension.description && ( {dimension.description && (
<div style={{ <div style={{
fontSize: '11px', fontSize: '11px',
color: '#666', color: '#555',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
marginTop: '2px' marginTop: '1px'
}}> }}>
{dimension.description} {dimension.description}
</div> </div>
)} )}
</div> </div>
{/* Count badge */} {/* Count - minimal */}
<span style={{ <span style={{
fontSize: '11px', fontSize: '11px',
fontWeight: 600, fontWeight: 500,
color: dimension.isPriority ? '#22c55e' : '#666', color: isLocked ? '#22c55e' : '#444',
background: dimension.isPriority ? 'rgba(34, 197, 94, 0.1)' : '#1a1a1a', fontFamily: 'monospace',
padding: '4px 8px',
borderRadius: '6px',
flexShrink: 0 flexShrink: 0
}}> }}>
{dimension.count} {dimension.count}
</span> </span>
{/* Action buttons - show on hover via CSS would be ideal, but inline for now */} {/* Action buttons - subtle */}
<div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}> <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 <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -968,39 +977,43 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
width: '24px', width: '22px',
height: '24px', height: '22px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
cursor: 'pointer', cursor: 'pointer',
color: '#555', color: '#666',
transition: 'color 0.15s ease' transition: 'color 0.1s ease'
}} }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#999'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
> >
<Edit2 size={14} /> <Edit2 size={12} />
</button> </button>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleToggleLock(dimension.dimension); handleToggleLock(dimension.dimension);
}} }}
title={dimension.isPriority ? 'Unpin' : 'Pin'} title={isLocked ? 'Unlock' : 'Lock'}
style={{ style={{
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
width: '24px', width: '22px',
height: '24px', height: '22px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
cursor: 'pointer', cursor: 'pointer',
color: dimension.isPriority ? '#22c55e' : '#555', color: isLocked ? '#22c55e' : '#666',
transition: 'color 0.15s ease' transition: 'color 0.1s ease'
}} }}
onMouseEnter={(e) => { if (!isLocked) e.currentTarget.style.color = '#22c55e'; }}
onMouseLeave={(e) => { if (!isLocked) e.currentTarget.style.color = '#666'; }}
> >
<Check size={14} /> {isLocked ? <Check size={12} /> : <Lock size={12} />}
</button> </button>
<button <button
onClick={(e) => { onClick={(e) => {
@@ -1012,23 +1025,26 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
width: '24px', width: '22px',
height: '24px', height: '22px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
cursor: deletingDimension === dimension.dimension ? 'not-allowed' : 'pointer', cursor: deletingDimension === dimension.dimension ? 'not-allowed' : 'pointer',
color: '#555', color: '#666',
opacity: deletingDimension === dimension.dimension ? 0.4 : 1, opacity: deletingDimension === dimension.dimension ? 0.3 : 1,
transition: 'color 0.15s ease' transition: 'color 0.1s ease'
}} }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
disabled={deletingDimension === dimension.dimension} disabled={deletingDimension === dimension.dimension}
> >
<Trash2 size={14} /> <Trash2 size={12} />
</button> </button>
</div> </div>
</div> </div>
))} );
})}
</div> </div>
); );
}; };
@@ -1055,13 +1071,23 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
const handleKanbanNodeDragStart = (e: DragEvent<HTMLDivElement>, nodeId: number, fromColumn: string) => { const handleKanbanNodeDragStart = (e: DragEvent<HTMLDivElement>, nodeId: number, fromColumn: string) => {
setDraggedNodeId(nodeId); setDraggedNodeId(nodeId);
setDraggedFromColumn(fromColumn); setDraggedFromColumn(fromColumn);
e.dataTransfer.effectAllowed = 'move'; 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 = () => { const handleKanbanNodeDragEnd = () => {
setDraggedNodeId(null); setDraggedNodeId(null);
setDraggedFromColumn(null); setDraggedFromColumn(null);
setDragOverColumn(null); setDragOverColumn(null);
draggedNodeRef.current = null;
}; };
const handleKanbanColumnDragOver = (e: DragEvent<HTMLDivElement>, columnDimension: string) => { const handleKanbanColumnDragOver = (e: DragEvent<HTMLDivElement>, columnDimension: string) => {
@@ -1153,17 +1179,15 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
style={{ style={{
background: '#0a0a0a', background: '#0a0a0a',
border: '1px solid #161616', border: '1px solid #161616',
borderRadius: '16px', borderRadius: '12px',
padding: '20px', padding: '14px',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '12px', gap: '6px',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)', transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
position: 'relative', position: 'relative',
minHeight: '200px', minHeight: '120px',
maxHeight: '200px',
overflow: 'hidden',
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.2)' boxShadow: '0 1px 4px rgba(0, 0, 0, 0.2)'
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
@@ -1177,10 +1201,24 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
e.currentTarget.style.boxShadow = '0 1px 4px rgba(0, 0, 0, 0.2)'; e.currentTarget.style.boxShadow = '0 1px 4px rgba(0, 0, 0, 0.2)';
}} }}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '10px' }}> {/* Header: ID | Title | Icon */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', overflow: 'hidden', flex: 1 }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ flexShrink: 0 }}> <span style={{
{getNodeIcon(node)} 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> </span>
<div style={{ <div style={{
fontSize: '14px', fontSize: '14px',
@@ -1189,22 +1227,16 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
lineHeight: '1.2' lineHeight: '1.2',
flex: 1
}}> }}>
{node.title || 'Untitled'} {node.title || 'Untitled'}
</div> </div>
</div> {node.link && (
<span style={{ <span style={{ flexShrink: 0 }}>
fontSize: '10px', {getNodeIcon(node)}
color: '#22c55e',
background: 'rgba(34, 197, 94, 0.1)',
padding: '2px 6px',
borderRadius: '6px',
fontWeight: 600,
flexShrink: 0
}}>
#{node.id}
</span> </span>
)}
</div> </div>
{node.content && ( {node.content && (
<div style={{ <div style={{
@@ -1213,31 +1245,16 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
lineHeight: '1.4', lineHeight: '1.4',
overflow: 'hidden', overflow: 'hidden',
display: '-webkit-box', display: '-webkit-box',
WebkitLineClamp: 3, WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical', WebkitBoxOrient: 'vertical',
fontWeight: 400 fontWeight: 400
}}> }}>
{getContentPreview(node.content)} {getContentPreview(node.content)}
</div> </div>
)} )}
{node.link && (
<div style={{
fontSize: '11px',
color: '#60a5fa',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
background: 'rgba(96, 165, 250, 0.1)',
padding: '4px 8px',
borderRadius: '6px',
fontWeight: 500
}}>
{node.link}
</div>
)}
{node.dimensions && node.dimensions.length > 0 && ( {node.dimensions && node.dimensions.length > 0 && (
<div style={{ display: 'flex', gap: '6px', overflow: 'hidden', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '6px', overflow: 'hidden', flexWrap: 'nowrap' }}>
{node.dimensions.slice(0, 4).map((dimension, index) => { {node.dimensions.slice(0, 3).map((dimension, index) => {
const isCurrentDimension = dimension === selectedDimension?.dimension; const isCurrentDimension = dimension === selectedDimension?.dimension;
return ( return (
<span <span
@@ -1254,23 +1271,28 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
borderRadius: '8px', borderRadius: '8px',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: '0.025em' letterSpacing: '0.025em',
flexShrink: 0,
maxWidth: '100px',
overflow: 'hidden',
textOverflow: 'ellipsis'
}} }}
> >
{dimension} {dimension}
</span> </span>
); );
})} })}
{node.dimensions.length > 4 && ( {node.dimensions.length > 3 && (
<span style={{ <span style={{
fontSize: '10px', fontSize: '10px',
color: '#64748b', color: '#64748b',
fontWeight: 500, fontWeight: 500,
padding: '3px 6px', padding: '3px 6px',
background: 'rgba(100, 116, 139, 0.1)', background: 'rgba(100, 116, 139, 0.1)',
borderRadius: '6px' borderRadius: '6px',
flexShrink: 0
}}> }}>
+{node.dimensions.length - 4} +{node.dimensions.length - 3}
</span> </span>
)} )}
</div> </div>
@@ -1410,12 +1432,19 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
)} )}
<span style={{ <span style={{
fontSize: '10px', display: 'inline-flex',
color: '#22c55e', alignItems: 'center',
background: 'rgba(34, 197, 94, 0.1)', justifyContent: 'center',
padding: '2px 6px', background: '#22c55e',
borderRadius: '4px', color: '#0a0a0a',
fontWeight: 500 fontSize: '9px',
fontWeight: 600,
padding: '1px 5px',
borderRadius: '3px',
flexShrink: 0,
fontFamily: 'monospace',
lineHeight: 1,
height: '16px'
}}> }}>
#{node.id} #{node.id}
</span> </span>
@@ -1856,12 +1885,19 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
</div> </div>
)} )}
<span style={{ <span style={{
fontSize: '10px', display: 'inline-flex',
color: '#22c55e', alignItems: 'center',
background: 'rgba(34, 197, 94, 0.1)', justifyContent: 'center',
padding: '2px 6px', background: '#22c55e',
borderRadius: '4px', color: '#0a0a0a',
fontWeight: 500 fontSize: '9px',
fontWeight: 600,
padding: '1px 5px',
borderRadius: '3px',
flexShrink: 0,
fontFamily: 'monospace',
lineHeight: 1,
height: '16px'
}}> }}>
#{node.id} #{node.id}
</span> </span>
@@ -2345,7 +2381,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
<div style={{ <div style={{
position: 'absolute', position: 'absolute',
top: '100%', top: '100%',
right: 0, left: 0,
marginTop: '4px', marginTop: '4px',
width: '180px', width: '180px',
maxHeight: '200px', maxHeight: '200px',
@@ -2438,11 +2474,19 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
draggable draggable
onDragStart={(e) => { onDragStart={(e) => {
setReorderDrag({ nodeId: node.id, dimension: group.column.dimension, index }); setReorderDrag({ nodeId: node.id, dimension: group.column.dimension, index });
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'copyMove';
// Set MIME types for chat input and folder drops
const title = node.title || 'Untitled';
e.dataTransfer.setData('application/x-rah-node', JSON.stringify({ id: node.id, title }));
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: node.id, title, dimensions: node.dimensions || [] }));
e.dataTransfer.setData('text/plain', `[NODE:${node.id}:"${title}"]`);
// Store in ref for webview compatibility
draggedNodeRef.current = { id: node.id, title, dimensions: node.dimensions || [] };
}} }}
onDragEnd={() => { onDragEnd={() => {
setReorderDrag(null); setReorderDrag(null);
setReorderDropIndex(null); setReorderDropIndex(null);
draggedNodeRef.current = null;
}} }}
onDragOver={(e) => { onDragOver={(e) => {
e.preventDefault(); e.preventDefault();
@@ -2491,13 +2535,21 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
userSelect: 'none' userSelect: 'none'
}}></span> }}></span>
<span style={{ <span style={{
fontSize: '10px', display: 'inline-flex',
color: '#22c55e', alignItems: 'center',
justifyContent: 'center',
background: '#22c55e',
color: '#0a0a0a',
fontSize: '9px',
fontWeight: 600, fontWeight: 600,
padding: '1px 5px',
borderRadius: '3px',
flexShrink: 0,
fontFamily: 'monospace', fontFamily: 'monospace',
opacity: 0.7 lineHeight: 1,
height: '16px'
}}> }}>
{node.id} #{node.id}
</span> </span>
<div style={{ <div style={{
width: '24px', width: '24px',
@@ -2610,7 +2662,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
<div style={{ <div style={{
position: 'absolute', position: 'absolute',
top: '100%', top: '100%',
right: 0, left: 0,
marginTop: '4px', marginTop: '4px',
width: '180px', width: '180px',
maxHeight: '200px', maxHeight: '200px',
@@ -2710,11 +2762,19 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
draggable draggable
onDragStart={(e) => { onDragStart={(e) => {
setReorderDrag({ nodeId: node.id, dimension: group.column.dimension, index }); setReorderDrag({ nodeId: node.id, dimension: group.column.dimension, index });
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'copyMove';
// Set MIME types for chat input and folder drops
const title = node.title || 'Untitled';
e.dataTransfer.setData('application/x-rah-node', JSON.stringify({ id: node.id, title }));
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: node.id, title, dimensions: node.dimensions || [] }));
e.dataTransfer.setData('text/plain', `[NODE:${node.id}:"${title}"]`);
// Store in ref for webview compatibility
draggedNodeRef.current = { id: node.id, title, dimensions: node.dimensions || [] };
}} }}
onDragEnd={() => { onDragEnd={() => {
setReorderDrag(null); setReorderDrag(null);
setReorderDropIndex(null); setReorderDropIndex(null);
draggedNodeRef.current = null;
}} }}
onDragOver={(e) => { onDragOver={(e) => {
e.preventDefault(); e.preventDefault();
@@ -2771,14 +2831,21 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
marginTop: '2px' marginTop: '2px'
}}></span> }}></span>
<span style={{ <span style={{
fontSize: '10px', display: 'inline-flex',
color: '#22c55e', alignItems: 'center',
justifyContent: 'center',
background: '#22c55e',
color: '#0a0a0a',
fontSize: '9px',
fontWeight: 600, fontWeight: 600,
padding: '1px 5px',
borderRadius: '3px',
flexShrink: 0,
fontFamily: 'monospace', fontFamily: 'monospace',
opacity: 0.7, lineHeight: 1,
marginTop: '2px' height: '16px'
}}> }}>
{node.id} #{node.id}
</span> </span>
<span style={{ flexShrink: 0, marginTop: '1px' }}>{getNodeIcon(node)}</span> <span style={{ flexShrink: 0, marginTop: '1px' }}>{getNodeIcon(node)}</span>
<div style={{ <div style={{
@@ -2981,7 +3048,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
<div style={{ <div style={{
position: 'absolute', position: 'absolute',
top: '100%', top: '100%',
right: 0, left: 0,
marginTop: '4px', marginTop: '4px',
width: '180px', width: '180px',
maxHeight: '200px', maxHeight: '200px',
@@ -3058,13 +3125,21 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
onDragStart={(e) => { onDragStart={(e) => {
setDraggedNode({ id: node.id, fromDimension: column.dimension }); setDraggedNode({ id: node.id, fromDimension: column.dimension });
setReorderDrag({ nodeId: node.id, dimension: column.dimension, index }); setReorderDrag({ nodeId: node.id, dimension: column.dimension, index });
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'copyMove';
// Set MIME types for chat input and folder drops
const title = node.title || 'Untitled';
e.dataTransfer.setData('application/x-rah-node', JSON.stringify({ id: node.id, title }));
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: node.id, title, dimensions: node.dimensions || [] }));
e.dataTransfer.setData('text/plain', `[NODE:${node.id}:"${title}"]`);
// Store in ref for webview compatibility
draggedNodeRef.current = { id: node.id, title, dimensions: node.dimensions || [] };
}} }}
onDragEnd={() => { onDragEnd={() => {
setDraggedNode(null); setDraggedNode(null);
setDropTargetDimension(null); setDropTargetDimension(null);
setReorderDrag(null); setReorderDrag(null);
setReorderDropIndex(null); setReorderDropIndex(null);
draggedNodeRef.current = null;
}} }}
onDragOver={(e) => { onDragOver={(e) => {
e.preventDefault(); e.preventDefault();
@@ -3118,14 +3193,21 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
userSelect: 'none' userSelect: 'none'
}}></span> }}></span>
<span style={{ <span style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: '#22c55e',
color: '#0a0a0a',
fontSize: '9px', fontSize: '9px',
color: '#22c55e',
fontWeight: 600, fontWeight: 600,
padding: '1px 5px',
borderRadius: '3px',
flexShrink: 0,
fontFamily: 'monospace', fontFamily: 'monospace',
opacity: 0.7, lineHeight: 1,
marginTop: '2px' height: '16px'
}}> }}>
{node.id} #{node.id}
</span> </span>
<div style={{ <div style={{
fontSize: '12px', fontSize: '12px',
+7 -3
View File
@@ -389,16 +389,20 @@ export default function NodesPanel({ selectedNodes, onNodeSelect, onNodeCreated,
}; };
const handleNodeDragStart = (event: DragEvent<HTMLElement>, node: Node) => { const handleNodeDragStart = (event: DragEvent<HTMLElement>, node: Node) => {
event.dataTransfer.effectAllowed = 'copy'; event.dataTransfer.effectAllowed = 'copyMove';
const nodeData = { const nodeData = {
id: node.id, 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) // Store in ref for webview compatibility (dataTransfer.getData can fail in Electron/Tauri)
draggedNodeRef.current = nodeData; draggedNodeRef.current = nodeData;
// Also set in dataTransfer for browser compatibility // Set multiple MIME types for different drop targets
event.dataTransfer.setData('application/node-info', JSON.stringify(nodeData)); event.dataTransfer.setData('application/node-info', JSON.stringify(nodeData));
event.dataTransfer.setData('text/plain', 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'}"]`);
const preview = document.createElement('div'); const preview = document.createElement('div');
preview.textContent = node.title || `Node #${node.id}`; preview.textContent = node.title || `Node #${node.id}`;
+159
View File
@@ -0,0 +1,159 @@
"use client";
import { Node } from '@/types/database';
import { File } from 'lucide-react';
interface GridViewProps {
nodes: Node[];
onNodeClick: (nodeId: number) => void;
}
export default function GridView({ nodes, onNodeClick }: GridViewProps) {
const truncateContent = (content?: string, maxLength: number = 120) => {
if (!content) return '';
if (content.length <= maxLength) return content;
return content.substring(0, maxLength) + '...';
};
if (nodes.length === 0) {
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#666',
fontSize: '13px'
}}>
No nodes match the current filters
</div>
);
}
return (
<div style={{
height: '100%',
overflowY: 'auto',
padding: '12px'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: '12px'
}}>
{nodes.map(node => (
<button
key={node.id}
onClick={() => onNodeClick(node.id)}
style={{
display: 'flex',
flexDirection: 'column',
padding: '16px',
background: '#0a0a0a',
border: '1px solid #1a1a1a',
borderRadius: '8px',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.2s',
minHeight: '140px'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#111';
e.currentTarget.style.borderColor = '#333';
e.currentTarget.style.transform = 'translateY(-2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#0a0a0a';
e.currentTarget.style.borderColor = '#1a1a1a';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
{/* Header with Icon */}
<div style={{
display: 'flex',
alignItems: 'flex-start',
gap: '10px',
marginBottom: '10px'
}}>
<div style={{
width: '28px',
height: '28px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#1a1a1a',
borderRadius: '6px',
flexShrink: 0
}}>
<File size={14} color="#666" />
</div>
<div style={{
fontSize: '13px',
fontWeight: 500,
color: '#e5e5e5',
lineHeight: '1.3',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical'
}}>
{node.title || 'Untitled'}
</div>
</div>
{/* Content Preview */}
{node.content && (
<div style={{
flex: 1,
fontSize: '11px',
color: '#666',
lineHeight: '1.5',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
marginBottom: '10px'
}}>
{truncateContent(node.content)}
</div>
)}
{/* Footer with Dimensions */}
{node.dimensions && node.dimensions.length > 0 && (
<div style={{
display: 'flex',
gap: '4px',
flexWrap: 'wrap',
marginTop: 'auto'
}}>
{node.dimensions.slice(0, 3).map(dim => (
<span
key={dim}
style={{
padding: '2px 6px',
background: '#1a1a1a',
borderRadius: '3px',
fontSize: '10px',
color: '#888'
}}
>
{dim}
</span>
))}
{node.dimensions.length > 3 && (
<span style={{
padding: '2px 6px',
fontSize: '10px',
color: '#555'
}}>
+{node.dimensions.length - 3}
</span>
)}
</div>
)}
</button>
))}
</div>
</div>
);
}
+536
View File
@@ -0,0 +1,536 @@
"use client";
import { useState, DragEvent } from 'react';
import { Plus, GripVertical, X } from 'lucide-react';
import { Node } from '@/types/database';
import { KanbanColumn } from '@/types/views';
interface KanbanViewProps {
nodes: Node[];
columns: KanbanColumn[];
dimensions: string[];
onNodeClick: (nodeId: number) => void;
onColumnChange: (columns: KanbanColumn[]) => void;
onNodeDimensionUpdate: (nodeId: number, newDimension: string, oldDimension?: string) => void;
}
export default function KanbanView({
nodes,
columns,
dimensions,
onNodeClick,
onColumnChange,
onNodeDimensionUpdate
}: KanbanViewProps) {
const [showColumnPicker, setShowColumnPicker] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [draggedNodeId, setDraggedNodeId] = useState<number | null>(null);
const [draggedFromColumn, setDraggedFromColumn] = useState<string | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<string | null>(null);
const [draggingColumnId, setDraggingColumnId] = useState<string | null>(null);
const [dragOverColumnId, setDragOverColumnId] = useState<string | null>(null);
// Get nodes for a specific column (dimension)
const getNodesForColumn = (dimension: string) => {
return nodes.filter(node => node.dimensions?.includes(dimension));
};
// Get uncategorized nodes (not in any column)
const getUncategorizedNodes = () => {
const columnDimensions = columns.map(c => c.dimension);
return nodes.filter(node =>
!node.dimensions || !node.dimensions.some(d => columnDimensions.includes(d))
);
};
const handleAddColumn = (dimension: string) => {
const newColumn: KanbanColumn = {
id: `col-${Date.now()}`,
dimension,
order: columns.length
};
onColumnChange([...columns, newColumn]);
setShowColumnPicker(false);
setSearchQuery('');
};
const handleRemoveColumn = (columnId: string) => {
onColumnChange(columns.filter(c => c.id !== columnId));
};
// Node drag handlers
const handleNodeDragStart = (e: DragEvent, nodeId: number, fromColumn: string) => {
setDraggedNodeId(nodeId);
setDraggedFromColumn(fromColumn);
e.dataTransfer.effectAllowed = 'copyMove';
// Find the node to get its title for chat drop
const node = nodes.find(n => n.id === nodeId);
const title = node?.title || 'Untitled';
// Set MIME types for chat input 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}"]`);
};
const handleNodeDragEnd = () => {
setDraggedNodeId(null);
setDraggedFromColumn(null);
setDragOverColumn(null);
};
const handleColumnDragOver = (e: DragEvent, columnDimension: string) => {
e.preventDefault();
if (draggedNodeId !== null) {
setDragOverColumn(columnDimension);
}
};
const handleColumnDragLeave = () => {
setDragOverColumn(null);
};
const handleColumnDrop = (e: DragEvent, targetDimension: string) => {
e.preventDefault();
if (draggedNodeId !== null && draggedFromColumn !== targetDimension) {
onNodeDimensionUpdate(
draggedNodeId,
targetDimension,
draggedFromColumn || undefined
);
}
handleNodeDragEnd();
};
// Column reorder drag handlers
const handleColumnDragStart = (e: DragEvent, columnId: string) => {
setDraggingColumnId(columnId);
e.dataTransfer.effectAllowed = 'move';
};
const handleColumnDragEnd = () => {
setDraggingColumnId(null);
setDragOverColumnId(null);
};
const handleColumnReorderDragOver = (e: DragEvent, columnId: string) => {
e.preventDefault();
if (draggingColumnId && draggingColumnId !== columnId) {
setDragOverColumnId(columnId);
}
};
const handleColumnReorderDrop = (e: DragEvent, targetColumnId: string) => {
e.preventDefault();
if (!draggingColumnId || draggingColumnId === targetColumnId) return;
const newColumns = [...columns];
const dragIndex = newColumns.findIndex(c => c.id === draggingColumnId);
const dropIndex = newColumns.findIndex(c => c.id === targetColumnId);
if (dragIndex !== -1 && dropIndex !== -1) {
const [removed] = newColumns.splice(dragIndex, 1);
newColumns.splice(dropIndex, 0, removed);
// Update order values
newColumns.forEach((col, idx) => { col.order = idx; });
onColumnChange(newColumns);
}
handleColumnDragEnd();
};
const filteredDimensions = dimensions.filter(d =>
d.toLowerCase().includes(searchQuery.toLowerCase()) &&
!columns.some(c => c.dimension === d)
);
const sortedColumns = [...columns].sort((a, b) => a.order - b.order);
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
background: '#000'
}}>
{/* Column Setup Bar */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
borderBottom: '1px solid #222',
background: '#0a0a0a',
flexShrink: 0
}}>
<span style={{ fontSize: '11px', color: '#666', fontWeight: 500 }}>
Columns:
</span>
{columns.length === 0 && (
<span style={{ fontSize: '11px', color: '#555', fontStyle: 'italic' }}>
Add dimensions to create columns
</span>
)}
{/* Add Column Button */}
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowColumnPicker(!showColumnPicker)}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '4px',
fontSize: '11px',
color: '#888',
cursor: 'pointer'
}}
>
<Plus size={12} />
Add Column
</button>
{/* Dimension Picker */}
{showColumnPicker && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
width: '200px',
maxHeight: '300px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px',
overflow: 'hidden',
zIndex: 100,
boxShadow: '0 4px 12px rgba(0,0,0,0.5)'
}}>
<input
type="text"
placeholder="Search dimensions..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
background: '#0a0a0a',
border: 'none',
borderBottom: '1px solid #333',
color: '#fff',
fontSize: '12px',
outline: 'none'
}}
autoFocus
/>
<div style={{ maxHeight: '250px', overflowY: 'auto' }}>
{filteredDimensions.length === 0 ? (
<div style={{
padding: '12px',
fontSize: '12px',
color: '#666',
textAlign: 'center'
}}>
No dimensions available
</div>
) : (
filteredDimensions.map(dim => (
<button
key={dim}
onClick={() => handleAddColumn(dim)}
style={{
width: '100%',
padding: '8px 12px',
background: 'transparent',
border: 'none',
color: '#ccc',
fontSize: '12px',
textAlign: 'left',
cursor: 'pointer'
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#2a2a2a'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
{dim}
</button>
))
)}
</div>
</div>
)}
</div>
{/* Click outside to close */}
{showColumnPicker && (
<div
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
onClick={() => setShowColumnPicker(false)}
/>
)}
</div>
{/* Kanban Board */}
<div style={{
flex: 1,
display: 'flex',
gap: '12px',
padding: '12px',
overflowX: 'auto',
overflowY: 'hidden'
}}>
{sortedColumns.map(column => {
const columnNodes = getNodesForColumn(column.dimension);
const isDropTarget = dragOverColumn === column.dimension && draggedFromColumn !== column.dimension;
const isReorderTarget = dragOverColumnId === column.id;
return (
<div
key={column.id}
style={{
width: '280px',
minWidth: '280px',
display: 'flex',
flexDirection: 'column',
background: isDropTarget ? '#0f2417' : '#0a0a0a',
border: isReorderTarget ? '2px dashed #22c55e' : '1px solid #1a1a1a',
borderRadius: '8px',
transition: 'all 0.2s'
}}
onDragOver={(e) => handleColumnDragOver(e, column.dimension)}
onDragLeave={handleColumnDragLeave}
onDrop={(e) => handleColumnDrop(e, column.dimension)}
>
{/* Column Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 12px',
borderBottom: '1px solid #1a1a1a',
cursor: 'grab'
}}
draggable
onDragStart={(e) => handleColumnDragStart(e, column.id)}
onDragEnd={handleColumnDragEnd}
onDragOver={(e) => handleColumnReorderDragOver(e, column.id)}
onDrop={(e) => handleColumnReorderDrop(e, column.id)}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<GripVertical size={14} color="#444" />
<span style={{
fontSize: '12px',
fontWeight: 600,
color: '#e5e5e5',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
{column.dimension}
</span>
<span style={{
fontSize: '11px',
color: '#666',
background: '#1a1a1a',
padding: '2px 6px',
borderRadius: '10px'
}}>
{columnNodes.length}
</span>
</div>
<button
onClick={() => handleRemoveColumn(column.id)}
style={{
background: 'none',
border: 'none',
padding: '4px',
cursor: 'pointer',
color: '#666',
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) => handleNodeDragStart(e, node.id, column.dimension)}
onDragEnd={handleNodeDragEnd}
onClick={() => onNodeClick(node.id)}
style={{
padding: '10px',
marginBottom: '6px',
background: draggedNodeId === node.id ? '#1a1a1a' : '#111',
border: '1px solid #222',
borderRadius: '6px',
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: '#e5e5e5',
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)
.slice(0, 2)
.map(dim => (
<span
key={dim}
style={{
padding: '2px 6px',
background: '#1a1a1a',
borderRadius: '3px',
fontSize: '10px',
color: '#666'
}}
>
{dim}
</span>
))}
</div>
)}
</div>
))}
{columnNodes.length === 0 && (
<div style={{
padding: '20px',
textAlign: 'center',
color: '#444',
fontSize: '11px'
}}>
Drop nodes here
</div>
)}
</div>
</div>
);
})}
{/* Uncategorized Column (if nodes exist without column dimensions) */}
{columns.length > 0 && getUncategorizedNodes().length > 0 && (
<div
style={{
width: '280px',
minWidth: '280px',
display: 'flex',
flexDirection: 'column',
background: '#0a0a0a',
border: '1px dashed #333',
borderRadius: '8px'
}}
>
<div style={{
padding: '10px 12px',
borderBottom: '1px solid #1a1a1a'
}}>
<span style={{
fontSize: '12px',
fontWeight: 600,
color: '#666',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
Uncategorized ({getUncategorizedNodes().length})
</span>
</div>
<div style={{
flex: 1,
overflowY: 'auto',
padding: '8px'
}}>
{getUncategorizedNodes().map(node => (
<div
key={node.id}
draggable
onDragStart={(e) => handleNodeDragStart(e, node.id, '__uncategorized__')}
onDragEnd={handleNodeDragEnd}
onClick={() => onNodeClick(node.id)}
style={{
padding: '10px',
marginBottom: '6px',
background: '#111',
border: '1px solid #222',
borderRadius: '6px',
cursor: 'pointer'
}}
>
<div style={{
fontSize: '12px',
fontWeight: 500,
color: '#888',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{node.title || 'Untitled'}
</div>
</div>
))}
</div>
</div>
)}
{/* Empty State */}
{columns.length === 0 && (
<div style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#666',
fontSize: '13px'
}}>
Add dimension columns to organize your nodes
</div>
)}
</div>
</div>
);
}
+184
View File
@@ -0,0 +1,184 @@
"use client";
import { Node } from '@/types/database';
import { File } from 'lucide-react';
interface ListViewProps {
nodes: Node[];
onNodeClick: (nodeId: number) => void;
}
export default function ListView({ nodes, onNodeClick }: ListViewProps) {
const formatDate = (dateString?: string) => {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
};
const truncateContent = (content?: string, maxLength: number = 100) => {
if (!content) return '';
if (content.length <= maxLength) return content;
return content.substring(0, maxLength) + '...';
};
if (nodes.length === 0) {
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#666',
fontSize: '13px'
}}>
No nodes match the current filters
</div>
);
}
return (
<div style={{
height: '100%',
overflowY: 'auto',
padding: '8px'
}}>
{nodes.map(node => (
<button
key={node.id}
onClick={() => onNodeClick(node.id)}
style={{
width: '100%',
display: 'flex',
alignItems: 'flex-start',
gap: '12px',
padding: '12px',
marginBottom: '4px',
background: '#0a0a0a',
border: '1px solid #1a1a1a',
borderRadius: '6px',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#111';
e.currentTarget.style.borderColor = '#333';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#0a0a0a';
e.currentTarget.style.borderColor = '#1a1a1a';
}}
>
{/* Icon */}
<div style={{
width: '32px',
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#1a1a1a',
borderRadius: '6px',
flexShrink: 0
}}>
<File size={16} color="#666" />
</div>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
{/* Title */}
<div style={{
fontSize: '13px',
fontWeight: 500,
color: '#e5e5e5',
marginBottom: '4px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{node.title || 'Untitled'}
</div>
{/* Content Preview */}
{node.content && (
<div style={{
fontSize: '12px',
color: '#666',
marginBottom: '8px',
lineHeight: '1.4',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden'
}}>
{truncateContent(node.content)}
</div>
)}
{/* Metadata Row */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
flexWrap: 'wrap'
}}>
{/* Dimensions */}
{node.dimensions && node.dimensions.length > 0 && (
<div style={{
display: 'flex',
gap: '4px',
flexWrap: 'wrap'
}}>
{node.dimensions.slice(0, 3).map(dim => (
<span
key={dim}
style={{
padding: '2px 6px',
background: '#1a1a1a',
borderRadius: '3px',
fontSize: '10px',
color: '#888'
}}
>
{dim}
</span>
))}
{node.dimensions.length > 3 && (
<span style={{
padding: '2px 6px',
fontSize: '10px',
color: '#666'
}}>
+{node.dimensions.length - 3}
</span>
)}
</div>
)}
{/* Date */}
<span style={{
fontSize: '10px',
color: '#555'
}}>
{formatDate(node.updated_at || node.created_at)}
</span>
{/* Edge count */}
{node.edge_count !== undefined && node.edge_count > 0 && (
<span style={{
fontSize: '10px',
color: '#555'
}}>
{node.edge_count} connections
</span>
)}
</div>
</div>
</button>
))}
</div>
);
}
+242
View File
@@ -0,0 +1,242 @@
"use client";
import { useState } from 'react';
import { X, Plus, ChevronDown } from 'lucide-react';
import { ViewFilter } from '@/types/views';
interface ViewFiltersProps {
filters: ViewFilter[];
filterLogic: 'and' | 'or';
dimensions: string[];
onFilterChange: (filters: ViewFilter[]) => void;
onFilterLogicChange: (logic: 'and' | 'or') => void;
}
export default function ViewFilters({
filters,
filterLogic,
dimensions,
onFilterChange,
onFilterLogicChange
}: ViewFiltersProps) {
const [showDimensionPicker, setShowDimensionPicker] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const handleAddFilter = (dimension: string) => {
if (!filters.some(f => f.dimension === dimension)) {
onFilterChange([...filters, { dimension, operator: 'includes' }]);
}
setShowDimensionPicker(false);
setSearchQuery('');
};
const handleRemoveFilter = (dimension: string) => {
onFilterChange(filters.filter(f => f.dimension !== dimension));
};
const handleToggleOperator = (dimension: string) => {
onFilterChange(filters.map(f =>
f.dimension === dimension
? { ...f, operator: f.operator === 'includes' ? 'excludes' : 'includes' }
: f
));
};
const filteredDimensions = dimensions.filter(d =>
d.toLowerCase().includes(searchQuery.toLowerCase()) &&
!filters.some(f => f.dimension === d)
);
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
borderBottom: '1px solid #222',
background: '#0a0a0a',
flexWrap: 'wrap',
minHeight: '40px'
}}>
<span style={{ fontSize: '11px', color: '#666', fontWeight: 500 }}>
Filters:
</span>
{/* Filter Chips */}
{filters.map(filter => (
<div
key={filter.dimension}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: filter.operator === 'includes' ? '#0f2417' : '#2a1515',
border: `1px solid ${filter.operator === 'includes' ? '#166534' : '#7f1d1d'}`,
borderRadius: '4px',
fontSize: '11px',
color: filter.operator === 'includes' ? '#22c55e' : '#ef4444'
}}
>
<button
onClick={() => handleToggleOperator(filter.dimension)}
style={{
background: 'none',
border: 'none',
padding: '0 2px',
cursor: 'pointer',
color: 'inherit',
fontSize: '10px',
opacity: 0.7
}}
title={filter.operator === 'includes' ? 'Click to exclude' : 'Click to include'}
>
{filter.operator === 'includes' ? '+' : '-'}
</button>
<span>{filter.dimension}</span>
<button
onClick={() => handleRemoveFilter(filter.dimension)}
style={{
background: 'none',
border: 'none',
padding: '0',
cursor: 'pointer',
color: 'inherit',
display: 'flex',
alignItems: 'center'
}}
>
<X size={12} />
</button>
</div>
))}
{/* Add Filter Button */}
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowDimensionPicker(!showDimensionPicker)}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '4px',
fontSize: '11px',
color: '#888',
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
<Plus size={12} />
Add
</button>
{/* Dimension Picker Dropdown */}
{showDimensionPicker && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
width: '200px',
maxHeight: '300px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px',
overflow: 'hidden',
zIndex: 100,
boxShadow: '0 4px 12px rgba(0,0,0,0.5)'
}}>
<input
type="text"
placeholder="Search dimensions..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
background: '#0a0a0a',
border: 'none',
borderBottom: '1px solid #333',
color: '#fff',
fontSize: '12px',
outline: 'none'
}}
autoFocus
/>
<div style={{ maxHeight: '250px', overflowY: 'auto' }}>
{filteredDimensions.length === 0 ? (
<div style={{
padding: '12px',
fontSize: '12px',
color: '#666',
textAlign: 'center'
}}>
No dimensions found
</div>
) : (
filteredDimensions.map(dim => (
<button
key={dim}
onClick={() => handleAddFilter(dim)}
style={{
width: '100%',
padding: '8px 12px',
background: 'transparent',
border: 'none',
color: '#ccc',
fontSize: '12px',
textAlign: 'left',
cursor: 'pointer',
transition: 'background 0.2s'
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#2a2a2a'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
{dim}
</button>
))
)}
</div>
</div>
)}
</div>
{/* Logic Toggle */}
{filters.length > 1 && (
<button
onClick={() => onFilterLogicChange(filterLogic === 'and' ? 'or' : 'and')}
style={{
padding: '4px 8px',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '4px',
fontSize: '10px',
fontWeight: 600,
color: filterLogic === 'and' ? '#22c55e' : '#3b82f6',
cursor: 'pointer',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}
title={filterLogic === 'and' ? 'Match ALL filters' : 'Match ANY filter'}
>
{filterLogic}
</button>
)}
{/* Click outside to close */}
{showDimensionPicker && (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 99
}}
onClick={() => setShowDimensionPicker(false)}
/>
)}
</div>
);
}
+286
View File
@@ -0,0 +1,286 @@
"use client";
import { useState, useEffect, useCallback } from 'react';
import { Eye, List, Columns, LayoutGrid } from 'lucide-react';
import { ViewType, ViewConfig, ViewFilter, DEFAULT_VIEW_CONFIG, KanbanColumn } from '@/types/views';
import { Node } from '@/types/database';
import ListView from './ListView';
import KanbanView from './KanbanView';
import GridView from './GridView';
import ViewFilters from './ViewFilters';
interface ViewPanelProps {
viewMode: ViewType;
onViewModeChange: (mode: ViewType) => void;
onNodeClick: (nodeId: number) => void;
refreshTrigger?: number;
}
export default function ViewPanel({
viewMode,
onViewModeChange,
onNodeClick,
refreshTrigger
}: ViewPanelProps) {
const [nodes, setNodes] = useState<Node[]>([]);
const [loading, setLoading] = useState(true);
const [config, setConfig] = useState<ViewConfig>(DEFAULT_VIEW_CONFIG);
const [dimensions, setDimensions] = useState<string[]>([]);
// Fetch all nodes
const fetchNodes = useCallback(async () => {
setLoading(true);
try {
// Build query params from filters
const params = new URLSearchParams();
params.set('limit', '500');
if (config.filters.length > 0) {
const includeDimensions = config.filters
.filter(f => f.operator === 'includes')
.map(f => f.dimension);
if (includeDimensions.length > 0) {
params.set('dimensions', includeDimensions.join(','));
}
}
if (config.sort) {
params.set('sortBy', config.sort.field);
params.set('sortOrder', config.sort.direction);
}
const response = await fetch(`/api/nodes?${params.toString()}`);
if (response.ok) {
const data = await response.json();
let filteredNodes = data.nodes || [];
// Apply exclude filters client-side
const excludeDimensions = config.filters
.filter(f => f.operator === 'excludes')
.map(f => f.dimension);
if (excludeDimensions.length > 0) {
filteredNodes = filteredNodes.filter((node: Node) =>
!excludeDimensions.some(dim => node.dimensions?.includes(dim))
);
}
setNodes(filteredNodes);
}
} catch (error) {
console.error('Failed to fetch nodes:', error);
} finally {
setLoading(false);
}
}, [config.filters, config.sort]);
// Fetch available dimensions
const fetchDimensions = useCallback(async () => {
try {
const response = await fetch('/api/dimensions/popular');
if (response.ok) {
const data = await response.json();
const dims = data.data?.map((d: { dimension: string }) => d.dimension) || [];
setDimensions(dims);
}
} catch (error) {
console.error('Failed to fetch dimensions:', error);
}
}, []);
useEffect(() => {
fetchNodes();
}, [fetchNodes, refreshTrigger]);
useEffect(() => {
fetchDimensions();
}, [fetchDimensions]);
const handleFilterChange = (filters: ViewFilter[]) => {
setConfig(prev => ({ ...prev, filters }));
};
const handleFilterLogicChange = (logic: 'and' | 'or') => {
setConfig(prev => ({ ...prev, filterLogic: logic }));
};
const handleColumnChange = (columns: KanbanColumn[]) => {
setConfig(prev => ({ ...prev, columns }));
};
const handleNodeDimensionUpdate = async (nodeId: number, newDimension: string, oldDimension?: string) => {
// Update node's dimensions (add new, remove old if kanban move)
try {
const node = nodes.find(n => n.id === nodeId);
if (!node) return;
let newDimensions = [...(node.dimensions || [])];
// Remove old dimension if moving in kanban
if (oldDimension) {
newDimensions = newDimensions.filter(d => d !== oldDimension);
}
// Add new dimension if not already present
if (!newDimensions.includes(newDimension)) {
newDimensions.push(newDimension);
}
const response = await fetch(`/api/nodes/${nodeId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dimensions: newDimensions })
});
if (response.ok) {
// Refresh nodes to get updated data
fetchNodes();
}
} catch (error) {
console.error('Failed to update node dimensions:', error);
}
};
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#000' }}>
{/* Header with View Mode Selector */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
borderBottom: '1px solid #333',
background: '#0a0a0a',
flexShrink: 0
}}>
<div style={{ fontSize: '12px', color: '#888', fontWeight: 500 }}>
{nodes.length} nodes
</div>
{/* View Mode Buttons */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '2px'
}}>
<button
onClick={() => onViewModeChange('focus')}
style={{
padding: '6px 8px',
background: viewMode === 'focus' ? '#1a1a1a' : 'transparent',
border: 'none',
borderRadius: '4px',
color: viewMode === 'focus' ? '#22c55e' : '#666',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
transition: 'all 0.2s'
}}
title="Focus View"
>
<Eye size={14} />
</button>
<button
onClick={() => onViewModeChange('list')}
style={{
padding: '6px 8px',
background: viewMode === 'list' ? '#1a1a1a' : 'transparent',
border: 'none',
borderRadius: '4px',
color: viewMode === 'list' ? '#22c55e' : '#666',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
transition: 'all 0.2s'
}}
title="List View"
>
<List size={14} />
</button>
<button
onClick={() => onViewModeChange('kanban')}
style={{
padding: '6px 8px',
background: viewMode === 'kanban' ? '#1a1a1a' : 'transparent',
border: 'none',
borderRadius: '4px',
color: viewMode === 'kanban' ? '#22c55e' : '#666',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
transition: 'all 0.2s'
}}
title="Kanban View"
>
<Columns size={14} />
</button>
<button
onClick={() => onViewModeChange('grid')}
style={{
padding: '6px 8px',
background: viewMode === 'grid' ? '#1a1a1a' : 'transparent',
border: 'none',
borderRadius: '4px',
color: viewMode === 'grid' ? '#22c55e' : '#666',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
transition: 'all 0.2s'
}}
title="Grid View"
>
<LayoutGrid size={14} />
</button>
</div>
</div>
{/* Filters Bar */}
<ViewFilters
filters={config.filters}
filterLogic={config.filterLogic}
dimensions={dimensions}
onFilterChange={handleFilterChange}
onFilterLogicChange={handleFilterLogicChange}
/>
{/* Content Area */}
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
{loading ? (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#666'
}}>
Loading...
</div>
) : (
<>
{viewMode === 'list' && (
<ListView
nodes={nodes}
onNodeClick={onNodeClick}
/>
)}
{viewMode === 'kanban' && (
<KanbanView
nodes={nodes}
columns={config.columns || []}
dimensions={dimensions}
onNodeClick={onNodeClick}
onColumnChange={handleColumnChange}
onNodeDimensionUpdate={handleNodeDimensionUpdate}
/>
)}
{viewMode === 'grid' && (
<GridView
nodes={nodes}
onNodeClick={onNodeClick}
/>
)}
</>
)}
</div>
</div>
);
}