sync: multiple features from private repo
- quick-add-loading: fire-and-forget with loading placeholders, auto-open feed, SSE events, QuickAddInput redesign - database-table-pane: TablePane + DatabaseTableView, countNodes(), event_date sort - ui-polish-fixes: focus tab order, dim name editing, tab title sync, cost chip removal, custom sort drag-reorder, refresh button, dimension filter removal (~2,200 lines) - node-creation-quality: description service prompt rewrite, title sanitization, extraction tool AI prompt rewrites - feed-pane-ux: stripped kanban/grid/saved views, sort dropdown, AND dimension filtering, description preview Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
9954792b1d
commit
2f6518207d
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface QuickAddSubmitPayload {
|
||||
@@ -11,11 +11,40 @@ interface QuickAddSubmitPayload {
|
||||
|
||||
interface QuickAddInputProps {
|
||||
onSubmit: (payload: QuickAddSubmitPayload) => Promise<void>;
|
||||
// External control (optional - if provided, component is controlled)
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
type DetectedType = 'youtube' | 'website' | 'pdf-url' | 'note' | 'chat';
|
||||
|
||||
function detectType(raw: string): DetectedType {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return 'note';
|
||||
if (/youtu(\.be|be\.com)/i.test(trimmed)) return 'youtube';
|
||||
if (/\.pdf($|\?)/i.test(trimmed) || /arxiv\.org\//i.test(trimmed)) return 'pdf-url';
|
||||
if (/^https?:\/\//i.test(trimmed)) return 'website';
|
||||
const newlines = (trimmed.match(/\n/g)?.length ?? 0);
|
||||
if (newlines >= 3 && trimmed.length > 300) return 'chat';
|
||||
if (/You said:|ChatGPT said:|Claude said:/i.test(trimmed)) return 'chat';
|
||||
return 'note';
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<DetectedType, string> = {
|
||||
youtube: 'YouTube',
|
||||
website: 'Link',
|
||||
'pdf-url': 'PDF',
|
||||
note: 'Note',
|
||||
chat: 'Transcript',
|
||||
};
|
||||
|
||||
const TYPE_COLORS: Record<DetectedType, string> = {
|
||||
youtube: '#ef4444',
|
||||
website: '#3b82f6',
|
||||
'pdf-url': '#f59e0b',
|
||||
note: '#22c55e',
|
||||
chat: '#a78bfa',
|
||||
};
|
||||
|
||||
export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInputProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [isPosting, setIsPosting] = useState(false);
|
||||
@@ -24,42 +53,32 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
|
||||
// Support both controlled (isOpen/onClose) and uncontrolled (internal state) modes
|
||||
const isControlled = isOpen !== undefined;
|
||||
const isExpanded = isControlled ? isOpen : isExpandedInternal;
|
||||
const setIsExpanded = isControlled
|
||||
? (value: boolean) => { if (!value && onClose) onClose(); }
|
||||
: setIsExpandedInternal;
|
||||
|
||||
const detectedType = useMemo(() => detectType(input), [input]);
|
||||
const showTypePill = input.trim().length > 0 && !uploadedFile;
|
||||
|
||||
const handleFileUpload = useCallback(async (file: File) => {
|
||||
setIsPosting(true);
|
||||
setUploadError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch('/api/extract/pdf/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || 'Upload failed');
|
||||
}
|
||||
|
||||
// Success - clear state
|
||||
setUploadedFile(null);
|
||||
setInput('');
|
||||
setIsExpanded(false);
|
||||
|
||||
// Show warning if present (large file)
|
||||
if (result.warning) {
|
||||
console.log('[QuickAddInput] Upload warning:', result.warning);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[QuickAddInput] Upload error:', error);
|
||||
setUploadError(error instanceof Error ? error.message : 'Upload failed');
|
||||
@@ -69,22 +88,14 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// If there's a file, upload it
|
||||
if (uploadedFile) {
|
||||
await handleFileUpload(uploadedFile);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, submit text as before
|
||||
if (!input.trim() || isPosting) return;
|
||||
|
||||
setIsPosting(true);
|
||||
try {
|
||||
// Mode is auto-detected server-side via quickAdd.ts detectInputType()
|
||||
await onSubmit({
|
||||
input: input.trim(),
|
||||
mode: 'link', // Default; actual type is inferred server-side
|
||||
});
|
||||
await onSubmit({ input: input.trim(), mode: 'link' });
|
||||
setInput('');
|
||||
setIsExpanded(false);
|
||||
} catch (error) {
|
||||
@@ -124,30 +135,18 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
|
||||
e.stopPropagation();
|
||||
setDragOver(false);
|
||||
setUploadError(null);
|
||||
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Check if it's a PDF
|
||||
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
|
||||
setUploadError('Only PDF files are supported');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check size (50MB limit)
|
||||
const MAX_SIZE = 50 * 1024 * 1024;
|
||||
if (file.size > MAX_SIZE) {
|
||||
setUploadError(`File too large (${Math.round(file.size / 1024 / 1024)}MB). Maximum is 50MB.`);
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
setUploadError(`File too large (${Math.round(file.size / 1024 / 1024)}MB). Max 50MB.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadedFile(file);
|
||||
setInput(''); // Clear text input when file is dropped
|
||||
}, []);
|
||||
|
||||
const clearFile = useCallback(() => {
|
||||
setUploadedFile(null);
|
||||
setUploadError(null);
|
||||
setInput('');
|
||||
}, []);
|
||||
|
||||
const hasContent = input.trim() || uploadedFile;
|
||||
@@ -159,12 +158,8 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
|
||||
setUploadError(null);
|
||||
};
|
||||
|
||||
// Collapsed state - only show button if NOT controlled externally
|
||||
if (!isExpanded) {
|
||||
// In controlled mode, don't render anything when closed
|
||||
if (isControlled) return null;
|
||||
|
||||
// Uncontrolled mode - show the "ADD STUFF" button
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsExpanded(true)}
|
||||
@@ -190,32 +185,23 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(34, 197, 94, 0.2)';
|
||||
e.currentTarget.style.borderColor = '#22c55e';
|
||||
e.currentTarget.style.boxShadow = '0 0 20px rgba(34, 197, 94, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(34, 197, 94, 0.1)';
|
||||
e.currentTarget.style.borderColor = 'rgba(34, 197, 94, 0.3)';
|
||||
e.currentTarget.style.boxShadow = '0 0 12px rgba(34, 197, 94, 0.15)';
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
borderRadius: '50%',
|
||||
background: '#22c55e',
|
||||
color: '#0a0a0a',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700
|
||||
width: '18px', height: '18px', borderRadius: '50%',
|
||||
background: '#22c55e', color: '#0a0a0a',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '12px', fontWeight: 700
|
||||
}}>+</span>
|
||||
Add Stuff
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded state - the card content
|
||||
const modalContent = (
|
||||
<div
|
||||
className="qa-card"
|
||||
@@ -224,117 +210,76 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
|
||||
onDrop={handleDrop}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="qa-header">
|
||||
<span className="qa-title">Add Stuff</span>
|
||||
<button onClick={handleClose} className="qa-close">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File preview (when a file is dropped) */}
|
||||
{/* File preview */}
|
||||
{uploadedFile && (
|
||||
<div className="qa-file-preview">
|
||||
{/* PDF icon */}
|
||||
<div className="qa-file-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13" strokeLinecap="round"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* File info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
color: '#e5e5e5',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{uploadedFile.name}
|
||||
</div>
|
||||
<div style={{ color: '#666', fontSize: '11px', marginTop: '2px' }}>
|
||||
{uploadedFile.size < 1024 * 1024
|
||||
? `${Math.round(uploadedFile.size / 1024)} KB`
|
||||
: `${(uploadedFile.size / 1024 / 1024).toFixed(1)} MB`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remove button */}
|
||||
<button onClick={clearFile} className="qa-close" style={{ color: '#666' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<div className="qa-file-row">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span className="qa-file-name">{uploadedFile.name}</span>
|
||||
<span className="qa-file-size">
|
||||
{uploadedFile.size < 1024 * 1024
|
||||
? `${Math.round(uploadedFile.size / 1024)} KB`
|
||||
: `${(uploadedFile.size / 1024 / 1024).toFixed(1)} MB`}
|
||||
</span>
|
||||
<button onClick={() => { setUploadedFile(null); setUploadError(null); }} className="qa-file-remove">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{/* Error */}
|
||||
{uploadError && (
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||
borderRadius: '10px',
|
||||
color: '#ef4444',
|
||||
fontSize: '12px',
|
||||
}}>
|
||||
{uploadError}
|
||||
</div>
|
||||
<div className="qa-error">{uploadError}</div>
|
||||
)}
|
||||
|
||||
{/* Input area - show if no file uploaded */}
|
||||
{/* Input area */}
|
||||
{!uploadedFile && (
|
||||
<div className={`qa-input-wrapper ${dragOver ? 'dragging' : ''}`}>
|
||||
{/* Drag overlay */}
|
||||
<div className={`qa-input-area ${dragOver ? 'dragging' : ''}`}>
|
||||
{dragOver && (
|
||||
<div className="qa-drag-overlay">
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
color: '#22c55e'
|
||||
}}>
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span style={{ fontSize: '13px', fontWeight: 500 }}>Drop PDF here</span>
|
||||
</div>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span>Drop PDF</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Paste a URL, note, or transcript — or drop a PDF"
|
||||
placeholder="Paste a URL, write a note, or drop a PDF..."
|
||||
disabled={isPosting}
|
||||
autoFocus
|
||||
className="qa-textarea"
|
||||
style={{ opacity: dragOver ? 0.3 : 1 }}
|
||||
style={{ opacity: dragOver ? 0.2 : 1 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="qa-footer">
|
||||
<span className="qa-hint">
|
||||
{uploadedFile
|
||||
? 'Ready to upload'
|
||||
: <><kbd>{'\u2318\u21B5'}</kbd> submit <span className="qa-hint-sep">·</span> <kbd>esc</kbd> close</>
|
||||
}
|
||||
</span>
|
||||
<div className="qa-footer-left">
|
||||
{showTypePill && (
|
||||
<span className="qa-type-pill" style={{
|
||||
color: TYPE_COLORS[detectedType],
|
||||
borderColor: TYPE_COLORS[detectedType] + '30',
|
||||
background: TYPE_COLORS[detectedType] + '0a',
|
||||
}}>
|
||||
{TYPE_LABELS[detectedType]}
|
||||
</span>
|
||||
)}
|
||||
<span className="qa-hint">
|
||||
{uploadedFile
|
||||
? 'Ready to upload'
|
||||
: <><kbd>{'\u2318\u21B5'}</kbd><span className="qa-hint-sep">send</span><kbd>esc</kbd><span className="qa-hint-sep">close</span></>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!hasContent || isPosting}
|
||||
@@ -344,8 +289,8 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
|
||||
<span className="qa-spinner" />
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 19V5"/>
|
||||
<path d="M5 12l7-7 7 7"/>
|
||||
<path d="M5 12h14"/>
|
||||
<path d="M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
@@ -355,86 +300,27 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
|
||||
.qa-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background: #141414;
|
||||
padding: 24px;
|
||||
gap: 0;
|
||||
background: #111111;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #262626;
|
||||
transition: border-color 0.15s ease;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
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: qaCardIn 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
width: ${isControlled ? '540px' : 'auto'};
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03),
|
||||
0 24px 80px -12px rgba(0, 0, 0, 0.8),
|
||||
0 0 60px -10px rgba(34, 197, 94, 0.06);
|
||||
animation: qaIn 250ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
width: ${isControlled ? '600px' : 'auto'};
|
||||
max-width: ${isControlled ? '90vw' : 'none'};
|
||||
}
|
||||
|
||||
.qa-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.qa-title {
|
||||
color: #fafafa;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.qa-close {
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #525252;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.qa-close:hover {
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.qa-file-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: #0f1a0f;
|
||||
border: 1px solid #1a3a1a;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.qa-file-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qa-input-wrapper {
|
||||
.qa-input-area {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #262626;
|
||||
background: #0a0a0a;
|
||||
transition: all 0.15s ease;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.qa-input-wrapper:focus-within {
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.qa-input-wrapper.dragging {
|
||||
border: 2px dashed #22c55e;
|
||||
background: rgba(34, 197, 94, 0.05);
|
||||
.qa-input-area.dragging {
|
||||
background: rgba(34, 197, 94, 0.03);
|
||||
}
|
||||
|
||||
.qa-drag-overlay {
|
||||
@@ -443,8 +329,10 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(34, 197, 94, 0.05);
|
||||
border-radius: 12px;
|
||||
gap: 8px;
|
||||
color: #22c55e;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -453,10 +341,10 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
max-height: 300px;
|
||||
padding: 16px 18px;
|
||||
padding: 20px 22px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fafafa;
|
||||
color: #e5e5e5;
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
@@ -466,50 +354,119 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
|
||||
}
|
||||
|
||||
.qa-textarea::placeholder {
|
||||
color: #525252;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.qa-file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.qa-file-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: #e5e5e5;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.qa-file-size {
|
||||
color: #555;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qa-file-remove {
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.qa-file-remove:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.qa-error {
|
||||
padding: 10px 20px;
|
||||
color: #ef4444;
|
||||
font-size: 12px;
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border-bottom: 1px solid rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.qa-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px 12px 18px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.qa-footer-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.qa-type-pill {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid;
|
||||
text-transform: uppercase;
|
||||
animation: pillIn 150ms ease-out;
|
||||
}
|
||||
|
||||
.qa-hint {
|
||||
font-size: 11px;
|
||||
color: #525252;
|
||||
color: #3a3a3a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.qa-hint kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 6px;
|
||||
background: #262626;
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-family: inherit;
|
||||
color: #737373;
|
||||
border: 1px solid #333;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.qa-hint-sep {
|
||||
margin: 0 2px;
|
||||
margin: 0 1px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.qa-submit {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: #262626;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: #525252;
|
||||
color: #333;
|
||||
cursor: default;
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.qa-submit.active {
|
||||
@@ -520,23 +477,23 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
|
||||
|
||||
.qa-submit.active:hover {
|
||||
background: #16a34a;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.3);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 20px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.qa-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid #0a0a0a;
|
||||
border: 2px solid #052e16;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: qaSpin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes qaCardIn {
|
||||
@keyframes qaIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96) translateY(-8px);
|
||||
transform: scale(0.97) translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -544,6 +501,11 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pillIn {
|
||||
from { opacity: 0; transform: scale(0.9); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes qaSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -551,55 +513,42 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
|
||||
</div>
|
||||
);
|
||||
|
||||
// In controlled mode, wrap with blurred backdrop + portal
|
||||
if (isControlled) {
|
||||
const backdrop = (
|
||||
<div
|
||||
className="qa-backdrop"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div className="qa-backdrop" onClick={handleClose}>
|
||||
<div className="qa-container">
|
||||
{modalContent}
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.qa-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 15vh;
|
||||
z-index: 9999;
|
||||
animation: qaBackdropIn 200ms ease-out;
|
||||
animation: qaFadeIn 200ms ease-out;
|
||||
}
|
||||
|
||||
.qa-container {
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
max-width: 600px;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
@keyframes qaBackdropIn {
|
||||
@keyframes qaFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof window !== 'undefined' ? createPortal(backdrop, document.body) : null;
|
||||
}
|
||||
|
||||
// Uncontrolled mode - render with absolute positioning
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '60px',
|
||||
left: '20px',
|
||||
right: '20px',
|
||||
zIndex: 100,
|
||||
}}>
|
||||
<div style={{ position: 'absolute', top: '60px', left: '20px', right: '20px', zIndex: 100 }}>
|
||||
{modalContent}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -119,7 +119,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
const [regeneratingDescription, setRegeneratingDescription] = useState<number | null>(null);
|
||||
|
||||
// Content tab state: 'notes', 'desc', or 'source'
|
||||
const [activeContentTab, setActiveContentTab] = useState<'notes' | 'desc' | 'edges' | 'source'>('notes');
|
||||
const [activeContentTab, setActiveContentTab] = useState<'notes' | 'desc' | 'edges' | 'source'>('desc');
|
||||
|
||||
// Desc (description) edit mode state
|
||||
const [descEditMode, setDescEditMode] = useState(false);
|
||||
@@ -2106,23 +2106,6 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
marginBottom: '12px',
|
||||
borderBottom: '1px solid #1a1a1a'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => { setActiveContentTab('notes'); setDescEditMode(false); setSourceEditMode(false); }}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: '11px',
|
||||
fontWeight: activeContentTab === 'notes' ? 600 : 400,
|
||||
color: activeContentTab === 'notes' ? '#e5e5e5' : '#666',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: activeContentTab === 'notes' ? '2px solid #22c55e' : '2px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
marginBottom: '-1px'
|
||||
}}
|
||||
>
|
||||
Notes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveContentTab('desc'); setNotesEditMode(false); setSourceEditMode(false); }}
|
||||
style={{
|
||||
@@ -2140,6 +2123,23 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
>
|
||||
Desc
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveContentTab('notes'); setDescEditMode(false); setSourceEditMode(false); }}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: '11px',
|
||||
fontWeight: activeContentTab === 'notes' ? 600 : 400,
|
||||
color: activeContentTab === 'notes' ? '#e5e5e5' : '#666',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: activeContentTab === 'notes' ? '2px solid #22c55e' : '2px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
marginBottom: '-1px'
|
||||
}}
|
||||
>
|
||||
Notes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveContentTab('edges'); setDescEditMode(false); setNotesEditMode(false); setSourceEditMode(false); }}
|
||||
style={{
|
||||
|
||||
@@ -4,9 +4,11 @@ import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
LayoutList,
|
||||
Map,
|
||||
Folder,
|
||||
Table2,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import type { PaneType } from '../panes/types';
|
||||
@@ -14,6 +16,7 @@ import type { PaneType } from '../panes/types';
|
||||
interface LeftToolbarProps {
|
||||
onSearchClick: () => void;
|
||||
onAddStuffClick: () => void;
|
||||
onRefreshClick: () => void;
|
||||
onSettingsClick: () => void;
|
||||
onPaneTypeClick: (paneType: PaneType) => void;
|
||||
activePane: 'A' | 'B';
|
||||
@@ -26,16 +29,18 @@ const PANE_TYPE_ICONS: Record<string, typeof LayoutList> = {
|
||||
views: LayoutList,
|
||||
map: Map,
|
||||
dimensions: Folder,
|
||||
table: Table2,
|
||||
};
|
||||
|
||||
const PANE_TYPE_LABELS: Record<string, string> = {
|
||||
views: 'Feed',
|
||||
map: 'Map',
|
||||
dimensions: 'Dimensions',
|
||||
table: 'Table',
|
||||
};
|
||||
|
||||
// Pane types shown in the toolbar (excludes 'node', 'chat', and 'guides' which is in settings)
|
||||
const TOOLBAR_PANE_TYPES: PaneType[] = ['views', 'map', 'dimensions'];
|
||||
const TOOLBAR_PANE_TYPES: PaneType[] = ['views', 'map', 'dimensions', 'table'];
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
icon: typeof Search;
|
||||
@@ -140,6 +145,7 @@ function PaneTypeButton({ icon: Icon, label, paneType, isOpen, isActivePane, onC
|
||||
export default function LeftToolbar({
|
||||
onSearchClick,
|
||||
onAddStuffClick,
|
||||
onRefreshClick,
|
||||
onSettingsClick,
|
||||
onPaneTypeClick,
|
||||
activePane,
|
||||
@@ -180,6 +186,12 @@ export default function LeftToolbar({
|
||||
label="Add Stuff"
|
||||
onClick={onAddStuffClick}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={RefreshCw}
|
||||
label="Refresh"
|
||||
shortcut="⌘⇧R"
|
||||
onClick={onRefreshClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Middle section - Pane Types */}
|
||||
|
||||
@@ -21,12 +21,21 @@ type AgentDelegation = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export interface PendingNode {
|
||||
id: string;
|
||||
input: string;
|
||||
inputType: string;
|
||||
submittedAt: number;
|
||||
status: 'processing' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Layout components
|
||||
import LeftToolbar from './LeftToolbar';
|
||||
import SplitHandle from './SplitHandle';
|
||||
|
||||
// Pane components (ChatPane removed in rah-light, GuidesPane moved to settings)
|
||||
import { NodePane, DimensionsPane, MapPane, ViewsPane } from '../panes';
|
||||
import { NodePane, DimensionsPane, MapPane, ViewsPane, TablePane } from '../panes';
|
||||
import QuickAddInput from '../agents/QuickAddInput';
|
||||
import type { PaneType, SlotState, PaneAction } from '../panes/types';
|
||||
|
||||
@@ -99,6 +108,9 @@ export default function ThreePanelLayout() {
|
||||
selectedText: string;
|
||||
} | null>(null);
|
||||
|
||||
// Pending quick-add nodes (loading placeholders)
|
||||
const [pendingNodes, setPendingNodes] = useState<PendingNode[]>([]);
|
||||
|
||||
// Ref to get current openTabs value in SSE handler
|
||||
const openTabsRef = useRef<number[]>([]);
|
||||
|
||||
@@ -164,16 +176,23 @@ export default function ThreePanelLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
// Update tab data whenever openTabs changes (use string key to prevent infinite loops)
|
||||
// Update tab data whenever openTabs changes or focus panel refreshes (use string key to prevent infinite loops)
|
||||
const openTabsKey = openTabs.join(',');
|
||||
useEffect(() => {
|
||||
openTabsRef.current = openTabs;
|
||||
fetchOpenTabsData(openTabs);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [openTabsKey]);
|
||||
}, [openTabsKey, focusPanelRefresh]);
|
||||
|
||||
// Delegations loading removed (delegation system removed in rah-light)
|
||||
|
||||
// Refresh all panes
|
||||
const handleRefreshAll = useCallback(() => {
|
||||
setNodesPanelRefresh(prev => prev + 1);
|
||||
setFolderViewRefresh(prev => prev + 1);
|
||||
setFocusPanelRefresh(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcut handler
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -192,6 +211,11 @@ export default function ThreePanelLayout() {
|
||||
setSlotB({ type: 'node', nodeTabs: [], activeNodeTab: null });
|
||||
}
|
||||
}
|
||||
// Cmd+Shift+R - refresh all panes
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'r') {
|
||||
e.preventDefault();
|
||||
handleRefreshAll();
|
||||
}
|
||||
// Cmd+N - open Add Stuff modal
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'n') {
|
||||
// Don't prevent default - browser may want this for new window
|
||||
@@ -205,7 +229,7 @@ export default function ThreePanelLayout() {
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [slotB, setSlotB]);
|
||||
}, [slotB, setSlotB, handleRefreshAll]);
|
||||
|
||||
// SSE connection for real-time updates
|
||||
useEffect(() => {
|
||||
@@ -274,6 +298,22 @@ export default function ThreePanelLayout() {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'QUICK_ADD_COMPLETED':
|
||||
if (data.data?.quickAddId) {
|
||||
setPendingNodes(prev => prev.filter(p => p.id !== data.data.quickAddId));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'QUICK_ADD_FAILED':
|
||||
if (data.data?.quickAddId) {
|
||||
setPendingNodes(prev => prev.map(p =>
|
||||
p.id === data.data.quickAddId
|
||||
? { ...p, status: 'error' as const, error: data.data.error || 'Unknown error' }
|
||||
: p
|
||||
));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'CONNECTION_ESTABLISHED':
|
||||
console.log('✅ SSE connection established');
|
||||
break;
|
||||
@@ -301,6 +341,21 @@ export default function ThreePanelLayout() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-dismiss pending nodes after timeout
|
||||
useEffect(() => {
|
||||
if (pendingNodes.length === 0) return;
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
setPendingNodes(prev => prev.filter(p => {
|
||||
const age = now - p.submittedAt;
|
||||
if (p.status === 'processing' && age > 90_000) return false; // 90s timeout
|
||||
if (p.status === 'error' && age > 120_000) return false; // 120s for errors
|
||||
return true;
|
||||
}));
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [pendingNodes.length]);
|
||||
|
||||
// Node tab management
|
||||
const handleNodeSelect = useCallback((nodeId: number, multiSelect: boolean) => {
|
||||
// If slotA is not a node pane (or doesn't exist), switch it to node
|
||||
@@ -405,27 +460,6 @@ export default function ThreePanelLayout() {
|
||||
setActivePane('A');
|
||||
}, [slotA, setSlotA]);
|
||||
|
||||
// Handle Quick Add submit (used by global Add Stuff modal)
|
||||
const handleQuickAddSubmit = useCallback(async ({ input, mode, description }: { input: string; mode: 'link' | 'note' | 'chat'; description?: string }) => {
|
||||
try {
|
||||
const response = await fetch('/api/quick-add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input, mode, description })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to submit Quick Add');
|
||||
}
|
||||
|
||||
// Close the modal on success
|
||||
setShowAddStuff(false);
|
||||
} catch (error) {
|
||||
console.error('[ThreePanelLayout] Quick Add error:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleNodeDeleted = useCallback((nodeId: number) => {
|
||||
handleCloseTab(nodeId);
|
||||
}, [handleCloseTab]);
|
||||
@@ -494,6 +528,51 @@ export default function ThreePanelLayout() {
|
||||
}
|
||||
}, [activePane, slotA, slotB, setSlotA, setSlotB]);
|
||||
|
||||
// Ensure the Feed pane is visible (for quick-add loading placeholders)
|
||||
const ensureFeedOpen = useCallback(() => {
|
||||
if (slotA?.type === 'views') return;
|
||||
if (slotB?.type === 'views') return;
|
||||
handlePaneTypeClick('views');
|
||||
}, [slotA, slotB, handlePaneTypeClick]);
|
||||
|
||||
// Handle Quick Add submit (used by global Add Stuff modal)
|
||||
const handleQuickAddSubmit = useCallback(async ({ input, mode, description }: { input: string; mode: 'link' | 'note' | 'chat'; description?: string }) => {
|
||||
try {
|
||||
const response = await fetch('/api/quick-add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input, mode, description })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to submit Quick Add');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const delegation = result.delegation;
|
||||
|
||||
// Add pending placeholder
|
||||
if (delegation?.id && delegation?.inputType) {
|
||||
setPendingNodes(prev => [{
|
||||
id: delegation.id,
|
||||
input: input.slice(0, 120),
|
||||
inputType: delegation.inputType,
|
||||
submittedAt: Date.now(),
|
||||
status: 'processing' as const,
|
||||
}, ...prev]);
|
||||
}
|
||||
|
||||
// Ensure feed pane is visible
|
||||
ensureFeedOpen();
|
||||
|
||||
// Close the modal on success
|
||||
setShowAddStuff(false);
|
||||
} catch (error) {
|
||||
console.error('[ThreePanelLayout] Quick Add error:', error);
|
||||
}
|
||||
}, [ensureFeedOpen]);
|
||||
|
||||
// Handle closing a pane
|
||||
const handleCloseSlotA = useCallback(() => {
|
||||
if (slotB) {
|
||||
@@ -860,6 +939,24 @@ export default function ThreePanelLayout() {
|
||||
}}
|
||||
onNodeOpenInOtherPane={slot === 'A' ? handleNodeOpenInSlotB : handleNodeOpenInSlotA}
|
||||
refreshToken={nodesPanelRefresh}
|
||||
pendingNodes={pendingNodes}
|
||||
onDismissPending={(id) => setPendingNodes(prev => prev.filter(p => p.id !== id))}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'table':
|
||||
return (
|
||||
<TablePane
|
||||
slot={slot}
|
||||
isActive={isActive}
|
||||
onPaneAction={slot === 'A' ? handleSlotAAction : handleSlotBAction}
|
||||
onCollapse={onCollapse}
|
||||
onSwapPanes={slotB ? handleSwapPanes : undefined}
|
||||
onNodeClick={(nodeId) => {
|
||||
handleNodeSelect(nodeId, false);
|
||||
setActivePane(slot);
|
||||
}}
|
||||
refreshToken={nodesPanelRefresh}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -892,6 +989,7 @@ export default function ThreePanelLayout() {
|
||||
activePane={activePane}
|
||||
slotAType={slotA?.type ?? null}
|
||||
slotBType={slotB?.type ?? null}
|
||||
onRefreshClick={handleRefreshAll}
|
||||
/>
|
||||
|
||||
{/* Main content area */}
|
||||
@@ -931,7 +1029,7 @@ export default function ThreePanelLayout() {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
// Center single pane (except map)
|
||||
...((!slotB && slotA.type !== 'map') ? {
|
||||
...((!slotB && slotA.type !== 'map' && slotA.type !== 'table') ? {
|
||||
maxWidth: '900px',
|
||||
margin: '0 auto',
|
||||
width: '100%',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import PaneHeader from './PaneHeader';
|
||||
import DatabaseTableView from '../views/DatabaseTableView';
|
||||
import type { BasePaneProps } from './types';
|
||||
|
||||
export interface TablePaneProps extends BasePaneProps {
|
||||
onNodeClick: (nodeId: number) => void;
|
||||
refreshToken?: number;
|
||||
}
|
||||
|
||||
export default function TablePane({
|
||||
slot,
|
||||
isActive,
|
||||
onPaneAction,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
tabBar,
|
||||
onNodeClick,
|
||||
refreshToken
|
||||
}: TablePaneProps) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
background: 'transparent',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar} />
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<DatabaseTableView
|
||||
onNodeClick={onNodeClick}
|
||||
refreshToken={refreshToken}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,14 @@
|
||||
import PaneHeader from './PaneHeader';
|
||||
import ViewsOverlay from '../views/ViewsOverlay';
|
||||
import type { BasePaneProps, PaneAction, PaneType } from './types';
|
||||
import type { PendingNode } from '../layout/ThreePanelLayout';
|
||||
|
||||
export interface ViewsPaneProps extends BasePaneProps {
|
||||
onNodeClick: (nodeId: number) => void;
|
||||
onNodeOpenInOtherPane?: (nodeId: number) => void;
|
||||
refreshToken?: number;
|
||||
pendingNodes?: PendingNode[];
|
||||
onDismissPending?: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function ViewsPane({
|
||||
@@ -18,7 +21,9 @@ export default function ViewsPane({
|
||||
onSwapPanes,
|
||||
onNodeClick,
|
||||
onNodeOpenInOtherPane,
|
||||
refreshToken
|
||||
refreshToken,
|
||||
pendingNodes,
|
||||
onDismissPending,
|
||||
}: ViewsPaneProps) {
|
||||
const handleTypeChange = (type: PaneType) => {
|
||||
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
|
||||
@@ -38,6 +43,8 @@ export default function ViewsPane({
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeOpenInOtherPane={onNodeOpenInOtherPane}
|
||||
refreshToken={refreshToken}
|
||||
pendingNodes={pendingNodes}
|
||||
onDismissPending={onDismissPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,5 +2,6 @@ export { default as NodePane } from './NodePane';
|
||||
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 PaneHeader } from './PaneHeader';
|
||||
export * from './types';
|
||||
|
||||
@@ -15,7 +15,7 @@ export type AgentDelegation = {
|
||||
};
|
||||
|
||||
// The four pane types (chat removed in rah-light, guides moved to settings)
|
||||
export type PaneType = 'node' | 'dimensions' | 'map' | 'views';
|
||||
export type PaneType = 'node' | 'dimensions' | 'map' | 'views' | 'table';
|
||||
|
||||
// State for each slot
|
||||
export interface SlotState {
|
||||
@@ -89,6 +89,12 @@ export interface ViewsPaneProps extends BasePaneProps {
|
||||
refreshToken?: number;
|
||||
}
|
||||
|
||||
// TablePane specific props
|
||||
export interface TablePaneProps extends BasePaneProps {
|
||||
onNodeClick: (nodeId: number) => void;
|
||||
refreshToken?: number;
|
||||
}
|
||||
|
||||
// Pane header props
|
||||
export interface PaneHeaderProps {
|
||||
slot?: 'A' | 'B';
|
||||
@@ -104,6 +110,7 @@ export const PANE_LABELS: Record<PaneType, string> = {
|
||||
dimensions: 'Dimensions',
|
||||
map: 'Map',
|
||||
views: 'Feed',
|
||||
table: 'Table',
|
||||
};
|
||||
|
||||
// Default slot states
|
||||
|
||||
@@ -97,11 +97,6 @@ export default function LogsRow({ log, isEven }: LogsRowProps) {
|
||||
🧵 {metrics.thread.substring(0, 16)}…
|
||||
</span>
|
||||
)}
|
||||
{metrics.cost_usd !== undefined && (
|
||||
<span style={{ color: '#34d399' }}>
|
||||
💰 ${metrics.cost_usd.toFixed(6)}
|
||||
</span>
|
||||
)}
|
||||
{metrics.input_tokens !== undefined && metrics.output_tokens !== undefined && (
|
||||
<span>
|
||||
📊 {metrics.input_tokens}↓ {metrics.output_tokens}↑
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { Filter, ChevronDown, ChevronLeft, ChevronRight, X, ArrowUpDown, Search, ExternalLink } from 'lucide-react';
|
||||
import type { Node } from '@/types/database';
|
||||
|
||||
type SortOrder = 'updated' | 'edges' | 'created' | 'event_date';
|
||||
|
||||
const SORT_LABELS: Record<SortOrder, string> = {
|
||||
updated: 'Recently Updated',
|
||||
edges: 'Most Edges',
|
||||
created: 'Creation Date',
|
||||
event_date: 'Event Date',
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
interface DimensionSummary {
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
}
|
||||
|
||||
interface DatabaseTableViewProps {
|
||||
onNodeClick: (nodeId: number) => void;
|
||||
refreshToken?: number;
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(dateStr).getTime();
|
||||
const diff = now - then;
|
||||
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12) return `${months}mo ago`;
|
||||
|
||||
return `${Math.floor(months / 12)}y ago`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '\u2014';
|
||||
try {
|
||||
return dateStr.slice(0, 10);
|
||||
} catch {
|
||||
return '\u2014';
|
||||
}
|
||||
}
|
||||
|
||||
export default function DatabaseTableView({ onNodeClick, refreshToken = 0 }: DatabaseTableViewProps) {
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
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);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(PAGE_SIZE),
|
||||
offset: String((page - 1) * PAGE_SIZE),
|
||||
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();
|
||||
if (data.success) {
|
||||
setNodes(data.data || []);
|
||||
setTotal(data.total ?? data.count ?? 0);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching nodes:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, sortOrder, activeSearch, selectedFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNodes();
|
||||
}, [fetchNodes, refreshToken]);
|
||||
|
||||
// Reset to page 1 when filters/sort/search change
|
||||
const filtersKey = selectedFilters.join(',');
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [sortOrder, activeSearch, filtersKey]);
|
||||
|
||||
// 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]);
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setActiveSearch(searchQuery);
|
||||
};
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const startItem = (page - 1) * PAGE_SIZE + 1;
|
||||
const endItem = Math.min(page * PAGE_SIZE, total);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'transparent' }}>
|
||||
{/* Compact toolbar */}
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}>
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearchSubmit} style={{ display: 'flex', alignItems: 'center', gap: '0' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
background: '#0d0d0d',
|
||||
border: '1px solid #222',
|
||||
borderRadius: '6px',
|
||||
padding: '0 8px',
|
||||
gap: '6px',
|
||||
}}>
|
||||
<Search size={12} style={{ color: '#555', flexShrink: 0 }} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search nodes..."
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#f0f0f0',
|
||||
fontSize: '12px',
|
||||
padding: '5px 0',
|
||||
outline: 'none',
|
||||
width: '140px',
|
||||
}}
|
||||
/>
|
||||
{activeSearch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSearchQuery(''); setActiveSearch(''); }}
|
||||
style={{ background: 'transparent', border: 'none', color: '#555', cursor: 'pointer', padding: 0, display: 'flex' }}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</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 #222', borderRadius: '5px',
|
||||
color: '#888', 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: '#141414', border: '1px solid #222', 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: '#0d0d0d',
|
||||
border: '1px solid transparent', borderRadius: '6px',
|
||||
color: '#f0f0f0', fontSize: '12px', marginBottom: '4px', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
{filterPickerDimensions.length === 0 ? (
|
||||
<div style={{ padding: '12px', color: '#666', 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: '#ccc',
|
||||
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: '#555', fontSize: '10px', background: '#1a1a1a', 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: '#666', fontSize: '11px', cursor: 'pointer' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowSortDropdown(!showSortDropdown)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '4px',
|
||||
padding: '4px 7px', background: 'transparent',
|
||||
border: '1px solid #222', borderRadius: '5px',
|
||||
color: '#888', fontSize: '11px', cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<ArrowUpDown size={11} />
|
||||
{SORT_LABELS[sortOrder]}
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
|
||||
{showSortDropdown && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', right: 0, marginTop: '4px',
|
||||
background: '#141414', border: '1px solid #222', borderRadius: '10px',
|
||||
padding: '4px', minWidth: '160px', zIndex: 1000,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
||||
}}>
|
||||
{(Object.keys(SORT_LABELS) as SortOrder[]).map(key => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => { setSortOrder(key); setShowSortDropdown(false); }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '8px',
|
||||
width: '100%', padding: '7px 10px',
|
||||
background: sortOrder === key ? 'rgba(255,255,255,0.04)' : 'transparent',
|
||||
border: 'none', borderRadius: '5px',
|
||||
color: sortOrder === key ? '#f0f0f0' : '#999',
|
||||
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = sortOrder === key ? 'rgba(255,255,255,0.04)' : 'transparent'; }}
|
||||
>
|
||||
{sortOrder === key && <span style={{ color: '#22c55e', fontSize: '12px' }}>✓</span>}
|
||||
{SORT_LABELS[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
fontSize: '11px', color: '#666', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<span>{total > 0 ? `${startItem}-${endItem} of ${total}` : '0 nodes'}</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
style={{
|
||||
background: 'transparent', border: '1px solid #222', borderRadius: '4px',
|
||||
color: page <= 1 ? '#333' : '#888', cursor: page <= 1 ? 'default' : 'pointer',
|
||||
padding: '2px 4px', display: 'flex',
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
style={{
|
||||
background: 'transparent', border: '1px solid #222', borderRadius: '4px',
|
||||
color: page >= totalPages ? '#333' : '#888',
|
||||
cursor: page >= totalPages ? 'default' : 'pointer',
|
||||
padding: '2px 4px', display: 'flex',
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: '40px', color: '#666', textAlign: 'center', fontSize: '13px' }}>Loading...</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<div style={{ padding: '40px', color: '#666', textAlign: 'center', fontSize: '13px' }}>
|
||||
{activeSearch || selectedFilters.length > 0 ? 'No nodes match your filters.' : 'No nodes yet.'}
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ minWidth: '1600px', width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<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: '180px' })}>LINK</th>
|
||||
<th style={thStyle({ width: '160px' })}>DIMENSIONS</th>
|
||||
<th style={thStyle({ width: '50px', textAlign: 'right' })}>EDGES</th>
|
||||
<th style={thStyle({ width: '90px' })}>EVENT</th>
|
||||
<th style={thStyle({ width: '85px' })}>UPDATED</th>
|
||||
<th style={thStyle({ width: '85px' })}>CREATED</th>
|
||||
<th style={thStyle({ width: '160px' })}>METADATA</th>
|
||||
<th style={thStyle({ width: '160px' })}>CHUNK</th>
|
||||
<th style={thStyle({ width: '80px' })}>CHUNK STATUS</th>
|
||||
<th style={thStyle({ width: '85px' })}>EMB UPDATED</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodes.map((node, i) => {
|
||||
const metaStr = node.metadata
|
||||
? (typeof node.metadata === 'string' ? node.metadata : JSON.stringify(node.metadata))
|
||||
: '';
|
||||
return (
|
||||
<tr
|
||||
key={node.id}
|
||||
onClick={() => onNodeClick(node.id)}
|
||||
onMouseEnter={() => setHoveredRow(node.id)}
|
||||
onMouseLeave={() => setHoveredRow(null)}
|
||||
style={{
|
||||
height: '44px',
|
||||
cursor: 'pointer',
|
||||
background: hoveredRow === node.id
|
||||
? '#141414'
|
||||
: i % 2 === 0 ? '#080808' : '#0d0d0d',
|
||||
transition: 'background 0.1s ease',
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<td style={tdStyle()}>
|
||||
<div style={truncCell}>
|
||||
<span style={{ fontSize: '13px', color: '#e0e0e0', fontWeight: 400 }}>
|
||||
{node.title || 'Untitled'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* ID */}
|
||||
<td style={tdStyle({ textAlign: 'right' })}>
|
||||
<span style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
|
||||
fontSize: '11px', color: '#555',
|
||||
}}>
|
||||
{node.id}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Description */}
|
||||
<td style={tdStyle()}>
|
||||
<div style={truncCell}>
|
||||
<span style={{ fontSize: '11px', color: '#888' }}>
|
||||
{node.description || '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Notes */}
|
||||
<td style={tdStyle()}>
|
||||
<div style={truncCell}>
|
||||
<span style={{ fontSize: '11px', color: '#888' }}>
|
||||
{node.notes ? node.notes.slice(0, 120) : '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Link */}
|
||||
<td style={tdStyle()}>
|
||||
<div style={truncCell}>
|
||||
{node.link ? (
|
||||
<span style={{ fontSize: '11px', color: '#6a9fd8', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<ExternalLink size={10} style={{ flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{node.link.replace(/^https?:\/\/(www\.)?/, '')}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: '#333' }}>{'\u2014'}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Dimensions */}
|
||||
<td style={tdStyle()}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px', overflow: 'hidden', maxHeight: '36px' }}>
|
||||
{node.dimensions && node.dimensions.length > 0 ? (
|
||||
<>
|
||||
{node.dimensions.slice(0, 3).map(d => (
|
||||
<span key={d} style={{
|
||||
fontSize: '9px', padding: '1px 5px',
|
||||
background: '#111914', border: '1px solid #1f2f24',
|
||||
color: '#bbf7d0', borderRadius: '3px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
{node.dimensions.length > 3 && (
|
||||
<span style={{ fontSize: '9px', color: '#555' }}>
|
||||
+{node.dimensions.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span style={{ fontSize: '10px', color: '#333' }}>{'\u2014'}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Edges */}
|
||||
<td style={tdStyle({ textAlign: 'right' })}>
|
||||
<span style={{ fontSize: '12px', color: node.edge_count ? '#888' : '#333' }}>
|
||||
{node.edge_count ?? 0}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Event Date */}
|
||||
<td style={tdStyle()}>
|
||||
<span style={{ fontSize: '11px', color: node.event_date ? '#888' : '#333' }}>
|
||||
{formatDate(node.event_date)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Updated */}
|
||||
<td style={tdStyle()}>
|
||||
<span style={{ fontSize: '11px', color: '#666' }}>
|
||||
{formatRelativeTime(node.updated_at)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Created */}
|
||||
<td style={tdStyle()}>
|
||||
<span style={{ fontSize: '11px', color: '#666' }}>
|
||||
{formatRelativeTime(node.created_at)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Metadata */}
|
||||
<td style={tdStyle()}>
|
||||
<div style={truncCell}>
|
||||
<span style={{ fontSize: '10px', color: '#555', fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace' }}>
|
||||
{metaStr || '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Chunk */}
|
||||
<td style={tdStyle()}>
|
||||
<div style={truncCell}>
|
||||
<span style={{ fontSize: '10px', color: '#555' }}>
|
||||
{node.chunk ? node.chunk.slice(0, 100) : '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Chunk Status */}
|
||||
<td style={tdStyle()}>
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
color: node.chunk_status === 'chunked' ? '#4a9' : node.chunk_status === 'error' ? '#e55' : '#555',
|
||||
}}>
|
||||
{node.chunk_status || '\u2014'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Embedding Updated */}
|
||||
<td style={tdStyle()}>
|
||||
<span style={{ fontSize: '11px', color: '#666' }}>
|
||||
{node.embedding_updated_at ? formatRelativeTime(node.embedding_updated_at) : '\u2014'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function thStyle(extra: React.CSSProperties = {}): React.CSSProperties {
|
||||
return {
|
||||
position: 'sticky' as const,
|
||||
top: 0,
|
||||
background: '#0a0a0a',
|
||||
padding: '8px 12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
color: '#555',
|
||||
textAlign: 'left',
|
||||
letterSpacing: '0.05em',
|
||||
whiteSpace: 'nowrap',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||
zIndex: 1,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function tdStyle(extra: React.CSSProperties = {}): React.CSSProperties {
|
||||
return {
|
||||
padding: '0 12px',
|
||||
verticalAlign: 'middle',
|
||||
borderBottom: '1px solid #111',
|
||||
overflow: 'hidden',
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const truncCell: React.CSSProperties = {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
@@ -1,18 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||
import { Filter, ChevronDown, X, ArrowUpDown } from 'lucide-react';
|
||||
import { Filter, ChevronDown, X, ArrowUpDown, GripVertical } from 'lucide-react';
|
||||
import type { Node } from '@/types/database';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||
import { usePersistentState } from '@/hooks/usePersistentState';
|
||||
import type { PendingNode } from '../layout/ThreePanelLayout';
|
||||
|
||||
type SortOrder = 'updated' | 'edges' | 'created';
|
||||
type SortOrder = 'updated' | 'edges' | 'created' | 'custom';
|
||||
|
||||
const SORT_LABELS: Record<SortOrder, string> = {
|
||||
updated: 'Updated',
|
||||
edges: 'Edges',
|
||||
created: 'Created',
|
||||
custom: 'Custom',
|
||||
};
|
||||
|
||||
interface ColumnFilter {
|
||||
@@ -27,13 +29,112 @@ interface DimensionSummary {
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
const INPUT_TYPE_LABELS: Record<string, string> = {
|
||||
youtube: 'Extracting YouTube video...',
|
||||
website: 'Extracting webpage...',
|
||||
pdf: 'Extracting PDF...',
|
||||
note: 'Creating note...',
|
||||
chat: 'Importing transcript...',
|
||||
};
|
||||
|
||||
function PendingNodeCard({ pending, onDismiss }: { pending: PendingNode; onDismiss?: () => void }) {
|
||||
const isError = pending.status === 'error';
|
||||
return (
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
background: 'transparent',
|
||||
borderBottom: '1px solid #141414',
|
||||
borderLeft: `3px solid ${isError ? '#ef4444' : '#22c55e'}`,
|
||||
opacity: 0.8,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
|
||||
<div style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '8px',
|
||||
background: '#141414',
|
||||
border: `1px solid ${isError ? 'rgba(239,68,68,0.3)' : '#1f1f1f'}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{isError ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<span className="pending-spinner" />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: isError ? '#ef4444' : '#888',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{isError ? 'Failed' : (INPUT_TYPE_LABELS[pending.inputType] || 'Processing...')}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#555',
|
||||
marginTop: '2px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{isError && pending.error ? pending.error : pending.input}
|
||||
</div>
|
||||
</div>
|
||||
{isError && onDismiss && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDismiss(); }}
|
||||
style={{
|
||||
padding: '4px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#555',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#555'; }}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<style jsx>{`
|
||||
.pending-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid #22c55e;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: pendingSpin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes pendingSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ViewsOverlayProps {
|
||||
onNodeClick: (nodeId: number) => void;
|
||||
onNodeOpenInOtherPane?: (nodeId: number) => void;
|
||||
refreshToken?: number;
|
||||
pendingNodes?: PendingNode[];
|
||||
onDismissPending?: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refreshToken = 0 }: ViewsOverlayProps) {
|
||||
export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refreshToken = 0, pendingNodes, onDismissPending }: ViewsOverlayProps) {
|
||||
const { dimensionIcons } = useDimensionIcons();
|
||||
|
||||
// Dimensions for filter picker
|
||||
@@ -43,6 +144,13 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
|
||||
// Sort order (persisted)
|
||||
const [sortOrder, setSortOrder] = usePersistentState<SortOrder>('ui.feedSortOrder', 'updated');
|
||||
|
||||
// Custom order (persisted) — stores node IDs in user-defined order
|
||||
const [customOrder, setCustomOrder] = usePersistentState<number[]>('ui.feedCustomOrder', []);
|
||||
|
||||
// Drag-to-reorder state
|
||||
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[]>([]);
|
||||
@@ -86,18 +194,37 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
|
||||
const fetchAllNodes = useCallback(async () => {
|
||||
setFilteredNodesLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/nodes?limit=500&sortBy=${sortOrder}`);
|
||||
// Custom sort fetches with 'updated' then reorders client-side
|
||||
const apiSort = sortOrder === 'custom' ? 'updated' : sortOrder;
|
||||
const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}`);
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Failed to fetch nodes');
|
||||
}
|
||||
setFilteredNodes(data.data || []);
|
||||
const nodes: Node[] = data.data || [];
|
||||
if (sortOrder === 'custom' && customOrder.length > 0) {
|
||||
// Reorder nodes based on saved custom order
|
||||
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); // New nodes not in custom order — append at bottom
|
||||
}
|
||||
}
|
||||
ordered.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
|
||||
setFilteredNodes([...ordered, ...unordered]);
|
||||
} else {
|
||||
setFilteredNodes(nodes);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching nodes:', error);
|
||||
} finally {
|
||||
setFilteredNodesLoading(false);
|
||||
}
|
||||
}, [sortOrder]);
|
||||
}, [sortOrder, customOrder]);
|
||||
|
||||
const fetchFilteredNodes = useCallback(async (filters: string[]) => {
|
||||
if (filters.length === 0) {
|
||||
@@ -106,18 +233,35 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
|
||||
}
|
||||
setFilteredNodesLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/nodes?limit=500&sortBy=${sortOrder}&dimensions=${encodeURIComponent(filters.join(','))}&dimensionsMatch=all`);
|
||||
const apiSort = sortOrder === 'custom' ? 'updated' : sortOrder;
|
||||
const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}&dimensions=${encodeURIComponent(filters.join(','))}&dimensionsMatch=all`);
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Failed to fetch nodes');
|
||||
}
|
||||
setFilteredNodes(data.data || []);
|
||||
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([...ordered, ...unordered]);
|
||||
} else {
|
||||
setFilteredNodes(nodes);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching filtered nodes:', error);
|
||||
} finally {
|
||||
setFilteredNodesLoading(false);
|
||||
}
|
||||
}, [fetchAllNodes, sortOrder]);
|
||||
}, [fetchAllNodes, sortOrder, customOrder]);
|
||||
|
||||
// Stringify filters for stable dependency
|
||||
const filtersKey = selectedFilters.join(',');
|
||||
@@ -192,9 +336,29 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showFilterPicker, showSortDropdown]);
|
||||
|
||||
// Reorder handlers
|
||||
const handleReorderDrop = useCallback((dropIdx: number) => {
|
||||
if (reorderDragIndex === null || reorderDragIndex === dropIdx) {
|
||||
setReorderDragIndex(null);
|
||||
setReorderDropIndex(null);
|
||||
return;
|
||||
}
|
||||
// Reorder filteredNodes and persist
|
||||
const newNodes = [...filteredNodes];
|
||||
const [moved] = newNodes.splice(reorderDragIndex, 1);
|
||||
newNodes.splice(dropIdx > reorderDragIndex ? dropIdx - 1 : dropIdx, 0, moved);
|
||||
setFilteredNodes(newNodes);
|
||||
setCustomOrder(newNodes.map(n => n.id));
|
||||
setReorderDragIndex(null);
|
||||
setReorderDropIndex(null);
|
||||
}, [reorderDragIndex, filteredNodes, setCustomOrder]);
|
||||
|
||||
// Render node card
|
||||
const renderNodeCard = (node: Node) => {
|
||||
const renderNodeCard = (node: Node, index: number) => {
|
||||
const nodeIcon = getNodeIcon(node, dimensionIcons, 18);
|
||||
const isCustomSort = sortOrder === 'custom';
|
||||
const isDragSource = reorderDragIndex === index;
|
||||
const isDropTarget = reorderDropIndex === index;
|
||||
|
||||
// Description preview — first meaningful line, truncated
|
||||
const descPreview = node.description && node.description.length > 10
|
||||
@@ -212,6 +376,21 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
|
||||
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: node.id, title, dimensions: node.dimensions || [] }));
|
||||
e.dataTransfer.setData('text/plain', `[NODE:${node.id}:"${title}"]`);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!isCustomSort || reorderDragIndex === null) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setReorderDropIndex(index);
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (!isCustomSort) return;
|
||||
setReorderDropIndex(null);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!isCustomSort || reorderDragIndex === null) return;
|
||||
e.preventDefault();
|
||||
handleReorderDrop(index);
|
||||
}}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
background: 'transparent',
|
||||
@@ -219,6 +398,8 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
|
||||
transition: 'all 0.15s ease',
|
||||
borderBottom: '1px solid #141414',
|
||||
borderLeft: '3px solid transparent',
|
||||
opacity: isDragSource ? 0.4 : 1,
|
||||
borderTop: isDropTarget ? '2px solid #22c55e' : '2px solid transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.02)';
|
||||
@@ -234,6 +415,38 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
|
||||
{/* Grip handle — only in custom sort mode */}
|
||||
{isCustomSort && (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation();
|
||||
setReorderDragIndex(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('application/x-rah-reorder', String(index));
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setReorderDragIndex(null);
|
||||
setReorderDropIndex(null);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '16px',
|
||||
cursor: 'grab',
|
||||
color: '#444',
|
||||
flexShrink: 0,
|
||||
alignSelf: 'center',
|
||||
transition: 'color 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#888'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#444'; }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
@@ -622,7 +835,14 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
|
||||
flexDirection: 'column',
|
||||
gap: '8px'
|
||||
}}>
|
||||
{filteredNodes.map(node => renderNodeCard(node))}
|
||||
{pendingNodes && pendingNodes.length > 0 && pendingNodes.map(p => (
|
||||
<PendingNodeCard
|
||||
key={p.id}
|
||||
pending={p}
|
||||
onDismiss={onDismissPending ? () => onDismissPending(p.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
{filteredNodes.map((node, index) => renderNodeCard(node, index))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user