feat: port holistic node refinement contract

This commit is contained in:
“BeeRad”
2026-04-11 21:37:52 +10:00
parent 35f9ecf89c
commit 3ae46245ec
119 changed files with 6596 additions and 10982 deletions
+135 -133
View File
@@ -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 &quot;{searchQuery.trim()}&quot;</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}