feat: port holistic node refinement contract
This commit is contained in:
+135
-133
@@ -4,9 +4,7 @@ import { useEffect, useRef, useState, type DragEvent } from 'react';
|
||||
import { Trash2, Loader, Database, RefreshCw, Pencil, X, Save, Plus, Link2, Tag, Share2, AlignLeft, ChevronDown, ChevronRight, Check, Folder } from 'lucide-react';
|
||||
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
|
||||
import { Node, NodeConnection } from '@/types/database';
|
||||
import DimensionTags from './dimensions/DimensionTags';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||
import ConfirmDialog from '../common/ConfirmDialog';
|
||||
import { SourceReader } from './source';
|
||||
import SourceEditor from './source/SourceEditor';
|
||||
@@ -39,7 +37,6 @@ export default function FocusPanel({
|
||||
onTextSelect,
|
||||
highlightedPassage,
|
||||
}: FocusPanelProps) {
|
||||
const { dimensionIcons } = useDimensionIcons();
|
||||
const [nodesData, setNodesData] = useState<Record<number, Node>>({});
|
||||
const [edgesData, setEdgesData] = useState<Record<number, NodeConnection[]>>({});
|
||||
const [loadingNodes, setLoadingNodes] = useState<Set<number>>(new Set());
|
||||
@@ -837,7 +834,7 @@ export default function FocusPanel({
|
||||
{isOutgoing ? '↗' : '↙'}
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
{getNodeIcon(connection.connected_node, dimensionIcons, 12)}
|
||||
{getNodeIcon(connection.connected_node, 12)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1150,7 +1147,7 @@ export default function FocusPanel({
|
||||
<div className="prop-label">node</div>
|
||||
<div className="prop-value">
|
||||
<span className="node-meta-value">
|
||||
<span className="node-meta-icon">{getNodeIcon(currentNode, dimensionIcons, 12)}</span>
|
||||
<span className="node-meta-icon">{getNodeIcon(currentNode, 12)}</span>
|
||||
<span className="node-id-pill">#{currentNode.id}</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -1235,21 +1232,24 @@ export default function FocusPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="prop-row prop-row-top">
|
||||
<div className="prop-label">ctx</div>
|
||||
<div className="prop-value" style={{ overflow: 'visible' }}>
|
||||
<div ref={contextMenuRef} style={{ position: 'relative', width: '100%' }}>
|
||||
<div className="prop-row">
|
||||
<div className="prop-label">context</div>
|
||||
<div className="prop-value context-prop-value">
|
||||
<div className="context-select-shell" ref={contextMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setContextMenuOpen((prev) => !prev)}
|
||||
disabled={contextSaving}
|
||||
className="context-select-trigger"
|
||||
disabled={contextSaving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setContextMenuOpen((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<span className="context-select-main">
|
||||
<span className="context-select-icon">
|
||||
<span className="context-select-current">
|
||||
<span className="context-select-badge">
|
||||
<Folder size={13} />
|
||||
</span>
|
||||
<span className="context-select-text">
|
||||
<span className={currentNode.context ? 'context-select-name' : 'context-select-empty'}>
|
||||
{currentNode.context?.name || 'No context'}
|
||||
</span>
|
||||
</span>
|
||||
@@ -1261,7 +1261,8 @@ export default function FocusPanel({
|
||||
<button
|
||||
type="button"
|
||||
className={`context-option ${currentNode.context_id == null ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void saveContext('');
|
||||
}}
|
||||
>
|
||||
@@ -1272,7 +1273,8 @@ export default function FocusPanel({
|
||||
key={context.id}
|
||||
type="button"
|
||||
className={`context-option ${currentNode.context_id === context.id ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void saveContext(String(context.id));
|
||||
}}
|
||||
>
|
||||
@@ -1286,20 +1288,6 @@ export default function FocusPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dimensions */}
|
||||
<div className="prop-row prop-row-top">
|
||||
<div className="prop-label">dime</div>
|
||||
<div className="prop-value">
|
||||
<DimensionTags
|
||||
dimensions={currentNode.dimensions || []}
|
||||
onUpdate={async (newDimensions) => {
|
||||
try { await updateNode(activeTab, { dimensions: newDimensions }); }
|
||||
catch (error) { console.error('Error saving dimensions:', error); window.alert('Failed to save dimensions. Please try again.'); }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connections */}
|
||||
<div className="prop-row prop-row-top">
|
||||
<div className="prop-label">edge</div>
|
||||
@@ -1322,7 +1310,7 @@ export default function FocusPanel({
|
||||
onMouseLeave={() => setHoveredConnectionId(null)}
|
||||
>
|
||||
<span className={`conn-dir ${isOut ? 'out' : 'in'}`}>{isOut ? '↗' : '↙'}</span>
|
||||
<span className="conn-icon">{getNodeIcon(connection.connected_node, dimensionIcons, 12)}</span>
|
||||
<span className="conn-icon">{getNodeIcon(connection.connected_node, 12)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="conn-title-btn"
|
||||
@@ -1424,7 +1412,7 @@ export default function FocusPanel({
|
||||
const title = currentNode.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: currentNode.dimensions || [] }));
|
||||
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: activeTab, title }));
|
||||
e.dataTransfer.setData('text/plain', `[NODE:${activeTab}:"${title}"]`);
|
||||
}}
|
||||
className="node-drag-handle"
|
||||
@@ -1703,7 +1691,7 @@ export default function FocusPanel({
|
||||
}
|
||||
|
||||
.prop-label {
|
||||
width: 44px;
|
||||
width: 58px;
|
||||
flex-shrink: 0;
|
||||
padding: 7px 8px 7px 0;
|
||||
color: var(--rah-text-muted);
|
||||
@@ -1720,6 +1708,10 @@ export default function FocusPanel({
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.context-prop-value {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.props-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1769,6 +1761,116 @@ export default function FocusPanel({
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.context-select-shell {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.context-select-trigger {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 0 10px 0 8px;
|
||||
border: 1px solid var(--rah-border-strong);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, var(--rah-bg-surface), var(--rah-bg-panel));
|
||||
color: var(--rah-text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.context-select-current {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.context-select-badge {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--rah-border);
|
||||
background: var(--rah-bg-base);
|
||||
color: var(--rah-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.context-select-name,
|
||||
.context-select-empty {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.context-select-name {
|
||||
color: var(--rah-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.context-select-empty {
|
||||
color: var(--rah-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.context-select-chevron {
|
||||
color: var(--rah-text-muted);
|
||||
flex-shrink: 0;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.context-select-chevron.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.context-select-menu {
|
||||
position: relative;
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--rah-border);
|
||||
border-radius: 12px;
|
||||
background: var(--rah-bg-surface);
|
||||
box-shadow: var(--rah-shadow-floating);
|
||||
}
|
||||
|
||||
.context-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--rah-text-secondary);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.context-option.active {
|
||||
background: color-mix(in srgb, var(--rah-accent-green) 10%, var(--rah-bg-panel));
|
||||
color: var(--rah-text-primary);
|
||||
}
|
||||
|
||||
.context-option-count {
|
||||
font-size: 10px;
|
||||
color: var(--rah-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prop-empty-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -1780,106 +1882,6 @@ export default function FocusPanel({
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.context-select-trigger {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--rah-border);
|
||||
background: var(--rah-bg-panel);
|
||||
color: var(--rah-text-base);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: border-color 120ms ease, background 120ms ease;
|
||||
}
|
||||
|
||||
.context-select-trigger:hover {
|
||||
border-color: var(--rah-border-strong);
|
||||
background: var(--rah-bg-hover);
|
||||
}
|
||||
|
||||
.context-select-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.context-select-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--rah-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.context-select-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.context-select-chevron {
|
||||
color: var(--rah-text-muted);
|
||||
transition: transform 120ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.context-select-chevron.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.context-select-menu {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--rah-border);
|
||||
background: var(--rah-bg-panel);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.context-option {
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 0 10px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--rah-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.context-option:hover,
|
||||
.context-option.active {
|
||||
background: var(--rah-bg-hover);
|
||||
color: var(--rah-text-active);
|
||||
}
|
||||
|
||||
.context-option-count {
|
||||
color: var(--rah-text-muted);
|
||||
font-size: 10px;
|
||||
background: var(--rah-bg-active);
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prop-muted {
|
||||
color: var(--rah-text-muted);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -1,560 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface DimensionSearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDimensionSelect: (dimension: string, description?: string) => void;
|
||||
existingDimensions: string[];
|
||||
}
|
||||
|
||||
interface DimensionSuggestion {
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
}
|
||||
|
||||
export default function DimensionSearchModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onDimensionSelect,
|
||||
existingDimensions
|
||||
}: DimensionSearchModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<DimensionSuggestion[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [newDimensionDescription, setNewDimensionDescription] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Store the element that triggered the modal for return focus
|
||||
useEffect(() => {
|
||||
if (isOpen && document.activeElement instanceof HTMLElement) {
|
||||
returnFocusRef.current = document.activeElement;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Focus trap and accessibility
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Autofocus input
|
||||
inputRef.current?.focus();
|
||||
|
||||
// Lock body scroll
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Handle Escape key
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Focus trap: keep focus within modal
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
const focusableElements = modalRef.current?.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
if (!focusableElements || focusableElements.length === 0) return;
|
||||
|
||||
const firstElement = focusableElements[0] as HTMLElement;
|
||||
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.addEventListener('keydown', handleTab);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.removeEventListener('keydown', handleTab);
|
||||
|
||||
// Return focus to trigger element
|
||||
if (returnFocusRef.current) {
|
||||
returnFocusRef.current.focus();
|
||||
}
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Track if we're in "create new" mode to avoid re-fetching
|
||||
const isCreatingNew = searchQuery.trim() &&
|
||||
!suggestions.some(s => s.dimension.toLowerCase() === searchQuery.toLowerCase().trim());
|
||||
|
||||
// Fetch dimension suggestions
|
||||
useEffect(() => {
|
||||
const fetchSuggestions = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/dimensions/popular?limit=50');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const allDimensions: DimensionSuggestion[] = result.data;
|
||||
|
||||
// Filter based on search query and exclude existing dimensions
|
||||
const filtered = allDimensions.filter(dim => {
|
||||
const matchesQuery = !searchQuery.trim() ||
|
||||
dim.dimension.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const notExisting = !existingDimensions.includes(dim.dimension);
|
||||
return matchesQuery && notExisting;
|
||||
});
|
||||
|
||||
// Sort: priority first, then by count
|
||||
const sorted = filtered.sort((a, b) => {
|
||||
if (a.isPriority && !b.isPriority) return -1;
|
||||
if (!a.isPriority && b.isPriority) return 1;
|
||||
return b.count - a.count;
|
||||
});
|
||||
|
||||
setSuggestions(sorted.slice(0, 20));
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching dimension suggestions:', error);
|
||||
setSuggestions([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Only fetch when modal is open and we're not actively creating a new dimension
|
||||
// (user has typed description means they're committing to create)
|
||||
if (isOpen && !newDimensionDescription.trim()) {
|
||||
const timeoutId = setTimeout(fetchSuggestions, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [searchQuery, existingDimensions, isOpen, newDimensionDescription]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
|
||||
if (suggestions[selectedIndex]) {
|
||||
// Select existing dimension
|
||||
handleSelectDimension(suggestions[selectedIndex].dimension);
|
||||
} else if (searchQuery.trim()) {
|
||||
// Create new dimension with description
|
||||
handleSelectDimension(searchQuery.trim(), newDimensionDescription.trim() || undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectDimension = (dimension: string, description?: string) => {
|
||||
onDimensionSelect(dimension, description);
|
||||
setSearchQuery('');
|
||||
setNewDimensionDescription('');
|
||||
setSuggestions([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const canCreateNew = searchQuery.trim() &&
|
||||
!suggestions.some(s => s.dimension.toLowerCase() === searchQuery.toLowerCase());
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className="search-backdrop"
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search dimensions"
|
||||
>
|
||||
<div ref={modalRef} className="search-container" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Search Input */}
|
||||
<div className="search-input-wrapper">
|
||||
<svg className="search-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search or create dimension..."
|
||||
className="search-input"
|
||||
/>
|
||||
|
||||
<div className="search-shortcut">
|
||||
<kbd>esc</kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{suggestions.length > 0 && (
|
||||
<div className="search-results">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={suggestion.dimension}
|
||||
onClick={() => handleSelectDimension(suggestion.dimension)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`search-result-item ${index === selectedIndex ? 'selected' : ''}`}
|
||||
>
|
||||
<span className={`result-name ${suggestion.isPriority ? 'priority' : ''}`}>
|
||||
{suggestion.dimension}
|
||||
</span>
|
||||
<span className="result-count">{suggestion.count}</span>
|
||||
{index === selectedIndex && (
|
||||
<span className="result-hint">↵</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create New Option */}
|
||||
{canCreateNew && (
|
||||
<div
|
||||
className="search-create"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="create-header">
|
||||
<span className="create-icon">+</span>
|
||||
<span className="create-title">Create "{searchQuery.trim()}"</span>
|
||||
</div>
|
||||
<div className="description-input-wrapper">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={newDimensionDescription}
|
||||
onChange={(e) => setNewDimensionDescription(e.target.value.slice(0, 500))}
|
||||
onFocus={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="Describe what belongs in this dimension..."
|
||||
className="description-input"
|
||||
rows={2}
|
||||
/>
|
||||
<span className="description-counter">{newDimensionDescription.length}/500</span>
|
||||
</div>
|
||||
{!newDimensionDescription.trim() && (
|
||||
<div className="description-warning">
|
||||
Dimensions without descriptions may not auto-assign correctly
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelectDimension(searchQuery.trim(), newDimensionDescription.trim() || undefined);
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(suggestions.length)}
|
||||
className={`create-button ${selectedIndex === suggestions.length ? 'selected' : ''}`}
|
||||
>
|
||||
Create Dimension
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!searchQuery && suggestions.length === 0 && (
|
||||
<div className="search-empty">
|
||||
Start typing to search dimensions
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.search-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 15vh;
|
||||
z-index: 9999;
|
||||
animation: backdropIn 200ms ease-out;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
max-height: 70vh;
|
||||
animation: containerIn 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: #141414;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.04),
|
||||
0 24px 48px -12px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: #525252;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #fafafa;
|
||||
font-size: 18px;
|
||||
font-family: inherit;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #525252;
|
||||
}
|
||||
|
||||
.search-shortcut {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.search-shortcut kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
background: #262626;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
color: #737373;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
margin-top: 8px;
|
||||
background: #141414;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.04),
|
||||
0 24px 48px -12px rgba(0, 0, 0, 0.6);
|
||||
animation: resultsIn 150ms ease-out;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid #1f1f1f;
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.search-result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.search-result-item:hover,
|
||||
.search-result-item.selected {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
flex: 1;
|
||||
color: #e5e5e5;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.result-name.priority {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
color: #525252;
|
||||
font-size: 12px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.result-hint {
|
||||
color: #525252;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-create {
|
||||
margin-top: 8px;
|
||||
background: #141414;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
padding: 16px 20px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.04),
|
||||
0 24px 48px -12px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.create-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.create-title {
|
||||
color: #22c55e;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.description-input-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.description-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
color: #e5e5e5;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
outline: none;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.description-input:focus {
|
||||
border-color: #525252;
|
||||
}
|
||||
|
||||
.description-input::placeholder {
|
||||
color: #525252;
|
||||
}
|
||||
|
||||
.description-counter {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 12px;
|
||||
font-size: 11px;
|
||||
color: #525252;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.description-warning {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
border: 1px solid rgba(234, 179, 8, 0.2);
|
||||
border-radius: 6px;
|
||||
color: #eab308;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.create-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease;
|
||||
font-family: inherit;
|
||||
color: #22c55e;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.create-button:hover,
|
||||
.create-button.selected {
|
||||
background: #262626;
|
||||
}
|
||||
|
||||
.create-icon {
|
||||
font-size: 18px;
|
||||
font-weight: 300;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.search-empty {
|
||||
margin-top: 8px;
|
||||
padding: 32px 24px;
|
||||
background: #141414;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 16px;
|
||||
color: #525252;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes backdropIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes containerIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96) translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes resultsIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof window !== 'undefined' ? createPortal(modalContent, document.body) : null;
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import DimensionSearchModal from './DimensionSearchModal';
|
||||
|
||||
interface DimensionTagsProps {
|
||||
dimensions: string[];
|
||||
onUpdate: (dimensions: string[]) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface DimensionSuggestion {
|
||||
dimension: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export default function DimensionTags({
|
||||
dimensions,
|
||||
onUpdate,
|
||||
disabled = false
|
||||
}: DimensionTagsProps) {
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<DimensionSuggestion[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sortedDimensions = [...dimensions];
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdding && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isAdding]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery.length > 0) {
|
||||
fetchSuggestions(searchQuery);
|
||||
} else if (isAdding) {
|
||||
// Load popular dimensions when field is empty
|
||||
fetchPopularDimensions();
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
}
|
||||
}, [searchQuery, isAdding]);
|
||||
|
||||
const fetchSuggestions = async (query: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/dimensions/search?q=${encodeURIComponent(query)}`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setSuggestions(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching dimension suggestions:', error);
|
||||
setSuggestions([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPopularDimensions = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/dimensions/popular');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setSuggestions(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching popular dimensions:', error);
|
||||
setSuggestions([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addDimension = async (dimension: string, description?: string) => {
|
||||
if (!dimension.trim() || dimensions.includes(dimension.trim())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If description is provided, create/update the dimension in the database first
|
||||
if (description) {
|
||||
try {
|
||||
await fetch('/api/dimensions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: dimension.trim(),
|
||||
description: description.trim(),
|
||||
isPriority: false
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating dimension with description:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const newDimensions = [...dimensions, dimension.trim()];
|
||||
await onUpdate(newDimensions);
|
||||
|
||||
setSearchQuery('');
|
||||
setSuggestions([]);
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const removeDimension = async (index: number) => {
|
||||
const dimension = sortedDimensions[index];
|
||||
const newDimensions = dimensions.filter(d => d !== dimension);
|
||||
await onUpdate(newDimensions);
|
||||
};
|
||||
|
||||
const handleDragStart = (index: number) => {
|
||||
setDraggedIndex(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex === null || draggedIndex === index) return;
|
||||
|
||||
const draggedDimension = sortedDimensions[draggedIndex];
|
||||
const targetDimension = sortedDimensions[index];
|
||||
|
||||
// Find original positions in unsorted array
|
||||
const draggedOrigIndex = dimensions.indexOf(draggedDimension);
|
||||
const targetOrigIndex = dimensions.indexOf(targetDimension);
|
||||
|
||||
const newDimensions = [...dimensions];
|
||||
newDimensions.splice(draggedOrigIndex, 1);
|
||||
newDimensions.splice(targetOrigIndex, 0, draggedDimension);
|
||||
|
||||
onUpdate(newDimensions);
|
||||
setDraggedIndex(index);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
const moveDimension = async (fromIndex: number, direction: 'up' | 'down') => {
|
||||
const toIndex = direction === 'up' ? fromIndex - 1 : fromIndex + 1;
|
||||
if (toIndex < 0 || toIndex >= sortedDimensions.length) return;
|
||||
|
||||
const fromDimension = sortedDimensions[fromIndex];
|
||||
const toDimension = sortedDimensions[toIndex];
|
||||
|
||||
// Find original positions in unsorted array
|
||||
const fromOrigIndex = dimensions.indexOf(fromDimension);
|
||||
const toOrigIndex = dimensions.indexOf(toDimension);
|
||||
|
||||
const newDimensions = [...dimensions];
|
||||
[newDimensions[fromOrigIndex], newDimensions[toOrigIndex]] = [newDimensions[toOrigIndex], newDimensions[fromOrigIndex]];
|
||||
await onUpdate(newDimensions);
|
||||
};
|
||||
|
||||
// Check if dimensions overflow 2 lines (approximate)
|
||||
const shouldShowExpandButton = sortedDimensions.length > 6; // Rough estimate for 2 lines
|
||||
const displayedDimensions = (!isExpanded && shouldShowExpandButton)
|
||||
? sortedDimensions.slice(0, 6)
|
||||
: sortedDimensions;
|
||||
const hiddenCount = sortedDimensions.length - 6;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
onClick={() => {
|
||||
if (shouldShowExpandButton && !isExpanded) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '6px',
|
||||
marginBottom: '8px',
|
||||
cursor: (shouldShowExpandButton && !isExpanded) ? 'pointer' : 'default',
|
||||
position: 'relative',
|
||||
minHeight: dimensions.length === 0 ? '24px' : 'auto'
|
||||
}}
|
||||
>
|
||||
{/* Show placeholder when no dimensions */}
|
||||
{dimensions.length === 0 && !disabled && (
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
fontStyle: 'italic',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}>
|
||||
No dimensions
|
||||
</span>
|
||||
)}
|
||||
|
||||
{displayedDimensions.map((dimension, index) => {
|
||||
return (
|
||||
<div
|
||||
key={`${dimension}-${index}`}
|
||||
draggable={!disabled}
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px',
|
||||
fontSize: '10px',
|
||||
color: 'var(--rah-text-base)',
|
||||
background: 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
borderRadius: '8px',
|
||||
padding: '2px 6px',
|
||||
cursor: disabled ? 'default' : 'grab',
|
||||
opacity: draggedIndex === index ? 0.5 : 1,
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled) {
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||
}}
|
||||
>
|
||||
<span>{dimension}</span>
|
||||
|
||||
{/* Reorder buttons removed - no longer needed */}
|
||||
|
||||
{/* Remove button */}
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeDimension(index);
|
||||
}}
|
||||
style={{
|
||||
padding: '0 2px',
|
||||
fontSize: '14px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
marginLeft: '2px'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#ff6b6b'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Show "+X more" indicator */}
|
||||
{shouldShowExpandButton && !isExpanded && hiddenCount > 0 && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(true);
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
fontSize: '11px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
background: 'transparent',
|
||||
border: '1px dashed var(--rah-border-strong)',
|
||||
borderRadius: '12px',
|
||||
padding: '2px 8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--rah-text-soft)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--rah-text-muted)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||
}}
|
||||
>
|
||||
+{hiddenCount} more
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapse button when expanded */}
|
||||
{isExpanded && shouldShowExpandButton && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
fontSize: '11px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
background: 'transparent',
|
||||
border: '1px dashed var(--rah-border-strong)',
|
||||
borderRadius: '12px',
|
||||
padding: '2px 8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--rah-text-soft)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--rah-text-muted)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||
}}
|
||||
>
|
||||
show less
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add dimension button */}
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsAdding(true);
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1,
|
||||
color: 'var(--rah-text-muted)',
|
||||
background: 'transparent',
|
||||
border: '1px dashed var(--rah-border-strong)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 120ms ease, border-color 120ms ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--rah-text-soft)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--rah-text-muted)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||
}}
|
||||
title="Add dimension"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dimension Search Modal */}
|
||||
<DimensionSearchModal
|
||||
isOpen={isAdding}
|
||||
onClose={() => setIsAdding(false)}
|
||||
onDimensionSelect={(dim, description) => {
|
||||
addDimension(dim, description);
|
||||
}}
|
||||
existingDimensions={dimensions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ interface NodeSearchModalProps {
|
||||
interface NodeSuggestion {
|
||||
id: number;
|
||||
title: string;
|
||||
dimensions?: string[];
|
||||
}
|
||||
|
||||
export default function NodeSearchModal({
|
||||
@@ -96,7 +95,6 @@ export default function NodeSearchModal({
|
||||
.map((node) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
dimensions: node.dimensions || [],
|
||||
}));
|
||||
setSuggestions(nextSuggestions);
|
||||
setSelectedIndex(0);
|
||||
@@ -258,17 +256,6 @@ export default function NodeSearchModal({
|
||||
<div style={{ color: '#e0e0e0', fontSize: '13px', marginBottom: '2px' }}>
|
||||
{suggestion.title}
|
||||
</div>
|
||||
{suggestion.dimensions && suggestion.dimensions.length > 0 && (
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '11px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{suggestion.dimensions.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ color: '#444', fontSize: '11px', fontFamily: 'monospace', flexShrink: 0 }}>
|
||||
#{suggestion.id}
|
||||
|
||||
@@ -6,11 +6,10 @@ import { FileText } from 'lucide-react';
|
||||
interface NodeLabelProps {
|
||||
id: string;
|
||||
title: string;
|
||||
dimensions: string[];
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
function NodeLabel({ id, title, dimensions, onNodeClick }: NodeLabelProps) {
|
||||
function NodeLabel({ id, title, onNodeClick }: NodeLabelProps) {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// Prevent bubbling into parent containers (e.g., content view onClick that toggles edit)
|
||||
e.stopPropagation();
|
||||
@@ -62,7 +61,7 @@ function NodeLabel({ id, title, dimensions, onNodeClick }: NodeLabelProps) {
|
||||
export function parseAndRenderContent(content: string, onNodeClick?: (nodeId: number) => void): React.ReactNode[] {
|
||||
if (!content) return [content];
|
||||
|
||||
// Pattern to match [NODE:id:"title"] (dimensions removed)
|
||||
// Pattern to match [NODE:id:"title"]
|
||||
// Be tolerant of spaces and curly quotes
|
||||
// Use non-greedy match (.+?) to handle quotes inside titles
|
||||
const nodePattern = /\[NODE:\s*(\d+)\s*:\s*["""'](.+?)["""']\s*\]/g;
|
||||
@@ -80,15 +79,12 @@ export function parseAndRenderContent(content: string, onNodeClick?: (nodeId: nu
|
||||
// Parse the node data
|
||||
const id = match[1];
|
||||
const title = match[2];
|
||||
const dimensions: string[] = []; // No dimensions in new format
|
||||
|
||||
// Add the node label
|
||||
parts.push(
|
||||
<NodeLabel
|
||||
key={`node-${id}-${match.index}`}
|
||||
id={id}
|
||||
title={title}
|
||||
dimensions={dimensions}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
Sun,
|
||||
Moon,
|
||||
} from 'lucide-react';
|
||||
import type { PaneType, NavigablePaneType } from '../panes/types';
|
||||
import type { PaneType } from '../panes/types';
|
||||
import type { FocusedSkill } from '@/types/skills';
|
||||
import type { Theme } from '@/hooks/useTheme';
|
||||
import type { ContextSummary } from '@/types/database';
|
||||
|
||||
@@ -27,11 +28,12 @@ interface LeftToolbarProps {
|
||||
onAddStuffClick: () => void;
|
||||
onRefreshClick: () => void;
|
||||
onSettingsClick: () => void;
|
||||
onPaneTypeClick: (paneType: NavigablePaneType) => void;
|
||||
onPaneTypeClick: (paneType: PaneType) => void;
|
||||
isExpanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
openTabTypes: Set<PaneType>;
|
||||
activeTabType: PaneType | null;
|
||||
focusedSkill?: FocusedSkill | null;
|
||||
theme: Theme;
|
||||
onThemeToggle: () => void;
|
||||
contexts?: ContextSummary[];
|
||||
@@ -41,11 +43,10 @@ interface LeftToolbarProps {
|
||||
const NAV_WIDTH_COLLAPSED = 50;
|
||||
const NAV_WIDTH_EXPANDED = 280;
|
||||
|
||||
const VIEW_ITEMS: Array<{ paneType: NavigablePaneType; label: string; icon: typeof LayoutList }> = [
|
||||
const PRIMARY_VIEW_ITEMS: Array<{ paneType: PaneType; label: string; icon: typeof LayoutList }> = [
|
||||
{ paneType: 'views', label: 'Nodes', icon: LayoutList },
|
||||
{ paneType: 'skills', label: 'Skills', icon: BookOpen },
|
||||
{ paneType: 'map', label: 'Map', icon: Map },
|
||||
{ paneType: 'dimensions', label: 'Dimension', icon: Folder },
|
||||
{ paneType: 'table', label: 'Table', icon: Table2 },
|
||||
];
|
||||
|
||||
@@ -116,6 +117,7 @@ export default function LeftToolbar({
|
||||
onToggleExpanded,
|
||||
openTabTypes,
|
||||
activeTabType,
|
||||
focusedSkill,
|
||||
theme,
|
||||
onThemeToggle,
|
||||
contexts = [],
|
||||
@@ -123,6 +125,14 @@ export default function LeftToolbar({
|
||||
}: LeftToolbarProps) {
|
||||
const [contextsExpanded, setContextsExpanded] = useState(false);
|
||||
|
||||
const renderActionButtons = () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<NavButton icon={Search} label="Search" title="Search (⌘K)" expanded={isExpanded} onClick={onSearchClick} />
|
||||
<NavButton icon={Plus} label="Add Stuff" title="New node (⌘N)" expanded={isExpanded} onClick={onAddStuffClick} />
|
||||
<NavButton icon={RefreshCw} label="Refresh" expanded={isExpanded} onClick={onRefreshClick} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -131,6 +141,7 @@ export default function LeftToolbar({
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 8px',
|
||||
flexShrink: 0,
|
||||
transition: 'width 0.2s ease',
|
||||
@@ -138,7 +149,6 @@ export default function LeftToolbar({
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', height: '100%' }}>
|
||||
{/* Top: collapse + actions */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<NavButton
|
||||
icon={isExpanded ? PanelLeftClose : PanelLeftOpen}
|
||||
@@ -146,18 +156,30 @@ export default function LeftToolbar({
|
||||
expanded={isExpanded}
|
||||
onClick={onToggleExpanded}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<NavButton icon={Search} label="Search" title="Search (⌘K)" expanded={isExpanded} onClick={onSearchClick} />
|
||||
<NavButton icon={Plus} label="Add Stuff" title="New node (⌘N)" expanded={isExpanded} onClick={onAddStuffClick} />
|
||||
<NavButton icon={RefreshCw} label="Refresh" expanded={isExpanded} onClick={onRefreshClick} />
|
||||
</div>
|
||||
|
||||
{renderActionButtons()}
|
||||
</div>
|
||||
|
||||
{/* Middle: view items, vertically centered */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', minHeight: 0 }}>
|
||||
<div style={{ borderTop: '1px solid var(--rah-border)', paddingTop: '14px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '6px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', paddingTop: '4px' }}>
|
||||
{PRIMARY_VIEW_ITEMS.map((item) => (
|
||||
<NavButton
|
||||
key={`${item.paneType}-${item.label}`}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
expanded={isExpanded}
|
||||
active={
|
||||
item.paneType === 'skills'
|
||||
? focusedSkill != null || activeTabType === 'skills' || openTabTypes.has('skills')
|
||||
: activeTabType === item.paneType || openTabTypes.has(item.paneType)
|
||||
}
|
||||
onClick={() => onPaneTypeClick(item.paneType)}
|
||||
activeTone="green"
|
||||
/>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginTop: '10px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<NavButton
|
||||
@@ -227,18 +249,6 @@ export default function LeftToolbar({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{VIEW_ITEMS.map((item) => (
|
||||
<NavButton
|
||||
key={`${item.paneType}-${item.label}`}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
expanded={isExpanded}
|
||||
active={activeTabType === item.paneType || openTabTypes.has(item.paneType)}
|
||||
onClick={() => onPaneTypeClick(item.paneType)}
|
||||
activeTone="green"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,24 +4,21 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import Chip from '../common/Chip';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||
|
||||
interface SearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onNodeSelect: (nodeId: number) => void;
|
||||
existingFilters: {type: 'dimension' | 'title', value: string}[];
|
||||
existingFilters: {type: 'context' | 'title' | 'tag', value: string}[];
|
||||
}
|
||||
|
||||
interface NodeSuggestion {
|
||||
id: number;
|
||||
title: string;
|
||||
dimensions?: string[];
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFilters }: SearchModalProps) {
|
||||
const { dimensionIcons } = useDimensionIcons();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<NodeSuggestion[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
@@ -111,7 +108,6 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
const nodeSuggestions: NodeSuggestion[] = result.data.map((node: any) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
dimensions: node.dimensions || [],
|
||||
link: node.link || undefined,
|
||||
}));
|
||||
|
||||
@@ -208,7 +204,7 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
className={`search-result-item ${index === selectedIndex ? 'selected' : ''}`}
|
||||
>
|
||||
<span className="result-id">{suggestion.id}</span>
|
||||
<span className="result-icon">{getNodeIcon(suggestion as any, dimensionIcons, 14)}</span>
|
||||
<span className="result-icon">{getNodeIcon(suggestion as any, 14)}</span>
|
||||
<span className="result-title">{suggestion.title}</span>
|
||||
{index === selectedIndex && (
|
||||
<span className="result-hint">↵</span>
|
||||
@@ -250,7 +246,7 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: var(--rah-bg-panel);
|
||||
background: var(--rah-bg-modal);
|
||||
border: 1px solid var(--rah-border-strong);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
@@ -262,7 +258,7 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
.search-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: #525252;
|
||||
color: var(--rah-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -271,14 +267,14 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #fafafa;
|
||||
color: var(--rah-text-active);
|
||||
font-size: 18px;
|
||||
font-family: inherit;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #525252;
|
||||
color: var(--rah-text-muted);
|
||||
}
|
||||
|
||||
.search-shortcut {
|
||||
@@ -292,17 +288,17 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
background: var(--rah-border-strong);
|
||||
background: var(--rah-bg-active);
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
color: #737373;
|
||||
border: 1px solid var(--rah-border-stronger);
|
||||
color: var(--rah-text-muted);
|
||||
border: 1px solid var(--rah-border-strong);
|
||||
}
|
||||
|
||||
|
||||
.search-results {
|
||||
margin-top: 8px;
|
||||
background: var(--rah-bg-panel);
|
||||
background: var(--rah-bg-modal);
|
||||
border: 1px solid var(--rah-border-strong);
|
||||
border-radius: 16px;
|
||||
overflow-x: hidden;
|
||||
@@ -322,7 +318,7 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
padding: 16px 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--rah-bg-active);
|
||||
border-bottom: 1px solid var(--rah-border);
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease;
|
||||
text-align: left;
|
||||
@@ -345,8 +341,8 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--rah-bg-base);
|
||||
background: #22c55e;
|
||||
color: var(--rah-text-inverse);
|
||||
background: var(--rah-accent-green);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
min-width: 28px;
|
||||
@@ -369,17 +365,17 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
|
||||
}
|
||||
|
||||
.result-hint {
|
||||
color: #525252;
|
||||
color: var(--rah-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
|
||||
.search-empty {
|
||||
margin-top: 8px;
|
||||
padding: 32px 24px;
|
||||
background: var(--rah-bg-panel);
|
||||
background: var(--rah-bg-modal);
|
||||
border: 1px solid var(--rah-border-strong);
|
||||
border-radius: 16px;
|
||||
color: #525252;
|
||||
color: var(--rah-text-muted);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import FolderViewOverlay from '@/components/nodes/FolderViewOverlay';
|
||||
import PaneHeader from './PaneHeader';
|
||||
import { DimensionsPaneProps, PaneType } from './types';
|
||||
|
||||
export default function DimensionsPane({
|
||||
slot,
|
||||
isActive,
|
||||
onPaneAction,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
tabBar,
|
||||
onNodeOpen,
|
||||
refreshToken,
|
||||
onDataChanged,
|
||||
onDimensionSelect,
|
||||
}: DimensionsPaneProps) {
|
||||
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
|
||||
const handleTypeChange = (type: PaneType) => {
|
||||
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
|
||||
};
|
||||
|
||||
// When used as a pane, "close" means switch back to node view
|
||||
const handleClose = () => {
|
||||
onPaneAction?.({ type: 'switch-pane-type', paneType: 'node' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<PaneHeader
|
||||
slot={slot}
|
||||
onCollapse={onCollapse}
|
||||
onSwapPanes={onSwapPanes}
|
||||
tabBar={tabBar}
|
||||
toolbarHostRef={setToolbarHost}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', position: 'relative' }}>
|
||||
{/* FolderViewOverlay expects to be an overlay, so we wrap it in a container */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'transparent',
|
||||
}}>
|
||||
<FolderViewOverlay
|
||||
onClose={handleClose}
|
||||
onNodeOpen={onNodeOpen}
|
||||
refreshToken={refreshToken}
|
||||
onDataChanged={onDataChanged}
|
||||
onDimensionSelect={onDimensionSelect}
|
||||
replaceWithViewsOnDimensionSelect={true}
|
||||
toolbarHost={toolbarHost}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+173
-322
@@ -6,40 +6,29 @@ import {
|
||||
Background,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge as rfAddEdge,
|
||||
type Connection,
|
||||
type NodeMouseHandler,
|
||||
type Node as RFNode,
|
||||
type Edge as RFEdge,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
MiniMap,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import type { Edge as DbEdge, Node as DbNode } from '@/types/database';
|
||||
import PaneHeader from './PaneHeader';
|
||||
import type { MapPaneProps } from './types';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import { MiniMap } from '@xyflow/react';
|
||||
import { RahNode } from './map/RahNode';
|
||||
import { RahEdge } from './map/RahEdge';
|
||||
import EdgeExplanationModal from './map/EdgeExplanationModal';
|
||||
import { getPrimaryDimension, toRFNodes, toRFEdges, NODE_LIMIT, type MapViewMode, type RahNodeData } from './map/utils';
|
||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||
import { toRFNodes, toRFEdges, NODE_LIMIT, type MapViewMode, type RahNodeData } from './map/utils';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import './map/map-styles.css';
|
||||
|
||||
interface DimensionInfo {
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
const nodeTypes = { rahNode: RahNode };
|
||||
const edgeTypes = { rahEdge: RahEdge };
|
||||
|
||||
// Debounce helper
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
@@ -52,8 +41,6 @@ function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
|
||||
|
||||
function MapPaneInner({
|
||||
slot,
|
||||
isActive,
|
||||
onPaneAction,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
tabBar,
|
||||
@@ -61,111 +48,75 @@ function MapPaneInner({
|
||||
activeTabId,
|
||||
}: MapPaneProps) {
|
||||
const reactFlowInstance = useReactFlow();
|
||||
const { dimensionIcons } = useDimensionIcons();
|
||||
const [theme] = useTheme();
|
||||
|
||||
// --- Data state (DB-level) ---
|
||||
const [baseNodes, setBaseNodes] = useState<DbNode[]>([]);
|
||||
const [expandedNodes, setExpandedNodes] = useState<DbNode[]>([]);
|
||||
const [dbEdges, setDbEdges] = useState<DbEdge[]>([]);
|
||||
const [lockedDimensions, setLockedDimensions] = useState<DimensionInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// --- UI state ---
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null);
|
||||
const [selectedDimension, setSelectedDimension] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<MapViewMode>('dimension');
|
||||
const [dimensionDropdownOpen, setDimensionDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [viewMode, setViewMode] = useState<MapViewMode>('context');
|
||||
|
||||
// --- React Flow state ---
|
||||
const [rfNodes, setRfNodes, onNodesChange] = useNodesState<RFNode<RahNodeData>>([]);
|
||||
const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState<RFEdge>([]);
|
||||
|
||||
// --- Edge creation modal ---
|
||||
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null);
|
||||
|
||||
// Track current RF positions so we can preserve them across data refreshes
|
||||
const rfPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map());
|
||||
const hasInitialFitRef = useRef(false);
|
||||
|
||||
// Combine base + expanded
|
||||
const allDbNodes = useMemo(() => {
|
||||
const baseIds = new Set(baseNodes.map(n => n.id));
|
||||
return [...baseNodes, ...expandedNodes.filter(n => !baseIds.has(n.id))];
|
||||
const baseIds = new Set(baseNodes.map((node) => node.id));
|
||||
return [...baseNodes, ...expandedNodes.filter((node) => !baseIds.has(node.id))];
|
||||
}, [baseNodes, expandedNodes]);
|
||||
|
||||
// Selected DB node
|
||||
const selectedDbNode = useMemo(
|
||||
() => allDbNodes.find(n => n.id === selectedNodeId) ?? null,
|
||||
() => allDbNodes.find((node) => node.id === selectedNodeId) ?? null,
|
||||
[allDbNodes, selectedNodeId],
|
||||
);
|
||||
|
||||
// Connected node IDs for info panel
|
||||
const connectedNodeIds = useMemo(() => {
|
||||
if (!selectedNodeId) return new Set<number>();
|
||||
const connected = new Set<number>();
|
||||
dbEdges.forEach(e => {
|
||||
if (e.from_node_id === selectedNodeId) connected.add(e.to_node_id);
|
||||
if (e.to_node_id === selectedNodeId) connected.add(e.from_node_id);
|
||||
dbEdges.forEach((edge) => {
|
||||
if (edge.from_node_id === selectedNodeId) connected.add(edge.to_node_id);
|
||||
if (edge.to_node_id === selectedNodeId) connected.add(edge.from_node_id);
|
||||
});
|
||||
return connected;
|
||||
}, [selectedNodeId, dbEdges]);
|
||||
|
||||
const lockedDimensionNames = useMemo(
|
||||
() => new Set(lockedDimensions.map(d => d.dimension)),
|
||||
[lockedDimensions],
|
||||
);
|
||||
|
||||
const clusterLabels = useMemo(() => {
|
||||
if (viewMode !== 'dimension') {
|
||||
return [];
|
||||
}
|
||||
if (viewMode !== 'context') return [];
|
||||
|
||||
const grouped = new Map<string, { x: number; y: number; count: number }>();
|
||||
for (const node of rfNodes) {
|
||||
const dimension = getPrimaryDimension((node.data as RahNodeData).dimensions);
|
||||
const current = grouped.get(dimension) || { x: 0, y: 0, count: 0 };
|
||||
const clusterLabel = (node.data as RahNodeData).clusterLabel;
|
||||
const current = grouped.get(clusterLabel) || { x: 0, y: 0, count: 0 };
|
||||
current.x += node.position.x;
|
||||
current.y += node.position.y;
|
||||
current.count += 1;
|
||||
grouped.set(dimension, current);
|
||||
grouped.set(clusterLabel, current);
|
||||
}
|
||||
return [...grouped.entries()].map(([dimension, totals]) => ({
|
||||
dimension,
|
||||
|
||||
return [...grouped.entries()].map(([clusterLabel, totals]) => ({
|
||||
clusterLabel,
|
||||
x: totals.x / totals.count,
|
||||
y: totals.y / totals.count - 84,
|
||||
}));
|
||||
}, [rfNodes, viewMode]);
|
||||
|
||||
// ----- Close dropdown on outside click -----
|
||||
useEffect(() => {
|
||||
if (!dimensionDropdownOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as HTMLElement)) {
|
||||
setDimensionDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [dimensionDropdownOpen]);
|
||||
|
||||
// ----- Fetch base data -----
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const nodesUrl = selectedDimension
|
||||
? `/api/nodes?limit=${NODE_LIMIT}&sortBy=edges&dimensions=${encodeURIComponent(selectedDimension)}`
|
||||
: `/api/nodes?limit=${NODE_LIMIT}&sortBy=edges`;
|
||||
|
||||
const [nodesRes, edgesRes, dimsRes] = await Promise.all([
|
||||
fetch(nodesUrl),
|
||||
const [nodesRes, edgesRes] = await Promise.all([
|
||||
fetch(`/api/nodes?limit=${NODE_LIMIT}&sortBy=edges`),
|
||||
fetch('/api/edges'),
|
||||
fetch('/api/dimensions/popular'),
|
||||
]);
|
||||
|
||||
if (!nodesRes.ok || !edgesRes.ok) throw new Error('Failed to load data');
|
||||
if (!nodesRes.ok || !edgesRes.ok) throw new Error('Failed to load map data');
|
||||
|
||||
const nodesPayload = await nodesRes.json();
|
||||
const edgesPayload = await edgesRes.json();
|
||||
@@ -175,23 +126,16 @@ function MapPaneInner({
|
||||
setExpandedNodes([]);
|
||||
setSelectedNodeId(null);
|
||||
rfPositionsRef.current.clear();
|
||||
|
||||
if (dimsRes.ok) {
|
||||
const dimsPayload = await dimsRes.json();
|
||||
if (dimsPayload.success && dimsPayload.data) {
|
||||
setLockedDimensions(dimsPayload.data as DimensionInfo[]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [selectedDimension]);
|
||||
|
||||
// ----- Sync DB data → React Flow nodes/edges -----
|
||||
void fetchData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (allDbNodes.length === 0) {
|
||||
setRfNodes([]);
|
||||
@@ -199,9 +143,8 @@ function MapPaneInner({
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture current RF positions before rebuild
|
||||
rfNodes.forEach(n => {
|
||||
rfPositionsRef.current.set(n.id, n.position);
|
||||
rfNodes.forEach((node) => {
|
||||
rfPositionsRef.current.set(node.id, node.position);
|
||||
});
|
||||
|
||||
const centerX = 600;
|
||||
@@ -215,29 +158,30 @@ function MapPaneInner({
|
||||
selectedNodeId,
|
||||
connectedNodeIds,
|
||||
rfPositionsRef.current,
|
||||
dimensionIcons,
|
||||
viewMode,
|
||||
dbEdges,
|
||||
);
|
||||
|
||||
const nodeIdSet = new Set(newRfNodes.map(n => n.id));
|
||||
const nodeIdSet = new Set(newRfNodes.map((node) => node.id));
|
||||
const newRfEdges = toRFEdges(dbEdges, nodeIdSet, selectedNodeId);
|
||||
|
||||
setRfNodes(newRfNodes);
|
||||
setRfEdges(newRfEdges);
|
||||
}, [allDbNodes, baseNodes, expandedNodes, dbEdges, selectedNodeId, connectedNodeIds, dimensionIcons, viewMode]);
|
||||
}, [allDbNodes, baseNodes, expandedNodes, dbEdges, selectedNodeId, connectedNodeIds, viewMode, rfNodes, setRfEdges, setRfNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasInitialFitRef.current || rfNodes.length === 0 || loading) return;
|
||||
hasInitialFitRef.current = true;
|
||||
const hubNodeIds = baseNodes
|
||||
|
||||
const hubNodeIds = [...baseNodes]
|
||||
.sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0))
|
||||
.slice(0, 25)
|
||||
.map(n => String(n.id));
|
||||
.map((node) => String(node.id));
|
||||
|
||||
setTimeout(() => {
|
||||
if (hubNodeIds.length > 0) {
|
||||
reactFlowInstance.fitView({
|
||||
nodes: hubNodeIds.map(id => ({ id })),
|
||||
nodes: hubNodeIds.map((id) => ({ id })),
|
||||
padding: 0.3,
|
||||
duration: 300,
|
||||
});
|
||||
@@ -247,27 +191,26 @@ function MapPaneInner({
|
||||
|
||||
useEffect(() => {
|
||||
hasInitialFitRef.current = false;
|
||||
}, [selectedDimension, viewMode]);
|
||||
}, [viewMode]);
|
||||
|
||||
const fitAllNodes = useCallback(() => {
|
||||
reactFlowInstance.fitView({ padding: 0.2, duration: 300 });
|
||||
}, [reactFlowInstance]);
|
||||
|
||||
const fitHubNodes = useCallback(() => {
|
||||
const hubNodeIds = baseNodes
|
||||
const hubNodeIds = [...baseNodes]
|
||||
.sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0))
|
||||
.slice(0, 25)
|
||||
.map(n => String(n.id));
|
||||
.map((node) => String(node.id));
|
||||
if (hubNodeIds.length > 0) {
|
||||
reactFlowInstance.fitView({
|
||||
nodes: hubNodeIds.map(id => ({ id })),
|
||||
nodes: hubNodeIds.map((id) => ({ id })),
|
||||
padding: 0.3,
|
||||
duration: 300,
|
||||
});
|
||||
}
|
||||
}, [baseNodes, reactFlowInstance]);
|
||||
|
||||
// ----- Node traversal: fetch connected nodes -----
|
||||
const fetchConnectedNodes = useCallback(async (nodeId: number) => {
|
||||
try {
|
||||
const edgesRes = await fetch(`/api/nodes/${nodeId}/edges`);
|
||||
@@ -278,48 +221,49 @@ function MapPaneInner({
|
||||
nodeEdges = edgesData.data || [];
|
||||
|
||||
if (nodeEdges.length > 0) {
|
||||
setDbEdges(prev => {
|
||||
const existing = new Set(prev.map(e => e.id));
|
||||
const fresh = nodeEdges.filter(e => !existing.has(e.id));
|
||||
setDbEdges((prev) => {
|
||||
const existing = new Set(prev.map((edge) => edge.id));
|
||||
const fresh = nodeEdges.filter((edge) => !existing.has(edge.id));
|
||||
return fresh.length > 0 ? [...prev, ...fresh] : prev;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Find missing connected node IDs
|
||||
const connectedIds = new Set<number>();
|
||||
dbEdges.forEach(e => {
|
||||
if (e.from_node_id === nodeId) connectedIds.add(e.to_node_id);
|
||||
if (e.to_node_id === nodeId) connectedIds.add(e.from_node_id);
|
||||
dbEdges.forEach((edge) => {
|
||||
if (edge.from_node_id === nodeId) connectedIds.add(edge.to_node_id);
|
||||
if (edge.to_node_id === nodeId) connectedIds.add(edge.from_node_id);
|
||||
});
|
||||
nodeEdges.forEach(e => {
|
||||
if (e.from_node_id === nodeId) connectedIds.add(e.to_node_id);
|
||||
if (e.to_node_id === nodeId) connectedIds.add(e.from_node_id);
|
||||
nodeEdges.forEach((edge) => {
|
||||
if (edge.from_node_id === nodeId) connectedIds.add(edge.to_node_id);
|
||||
if (edge.to_node_id === nodeId) connectedIds.add(edge.from_node_id);
|
||||
});
|
||||
|
||||
const existingIds = new Set(allDbNodes.map(n => n.id));
|
||||
const missingIds = Array.from(connectedIds).filter(id => !existingIds.has(id));
|
||||
const existingIds = new Set(allDbNodes.map((node) => node.id));
|
||||
const missingIds = Array.from(connectedIds).filter((id) => !existingIds.has(id));
|
||||
if (missingIds.length === 0) return;
|
||||
|
||||
const fetched = (
|
||||
await Promise.all(
|
||||
missingIds.slice(0, 50).map(async id => {
|
||||
missingIds.slice(0, 50).map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/nodes/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return data.node as DbNode;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
)
|
||||
).filter((n): n is DbNode => n !== null);
|
||||
).filter((node): node is DbNode => node !== null);
|
||||
|
||||
if (fetched.length > 0) {
|
||||
setExpandedNodes(prev => {
|
||||
const ids = new Set(prev.map(n => n.id));
|
||||
const fresh = fetched.filter(n => !ids.has(n.id));
|
||||
setExpandedNodes((prev) => {
|
||||
const ids = new Set(prev.map((node) => node.id));
|
||||
const fresh = fetched.filter((node) => !ids.has(node.id));
|
||||
return fresh.length > 0 ? [...prev, ...fresh] : prev;
|
||||
});
|
||||
}
|
||||
@@ -329,44 +273,41 @@ function MapPaneInner({
|
||||
}, [dbEdges, allDbNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedNodeId) fetchConnectedNodes(selectedNodeId);
|
||||
if (selectedNodeId) {
|
||||
void fetchConnectedNodes(selectedNodeId);
|
||||
}
|
||||
}, [selectedNodeId, fetchConnectedNodes]);
|
||||
|
||||
// ----- Focused node awareness -----
|
||||
useEffect(() => {
|
||||
if (!activeTabId) return;
|
||||
const existing = allDbNodes.find(n => n.id === activeTabId);
|
||||
const existing = allDbNodes.find((node) => node.id === activeTabId);
|
||||
if (existing) {
|
||||
setSelectedNodeId(activeTabId);
|
||||
// Pan + zoom closer to the focused node
|
||||
const rfNode = rfNodes.find(n => n.id === String(activeTabId));
|
||||
const rfNode = rfNodes.find((node) => node.id === String(activeTabId));
|
||||
if (rfNode) {
|
||||
reactFlowInstance.setCenter(rfNode.position.x, rfNode.position.y, { duration: 400, zoom: 1.5 });
|
||||
}
|
||||
} else {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/nodes/${activeTabId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const node = data.node as DbNode;
|
||||
if (node) {
|
||||
setExpandedNodes(prev => prev.some(n => n.id === node.id) ? prev : [...prev, node]);
|
||||
setSelectedNodeId(node.id);
|
||||
// After the next render cycle, zoom to the newly added node
|
||||
setTimeout(() => {
|
||||
reactFlowInstance.setCenter(600, 400, { duration: 400, zoom: 1.5 });
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch focused node:', err);
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
}, [activeTabId]);
|
||||
|
||||
// ----- SSE real-time sync -----
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/nodes/${activeTabId}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const node = data.node as DbNode;
|
||||
if (!node) return;
|
||||
setExpandedNodes((prev) => (prev.some((existingNode) => existingNode.id === node.id) ? prev : [...prev, node]));
|
||||
setSelectedNodeId(node.id);
|
||||
setTimeout(() => {
|
||||
reactFlowInstance.setCenter(600, 400, { duration: 400, zoom: 1.5 });
|
||||
}, 100);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch focused node:', err);
|
||||
}
|
||||
})();
|
||||
}, [activeTabId, allDbNodes, rfNodes, reactFlowInstance]);
|
||||
|
||||
useEffect(() => {
|
||||
let eventSource: EventSource | null = null;
|
||||
|
||||
@@ -382,7 +323,7 @@ function MapPaneInner({
|
||||
const node = payload.data?.node as DbNode | undefined;
|
||||
if (node?.id) {
|
||||
const updater = (prev: DbNode[]) =>
|
||||
prev.map(n => n.id === node.id ? { ...n, ...node } : n);
|
||||
prev.map((existingNode) => (existingNode.id === node.id ? { ...existingNode, ...node } : existingNode));
|
||||
setBaseNodes(updater);
|
||||
setExpandedNodes(updater);
|
||||
}
|
||||
@@ -391,43 +332,36 @@ function MapPaneInner({
|
||||
case 'NODE_DELETED': {
|
||||
const deletedId = payload.data?.nodeId;
|
||||
if (deletedId) {
|
||||
setBaseNodes(prev => prev.filter(n => n.id !== deletedId));
|
||||
setExpandedNodes(prev => prev.filter(n => n.id !== deletedId));
|
||||
setDbEdges(prev => prev.filter(e => e.from_node_id !== deletedId && e.to_node_id !== deletedId));
|
||||
setSelectedNodeId(prev => prev === deletedId ? null : prev);
|
||||
setBaseNodes((prev) => prev.filter((node) => node.id !== deletedId));
|
||||
setExpandedNodes((prev) => prev.filter((node) => node.id !== deletedId));
|
||||
setDbEdges((prev) => prev.filter((edge) => edge.from_node_id !== deletedId && edge.to_node_id !== deletedId));
|
||||
setSelectedNodeId((prev) => (prev === deletedId ? null : prev));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'NODE_CREATED': {
|
||||
// If filtering by dimension and new node matches, could add it
|
||||
// For now, just note it happened — user can refresh
|
||||
break;
|
||||
}
|
||||
case 'EDGE_CREATED': {
|
||||
const edge = payload.data?.edge as DbEdge | undefined;
|
||||
if (edge?.id) {
|
||||
setDbEdges(prev => {
|
||||
if (prev.some(e => e.id === edge.id)) return prev;
|
||||
return [...prev, edge];
|
||||
});
|
||||
setDbEdges((prev) => (prev.some((existingEdge) => existingEdge.id === edge.id) ? prev : [...prev, edge]));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'EDGE_DELETED': {
|
||||
const edgeId = payload.data?.edgeId;
|
||||
if (edgeId) {
|
||||
setDbEdges(prev => prev.filter(e => e.id !== edgeId));
|
||||
setDbEdges((prev) => prev.filter((edge) => edge.id !== edgeId));
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors (keep-alive pings, etc.)
|
||||
// Ignore keep-alives and malformed payloads.
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
// EventSource auto-reconnects, just log
|
||||
console.error('Map SSE connection error');
|
||||
};
|
||||
} catch {
|
||||
@@ -439,7 +373,6 @@ function MapPaneInner({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ----- Node drag → save position to metadata (debounced) -----
|
||||
const savePositionRef = useRef(
|
||||
debounce(async (nodeId: number, x: number, y: number, mode: MapViewMode) => {
|
||||
try {
|
||||
@@ -448,7 +381,13 @@ function MapPaneInner({
|
||||
const { node: existing } = await res.json();
|
||||
const existingMetadata = existing?.metadata ?? {};
|
||||
const mergedMeta = typeof existingMetadata === 'string'
|
||||
? (() => { try { return JSON.parse(existingMetadata); } catch { return {}; } })()
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(existingMetadata);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
})()
|
||||
: existingMetadata;
|
||||
|
||||
await fetch(`/api/nodes/${nodeId}`, {
|
||||
@@ -472,8 +411,8 @@ function MapPaneInner({
|
||||
);
|
||||
|
||||
const onNodeDragStop: NodeMouseHandler<RFNode<RahNodeData>> = useCallback((_event, node) => {
|
||||
const nodeId = parseInt(node.id);
|
||||
if (!isNaN(nodeId)) {
|
||||
const nodeId = parseInt(node.id, 10);
|
||||
if (!Number.isNaN(nodeId)) {
|
||||
rfPositionsRef.current.set(node.id, node.position);
|
||||
savePositionRef.current(nodeId, node.position.x, node.position.y, viewMode);
|
||||
}
|
||||
@@ -485,36 +424,38 @@ function MapPaneInner({
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || rfNodes.length === 0) return;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
reactFlowInstance.fitView({ padding: 0.22, duration: 300 });
|
||||
}, 80);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [viewMode, selectedDimension, loading, rfNodes, reactFlowInstance]);
|
||||
|
||||
// ----- Node click → select + traverse -----
|
||||
return () => clearTimeout(timeout);
|
||||
}, [viewMode, loading, rfNodes, reactFlowInstance]);
|
||||
|
||||
const onNodeClickHandler: NodeMouseHandler<RFNode<RahNodeData>> = useCallback((_event, node) => {
|
||||
const nodeId = parseInt(node.id);
|
||||
if (isNaN(nodeId)) return;
|
||||
setSelectedNodeId(prev => prev === nodeId ? null : nodeId);
|
||||
const nodeId = parseInt(node.id, 10);
|
||||
if (!Number.isNaN(nodeId)) {
|
||||
setSelectedNodeId((prev) => (prev === nodeId ? null : nodeId));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ----- Node double-click → open in other pane -----
|
||||
const onNodeDoubleClick: NodeMouseHandler<RFNode<RahNodeData>> = useCallback((_event, node) => {
|
||||
const nodeId = parseInt(node.id);
|
||||
if (!isNaN(nodeId)) onNodeClick?.(nodeId);
|
||||
const nodeId = parseInt(node.id, 10);
|
||||
if (!Number.isNaN(nodeId)) {
|
||||
onNodeClick?.(nodeId);
|
||||
}
|
||||
}, [onNodeClick]);
|
||||
|
||||
// ----- Edge creation via drag -----
|
||||
const onConnect = useCallback((connection: Connection) => {
|
||||
if (connection.source === connection.target) return; // No self-connections
|
||||
if (connection.source === connection.target) return;
|
||||
setPendingConnection(connection);
|
||||
}, []);
|
||||
|
||||
const handleEdgeCreate = useCallback(async (explanation: string) => {
|
||||
if (!pendingConnection?.source || !pendingConnection?.target) return;
|
||||
|
||||
const fromId = parseInt(pendingConnection.source);
|
||||
const toId = parseInt(pendingConnection.target);
|
||||
const fromId = parseInt(pendingConnection.source, 10);
|
||||
const toId = parseInt(pendingConnection.target, 10);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/edges', {
|
||||
@@ -533,11 +474,7 @@ function MapPaneInner({
|
||||
const payload = await res.json();
|
||||
const edge = payload.data;
|
||||
if (edge?.id) {
|
||||
// Add to DB edges (SSE may also add it, dedup in handler)
|
||||
setDbEdges(prev => {
|
||||
if (prev.some(e => e.id === edge.id)) return prev;
|
||||
return [...prev, edge];
|
||||
});
|
||||
setDbEdges((prev) => (prev.some((existingEdge) => existingEdge.id === edge.id) ? prev : [...prev, edge]));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -551,12 +488,11 @@ function MapPaneInner({
|
||||
setPendingConnection(null);
|
||||
}, []);
|
||||
|
||||
// Get source/target titles for modal
|
||||
const pendingSourceTitle = pendingConnection?.source
|
||||
? allDbNodes.find(n => n.id === parseInt(pendingConnection.source!))?.title || 'Unknown'
|
||||
? allDbNodes.find((node) => node.id === parseInt(pendingConnection.source, 10))?.title || 'Unknown'
|
||||
: '';
|
||||
const pendingTargetTitle = pendingConnection?.target
|
||||
? allDbNodes.find(n => n.id === parseInt(pendingConnection.target!))?.title || 'Unknown'
|
||||
? allDbNodes.find((node) => node.id === parseInt(pendingConnection.target, 10))?.title || 'Unknown'
|
||||
: '';
|
||||
|
||||
return (
|
||||
@@ -564,29 +500,29 @@ function MapPaneInner({
|
||||
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<button
|
||||
onClick={() => setViewMode('dimension')}
|
||||
onClick={() => setViewMode('context')}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
background: viewMode === 'dimension' ? 'rgba(34, 197, 94, 0.12)' : 'transparent',
|
||||
background: viewMode === 'context' ? 'var(--rah-accent-green-soft)' : 'transparent',
|
||||
border: '1px solid',
|
||||
borderColor: viewMode === 'dimension' ? 'rgba(34, 197, 94, 0.35)' : 'var(--rah-border-strong)',
|
||||
borderColor: viewMode === 'context' ? 'var(--rah-accent-green-soft-strong)' : 'var(--rah-border-strong)',
|
||||
borderRadius: '6px',
|
||||
color: viewMode === 'dimension' ? '#a7f3b8' : 'var(--rah-text-muted)',
|
||||
color: viewMode === 'context' ? 'var(--rah-accent-green)' : 'var(--rah-text-muted)',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Dimension View
|
||||
Context View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('hub')}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
background: viewMode === 'hub' ? 'rgba(34, 197, 94, 0.12)' : 'transparent',
|
||||
background: viewMode === 'hub' ? 'var(--rah-accent-green-soft)' : 'transparent',
|
||||
border: '1px solid',
|
||||
borderColor: viewMode === 'hub' ? 'rgba(34, 197, 94, 0.35)' : 'var(--rah-border-strong)',
|
||||
borderColor: viewMode === 'hub' ? 'var(--rah-accent-green-soft-strong)' : 'var(--rah-border-strong)',
|
||||
borderRadius: '6px',
|
||||
color: viewMode === 'hub' ? '#a7f3b8' : 'var(--rah-text-muted)',
|
||||
color: viewMode === 'hub' ? 'var(--rah-accent-green)' : 'var(--rah-text-muted)',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
@@ -594,97 +530,8 @@ function MapPaneInner({
|
||||
Hub View
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dimension filter dropdown */}
|
||||
<div ref={dropdownRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setDimensionDropdownOpen(!dimensionDropdownOpen)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '6px 10px',
|
||||
background: selectedDimension ? 'rgba(34, 197, 94, 0.1)' : 'transparent',
|
||||
border: '1px solid',
|
||||
borderColor: selectedDimension ? 'rgba(34, 197, 94, 0.3)' : 'var(--rah-border-strong)',
|
||||
borderRadius: '6px',
|
||||
color: selectedDimension ? '#22c55e' : 'var(--rah-text-muted)',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<span>{selectedDimension || 'All dimensions'}</span>
|
||||
<ChevronDown size={12} style={{
|
||||
transform: dimensionDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.15s ease',
|
||||
}} />
|
||||
</button>
|
||||
|
||||
{dimensionDropdownOpen && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
marginTop: '4px',
|
||||
background: 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
borderRadius: '8px',
|
||||
padding: '4px',
|
||||
minWidth: '180px',
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
<button
|
||||
onClick={() => { setSelectedDimension(null); setDimensionDropdownOpen(false); }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', width: '100%', padding: '8px 12px',
|
||||
background: !selectedDimension ? 'var(--rah-bg-active)' : 'transparent',
|
||||
border: 'none', borderRadius: '4px',
|
||||
color: !selectedDimension ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
|
||||
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
All dimensions
|
||||
{!selectedDimension && <span style={{ marginLeft: 'auto', color: '#22c55e' }}>✓</span>}
|
||||
</button>
|
||||
|
||||
{lockedDimensions.map(dim => (
|
||||
<button
|
||||
key={dim.dimension}
|
||||
onClick={() => { setSelectedDimension(dim.dimension); setDimensionDropdownOpen(false); }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', width: '100%', padding: '8px 12px',
|
||||
background: selectedDimension === dim.dimension ? 'var(--rah-bg-active)' : 'transparent',
|
||||
border: 'none', borderRadius: '4px',
|
||||
color: selectedDimension === dim.dimension ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
|
||||
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
if (selectedDimension !== dim.dimension) {
|
||||
e.currentTarget.style.background = 'var(--rah-bg-active)';
|
||||
e.currentTarget.style.color = 'var(--rah-text-secondary)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
if (selectedDimension !== dim.dimension) {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--rah-text-muted)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{dim.dimension}
|
||||
{selectedDimension === dim.dimension && <span style={{ marginLeft: 'auto', color: '#22c55e' }}>✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PaneHeader>
|
||||
|
||||
{/* Map content */}
|
||||
<div style={{ position: 'relative', flex: 1, background: 'var(--rah-bg-base)' }}>
|
||||
{loading ? (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--rah-text-muted)' }}>
|
||||
@@ -715,24 +562,24 @@ function MapPaneInner({
|
||||
maxZoom={3}
|
||||
defaultEdgeOptions={{ type: 'rahEdge' }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
colorMode="dark"
|
||||
colorMode={theme}
|
||||
>
|
||||
<Background color="var(--rah-border)" gap={40} size={1} />
|
||||
<MiniMap
|
||||
style={{ background: 'var(--rah-bg-base)', border: '1px solid var(--rah-bg-active)', borderRadius: 6 }}
|
||||
maskColor="rgba(0, 0, 0, 0.7)"
|
||||
style={{ background: 'var(--rah-bg-panel)', border: '1px solid var(--rah-border)', borderRadius: 6 }}
|
||||
maskColor={theme === 'light' ? 'rgba(255, 255, 255, 0.72)' : 'rgba(0, 0, 0, 0.7)'}
|
||||
nodeColor={(node) => {
|
||||
const data = node.data as RahNodeData | undefined;
|
||||
return data?.primaryDimensionColor || '#2a2a2a';
|
||||
return data?.clusterColor || '#2a2a2a';
|
||||
}}
|
||||
pannable
|
||||
zoomable
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
{viewMode === 'dimension' && clusterLabels.map((label) => (
|
||||
{viewMode === 'context' && clusterLabels.map((label) => (
|
||||
<div
|
||||
key={label.dimension}
|
||||
key={label.clusterLabel}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(${label.x}px, ${label.y}px)`,
|
||||
@@ -741,27 +588,29 @@ function MapPaneInner({
|
||||
fontSize: '11px',
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase',
|
||||
textShadow: '0 1px 6px rgba(0,0,0,0.45)',
|
||||
textShadow: theme === 'light' ? '0 1px 3px rgba(255,255,255,0.95)' : '0 1px 6px rgba(0,0,0,0.45)',
|
||||
}}
|
||||
>
|
||||
{label.dimension}
|
||||
{label.clusterLabel}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
zIndex: 10,
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={fitAllNodes}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: 10,
|
||||
background: 'var(--rah-bg-active)',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
borderRadius: 4,
|
||||
color: 'var(--rah-text-muted)',
|
||||
@@ -777,7 +626,7 @@ function MapPaneInner({
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: 10,
|
||||
background: 'var(--rah-bg-active)',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
borderRadius: 4,
|
||||
color: 'var(--rah-text-muted)',
|
||||
@@ -790,7 +639,6 @@ function MapPaneInner({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected node info panel */}
|
||||
{selectedDbNode && (
|
||||
<div style={infoPanel}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: 8 }}>
|
||||
@@ -807,31 +655,35 @@ function MapPaneInner({
|
||||
<div style={{ fontSize: 12, color: 'var(--rah-text-muted)', marginBottom: 8 }}>
|
||||
{connectedNodeIds.size} connected nodes
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#22c55e', marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--rah-accent-green)', marginBottom: 8 }}>
|
||||
Click a connected node to traverse · Double-click to open
|
||||
</div>
|
||||
{selectedDbNode.dimensions && selectedDbNode.dimensions.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
|
||||
{selectedDbNode.dimensions.slice(0, 5).map(dim => (
|
||||
<span
|
||||
key={dim}
|
||||
style={{
|
||||
padding: '2px 8px', borderRadius: 999, fontSize: 11,
|
||||
background: lockedDimensionNames.has(dim) ? '#132018' : 'var(--rah-bg-active)',
|
||||
color: lockedDimensionNames.has(dim) ? '#86efac' : 'var(--rah-text-muted)',
|
||||
}}
|
||||
>
|
||||
{dim}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
background: selectedDbNode.context?.name ? 'var(--rah-accent-green-soft)' : 'var(--rah-bg-active)',
|
||||
color: selectedDbNode.context?.name ? 'var(--rah-accent-green)' : 'var(--rah-text-muted)',
|
||||
}}
|
||||
>
|
||||
{selectedDbNode.context?.name || 'Unscoped'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNodeClick?.(selectedDbNode.id)}
|
||||
style={{
|
||||
marginTop: 4, padding: '8px 12px', background: '#22c55e', color: '#052e16',
|
||||
border: 'none', borderRadius: '6px', fontSize: 12, fontWeight: 500,
|
||||
cursor: 'pointer', width: '100%',
|
||||
marginTop: 4,
|
||||
padding: '8px 12px',
|
||||
background: 'var(--rah-accent-green)',
|
||||
color: 'var(--rah-text-inverse)',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
Open Node
|
||||
@@ -842,7 +694,6 @@ function MapPaneInner({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edge creation explanation modal */}
|
||||
{pendingConnection && (
|
||||
<EdgeExplanationModal
|
||||
sourceTitle={pendingSourceTitle}
|
||||
@@ -855,7 +706,6 @@ function MapPaneInner({
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap with ReactFlowProvider
|
||||
export default function MapPane(props: MapPaneProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
@@ -869,9 +719,10 @@ const infoPanel: CSSProperties = {
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
width: 260,
|
||||
background: 'var(--rah-bg-base)',
|
||||
border: '1px solid var(--rah-bg-active)',
|
||||
background: 'var(--rah-bg-modal)',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
zIndex: 10,
|
||||
boxShadow: 'var(--rah-shadow-floating)',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { X, LayoutList, PanelsTopLeft, Map, FileText, Table2, BookOpen } from 'lucide-react';
|
||||
import type { SlotTab, PaneType, SlotId } from './types';
|
||||
|
||||
const TAB_TYPE_ICONS: Record<PaneType, typeof LayoutList> = {
|
||||
views: LayoutList,
|
||||
contexts: PanelsTopLeft,
|
||||
map: Map,
|
||||
node: FileText,
|
||||
table: Table2,
|
||||
skills: BookOpen,
|
||||
};
|
||||
|
||||
const TAB_TYPE_LABELS: Record<PaneType, string> = {
|
||||
views: 'Feed',
|
||||
contexts: 'Contexts',
|
||||
map: 'Map',
|
||||
node: 'Node',
|
||||
table: 'Table',
|
||||
skills: 'Skills',
|
||||
};
|
||||
|
||||
interface SlotTabBarProps {
|
||||
tabs: SlotTab[];
|
||||
activeTabId: string;
|
||||
slot: SlotId;
|
||||
onTabSelect: (tabId: string) => void;
|
||||
onTabClose: (tabId: string) => void;
|
||||
onReorderTabs?: (fromIndex: number, toIndex: number) => void;
|
||||
onCrossSlotDrop?: (tab: SlotTab, fromSlot: SlotId) => void;
|
||||
}
|
||||
|
||||
function truncateTitle(title: string, maxLength = 18): string {
|
||||
if (title.length <= maxLength) return title;
|
||||
return `${title.slice(0, maxLength - 1)}\u2026`;
|
||||
}
|
||||
|
||||
export default function SlotTabBar({
|
||||
tabs,
|
||||
activeTabId,
|
||||
slot,
|
||||
onTabSelect,
|
||||
onTabClose,
|
||||
onReorderTabs,
|
||||
onCrossSlotDrop,
|
||||
}: SlotTabBarProps) {
|
||||
const [nodeTitles, setNodeTitles] = useState<Record<string, string>>({});
|
||||
const fetchedRef = useRef<Set<string>>(new Set());
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nodeTabs = tabs.filter((tab) => tab.type === 'node' && tab.nodeId != null);
|
||||
for (const tab of nodeTabs) {
|
||||
if (fetchedRef.current.has(tab.id)) continue;
|
||||
fetchedRef.current.add(tab.id);
|
||||
|
||||
fetch(`/api/nodes/${tab.nodeId}`)
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((payload) => {
|
||||
if (payload?.success && payload.node) {
|
||||
setNodeTitles((prev) => ({ ...prev, [tab.id]: payload.node.title || 'Untitled' }));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
fetchedRef.current.delete(tab.id);
|
||||
});
|
||||
}
|
||||
}, [tabs]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentIds = new Set(tabs.map((tab) => tab.id));
|
||||
fetchedRef.current.forEach((id) => {
|
||||
if (!currentIds.has(id)) {
|
||||
fetchedRef.current.delete(id);
|
||||
}
|
||||
});
|
||||
}, [tabs]);
|
||||
|
||||
const handleDragStart = useCallback((event: React.DragEvent, index: number, tab: SlotTab) => {
|
||||
event.dataTransfer.setData(
|
||||
'application/x-rah-tab',
|
||||
JSON.stringify({
|
||||
sourceSlot: slot,
|
||||
sourceIndex: index,
|
||||
tab,
|
||||
})
|
||||
);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
}, [slot]);
|
||||
|
||||
const handleDrop = useCallback((event: React.DragEvent, targetIndex: number) => {
|
||||
event.preventDefault();
|
||||
setDragOverIndex(null);
|
||||
|
||||
const raw = event.dataTransfer.getData('application/x-rah-tab');
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
sourceSlot: SlotId;
|
||||
sourceIndex: number;
|
||||
tab: SlotTab;
|
||||
};
|
||||
|
||||
if (parsed.sourceSlot === slot) {
|
||||
onReorderTabs?.(parsed.sourceIndex, targetIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
onCrossSlotDrop?.(parsed.tab, parsed.sourceSlot);
|
||||
} catch {
|
||||
// Ignore malformed drag payloads.
|
||||
}
|
||||
}, [onCrossSlotDrop, onReorderTabs, slot]);
|
||||
|
||||
const handleBarDrop = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
setDragOverIndex(null);
|
||||
|
||||
const raw = event.dataTransfer.getData('application/x-rah-tab');
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
sourceSlot: SlotId;
|
||||
tab: SlotTab;
|
||||
};
|
||||
|
||||
if (parsed.sourceSlot !== slot) {
|
||||
onCrossSlotDrop?.(parsed.tab, parsed.sourceSlot);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed drag payloads.
|
||||
}
|
||||
}, [onCrossSlotDrop, slot]);
|
||||
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragOver={(event) => {
|
||||
if (event.dataTransfer.types.includes('application/x-rah-tab')) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
}}
|
||||
onDrop={handleBarDrop}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '4px', minWidth: 0, overflow: 'hidden' }}
|
||||
>
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const Icon = TAB_TYPE_ICONS[tab.type];
|
||||
const label = tab.type === 'node'
|
||||
? truncateTitle(nodeTitles[tab.id] || 'Loading...')
|
||||
: TAB_TYPE_LABELS[tab.type];
|
||||
const isDragOver = dragOverIndex === index;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
draggable={tab.type === 'node'}
|
||||
onDragStart={(event) => handleDragStart(event, index, tab)}
|
||||
onDragOver={(event) => {
|
||||
if (!event.dataTransfer.types.includes('application/x-rah-tab')) return;
|
||||
event.preventDefault();
|
||||
setDragOverIndex(index);
|
||||
}}
|
||||
onDragLeave={() => setDragOverIndex(null)}
|
||||
onDrop={(event) => handleDrop(event, index)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
minWidth: 0,
|
||||
padding: '6px 10px',
|
||||
borderRadius: '10px',
|
||||
border: isDragOver ? '1px dashed var(--rah-accent-green)' : '1px solid var(--rah-border)',
|
||||
background: isActive ? 'var(--rah-bg-hover)' : 'var(--rah-bg-panel)',
|
||||
color: isActive ? 'var(--rah-text-primary)' : 'var(--rah-text-secondary)',
|
||||
cursor: tab.type === 'node' ? 'grab' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabSelect(tab.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
minWidth: 0,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span style={{ fontSize: '12px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{tabs.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabClose(tab.id)}
|
||||
style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: 'none',
|
||||
borderRadius: '999px',
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,10 +13,8 @@ export interface ViewsPaneProps extends BasePaneProps {
|
||||
pendingNodes?: PendingNode[];
|
||||
onDismissPending?: (id: string) => void;
|
||||
externalContextFilterId?: number | null;
|
||||
externalDimensionFilter?: string | null;
|
||||
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||
onClearExternalContextFilter?: () => void;
|
||||
onClearExternalDimensionFilter?: () => void;
|
||||
}
|
||||
|
||||
export default function ViewsPane({
|
||||
@@ -32,12 +30,11 @@ export default function ViewsPane({
|
||||
pendingNodes,
|
||||
onDismissPending,
|
||||
externalContextFilterId,
|
||||
externalDimensionFilter,
|
||||
onContextFilterSelect,
|
||||
onClearExternalContextFilter,
|
||||
onClearExternalDimensionFilter,
|
||||
}: ViewsPaneProps) {
|
||||
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const handleTypeChange = (type: PaneType) => {
|
||||
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
|
||||
};
|
||||
@@ -65,10 +62,8 @@ export default function ViewsPane({
|
||||
pendingNodes={pendingNodes}
|
||||
onDismissPending={onDismissPending}
|
||||
externalContextFilterId={externalContextFilterId}
|
||||
externalDimensionFilter={externalDimensionFilter}
|
||||
onContextFilterSelect={onContextFilterSelect}
|
||||
onClearExternalContextFilter={onClearExternalContextFilter}
|
||||
onClearExternalDimensionFilter={onClearExternalDimensionFilter}
|
||||
toolbarHost={toolbarHost}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export { default as NodePane } from './NodePane';
|
||||
export { default as ContextsPane } from './ContextsPane';
|
||||
export { default as DimensionsPane } from './DimensionsPane';
|
||||
export { default as MapPane } from './MapPane';
|
||||
export { default as ViewsPane } from './ViewsPane';
|
||||
export { default as TablePane } from './TablePane';
|
||||
export { default as SkillsPane } from './SkillsPane';
|
||||
export { default as PaneHeader } from './PaneHeader';
|
||||
export { default as SlotTabBar } from './SlotTabBar';
|
||||
export * from './types';
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
|
||||
import type { RahNodeData } from './utils';
|
||||
import { LABEL_THRESHOLD } from './utils';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
|
||||
type RahNodeType = Node<RahNodeData, 'rahNode'>;
|
||||
|
||||
function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
|
||||
const { label, dimensions, edgeCount, isExpanded, dbNode, dimensionIcons, primaryDimensionColor } = data;
|
||||
const { label, clusterLabel, edgeCount, isExpanded, dbNode, clusterColor } = data;
|
||||
const isTop = !isExpanded && edgeCount > 3;
|
||||
|
||||
return (
|
||||
@@ -20,7 +19,7 @@ function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
|
||||
isTop && 'rah-map-node--top',
|
||||
selected && 'rah-map-node--selected',
|
||||
].filter(Boolean).join(' ')}
|
||||
style={primaryDimensionColor ? { borderLeftColor: primaryDimensionColor, borderLeftWidth: 3 } : undefined}
|
||||
style={clusterColor ? { borderLeftColor: clusterColor, borderLeftWidth: 3 } : undefined}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
@@ -29,13 +28,13 @@ function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
|
||||
/>
|
||||
<div className="rah-map-node__title">
|
||||
<span className="rah-map-node__icon">
|
||||
{getNodeIcon(dbNode, dimensionIcons, 14)}
|
||||
{getNodeIcon(dbNode, 14)}
|
||||
</span>
|
||||
{label.length > 26 ? label.slice(0, 24) + '\u2026' : label}
|
||||
</div>
|
||||
{(isTop || isExpanded) && dimensions.length > 0 && (
|
||||
{(isTop || isExpanded) && clusterLabel && (
|
||||
<div className="rah-map-node__dims">
|
||||
{dimensions.slice(0, 3).map(d => d.length > 12 ? d.slice(0, 11) + '\u2026' : d).join(' \u00b7 ')}
|
||||
{clusterLabel.length > 24 ? `${clusterLabel.slice(0, 23)}\u2026` : clusterLabel}
|
||||
</div>
|
||||
)}
|
||||
<Handle
|
||||
|
||||
@@ -1,55 +1,44 @@
|
||||
import type { Node as DbNode, Edge as DbEdge } from '@/types/database';
|
||||
import type { Node as RFNode, Edge as RFEdge } from '@xyflow/react';
|
||||
|
||||
// Fixed palette for dimension border colors (muted, dark-theme-friendly)
|
||||
const DIMENSION_COLORS = [
|
||||
'#22c55e', // green
|
||||
'#3b82f6', // blue
|
||||
'#f59e0b', // amber
|
||||
'#ef4444', // red
|
||||
'#8b5cf6', // violet
|
||||
'#ec4899', // pink
|
||||
'#06b6d4', // cyan
|
||||
'#f97316', // orange
|
||||
'#14b8a6', // teal
|
||||
'#a855f7', // purple
|
||||
const CLUSTER_COLORS = [
|
||||
'#22c55e',
|
||||
'#3b82f6',
|
||||
'#f59e0b',
|
||||
'#ef4444',
|
||||
'#8b5cf6',
|
||||
'#ec4899',
|
||||
'#06b6d4',
|
||||
'#f97316',
|
||||
'#14b8a6',
|
||||
'#a855f7',
|
||||
];
|
||||
|
||||
export type MapViewMode = 'dimension' | 'hub';
|
||||
export type MapViewMode = 'context' | 'hub';
|
||||
|
||||
function hashDimensionColor(dimension: string): string {
|
||||
function hashClusterColor(label: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < dimension.length; i++) {
|
||||
hash = ((hash << 5) - hash + dimension.charCodeAt(i)) | 0;
|
||||
for (let i = 0; i < label.length; i++) {
|
||||
hash = ((hash << 5) - hash + label.charCodeAt(i)) | 0;
|
||||
}
|
||||
return DIMENSION_COLORS[Math.abs(hash) % DIMENSION_COLORS.length];
|
||||
return CLUSTER_COLORS[Math.abs(hash) % CLUSTER_COLORS.length];
|
||||
}
|
||||
|
||||
export function getOrderedDimensions(dimensions: string[] | undefined): string[] {
|
||||
if (!dimensions || dimensions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [...dimensions].sort((a, b) => a.localeCompare(b));
|
||||
export function getNodeClusterLabel(node: DbNode): string {
|
||||
return node.context?.name?.trim() || 'Unscoped';
|
||||
}
|
||||
|
||||
export function getPrimaryDimension(dimensions: string[] | undefined): string {
|
||||
const ordered = getOrderedDimensions(dimensions);
|
||||
return ordered[0] || 'Unsorted';
|
||||
}
|
||||
|
||||
export function getDimensionColor(dimensions: string[] | undefined): string | undefined {
|
||||
const primary = getPrimaryDimension(dimensions);
|
||||
return primary === 'Unsorted' ? '#4b5563' : hashDimensionColor(primary);
|
||||
export function getClusterColor(label: string): string {
|
||||
return label === 'Unscoped' ? '#4b5563' : hashClusterColor(label);
|
||||
}
|
||||
|
||||
export interface RahNodeData {
|
||||
label: string;
|
||||
dimensions: string[];
|
||||
clusterLabel: string;
|
||||
edgeCount: number;
|
||||
isExpanded: boolean;
|
||||
dbNode: DbNode;
|
||||
dimensionIcons?: Record<string, string>;
|
||||
primaryDimensionColor?: string;
|
||||
clusterColor?: string;
|
||||
clusterKey?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -64,12 +53,15 @@ export function getSavedMapPosition(
|
||||
const metadata = typeof node.metadata === 'string'
|
||||
? safeParseJSON(node.metadata)
|
||||
: node.metadata;
|
||||
|
||||
const nested = metadata?.map_positions as Record<string, { x?: number; y?: number }> | undefined;
|
||||
const scoped = metadata?.[`map_position_${viewMode}`] as { x?: number; y?: number } | undefined;
|
||||
const saved = nested?.[viewMode] || scoped;
|
||||
|
||||
if (saved?.x !== undefined && saved?.y !== undefined) {
|
||||
return { x: saved.x, y: saved.y };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -78,11 +70,12 @@ function getAllNodes(baseNodes: DbNode[], expandedNodes: DbNode[]): DbNode[] {
|
||||
return [...baseNodes, ...expandedNodes.filter((node) => !baseIds.has(node.id))];
|
||||
}
|
||||
|
||||
function buildDimensionLayout(nodes: DbNode[], centerX: number, centerY: number): Map<string, { x: number; y: number }> {
|
||||
function buildContextLayout(nodes: DbNode[], centerX: number, centerY: number): Map<string, { x: number; y: number }> {
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
const groups = new Map<string, DbNode[]>();
|
||||
|
||||
for (const node of nodes) {
|
||||
const key = getPrimaryDimension(node.dimensions);
|
||||
const key = getNodeClusterLabel(node);
|
||||
const existing = groups.get(key) || [];
|
||||
existing.push(node);
|
||||
groups.set(key, existing);
|
||||
@@ -97,7 +90,10 @@ function buildDimensionLayout(nodes: DbNode[], centerX: number, centerY: number)
|
||||
const originY = centerY - ((rows - 1) * clusterGapY) / 2;
|
||||
|
||||
clusterKeys.forEach((clusterKey, clusterIndex) => {
|
||||
const clusterNodes = (groups.get(clusterKey) || []).sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0));
|
||||
const clusterNodes = (groups.get(clusterKey) || []).sort((a, b) => {
|
||||
return (b.edge_count ?? 0) - (a.edge_count ?? 0);
|
||||
});
|
||||
|
||||
const clusterColumn = clusterIndex % columns;
|
||||
const clusterRow = Math.floor(clusterIndex / columns);
|
||||
const clusterCenterX = originX + clusterColumn * clusterGapX;
|
||||
@@ -147,6 +143,7 @@ function buildHubLayout(
|
||||
const orphanNodes: DbNode[] = [];
|
||||
for (const node of sortedNodes) {
|
||||
if (hubIds.has(node.id)) continue;
|
||||
|
||||
const neighbours = adjacency.get(node.id) || [];
|
||||
const connectedHub = hubs
|
||||
.filter((hub) => neighbours.includes(hub.id))
|
||||
@@ -193,9 +190,10 @@ function buildHubLayout(
|
||||
}
|
||||
|
||||
export function getClusterKey(node: DbNode, viewMode: MapViewMode, dbEdges: DbEdge[]): string {
|
||||
if (viewMode === 'dimension') {
|
||||
return getPrimaryDimension(node.dimensions);
|
||||
if (viewMode === 'context') {
|
||||
return getNodeClusterLabel(node);
|
||||
}
|
||||
|
||||
return `hub:${node.id}`;
|
||||
}
|
||||
|
||||
@@ -207,14 +205,13 @@ export function toRFNodes(
|
||||
selectedNodeId: number | null,
|
||||
connectedNodeIds: Set<number>,
|
||||
existingPositions: Map<string, { x: number; y: number }>,
|
||||
dimensionIcons: Record<string, string> | undefined,
|
||||
viewMode: MapViewMode,
|
||||
dbEdges: DbEdge[],
|
||||
): RFNode<RahNodeData>[] {
|
||||
const allNodes = getAllNodes(baseNodes, expandedNodes);
|
||||
const hasSelection = selectedNodeId !== null;
|
||||
const clusterLayout = viewMode === 'dimension'
|
||||
? buildDimensionLayout(allNodes, centerX, centerY)
|
||||
const clusterLayout = viewMode === 'context'
|
||||
? buildContextLayout(allNodes, centerX, centerY)
|
||||
: buildHubLayout(allNodes, dbEdges, centerX, centerY);
|
||||
const baseNodeIds = new Set(baseNodes.map((node) => node.id));
|
||||
|
||||
@@ -225,6 +222,7 @@ export function toRFNodes(
|
||||
const fallbackPos = clusterLayout.get(id) || { x: centerX, y: centerY };
|
||||
const pos = existingPos || savedPos || fallbackPos;
|
||||
const isDimmed = hasSelection && node.id !== selectedNodeId && !connectedNodeIds.has(node.id);
|
||||
const clusterLabel = getNodeClusterLabel(node);
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -233,23 +231,17 @@ export function toRFNodes(
|
||||
className: isDimmed ? 'dimmed' : undefined,
|
||||
data: {
|
||||
label: node.title || 'Untitled',
|
||||
dimensions: getOrderedDimensions(node.dimensions),
|
||||
clusterLabel,
|
||||
edgeCount: node.edge_count ?? 0,
|
||||
isExpanded: !baseNodeIds.has(node.id),
|
||||
dbNode: node,
|
||||
dimensionIcons,
|
||||
primaryDimensionColor: getDimensionColor(node.dimensions),
|
||||
clusterKey: viewMode === 'dimension' ? getPrimaryDimension(node.dimensions) : undefined,
|
||||
clusterColor: getClusterColor(clusterLabel),
|
||||
clusterKey: viewMode === 'context' ? clusterLabel : undefined,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform DB edges into React Flow edges, filtering to only those
|
||||
* connecting nodes currently in the graph.
|
||||
* When a node is selected, connected edges are highlighted and others dimmed.
|
||||
*/
|
||||
export function toRFEdges(
|
||||
dbEdges: DbEdge[],
|
||||
nodeIds: Set<string>,
|
||||
@@ -258,19 +250,18 @@ export function toRFEdges(
|
||||
const hasSelection = selectedNodeId !== null;
|
||||
|
||||
return dbEdges
|
||||
.filter(e => nodeIds.has(String(e.from_node_id)) && nodeIds.has(String(e.to_node_id)))
|
||||
.map(e => {
|
||||
.filter((edge) => nodeIds.has(String(edge.from_node_id)) && nodeIds.has(String(edge.to_node_id)))
|
||||
.map((edge) => {
|
||||
const isConnected = hasSelection && (
|
||||
e.from_node_id === selectedNodeId || e.to_node_id === selectedNodeId
|
||||
edge.from_node_id === selectedNodeId || edge.to_node_id === selectedNodeId
|
||||
);
|
||||
const isDimmed = hasSelection && !isConnected;
|
||||
|
||||
const explanation = typeof e.context?.explanation === 'string' ? e.context.explanation : '';
|
||||
const explanation = typeof edge.context?.explanation === 'string' ? edge.context.explanation : '';
|
||||
|
||||
return {
|
||||
id: String(e.id),
|
||||
source: String(e.from_node_id),
|
||||
target: String(e.to_node_id),
|
||||
id: String(edge.id),
|
||||
source: String(edge.from_node_id),
|
||||
target: String(edge.to_node_id),
|
||||
type: 'rahEdge',
|
||||
animated: isConnected,
|
||||
data: { explanation },
|
||||
@@ -284,7 +275,7 @@ export function toRFEdges(
|
||||
});
|
||||
}
|
||||
|
||||
function safeParseJSON(str: string | null | undefined): Record<string, unknown> | null {
|
||||
function safeParseJSON(str: string | null | undefined): Record<string, any> | null {
|
||||
if (!str || str === 'null') return null;
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
|
||||
@@ -1,41 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Node } from '@/types/database';
|
||||
import type { FocusedSkill } from '@/types/skills';
|
||||
|
||||
export type SlotId = 'A' | 'B' | 'C';
|
||||
export type NavigablePaneType = Exclude<PaneType, 'node'>;
|
||||
|
||||
// Stub type for delegation (delegation system removed in rah-light)
|
||||
export type AgentDelegation = {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
task: string;
|
||||
context: string[];
|
||||
status: 'queued' | 'in_progress' | 'completed' | 'failed';
|
||||
summary?: string | null;
|
||||
agentType: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
// The pane types
|
||||
export type PaneType = 'node' | 'contexts' | 'map' | 'views' | 'table' | 'skills';
|
||||
|
||||
// Pane types (chat removed in rah-light)
|
||||
export type PaneType = 'node' | 'contexts' | 'dimensions' | 'map' | 'views' | 'table' | 'skills';
|
||||
|
||||
// State for each slot
|
||||
export interface SlotState {
|
||||
// A single tab within a slot
|
||||
export interface SlotTab {
|
||||
id: string; // 'views', 'map', or 'node-{nodeId}'
|
||||
type: PaneType;
|
||||
// NodePane state
|
||||
nodeTabs?: number[];
|
||||
activeNodeTab?: number | null;
|
||||
// DimensionsPane state
|
||||
selectedDimension?: string | null;
|
||||
viewMode?: 'grid' | 'list' | 'kanban';
|
||||
nodeId?: number; // Only for type === 'node'
|
||||
}
|
||||
|
||||
// State for each slot — persistent tabs
|
||||
export interface SlotState {
|
||||
tabs: SlotTab[];
|
||||
activeTabId: string;
|
||||
}
|
||||
|
||||
// Helper to create a consistent tab ID
|
||||
export function createTabId(type: PaneType, nodeId?: number): string {
|
||||
return type === 'node' && nodeId != null ? `node-${nodeId}` : type;
|
||||
}
|
||||
|
||||
// Helper to get the active tab from a slot state
|
||||
export function getActiveTab(state: SlotState): SlotTab | undefined {
|
||||
return state.tabs.find(t => t.id === state.activeTabId);
|
||||
}
|
||||
|
||||
// Actions panes can emit to the layout
|
||||
export type PaneAction =
|
||||
| { type: 'open-node'; nodeId: number; targetSlot?: SlotId }
|
||||
| { type: 'open-context'; contextId: number | null; contextName?: string | null; targetSlot?: SlotId }
|
||||
| { type: 'open-dimension'; dimension: string; targetSlot?: SlotId }
|
||||
| { type: 'switch-pane-type'; paneType: PaneType }
|
||||
| { type: 'close-pane' };
|
||||
|
||||
@@ -70,14 +67,24 @@ export interface HighlightedPassage {
|
||||
selectedText: string;
|
||||
}
|
||||
|
||||
// ChatPaneProps removed in rah-light
|
||||
|
||||
// DimensionsPane specific props
|
||||
export interface DimensionsPaneProps extends BasePaneProps {
|
||||
onNodeOpen: (nodeId: number) => void;
|
||||
refreshToken: number;
|
||||
onDataChanged?: () => void;
|
||||
onDimensionSelect?: (dimensionName: string | null) => void;
|
||||
export interface ChatPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onOpen: () => void;
|
||||
openTabsData: Node[];
|
||||
activeTabId: number | null;
|
||||
activeContextId?: number | null;
|
||||
activeContextName?: string | null;
|
||||
onClearContext?: () => void;
|
||||
focusedSkill?: FocusedSkill | null;
|
||||
onClearFocusedSkill?: () => void;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
chatMessages?: unknown[];
|
||||
setChatMessages?: React.Dispatch<React.SetStateAction<unknown[]>>;
|
||||
highlightedPassage?: HighlightedPassage | null;
|
||||
onClearPassage?: () => void;
|
||||
onboardingHint?: string | null;
|
||||
onDismissOnboardingHint?: () => void;
|
||||
}
|
||||
|
||||
// MapPane specific props
|
||||
@@ -92,13 +99,12 @@ export interface ViewsPaneProps extends BasePaneProps {
|
||||
onNodeOpenInOtherPane?: (nodeId: number) => void;
|
||||
refreshToken?: number;
|
||||
externalContextFilterId?: number | null;
|
||||
externalDimensionFilter?: string | null;
|
||||
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||
onClearExternalContextFilter?: () => void;
|
||||
onClearExternalDimensionFilter?: () => void;
|
||||
}
|
||||
|
||||
export interface ContextsPaneProps extends BasePaneProps {
|
||||
onNodeOpen: (nodeId: number) => void;
|
||||
onContextSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||
}
|
||||
|
||||
@@ -122,7 +128,6 @@ export interface PaneHeaderProps {
|
||||
export const PANE_LABELS: Record<PaneType, string> = {
|
||||
node: 'Nodes',
|
||||
contexts: 'Contexts',
|
||||
dimensions: 'Dimensions',
|
||||
map: 'Map',
|
||||
views: 'Feed',
|
||||
table: 'Table',
|
||||
@@ -131,11 +136,8 @@ export const PANE_LABELS: Record<PaneType, string> = {
|
||||
|
||||
// Default slot states
|
||||
export const DEFAULT_SLOT_A: SlotState = {
|
||||
type: 'node',
|
||||
nodeTabs: [],
|
||||
activeNodeTab: null,
|
||||
tabs: [{ id: 'views', type: 'views' }],
|
||||
activeTabId: 'views',
|
||||
};
|
||||
|
||||
export const DEFAULT_SLOT_B: SlotState = {
|
||||
type: 'views',
|
||||
};
|
||||
export const DEFAULT_SLOT_B: SlotState | null = null;
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function ApiKeysViewer() {
|
||||
<div style={featuresHeaderStyle}>OpenAI API Key enables:</div>
|
||||
<ul style={featuresListStyle}>
|
||||
<li>Auto-generated descriptions for new nodes</li>
|
||||
<li>Smart dimension assignment</li>
|
||||
<li>Edge explanation inference</li>
|
||||
<li>Semantic search via embeddings</li>
|
||||
</ul>
|
||||
<div style={noteStyle}>
|
||||
|
||||
@@ -3,123 +3,126 @@
|
||||
import { useEffect, useState, type CSSProperties } from 'react';
|
||||
import type { Node } from '@/types/database';
|
||||
|
||||
interface AutoContextSettings {
|
||||
autoContextEnabled: boolean;
|
||||
lastPinnedMigration?: string;
|
||||
}
|
||||
|
||||
interface NodeWithMetrics extends Node {
|
||||
edge_count?: number;
|
||||
}
|
||||
|
||||
interface CapsuleData {
|
||||
userProfile: string;
|
||||
agentProfile: string;
|
||||
lastUpdatedAt: string;
|
||||
}
|
||||
|
||||
export default function ContextViewer() {
|
||||
const [nodes, setNodes] = useState<NodeWithMetrics[]>([]);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [capsule, setCapsule] = useState<CapsuleData | null>(null);
|
||||
const [loadingNodes, setLoadingNodes] = useState(true);
|
||||
const [loadingSettings, setLoadingSettings] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadingCapsule, setLoadingCapsule] = useState(true);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
|
||||
const loadNodes = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/nodes?sortBy=edges&limit=5');
|
||||
const payload = await res.json();
|
||||
setNodes(payload.data || []);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setNodes([]);
|
||||
} finally {
|
||||
setLoadingNodes(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCapsule = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/rah/memory');
|
||||
const payload = await res.json();
|
||||
setCapsule(payload.data?.capsule ?? null);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setCapsule(null);
|
||||
} finally {
|
||||
setLoadingCapsule(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadNodes = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/nodes?sortBy=edges&limit=10');
|
||||
const payload = await res.json();
|
||||
setNodes(payload.data || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoadingNodes(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/system/auto-context');
|
||||
const payload = await res.json() as { success: boolean; data?: AutoContextSettings };
|
||||
if (payload.success && payload.data) {
|
||||
setEnabled(payload.data.autoContextEnabled);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoadingSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadNodes();
|
||||
loadSettings();
|
||||
void loadNodes();
|
||||
void loadCapsule();
|
||||
}, []);
|
||||
|
||||
const handleToggle = async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
const handleReset = async () => {
|
||||
if (!confirm('Reset the context capsule to neutral defaults?')) return;
|
||||
try {
|
||||
const res = await fetch('/api/system/auto-context', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoContextEnabled: !enabled }),
|
||||
});
|
||||
const payload = await res.json() as { success: boolean; data?: AutoContextSettings };
|
||||
if (payload.success && payload.data) {
|
||||
setEnabled(payload.data.autoContextEnabled);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setResetting(true);
|
||||
await fetch('/api/rah/memory', { method: 'DELETE' });
|
||||
await loadCapsule();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<p style={descStyle}>
|
||||
Context summaries and anchor nodes are used first. Global hub nodes remain secondary diagnostics in background context.
|
||||
RA-H now carries one compact context capsule into every conversation. It stays neutral by default,
|
||||
updates only on meaningful changes, and sits alongside hub nodes rather than replacing them.
|
||||
</p>
|
||||
|
||||
{/* Toggle */}
|
||||
<div style={cardStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={labelStyle}>Auto-Context</div>
|
||||
<div style={subLabelStyle}>
|
||||
{loadingSettings ? 'Loading...' : enabled ? 'Enabled' : 'Disabled'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={loadingSettings || saving}
|
||||
style={{
|
||||
...toggleStyle,
|
||||
background: enabled ? 'var(--settings-active-bg)' : 'var(--settings-code-bg)',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
...toggleKnobStyle,
|
||||
left: enabled ? 26 : 4,
|
||||
}} />
|
||||
</button>
|
||||
<div style={capsuleHeaderStyle}>
|
||||
<div>
|
||||
<div style={labelStyle}>Context Capsule</div>
|
||||
<div style={subLabelStyle}>Always injected into the system prompt. Max 200 words total.</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={resetting}
|
||||
style={resetButtonStyle}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--settings-button-hover-bg)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
{resetting ? 'Resetting...' : 'Reset Capsule'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingCapsule ? (
|
||||
<div style={mutedStyle}>Loading capsule...</div>
|
||||
) : capsule ? (
|
||||
<div style={capsuleGridStyle}>
|
||||
<CapsuleSection
|
||||
title="User"
|
||||
value={capsule.userProfile}
|
||||
footer={capsule.lastUpdatedAt ? `Updated ${new Date(capsule.lastUpdatedAt).toLocaleString()}` : 'Never updated'}
|
||||
/>
|
||||
<CapsuleSection title="Agent" value={capsule.agentProfile} />
|
||||
</div>
|
||||
) : (
|
||||
<div style={mutedStyle}>Capsule unavailable.</div>
|
||||
)}
|
||||
|
||||
<div style={{ ...labelStyle, marginTop: 28 }}>Hub Nodes</div>
|
||||
<div style={subLabelStyle}>
|
||||
Your 5 most-connected nodes remain the raw graph grounding and are included in every conversation.
|
||||
</div>
|
||||
|
||||
{/* Nodes List */}
|
||||
<div style={labelStyle}>Top Nodes</div>
|
||||
{loadingNodes ? (
|
||||
<div style={mutedStyle}>Loading...</div>
|
||||
<div style={mutedStyle}>Loading hub nodes...</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<div style={mutedStyle}>No connected nodes yet.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 12 }}>
|
||||
{nodes.map((node) => (
|
||||
<div key={node.id} style={nodeCardStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={nodeTitleStyle}>{node.title || 'Untitled'}</span>
|
||||
<span style={edgeCountStyle}>{node.edge_count ?? 0}</span>
|
||||
</div>
|
||||
{node.dimensions && node.dimensions.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 6, flexWrap: 'wrap' }}>
|
||||
{node.dimensions.slice(0, 3).map((dim) => (
|
||||
<span key={dim} style={dimTagStyle}>{dim}</span>
|
||||
))}
|
||||
{node.description && (
|
||||
<div style={nodeDescriptionStyle}>{node.description}</div>
|
||||
)}
|
||||
{node.context?.name && (
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 8, flexWrap: 'wrap' }}>
|
||||
<span style={contextTagStyle}>{node.context.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -130,52 +133,62 @@ export default function ContextViewer() {
|
||||
);
|
||||
}
|
||||
|
||||
const containerStyle: CSSProperties = { padding: 24, height: '100%', overflow: 'auto' };
|
||||
const descStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)', marginBottom: 20, lineHeight: 1.5 };
|
||||
function CapsuleSection({
|
||||
title,
|
||||
value,
|
||||
footer,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
footer?: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
<div style={labelStyle}>{title}</div>
|
||||
<div style={capsuleBodyStyle}>{value}</div>
|
||||
{footer && <div style={capsuleFooterStyle}>{footer}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerStyle: CSSProperties = { padding: 24, height: '100%', overflow: 'auto' };
|
||||
const descStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)', marginBottom: 20, lineHeight: 1.5, maxWidth: 780 };
|
||||
const capsuleHeaderStyle: CSSProperties = { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, marginBottom: 12 };
|
||||
const capsuleGridStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 };
|
||||
const cardStyle: CSSProperties = {
|
||||
background: 'var(--settings-card-bg)',
|
||||
border: '1px solid var(--settings-border)',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 24,
|
||||
minHeight: 140,
|
||||
};
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: 'var(--settings-text)', marginBottom: 8 };
|
||||
const subLabelStyle: CSSProperties = { fontSize: 12, color: 'var(--settings-muted)' };
|
||||
const mutedStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)' };
|
||||
|
||||
const toggleStyle: CSSProperties = {
|
||||
width: 48,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
border: '1px solid var(--settings-border)',
|
||||
const mutedStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)', marginTop: 10 };
|
||||
const capsuleBodyStyle: CSSProperties = { fontSize: 13, lineHeight: 1.6, color: 'var(--settings-subtext)', whiteSpace: 'pre-wrap' };
|
||||
const capsuleFooterStyle: CSSProperties = { fontSize: 11, color: 'var(--settings-muted)', marginTop: 12 };
|
||||
const resetButtonStyle: CSSProperties = {
|
||||
padding: '8px 14px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--settings-border-strong)',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--settings-text)',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
transition: 'background 0.15s',
|
||||
transition: 'all 0.15s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
const toggleKnobStyle: CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--settings-text)',
|
||||
transition: 'left 0.15s',
|
||||
};
|
||||
|
||||
const nodeCardStyle: CSSProperties = {
|
||||
padding: '12px 14px',
|
||||
background: 'var(--settings-card-bg)',
|
||||
border: '1px solid var(--settings-border)',
|
||||
borderRadius: 6,
|
||||
};
|
||||
|
||||
const nodeTitleStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: 'var(--settings-text)' };
|
||||
const nodeDescriptionStyle: CSSProperties = { fontSize: 12, lineHeight: 1.5, color: 'var(--settings-subtext)', marginTop: 8 };
|
||||
const edgeCountStyle: CSSProperties = { fontSize: 12, color: 'var(--settings-muted)' };
|
||||
|
||||
const dimTagStyle: CSSProperties = {
|
||||
const contextTagStyle: CSSProperties = {
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, type ChangeEvent, type KeyboardEvent } from 'react';
|
||||
import { Folder, Link as LinkIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from 'react';
|
||||
import { Link as LinkIcon } from 'lucide-react';
|
||||
import { Node } from '@/types/database';
|
||||
import { openExternalUrl } from '@/utils/openExternalUrl';
|
||||
|
||||
@@ -11,16 +11,9 @@ interface NodeWithMetrics extends Node {
|
||||
|
||||
interface AppliedFilters {
|
||||
search?: string;
|
||||
dimensions?: string[];
|
||||
sortBy: 'updated' | 'edges';
|
||||
}
|
||||
|
||||
interface PopularDimension {
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
}
|
||||
|
||||
const LIMIT = 50;
|
||||
|
||||
export default function DatabaseViewer() {
|
||||
@@ -30,16 +23,12 @@ export default function DatabaseViewer() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [dimensionInput, setDimensionInput] = useState('');
|
||||
const [dimensionFilters, setDimensionFilters] = useState<string[]>([]);
|
||||
const [sortBy, setSortBy] = useState<'updated' | 'edges'>('updated');
|
||||
|
||||
const [appliedFilters, setAppliedFilters] = useState<AppliedFilters>({ sortBy: 'updated' });
|
||||
const [lockedDimensionSet, setLockedDimensionSet] = useState<Set<string>>(new Set());
|
||||
const [contextHubIds, setContextHubIds] = useState<Set<number>>(new Set());
|
||||
const [hubNodeIds, setHubNodeIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const filtersActive = useMemo(
|
||||
() => Boolean(appliedFilters.search || (appliedFilters.dimensions && appliedFilters.dimensions.length > 0)),
|
||||
() => Boolean(appliedFilters.search),
|
||||
[appliedFilters]
|
||||
);
|
||||
|
||||
@@ -53,9 +42,6 @@ export default function DatabaseViewer() {
|
||||
params.set('offset', ((pageNumber - 1) * LIMIT).toString());
|
||||
params.set('sortBy', filters.sortBy);
|
||||
if (filters.search) params.set('search', filters.search);
|
||||
if (filters.dimensions && filters.dimensions.length > 0) {
|
||||
params.set('dimensions', filters.dimensions.join(','));
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/nodes?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
@@ -74,82 +60,39 @@ export default function DatabaseViewer() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes(page, appliedFilters);
|
||||
void fetchNodes(page, appliedFilters);
|
||||
}, [page, appliedFilters, fetchNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadLockedDimensions = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/dimensions/popular');
|
||||
if (!response.ok) return;
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const priorityDimensions: PopularDimension[] = result.data;
|
||||
setLockedDimensionSet(new Set(priorityDimensions.filter((d) => d.isPriority).map((d) => d.dimension)));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load locked dimensions', err);
|
||||
}
|
||||
};
|
||||
|
||||
loadLockedDimensions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadContextHubs = async () => {
|
||||
const loadHubNodes = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/nodes?sortBy=edges&limit=10');
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json();
|
||||
const ids = new Set<number>((payload.data || []).map((node: Node) => node.id));
|
||||
setContextHubIds(ids);
|
||||
setHubNodeIds(ids);
|
||||
} catch (err) {
|
||||
console.warn('Failed to load auto-context hubs', err);
|
||||
console.warn('Failed to load graph hubs', err);
|
||||
}
|
||||
};
|
||||
|
||||
loadContextHubs();
|
||||
void loadHubNodes();
|
||||
}, []);
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
const payload: AppliedFilters = {
|
||||
sortBy,
|
||||
};
|
||||
|
||||
const payload: AppliedFilters = { sortBy };
|
||||
if (searchInput.trim()) payload.search = searchInput.trim();
|
||||
if (dimensionFilters.length > 0) payload.dimensions = dimensionFilters;
|
||||
|
||||
setAppliedFilters(payload);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearchInput('');
|
||||
setDimensionInput('');
|
||||
setDimensionFilters([]);
|
||||
setSortBy('updated');
|
||||
setAppliedFilters({ sortBy: 'updated' });
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleAddDimension = () => {
|
||||
const next = dimensionInput.trim();
|
||||
if (!next) return;
|
||||
setDimensionFilters((prev) => (prev.includes(next) ? prev : [...prev, next]));
|
||||
setDimensionInput('');
|
||||
};
|
||||
|
||||
const handleDimensionKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleAddDimension();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveDimension = (dimension: string) => {
|
||||
setDimensionFilters((prev) => prev.filter((dim) => dim !== dimension));
|
||||
};
|
||||
|
||||
const handleSortChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = event.target.value === 'edges' ? 'edges' : 'updated';
|
||||
setSortBy(value);
|
||||
@@ -195,27 +138,15 @@ export default function DatabaseViewer() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: 'var(--rah-text-muted)' }}>
|
||||
Loading database...
|
||||
</div>
|
||||
);
|
||||
return <div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>Loading database...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#ef4444' }}>
|
||||
Error: {error}
|
||||
</div>
|
||||
);
|
||||
return <div style={{ padding: '40px', textAlign: 'center', color: '#ef4444' }}>Error: {error}</div>;
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: 'var(--rah-text-muted)' }}>
|
||||
No nodes found
|
||||
</div>
|
||||
);
|
||||
return <div style={{ padding: '40px', textAlign: 'center', color: '#888' }}>No nodes found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -223,71 +154,38 @@ export default function DatabaseViewer() {
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 24px',
|
||||
borderBottom: '1px solid var(--rah-border-strong)',
|
||||
borderBottom: '1px solid #2a2a2a',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: 'var(--rah-text-muted)', gap: '4px' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
|
||||
Search
|
||||
<input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="title or content"
|
||||
style={{
|
||||
background: 'var(--rah-bg-surface)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
color: 'var(--rah-text-base)',
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#ddd',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
minWidth: '220px',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: 'var(--rah-text-muted)', gap: '4px' }}>
|
||||
Dimension
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
value={dimensionInput}
|
||||
onChange={(e) => setDimensionInput(e.target.value)}
|
||||
onKeyDown={handleDimensionKeyDown}
|
||||
placeholder="e.g. research"
|
||||
style={{
|
||||
background: 'var(var(--rah-bg-surface))',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
color: 'var(var(--rah-text-base))',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
minWidth: '180px',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddDimension}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
background: '#1f3529',
|
||||
border: '1px solid #264333',
|
||||
borderRadius: '4px',
|
||||
color: '#c4f5d2',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: 'var(--rah-text-muted)', gap: '4px' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: '#888', gap: '4px' }}>
|
||||
Sort by
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={handleSortChange}
|
||||
style={{
|
||||
background: 'var(--rah-bg-surface)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
color: 'var(--rah-text-base)',
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #2a2a2a',
|
||||
color: '#ddd',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
@@ -298,46 +196,8 @@ export default function DatabaseViewer() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{dimensionFilters.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{dimensionFilters.map((dimension) => (
|
||||
<span
|
||||
key={dimension}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '3px 10px',
|
||||
borderRadius: '999px',
|
||||
background: '#142817',
|
||||
color: '#c4f5d2',
|
||||
fontSize: '11px',
|
||||
border: '1px solid #1f3b23',
|
||||
}}
|
||||
>
|
||||
<Folder size={12} />
|
||||
{dimension}
|
||||
<button
|
||||
onClick={() => handleRemoveDimension(dimension)}
|
||||
style={{
|
||||
marginLeft: '2px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#7de8a5',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
}}
|
||||
aria-label={`Remove ${dimension}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: '12px', color: 'var(--rah-text-muted)' }}>{filterStatus}</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>{filterStatus}</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={handleApplyFilters}
|
||||
@@ -357,10 +217,10 @@ export default function DatabaseViewer() {
|
||||
onClick={handleClearFilters}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-stronger)',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--rah-text-secondary)',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
@@ -372,10 +232,10 @@ export default function DatabaseViewer() {
|
||||
disabled={isFirstPage || filtersActive}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: isFirstPage || filtersActive ? 'var(--rah-bg-active)' : 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-stronger)',
|
||||
background: isFirstPage || filtersActive ? '#1a1a1a' : '#2a2a2a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: isFirstPage || filtersActive ? 'var(--rah-text-muted)' : 'var(--rah-text-secondary)',
|
||||
color: isFirstPage || filtersActive ? '#555' : '#ccc',
|
||||
cursor: isFirstPage || filtersActive ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
@@ -387,10 +247,10 @@ export default function DatabaseViewer() {
|
||||
disabled={isLastPage || filtersActive}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: isLastPage || filtersActive ? 'var(--rah-bg-active)' : 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-stronger)',
|
||||
background: isLastPage || filtersActive ? '#1a1a1a' : '#2a2a2a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: '4px',
|
||||
color: isLastPage || filtersActive ? 'var(--rah-text-muted)' : 'var(--rah-text-secondary)',
|
||||
color: isLastPage || filtersActive ? '#555' : '#ccc',
|
||||
cursor: isLastPage || filtersActive ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
@@ -403,20 +263,20 @@ export default function DatabaseViewer() {
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead style={{ position: 'sticky', top: 0, background: 'var(--rah-bg-active)', zIndex: 1 }}>
|
||||
<thead style={{ position: 'sticky', top: 0, background: '#1a1a1a', zIndex: 1 }}>
|
||||
<tr>
|
||||
{['Node', 'Categories', 'Edges', 'Highlights', 'Updated', 'Created'].map((column) => (
|
||||
{['Node', 'Context', 'Edges', 'Highlights', 'Updated', 'Created'].map((column) => (
|
||||
<th
|
||||
key={column}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
textAlign: 'left',
|
||||
fontSize: '11px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
color: '#888',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
fontWeight: 'normal',
|
||||
borderBottom: '1px solid var(--rah-border-strong)',
|
||||
borderBottom: '1px solid #2a2a2a',
|
||||
}}
|
||||
>
|
||||
{column}
|
||||
@@ -425,105 +285,91 @@ export default function DatabaseViewer() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodes.map((node, index) => {
|
||||
const belongsToLocked = node.dimensions?.some((dimension) => lockedDimensionSet.has(dimension));
|
||||
return (
|
||||
<tr
|
||||
key={node.id}
|
||||
style={{
|
||||
background: index % 2 === 0 ? 'var(--rah-bg-base)' : 'var(--rah-bg-surface)',
|
||||
borderBottom: '1px solid var(--rah-bg-surface)',
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '28%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ fontWeight: 600, color: 'var(--rah-text-base)', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{node.title || 'Untitled node'}
|
||||
{node.link && (
|
||||
<button
|
||||
onClick={() => {
|
||||
void openExternalUrl(node.link!).catch((error) => {
|
||||
console.error('[DatabaseViewer] Failed to open node link', error);
|
||||
window.alert(`Unable to open ${node.link}`);
|
||||
});
|
||||
}}
|
||||
title="Open original link"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#7de8a5',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<LinkIcon size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)', fontFamily: 'JetBrains Mono, monospace' }}>ID: {node.id}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '24%' }}>
|
||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||
{node.dimensions && node.dimensions.length > 0 ? (
|
||||
node.dimensions.slice(0, 3).map((dimension) => (
|
||||
<span
|
||||
key={`${node.id}-${dimension}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '999px',
|
||||
background: '#111914',
|
||||
border: '1px solid #1f2f24',
|
||||
fontSize: '11px',
|
||||
color: '#bbf7d0',
|
||||
}}
|
||||
>
|
||||
<Folder size={11} />
|
||||
{dimension}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>No categories</span>
|
||||
)}
|
||||
{node.dimensions && node.dimensions.length > 3 && (
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>+{node.dimensions.length - 3} more</span>
|
||||
{nodes.map((node, index) => (
|
||||
<tr
|
||||
key={node.id}
|
||||
style={{
|
||||
background: index % 2 === 0 ? '#080808' : '#0d0d0d',
|
||||
borderBottom: '1px solid #111',
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '34%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ fontWeight: 600, color: '#f5f5f5', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{node.title || 'Untitled node'}
|
||||
{node.link && (
|
||||
<button
|
||||
onClick={() => {
|
||||
void openExternalUrl(node.link!).catch((openError) => {
|
||||
console.error('[DatabaseViewer] Failed to open original link', openError);
|
||||
window.alert(`Unable to open ${node.link}`);
|
||||
});
|
||||
}}
|
||||
title="Open original link"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#7de8a5',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<LinkIcon size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '10%' }}>
|
||||
<div style={{ fontWeight: 600, color: 'var(--rah-text-base)' }}>{node.edge_count ?? 0}</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>connections</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '14%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{contextHubIds.has(node.id) ? (
|
||||
<span style={{ fontSize: '11px', color: '#facc15', fontWeight: 600 }}>
|
||||
Auto-context hub
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: '#475569' }}>—</span>
|
||||
)}
|
||||
{belongsToLocked && (
|
||||
<span style={{ fontSize: '11px', color: '#7de8a5' }}>Priority dimension</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
|
||||
<div style={{ fontSize: '12px', color: 'var(--rah-text-base)' }}>{formatTimestamp(node.updated_at)}</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>{formatRelative(node.updated_at)}</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
|
||||
<div style={{ fontSize: '12px', color: 'var(--rah-text-base)' }}>{formatTimestamp(node.created_at)}</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>{formatRelative(node.created_at)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<span style={{ fontSize: '11px', color: '#666', fontFamily: 'JetBrains Mono, monospace' }}>ID: {node.id}</span>
|
||||
{node.description && <span style={{ fontSize: '12px', color: '#9ca3af' }}>{node.description}</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '18%' }}>
|
||||
{node.context?.name ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
padding: '3px 10px',
|
||||
borderRadius: '999px',
|
||||
background: '#142817',
|
||||
color: '#c4f5d2',
|
||||
fontSize: '11px',
|
||||
border: '1px solid #1f3b23',
|
||||
}}
|
||||
>
|
||||
{node.context.name}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: '#555' }}>Unscoped</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '10%' }}>
|
||||
<div style={{ fontWeight: 600, color: '#e2e8f0' }}>{node.edge_count ?? 0}</div>
|
||||
<div style={{ fontSize: '11px', color: '#666' }}>connections</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '14%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{hubNodeIds.has(node.id) ? (
|
||||
<span style={{ fontSize: '11px', color: '#facc15', fontWeight: 600 }}>Graph hub</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: '#475569' }}>—</span>
|
||||
)}
|
||||
{node.metadata?.state && (
|
||||
<span style={{ fontSize: '11px', color: '#7de8a5' }}>{String(node.metadata.state)}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
|
||||
<div style={{ fontSize: '12px', color: '#e2e8f0' }}>{formatTimestamp(node.updated_at)}</div>
|
||||
<div style={{ fontSize: '11px', color: '#666' }}>{formatRelative(node.updated_at)}</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
|
||||
<div style={{ fontSize: '12px', color: '#cbd5f5' }}>{formatTimestamp(node.created_at)}</div>
|
||||
<div style={{ fontSize: '11px', color: '#666' }}>{formatRelative(node.created_at)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -127,7 +127,7 @@ export default function ExternalAgentsPanel() {
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
<HelperCard
|
||||
title="Add to RA-H"
|
||||
body={`"Summarize our meeting and add it to RA-H under dimensions Strategy, Q1 Execution."`}
|
||||
body={`"Summarize our meeting and add it to RA-H. If a context is obvious, use it. If not, leave context blank."`}
|
||||
/>
|
||||
<HelperCard
|
||||
title="Search RA-H"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"use client";
|
||||
|
||||
export default function AutoUpdateManager() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Filter, ChevronDown, ChevronLeft, ChevronRight, X, ArrowUpDown, Search, ExternalLink } from 'lucide-react';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, X, ArrowUpDown, Search, ExternalLink } from 'lucide-react';
|
||||
import type { Node } from '@/types/database';
|
||||
import { formatRelativeDate } from '@/utils/formatDate';
|
||||
|
||||
@@ -17,12 +17,6 @@ const SORT_LABELS: Record<SortOrder, string> = {
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
interface DimensionSummary {
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
}
|
||||
|
||||
interface DatabaseTableViewProps {
|
||||
onNodeClick: (nodeId: number) => void;
|
||||
refreshToken?: number;
|
||||
@@ -46,41 +40,11 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('updated');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeSearch, setActiveSearch] = useState('');
|
||||
const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
|
||||
const [dimensions, setDimensions] = useState<DimensionSummary[]>([]);
|
||||
const [showFilterPicker, setShowFilterPicker] = useState(false);
|
||||
const [filterSearchQuery, setFilterSearchQuery] = useState('');
|
||||
const [showSortDropdown, setShowSortDropdown] = useState(false);
|
||||
const [hoveredRow, setHoveredRow] = useState<number | null>(null);
|
||||
|
||||
const filterPickerRef = useRef<HTMLDivElement>(null);
|
||||
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sortedDimensions = useMemo(() => {
|
||||
return [...dimensions].sort((a, b) => {
|
||||
if (a.isPriority && !b.isPriority) return -1;
|
||||
if (!a.isPriority && b.isPriority) return 1;
|
||||
return a.dimension.localeCompare(b.dimension);
|
||||
});
|
||||
}, [dimensions]);
|
||||
|
||||
const filterPickerDimensions = sortedDimensions.filter(d =>
|
||||
d.dimension.toLowerCase().includes(filterSearchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Fetch dimensions
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/dimensions');
|
||||
const data = await res.json();
|
||||
if (data.success) setDimensions(data.data || []);
|
||||
} catch (e) {
|
||||
console.error('Error fetching dimensions:', e);
|
||||
}
|
||||
})();
|
||||
}, [refreshToken]);
|
||||
|
||||
// Fetch nodes
|
||||
const fetchNodes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -91,10 +55,6 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
||||
sortBy: sortOrder,
|
||||
});
|
||||
if (activeSearch) params.set('search', activeSearch);
|
||||
if (selectedFilters.length > 0) {
|
||||
params.set('dimensions', selectedFilters.join(','));
|
||||
params.set('dimensionsMatch', 'all');
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/nodes?${params}`);
|
||||
const data = await res.json();
|
||||
@@ -107,32 +67,27 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, sortOrder, activeSearch, selectedFilters]);
|
||||
}, [page, sortOrder, activeSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes();
|
||||
}, [fetchNodes, refreshToken]);
|
||||
|
||||
// Reset to page 1 when filters/sort/search change
|
||||
const filtersKey = selectedFilters.join(',');
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [sortOrder, activeSearch, filtersKey]);
|
||||
}, [sortOrder, activeSearch]);
|
||||
|
||||
// Close dropdowns on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (showFilterPicker && filterPickerRef.current && !filterPickerRef.current.contains(e.target as HTMLElement)) {
|
||||
setShowFilterPicker(false);
|
||||
setFilterSearchQuery('');
|
||||
}
|
||||
if (showSortDropdown && sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as HTMLElement)) {
|
||||
setShowSortDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showFilterPicker, showSortDropdown]);
|
||||
}, [showSortDropdown]);
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -189,108 +144,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Filter chips + button */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flex: 1, flexWrap: 'wrap' }}>
|
||||
{selectedFilters.map(f => (
|
||||
<div key={f} style={{
|
||||
display: 'flex', alignItems: 'center', gap: '4px',
|
||||
padding: '2px 7px',
|
||||
background: 'rgba(34, 197, 94, 0.06)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.12)',
|
||||
borderRadius: '4px', fontSize: '11px', color: '#5a9'
|
||||
}}>
|
||||
{f}
|
||||
<button
|
||||
onClick={() => setSelectedFilters(selectedFilters.filter(x => x !== f))}
|
||||
style={{ background: 'transparent', border: 'none', color: '#5a9', cursor: 'pointer', padding: 0, display: 'flex' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#5a9'; }}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ position: 'relative' }} ref={filterPickerRef}>
|
||||
<button
|
||||
onClick={() => setShowFilterPicker(!showFilterPicker)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '4px',
|
||||
padding: '4px 7px', background: 'transparent',
|
||||
border: '1px solid var(--rah-border)', borderRadius: '5px',
|
||||
color: 'var(--rah-text-soft)', fontSize: '11px', cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<Filter size={11} />
|
||||
Filter
|
||||
</button>
|
||||
|
||||
{showFilterPicker && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', left: 0, marginTop: '4px',
|
||||
background: 'var(--rah-bg-panel)', border: '1px solid var(--rah-border)', borderRadius: '10px',
|
||||
padding: '6px', minWidth: '220px', maxHeight: '320px', overflowY: 'auto',
|
||||
zIndex: 1000, boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
value={filterSearchQuery}
|
||||
onChange={(e) => setFilterSearchQuery(e.target.value)}
|
||||
placeholder="Search dimensions..."
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%', padding: '7px 10px', background: 'var(--rah-bg-base)',
|
||||
border: '1px solid transparent', borderRadius: '6px',
|
||||
color: 'var(--rah-text-active)', fontSize: '12px', marginBottom: '4px', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
{filterPickerDimensions.length === 0 ? (
|
||||
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
|
||||
No matching dimensions
|
||||
</div>
|
||||
) : (
|
||||
filterPickerDimensions.map(d => (
|
||||
<button
|
||||
key={d.dimension}
|
||||
onClick={() => {
|
||||
if (!selectedFilters.includes(d.dimension)) {
|
||||
setSelectedFilters([...selectedFilters, d.dimension]);
|
||||
}
|
||||
setShowFilterPicker(false);
|
||||
setFilterSearchQuery('');
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
width: '100%', padding: '7px 10px', background: 'transparent',
|
||||
border: 'none', borderRadius: '5px', color: 'var(--rah-text-secondary)',
|
||||
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<span>{d.dimension}</span>
|
||||
<span style={{ color: 'var(--rah-text-muted)', fontSize: '10px', background: 'var(--rah-bg-active)', padding: '1px 6px', borderRadius: '10px' }}>
|
||||
{d.count}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedFilters.length > 0 && (
|
||||
<button
|
||||
onClick={() => setSelectedFilters([])}
|
||||
style={{ padding: '4px 6px', background: 'transparent', border: 'none', color: 'var(--rah-text-muted)', fontSize: '11px', cursor: 'pointer' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
|
||||
@@ -393,7 +247,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
||||
<div style={{ padding: '40px', color: 'var(--rah-text-muted)', textAlign: 'center', fontSize: '13px' }}>Loading...</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<div style={{ padding: '40px', color: 'var(--rah-text-muted)', textAlign: 'center', fontSize: '13px' }}>
|
||||
{activeSearch || selectedFilters.length > 0 ? 'No nodes match your filters.' : 'No nodes yet.'}
|
||||
{activeSearch ? 'No nodes match your search.' : 'No nodes yet.'}
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ minWidth: '1600px', width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
@@ -402,9 +256,9 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
||||
<th style={thStyle({ width: '240px' })}>TITLE</th>
|
||||
<th style={thStyle({ width: '55px', textAlign: 'right' })}>ID</th>
|
||||
<th style={thStyle({ width: '200px' })}>DESCRIPTION</th>
|
||||
<th style={thStyle({ width: '160px' })}>NOTES</th>
|
||||
<th style={thStyle({ width: '160px' })}>SOURCE</th>
|
||||
<th style={thStyle({ width: '180px' })}>LINK</th>
|
||||
<th style={thStyle({ width: '160px' })}>DIMENSIONS</th>
|
||||
<th style={thStyle({ width: '160px' })}>CONTEXT</th>
|
||||
<th style={thStyle({ width: '50px', textAlign: 'right' })}>EDGES</th>
|
||||
<th style={thStyle({ width: '90px' })}>EVENT</th>
|
||||
<th style={thStyle({ width: '85px' })}>UPDATED</th>
|
||||
@@ -488,26 +342,19 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Dimensions */}
|
||||
{/* Context */}
|
||||
<td style={tdStyle()}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px', overflow: 'hidden', maxHeight: '36px' }}>
|
||||
{node.dimensions && node.dimensions.length > 0 ? (
|
||||
{node.context?.name ? (
|
||||
<>
|
||||
{node.dimensions.slice(0, 3).map(d => (
|
||||
<span key={d} style={{
|
||||
fontSize: '9px', padding: '1px 5px',
|
||||
background: 'var(--rah-accent-green-soft)', border: '1px solid var(--rah-accent-green-soft-strong)',
|
||||
color: 'var(--rah-accent-green)', borderRadius: '3px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
{node.dimensions.length > 3 && (
|
||||
<span style={{ fontSize: '9px', color: 'var(--rah-text-muted)' }}>
|
||||
+{node.dimensions.length - 3}
|
||||
</span>
|
||||
)}
|
||||
<span style={{
|
||||
fontSize: '9px', padding: '1px 5px',
|
||||
background: 'var(--rah-accent-green-soft)', border: '1px solid var(--rah-accent-green-soft-strong)',
|
||||
color: 'var(--rah-accent-green)', borderRadius: '3px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{node.context.name}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ fontSize: '10px', color: 'var(--rah-text-muted)' }}>{'\u2014'}</span>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { Node } from '@/types/database';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||
import { getNodeProcessedState } from '@/services/nodes/metadata';
|
||||
|
||||
interface GridViewProps {
|
||||
@@ -11,7 +10,6 @@ interface GridViewProps {
|
||||
}
|
||||
|
||||
export default function GridView({ nodes, onNodeClick }: GridViewProps) {
|
||||
const { dimensionIcons } = useDimensionIcons();
|
||||
const truncateContent = (content?: string, maxLength: number = 120) => {
|
||||
if (!content) return '';
|
||||
if (content.length <= maxLength) return content;
|
||||
@@ -93,7 +91,7 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
|
||||
borderRadius: '6px',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{getNodeIcon(node, dimensionIcons, 14)}
|
||||
{getNodeIcon(node, 14)}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
@@ -135,37 +133,25 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer with Dimensions */}
|
||||
{node.dimensions && node.dimensions.length > 0 && (
|
||||
{/* Footer with Context */}
|
||||
{node.context?.name && (
|
||||
<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={{
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
background: '#1a1a1a',
|
||||
borderRadius: '3px',
|
||||
fontSize: '10px',
|
||||
color: '#555'
|
||||
}}>
|
||||
+{node.dimensions.length - 3}
|
||||
</span>
|
||||
)}
|
||||
color: '#888'
|
||||
}}
|
||||
>
|
||||
{node.context.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -1,536 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
import { Inbox } from 'lucide-react';
|
||||
import { Node } from '@/types/database';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||
import { formatRelativeDate } from '@/utils/formatDate';
|
||||
import { getNodeProcessedState } from '@/services/nodes/metadata';
|
||||
|
||||
@@ -13,7 +12,6 @@ interface ListViewProps {
|
||||
}
|
||||
|
||||
export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
const { dimensionIcons } = useDimensionIcons();
|
||||
|
||||
const truncateContent = (content?: string, maxLength: number = 100) => {
|
||||
if (!content) return '';
|
||||
@@ -97,7 +95,7 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
borderRadius: '6px',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{getNodeIcon(node, dimensionIcons, 16)}
|
||||
{getNodeIcon(node, 16)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -148,38 +146,19 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
}}>
|
||||
{processedState}
|
||||
</span>
|
||||
{/* 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 8px',
|
||||
background: 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '11px',
|
||||
color: 'var(--rah-text-base)'
|
||||
}}
|
||||
>
|
||||
{dim}
|
||||
</span>
|
||||
))}
|
||||
{node.dimensions.length > 3 && (
|
||||
<span style={{
|
||||
padding: '2px 6px',
|
||||
fontSize: '11px',
|
||||
color: 'var(--rah-text-muted)'
|
||||
}}>
|
||||
+{node.dimensions.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{node.context?.name && (
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
background: 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '11px',
|
||||
color: 'var(--rah-text-base)'
|
||||
}}
|
||||
>
|
||||
{node.context.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Date */}
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Filter, ChevronDown, X, ArrowUpDown, GripVertical, Inbox, Check } from 'lucide-react';
|
||||
import type { ContextSummary, Node } from '@/types/database';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||
import { usePersistentState } from '@/hooks/usePersistentState';
|
||||
import type { PendingNode } from '@/components/layout/ThreePanelLayout';
|
||||
import { getNodeProcessedState } from '@/services/nodes/metadata';
|
||||
@@ -24,41 +23,6 @@ const SORT_LABELS: Record<SortOrder, string> = {
|
||||
|
||||
const DOCUMENT_MAX_WIDTH = '980px';
|
||||
|
||||
const pickerRowStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
padding: '7px 10px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
color: 'var(--rah-text-secondary)',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
};
|
||||
|
||||
const pickerCountStyle: React.CSSProperties = {
|
||||
color: 'var(--rah-text-muted)',
|
||||
fontSize: '10px',
|
||||
background: 'var(--rah-bg-active)',
|
||||
padding: '1px 6px',
|
||||
borderRadius: '10px',
|
||||
};
|
||||
|
||||
interface ColumnFilter {
|
||||
id: string;
|
||||
dimension: string;
|
||||
}
|
||||
|
||||
interface DimensionSummary {
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
interface ViewsOverlayProps {
|
||||
onNodeClick: (nodeId: number) => void;
|
||||
onNodeOpenInOtherPane?: (nodeId: number) => void;
|
||||
@@ -66,10 +30,8 @@ interface ViewsOverlayProps {
|
||||
pendingNodes?: PendingNode[];
|
||||
onDismissPending?: (id: string) => void;
|
||||
externalContextFilterId?: number | null;
|
||||
externalDimensionFilter?: string | null;
|
||||
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||
onClearExternalContextFilter?: () => void;
|
||||
onClearExternalDimensionFilter?: () => void;
|
||||
toolbarHost?: HTMLDivElement | null;
|
||||
}
|
||||
|
||||
@@ -225,6 +187,29 @@ function SkeletonCard() {
|
||||
);
|
||||
}
|
||||
|
||||
const pickerRowStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
padding: '7px 10px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
color: 'var(--rah-text-secondary)',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
};
|
||||
|
||||
const pickerCountStyle: React.CSSProperties = {
|
||||
color: 'var(--rah-text-muted)',
|
||||
fontSize: '10px',
|
||||
background: 'var(--rah-bg-active)',
|
||||
padding: '1px 6px',
|
||||
borderRadius: '10px',
|
||||
};
|
||||
|
||||
export default function ViewsOverlay({
|
||||
onNodeClick,
|
||||
onNodeOpenInOtherPane,
|
||||
@@ -232,17 +217,11 @@ export default function ViewsOverlay({
|
||||
pendingNodes,
|
||||
onDismissPending,
|
||||
externalContextFilterId = null,
|
||||
externalDimensionFilter = null,
|
||||
onContextFilterSelect,
|
||||
onClearExternalContextFilter,
|
||||
onClearExternalDimensionFilter,
|
||||
toolbarHost,
|
||||
}: ViewsOverlayProps) {
|
||||
const { dimensionIcons } = useDimensionIcons();
|
||||
|
||||
// Dimensions for filter picker
|
||||
const [dimensions, setDimensions] = useState<DimensionSummary[]>([]);
|
||||
const [dimensionsLoading, setDimensionsLoading] = useState(true);
|
||||
const [contexts, setContexts] = useState<ContextSummary[]>([]);
|
||||
const [contextsLoading, setContextsLoading] = useState(true);
|
||||
|
||||
@@ -256,13 +235,8 @@ export default function ViewsOverlay({
|
||||
const [reorderDragIndex, setReorderDragIndex] = useState<number | null>(null);
|
||||
const [reorderDropIndex, setReorderDropIndex] = useState<number | null>(null);
|
||||
|
||||
// Filter system state
|
||||
const [columns, setColumns] = useState<ColumnFilter[]>([]);
|
||||
const [filteredNodes, setFilteredNodes] = useState<Node[]>([]);
|
||||
const [filteredNodesLoading, setFilteredNodesLoading] = useState(false);
|
||||
const [showFilterPicker, setShowFilterPicker] = useState(false);
|
||||
const [showDimensionPicker, setShowDimensionPicker] = useState(false);
|
||||
const [filterSearchQuery, setFilterSearchQuery] = useState('');
|
||||
const [showSortDropdown, setShowSortDropdown] = useState(false);
|
||||
|
||||
const processedFilter: ProcessedFilter = sortOrder === 'processed'
|
||||
@@ -271,41 +245,6 @@ export default function ViewsOverlay({
|
||||
? 'not_processed'
|
||||
: 'all';
|
||||
|
||||
// Derive selectedFilters for backward compatibility (unique dimensions)
|
||||
const selectedFilters = useMemo(() => {
|
||||
if (externalDimensionFilter) {
|
||||
return [externalDimensionFilter];
|
||||
}
|
||||
|
||||
return [...new Set(columns.map(c => c.dimension))];
|
||||
}, [columns, externalDimensionFilter]);
|
||||
|
||||
// Sorted dimensions (locked first)
|
||||
const sortedDimensions = useMemo(() => {
|
||||
return [...dimensions].sort((a, b) => {
|
||||
if (a.isPriority && !b.isPriority) return -1;
|
||||
if (!a.isPriority && b.isPriority) return 1;
|
||||
return a.dimension.localeCompare(b.dimension);
|
||||
});
|
||||
}, [dimensions]);
|
||||
|
||||
// Fetch functions
|
||||
const fetchDimensions = useCallback(async () => {
|
||||
setDimensionsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/dimensions');
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Failed to fetch dimensions');
|
||||
}
|
||||
setDimensions(data.data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching dimensions:', error);
|
||||
} finally {
|
||||
setDimensionsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchContexts = useCallback(async () => {
|
||||
setContextsLoading(true);
|
||||
try {
|
||||
@@ -364,135 +303,35 @@ export default function ViewsOverlay({
|
||||
}
|
||||
}, [sortOrder, customOrder, applyProcessedFilter, externalContextFilterId]);
|
||||
|
||||
const fetchFilteredNodes = useCallback(async (filters: string[]) => {
|
||||
if (filters.length === 0) {
|
||||
fetchAllNodes();
|
||||
return;
|
||||
}
|
||||
setFilteredNodesLoading(true);
|
||||
try {
|
||||
const apiSort = sortOrder === 'custom' || sortOrder === 'processed' || sortOrder === 'not_processed'
|
||||
? 'updated'
|
||||
: sortOrder;
|
||||
const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}&dimensions=${encodeURIComponent(filters.join(','))}&dimensionsMatch=all${externalContextFilterId ? `&contextId=${externalContextFilterId}` : ''}`);
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Failed to fetch nodes');
|
||||
}
|
||||
const nodes: Node[] = data.data || [];
|
||||
if (sortOrder === 'custom' && customOrder.length > 0) {
|
||||
const orderMap = new Map(customOrder.map((id, idx) => [id, idx]));
|
||||
const ordered: Node[] = [];
|
||||
const unordered: Node[] = [];
|
||||
for (const node of nodes) {
|
||||
if (orderMap.has(node.id)) {
|
||||
ordered.push(node);
|
||||
} else {
|
||||
unordered.push(node);
|
||||
}
|
||||
}
|
||||
ordered.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
|
||||
setFilteredNodes(applyProcessedFilter([...ordered, ...unordered]));
|
||||
} else {
|
||||
setFilteredNodes(applyProcessedFilter(nodes));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching filtered nodes:', error);
|
||||
} finally {
|
||||
setFilteredNodesLoading(false);
|
||||
}
|
||||
}, [fetchAllNodes, sortOrder, customOrder, applyProcessedFilter, externalContextFilterId]);
|
||||
|
||||
// Stringify filters for stable dependency
|
||||
const filtersKey = selectedFilters.join(',');
|
||||
|
||||
// Fetch dimensions on mount
|
||||
useEffect(() => {
|
||||
fetchDimensions();
|
||||
}, [fetchDimensions]);
|
||||
|
||||
// Fetch contexts on mount
|
||||
useEffect(() => {
|
||||
fetchContexts();
|
||||
}, [fetchContexts]);
|
||||
|
||||
// Fetch nodes on mount and when filters/sort/refreshToken change
|
||||
// Fetch nodes on mount and when sort/refreshToken/context filter change
|
||||
useEffect(() => {
|
||||
if (refreshToken > 0) {
|
||||
console.log('🔄 Feed refreshing due to SSE event (refreshToken:', refreshToken, ')');
|
||||
}
|
||||
if (selectedFilters.length > 0) {
|
||||
fetchFilteredNodes(selectedFilters);
|
||||
} else {
|
||||
fetchAllNodes();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filtersKey, sortOrder, refreshToken, externalContextFilterId]);
|
||||
fetchAllNodes();
|
||||
}, [fetchAllNodes, refreshToken, sortOrder, externalContextFilterId]);
|
||||
|
||||
// Also refresh dimensions when data changes (for filter picker counts)
|
||||
// Refresh contexts when data changes
|
||||
useEffect(() => {
|
||||
if (refreshToken > 0) {
|
||||
fetchDimensions();
|
||||
fetchContexts();
|
||||
}
|
||||
}, [refreshToken, fetchDimensions, fetchContexts]);
|
||||
|
||||
// Column management
|
||||
const addColumn = (dimension: string) => {
|
||||
if (externalDimensionFilter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newColumn: ColumnFilter = {
|
||||
id: `col-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
dimension
|
||||
};
|
||||
setColumns([...columns, newColumn]);
|
||||
setShowFilterPicker(false);
|
||||
setFilterSearchQuery('');
|
||||
};
|
||||
|
||||
const removeFilter = (dimension: string) => {
|
||||
if (externalDimensionFilter && dimension === externalDimensionFilter) {
|
||||
onClearExternalDimensionFilter?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = columns.findIndex(c => c.dimension === dimension);
|
||||
if (idx !== -1) {
|
||||
setColumns(columns.filter((_, i) => i !== idx));
|
||||
}
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
if (externalContextFilterId) {
|
||||
onClearExternalContextFilter?.();
|
||||
}
|
||||
|
||||
if (externalDimensionFilter) {
|
||||
onClearExternalDimensionFilter?.();
|
||||
}
|
||||
|
||||
setColumns([]);
|
||||
};
|
||||
|
||||
// Filter dimensions for picker
|
||||
const filterPickerDimensions = sortedDimensions.filter(d =>
|
||||
d.dimension.toLowerCase().includes(filterSearchQuery.toLowerCase())
|
||||
);
|
||||
}, [refreshToken, fetchContexts]);
|
||||
|
||||
// Close dropdowns on outside click
|
||||
const filterPickerRef = useRef<HTMLDivElement>(null);
|
||||
const dimensionPickerRef = useRef<HTMLDivElement>(null);
|
||||
const contextPickerRef = useRef<HTMLDivElement>(null);
|
||||
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [showContextPicker, setShowContextPicker] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (showFilterPicker && filterPickerRef.current && !filterPickerRef.current.contains(e.target as HTMLElement)) {
|
||||
setShowFilterPicker(false);
|
||||
setFilterSearchQuery('');
|
||||
}
|
||||
if (showDimensionPicker && dimensionPickerRef.current && !dimensionPickerRef.current.contains(e.target as HTMLElement)) {
|
||||
setShowDimensionPicker(false);
|
||||
if (showContextPicker && contextPickerRef.current && !contextPickerRef.current.contains(e.target as HTMLElement)) {
|
||||
setShowContextPicker(false);
|
||||
}
|
||||
if (showSortDropdown && sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as HTMLElement)) {
|
||||
setShowSortDropdown(false);
|
||||
@@ -500,7 +339,7 @@ export default function ViewsOverlay({
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showDimensionPicker, showFilterPicker, showSortDropdown]);
|
||||
}, [showContextPicker, showSortDropdown]);
|
||||
|
||||
// Reorder handlers
|
||||
const handleReorderDrop = useCallback((dropIdx: number) => {
|
||||
@@ -557,17 +396,13 @@ export default function ViewsOverlay({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating processed state from feed:', error);
|
||||
if (selectedFilters.length > 0) {
|
||||
void fetchFilteredNodes(selectedFilters);
|
||||
} else {
|
||||
void fetchAllNodes();
|
||||
}
|
||||
void fetchAllNodes();
|
||||
}
|
||||
}, [fetchAllNodes, fetchFilteredNodes, selectedFilters]);
|
||||
}, [fetchAllNodes]);
|
||||
|
||||
// Render node card
|
||||
const renderNodeCard = (node: Node, index: number) => {
|
||||
const nodeIcon = getNodeIcon(node, dimensionIcons, 14);
|
||||
const nodeIcon = getNodeIcon(node, 14);
|
||||
const isCustomSort = sortOrder === 'custom';
|
||||
const isDragSource = reorderDragIndex === index;
|
||||
const isDropTarget = reorderDropIndex === index;
|
||||
@@ -587,7 +422,7 @@ export default function ViewsOverlay({
|
||||
onDragStart={(e) => {
|
||||
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('application/node-info', JSON.stringify({ id: node.id, title }));
|
||||
e.dataTransfer.setData('text/plain', `[NODE:${node.id}:"${title}"]`);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
@@ -636,8 +471,7 @@ export default function ViewsOverlay({
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', minWidth: 0 }}>
|
||||
{/* Grip handle — only in custom sort mode */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', minWidth: 0 }}>
|
||||
{isCustomSort && (
|
||||
<div
|
||||
draggable
|
||||
@@ -695,137 +529,112 @@ export default function ViewsOverlay({
|
||||
</button>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
|
||||
<span style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
fontWeight: 600,
|
||||
color: 'var(--rah-text-active)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
}}>
|
||||
{node.title || 'Untitled'}
|
||||
</span>
|
||||
{node.context?.name ? (
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
lineHeight: 1.2,
|
||||
padding: '3px 7px',
|
||||
borderRadius: '999px',
|
||||
background: 'rgba(34, 197, 94, 0.08)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.18)',
|
||||
color: 'var(--rah-accent-green)',
|
||||
flexShrink: 0,
|
||||
maxWidth: '40%',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--rah-accent-green)',
|
||||
background: 'color-mix(in srgb, var(--rah-accent-green) 12%, var(--rah-bg-panel))',
|
||||
border: '1px solid color-mix(in srgb, var(--rah-accent-green) 32%, var(--rah-border))',
|
||||
padding: '3px 7px',
|
||||
borderRadius: '999px',
|
||||
lineHeight: 1.2,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{node.context.name}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '6px',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
border: '1px solid var(--rah-border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{nodeIcon}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '999px',
|
||||
fontFamily: 'monospace',
|
||||
flexShrink: 0,
|
||||
lineHeight: 1.2,
|
||||
}}>
|
||||
#{node.id}
|
||||
</span>
|
||||
{node.edge_count != null && node.edge_count > 0 && (
|
||||
<span style={{
|
||||
minWidth: '18px',
|
||||
height: '18px',
|
||||
padding: '0 5px',
|
||||
borderRadius: '999px',
|
||||
background: 'var(--rah-accent-green-soft)',
|
||||
border: '1px solid var(--rah-accent-green-soft-strong)',
|
||||
color: 'var(--rah-accent-green)',
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
lineHeight: '1.35',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}>
|
||||
{descPreview || 'No description'}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
{node.edge_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
lineHeight: '1.35',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}>
|
||||
{descPreview || 'No description'}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px',
|
||||
minWidth: 0,
|
||||
maxWidth: '46%',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{node.dimensions && node.dimensions.length > 0 ? (
|
||||
<>
|
||||
{node.dimensions.map(d => (
|
||||
<span
|
||||
key={d}
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
padding: '2px 7px',
|
||||
background: 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
color: 'var(--rah-text-base)',
|
||||
borderRadius: '999px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{d}
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '6px',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
border: '1px solid var(--rah-border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{nodeIcon}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '999px',
|
||||
fontFamily: 'monospace',
|
||||
flexShrink: 0,
|
||||
lineHeight: 1.2,
|
||||
}}>
|
||||
#{node.id}
|
||||
</span>
|
||||
{node.edge_count != null && node.edge_count > 0 ? (
|
||||
<span style={{
|
||||
minWidth: '18px',
|
||||
height: '18px',
|
||||
padding: '0 5px',
|
||||
borderRadius: '999px',
|
||||
background: 'var(--rah-accent-green-soft)',
|
||||
border: '1px solid var(--rah-accent-green-soft-strong)',
|
||||
color: 'var(--rah-accent-green)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
{node.edge_count}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -843,87 +652,6 @@ export default function ViewsOverlay({
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', flex: 1 }}>
|
||||
<div style={{ position: 'relative' }} ref={filterPickerRef}>
|
||||
<button
|
||||
onClick={() => setShowFilterPicker(!showFilterPicker)}
|
||||
title="Context"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderRadius: '5px',
|
||||
color: 'var(--rah-text-soft)',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease'
|
||||
}}
|
||||
>
|
||||
<Filter size={11} />
|
||||
{externalContextFilterId
|
||||
? (contexts.find((context) => context.id === externalContextFilterId)?.name || 'Context')
|
||||
: 'Context'}
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
|
||||
{showFilterPicker && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
marginTop: '4px',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderRadius: '10px',
|
||||
padding: '6px',
|
||||
minWidth: '220px',
|
||||
maxHeight: '320px',
|
||||
overflowY: 'auto',
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.4)'
|
||||
}}>
|
||||
{contextsLoading ? (
|
||||
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
|
||||
Loading contexts...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
onContextFilterSelect?.(null, null);
|
||||
setShowFilterPicker(false);
|
||||
}}
|
||||
style={pickerRowStyle}
|
||||
>
|
||||
<span>All contexts</span>
|
||||
</button>
|
||||
{contexts.map((context) => (
|
||||
<button
|
||||
key={context.id}
|
||||
onClick={() => {
|
||||
onContextFilterSelect?.(context.id, context.name);
|
||||
setShowFilterPicker(false);
|
||||
}}
|
||||
style={{
|
||||
...pickerRowStyle,
|
||||
background: externalContextFilterId === context.id ? 'rgba(255,255,255,0.04)' : 'transparent',
|
||||
color: externalContextFilterId === context.id ? 'var(--rah-text-active)' : 'var(--rah-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', paddingRight: '8px' }}>
|
||||
{context.name}
|
||||
</span>
|
||||
<span style={pickerCountStyle}>{context.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{externalContextFilterId ? (
|
||||
<div
|
||||
style={{
|
||||
@@ -938,7 +666,7 @@ export default function ViewsOverlay({
|
||||
color: '#5a9'
|
||||
}}
|
||||
>
|
||||
{contexts.find((context) => context.id === externalContextFilterId)?.name || 'Context'}
|
||||
{contexts.find((context) => context.id === externalContextFilterId)?.name ?? 'Context'}
|
||||
<button
|
||||
onClick={() => onClearExternalContextFilter?.()}
|
||||
style={{
|
||||
@@ -956,53 +684,16 @@ export default function ViewsOverlay({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedFilters.map(filter => (
|
||||
<div
|
||||
key={filter}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '5px',
|
||||
padding: '3px 8px',
|
||||
background: 'rgba(34, 197, 94, 0.06)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.12)',
|
||||
borderRadius: '5px',
|
||||
fontSize: '11px',
|
||||
color: '#5a9'
|
||||
}}
|
||||
>
|
||||
{filter}
|
||||
<button
|
||||
onClick={() => removeFilter(filter)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#5a9',
|
||||
cursor: 'pointer',
|
||||
padding: '0',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#5a9'; }}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ position: 'relative' }} ref={dimensionPickerRef}>
|
||||
<div style={{ position: 'relative' }} ref={contextPickerRef}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDimensionPicker(!showDimensionPicker);
|
||||
}}
|
||||
title="Dimensions"
|
||||
onClick={() => setShowContextPicker(!showContextPicker)}
|
||||
title="Context filter"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
background: 'transparent',
|
||||
background: externalContextFilterId ? 'rgba(34, 197, 94, 0.06)' : 'transparent',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderRadius: '5px',
|
||||
color: 'var(--rah-text-soft)',
|
||||
@@ -1010,21 +701,12 @@ export default function ViewsOverlay({
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.04)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border)';
|
||||
}}
|
||||
>
|
||||
<Filter size={11} />
|
||||
Dimension
|
||||
<ChevronDown size={10} />
|
||||
Context
|
||||
</button>
|
||||
|
||||
{showDimensionPicker && (
|
||||
{showContextPicker && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
@@ -1040,71 +722,37 @@ export default function ViewsOverlay({
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.4)'
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
value={filterSearchQuery}
|
||||
onChange={(e) => setFilterSearchQuery(e.target.value)}
|
||||
placeholder="Search dimensions..."
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '7px 10px',
|
||||
background: 'var(--rah-bg-base)',
|
||||
border: '1px solid transparent',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--rah-text-active)',
|
||||
fontSize: '12px',
|
||||
marginBottom: '4px',
|
||||
outline: 'none',
|
||||
<button
|
||||
onClick={() => {
|
||||
onClearExternalContextFilter?.();
|
||||
setShowContextPicker(false);
|
||||
}}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--rah-border-strong)'; }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
|
||||
/>
|
||||
{dimensionsLoading ? (
|
||||
style={pickerRowStyle}
|
||||
>
|
||||
All contexts
|
||||
</button>
|
||||
{contextsLoading ? (
|
||||
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
|
||||
Loading dimensions...
|
||||
Loading contexts...
|
||||
</div>
|
||||
) : filterPickerDimensions.length === 0 ? (
|
||||
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
|
||||
{filterSearchQuery ? 'No matching dimensions' : 'No dimensions available'}
|
||||
</div>
|
||||
) : (
|
||||
filterPickerDimensions.map(d => (
|
||||
<button
|
||||
key={d.dimension}
|
||||
onClick={() => addColumn(d.dimension)}
|
||||
style={pickerRowStyle}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<span>{d.dimension}</span>
|
||||
<span style={pickerCountStyle}>{d.count}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
) : contexts.map((context) => (
|
||||
<button
|
||||
key={context.id}
|
||||
onClick={() => {
|
||||
onContextFilterSelect?.(context.id, context.name);
|
||||
setShowContextPicker(false);
|
||||
}}
|
||||
style={pickerRowStyle}
|
||||
>
|
||||
<span>{context.name}</span>
|
||||
<span style={pickerCountStyle}>{context.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(selectedFilters.length > 0 || externalContextFilterId) && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--rah-text-muted)',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
|
||||
<button
|
||||
@@ -1234,10 +882,10 @@ export default function ViewsOverlay({
|
||||
<div style={{ width: '100%', maxWidth: DOCUMENT_MAX_WIDTH, margin: '0 auto', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
|
||||
<Inbox size={28} strokeWidth={1.5} style={{ opacity: 0.4 }} />
|
||||
<span style={{ fontSize: '14px', color: 'var(--rah-text-secondary)' }}>
|
||||
{selectedFilters.length > 0 ? 'Nothing matches these filters' : 'Nothing here yet'}
|
||||
Nothing here yet
|
||||
</span>
|
||||
<span style={{ fontSize: '12px', opacity: 0.7 }}>
|
||||
{selectedFilters.length > 0 ? 'Try adjusting your filters, or add a node with ⌘N' : 'Add a node with ⌘N to get started'}
|
||||
Add a node with ⌘N to get started
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user