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:
@@ -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,7 +152,90 @@ 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 (
|
||||||
<>
|
<>
|
||||||
<style>{`
|
<style>{`
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,17 +1461,29 @@ 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
|
||||||
display: 'inline-block',
|
draggable
|
||||||
background: '#22c55e',
|
onDragStart={(e: DragEvent<HTMLSpanElement>) => {
|
||||||
color: '#0a0a0a',
|
const title = nodesData[activeTab]?.title || 'Untitled';
|
||||||
fontSize: '10px',
|
e.dataTransfer.effectAllowed = 'copyMove';
|
||||||
fontWeight: 600,
|
e.dataTransfer.setData('application/x-rah-node', JSON.stringify({ id: activeTab, title }));
|
||||||
padding: '2px 6px',
|
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: activeTab, title, dimensions: nodesData[activeTab]?.dimensions || [] }));
|
||||||
borderRadius: '4px',
|
e.dataTransfer.setData('text/plain', `[NODE:${activeTab}:"${title}"]`);
|
||||||
flexShrink: 0
|
}}
|
||||||
}}>
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
background: '#22c55e',
|
||||||
|
color: '#0a0a0a',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
flexShrink: 0,
|
||||||
|
cursor: 'grab'
|
||||||
|
}}
|
||||||
|
title="Drag to chat to reference this node"
|
||||||
|
>
|
||||||
#{activeTab}
|
#{activeTab}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -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,184 +855,196 @@ 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) => {
|
||||||
<div
|
const isLocked = dimension.isPriority;
|
||||||
key={dimension.dimension}
|
const isDragTarget = dragHoverDimension === 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: dragHoverDimension === dimension.dimension ? '#0d1a12' : '#0a0a0a',
|
|
||||||
border: dimension.isPriority ? '1px solid #1a3a25' : '1px solid #1a1a1a',
|
|
||||||
borderRadius: '10px',
|
|
||||||
padding: '14px 16px',
|
|
||||||
textAlign: 'left',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.15s ease'
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (dragHoverDimension !== dimension.dimension) {
|
|
||||||
e.currentTarget.style.background = '#111';
|
|
||||||
e.currentTarget.style.borderColor = dimension.isPriority ? '#22c55e' : '#333';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (dragHoverDimension !== dimension.dimension) {
|
|
||||||
e.currentTarget.style.background = '#0a0a0a';
|
|
||||||
e.currentTarget.style.borderColor = dimension.isPriority ? '#1a3a25' : '#1a1a1a';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Dimension icon */}
|
|
||||||
<div style={{
|
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
background: dimension.isPriority ? 'rgba(34, 197, 94, 0.1)' : '#111',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flexShrink: 0
|
|
||||||
}}>
|
|
||||||
<DynamicIcon
|
|
||||||
name={dimensionIcons[dimension.dimension] || 'Folder'}
|
|
||||||
size={16}
|
|
||||||
style={{ color: dimension.isPriority ? '#22c55e' : '#666' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Name and count */}
|
return (
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<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={{
|
<div style={{
|
||||||
fontSize: '13px',
|
width: '28px',
|
||||||
fontWeight: 600,
|
height: '28px',
|
||||||
color: dimension.isPriority ? '#22c55e' : '#e5e5e5',
|
borderRadius: '6px',
|
||||||
whiteSpace: 'nowrap',
|
background: isLocked ? 'rgba(34, 197, 94, 0.08)' : 'rgba(255, 255, 255, 0.03)',
|
||||||
overflow: 'hidden',
|
display: 'flex',
|
||||||
textOverflow: 'ellipsis'
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0
|
||||||
}}>
|
}}>
|
||||||
{dimension.dimension}
|
<DynamicIcon
|
||||||
|
name={dimensionIcons[dimension.dimension] || 'Folder'}
|
||||||
|
size={14}
|
||||||
|
style={{ color: isLocked ? '#22c55e' : '#555' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{dimension.description && (
|
|
||||||
|
{/* Name and description */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '11px',
|
fontSize: '13px',
|
||||||
color: '#666',
|
fontWeight: 500,
|
||||||
|
color: isLocked ? '#f0f0f0' : '#999',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
marginTop: '2px'
|
letterSpacing: '-0.01em'
|
||||||
}}>
|
}}>
|
||||||
{dimension.description}
|
{dimension.dimension}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{dimension.description && (
|
||||||
</div>
|
<div style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#555',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
marginTop: '1px'
|
||||||
|
}}>
|
||||||
|
{dimension.description}
|
||||||
|
</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',
|
flexShrink: 0
|
||||||
borderRadius: '6px',
|
}}>
|
||||||
flexShrink: 0
|
{dimension.count}
|
||||||
}}>
|
</span>
|
||||||
{dimension.count}
|
|
||||||
</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 }}
|
||||||
<button
|
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
|
||||||
onClick={(e) => {
|
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.4'; }}
|
||||||
e.stopPropagation();
|
|
||||||
openDimensionEditModal(dimension);
|
|
||||||
}}
|
|
||||||
title="Edit"
|
|
||||||
style={{
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
width: '24px',
|
|
||||||
height: '24px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: '#555',
|
|
||||||
transition: 'color 0.15s ease'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Edit2 size={14} />
|
<button
|
||||||
</button>
|
onClick={(e) => {
|
||||||
<button
|
e.stopPropagation();
|
||||||
onClick={(e) => {
|
openDimensionEditModal(dimension);
|
||||||
e.stopPropagation();
|
}}
|
||||||
handleToggleLock(dimension.dimension);
|
title="Edit"
|
||||||
}}
|
style={{
|
||||||
title={dimension.isPriority ? 'Unpin' : 'Pin'}
|
background: 'transparent',
|
||||||
style={{
|
border: 'none',
|
||||||
background: 'transparent',
|
borderRadius: '4px',
|
||||||
border: 'none',
|
width: '22px',
|
||||||
borderRadius: '4px',
|
height: '22px',
|
||||||
width: '24px',
|
display: 'flex',
|
||||||
height: '24px',
|
alignItems: 'center',
|
||||||
display: 'flex',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
cursor: 'pointer',
|
||||||
justifyContent: 'center',
|
color: '#666',
|
||||||
cursor: 'pointer',
|
transition: 'color 0.1s ease'
|
||||||
color: dimension.isPriority ? '#22c55e' : '#555',
|
}}
|
||||||
transition: 'color 0.15s ease'
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#999'; }}
|
||||||
}}
|
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||||||
>
|
>
|
||||||
<Check size={14} />
|
<Edit2 size={12} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDimensionPendingDelete(dimension.dimension);
|
handleToggleLock(dimension.dimension);
|
||||||
}}
|
}}
|
||||||
title="Delete"
|
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: deletingDimension === dimension.dimension ? 'not-allowed' : 'pointer',
|
cursor: 'pointer',
|
||||||
color: '#555',
|
color: isLocked ? '#22c55e' : '#666',
|
||||||
opacity: deletingDimension === dimension.dimension ? 0.4 : 1,
|
transition: 'color 0.1s ease'
|
||||||
transition: 'color 0.15s ease'
|
}}
|
||||||
}}
|
onMouseEnter={(e) => { if (!isLocked) e.currentTarget.style.color = '#22c55e'; }}
|
||||||
disabled={deletingDimension === dimension.dimension}
|
onMouseLeave={(e) => { if (!isLocked) e.currentTarget.style.color = '#666'; }}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
{isLocked ? <Check size={12} /> : <Lock size={12} />}
|
||||||
</button>
|
</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: '#666',
|
||||||
|
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>
|
||||||
</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,34 +1201,42 @@ 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 }}>
|
|
||||||
{getNodeIcon(node)}
|
|
||||||
</span>
|
|
||||||
<div style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: '#f8fafc',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
lineHeight: '1.2'
|
|
||||||
}}>
|
|
||||||
{node.title || 'Untitled'}
|
|
||||||
</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: '6px',
|
color: '#0a0a0a',
|
||||||
|
fontSize: '9px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
flexShrink: 0
|
padding: '1px 5px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
flexShrink: 0,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
lineHeight: 1,
|
||||||
|
height: '16px'
|
||||||
}}>
|
}}>
|
||||||
#{node.id}
|
#{node.id}
|
||||||
</span>
|
</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)}
|
||||||
|
</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',
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user