sync: ui polish pass from private repo
- increase base typography and relative date formatting - add card polish, empty states, and skeleton loaders - center the nodes pane content like the focus panel
This commit is contained in:
+15
-2
@@ -139,9 +139,9 @@ html, body {
|
||||
color: var(--rah-text-base);
|
||||
/* Geist Sans for optimal reading/writing experience */
|
||||
font-family: 'Geist', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.011em; /* Optimized letter spacing for Geist */
|
||||
letter-spacing: -0.012em; /* Optimized letter spacing for Geist */
|
||||
font-weight: 400;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@@ -220,6 +220,19 @@ html, body {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rah-shimmer {
|
||||
0%, 100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.rah-skeleton {
|
||||
animation: rah-shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Modal/Popup animations */
|
||||
@keyframes modalFadeIn {
|
||||
from {
|
||||
|
||||
@@ -52,6 +52,7 @@ function NavButton({
|
||||
onClick,
|
||||
trailing,
|
||||
activeTone = 'neutral',
|
||||
title,
|
||||
}: {
|
||||
icon: typeof Search;
|
||||
label: string;
|
||||
@@ -60,6 +61,7 @@ function NavButton({
|
||||
onClick: () => void;
|
||||
trailing?: ReactNode;
|
||||
activeTone?: 'neutral' | 'green';
|
||||
title?: string;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const activeColor = activeTone === 'green' ? 'var(--rah-accent-green)' : 'var(--rah-text-active)';
|
||||
@@ -69,7 +71,7 @@ function NavButton({
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
title={expanded ? undefined : label}
|
||||
title={title ?? (expanded ? undefined : label)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '36px',
|
||||
@@ -136,8 +138,8 @@ export default function LeftToolbar({
|
||||
onClick={onToggleExpanded}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<NavButton icon={Search} label="Search" expanded={isExpanded} onClick={onSearchClick} />
|
||||
<NavButton icon={Plus} label="Add Stuff" expanded={isExpanded} onClick={onAddStuffClick} />
|
||||
<NavButton icon={Search} label="Search" title="Search (⌘K)" expanded={isExpanded} onClick={onSearchClick} />
|
||||
<NavButton icon={Plus} label="Add Stuff" title="New node (⌘N)" expanded={isExpanded} onClick={onAddStuffClick} />
|
||||
<NavButton icon={RefreshCw} label="Refresh" expanded={isExpanded} onClick={onRefreshClick} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Filter, ChevronDown, ChevronLeft, ChevronRight, X, ArrowUpDown, Search, ExternalLink } from 'lucide-react';
|
||||
import type { Node } from '@/types/database';
|
||||
import { formatRelativeDate } from '@/utils/formatDate';
|
||||
|
||||
type SortOrder = 'updated' | 'edges' | 'created' | 'event_date';
|
||||
|
||||
@@ -28,27 +29,6 @@ interface DatabaseTableViewProps {
|
||||
toolbarHost?: HTMLDivElement | null;
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -552,14 +532,14 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
||||
{/* Updated */}
|
||||
<td style={tdStyle()}>
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>
|
||||
{formatRelativeTime(node.updated_at)}
|
||||
{formatRelativeDate(node.updated_at)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Created */}
|
||||
<td style={tdStyle()}>
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>
|
||||
{formatRelativeTime(node.created_at)}
|
||||
{formatRelativeDate(node.created_at)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -594,7 +574,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
||||
{/* Embedding Updated */}
|
||||
<td style={tdStyle()}>
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>
|
||||
{node.embedding_updated_at ? formatRelativeTime(node.embedding_updated_at) : '\u2014'}
|
||||
{node.embedding_updated_at ? formatRelativeDate(node.embedding_updated_at) : '\u2014'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Inbox } from 'lucide-react';
|
||||
import { Node } from '@/types/database';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
import { useDimensionIcons } from '@/context/DimensionIconsContext';
|
||||
import { formatRelativeDate } from '@/utils/formatDate';
|
||||
|
||||
interface ListViewProps {
|
||||
nodes: Node[];
|
||||
@@ -11,15 +13,6 @@ interface ListViewProps {
|
||||
|
||||
export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
const { dimensionIcons } = useDimensionIcons();
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const truncateContent = (content?: string, maxLength: number = 100) => {
|
||||
if (!content) return '';
|
||||
@@ -31,13 +24,20 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
height: '100%',
|
||||
color: '#666',
|
||||
fontSize: '13px'
|
||||
color: 'var(--rah-text-muted)',
|
||||
padding: '40px 20px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
No nodes match the current filters
|
||||
<Inbox size={28} strokeWidth={1.5} style={{ opacity: 0.4 }} />
|
||||
<span style={{ fontSize: '14px', color: 'var(--rah-text-secondary)' }}>Nothing here yet</span>
|
||||
<span style={{ fontSize: '12px', opacity: 0.7 }}>
|
||||
Try adjusting your filters, or add a node with ⌘N
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,20 +59,25 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
gap: '12px',
|
||||
padding: '12px',
|
||||
marginBottom: '4px',
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #1a1a1a',
|
||||
background: 'var(--rah-bg-base)',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderLeft: '2px solid var(--rah-border-stronger)',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'all 0.2s'
|
||||
transition: 'all 0.15s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#111';
|
||||
e.currentTarget.style.borderColor = '#333';
|
||||
e.currentTarget.style.background = 'var(--rah-bg-surface)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = 'var(--rah-shadow-floating)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#0a0a0a';
|
||||
e.currentTarget.style.borderColor = '#1a1a1a';
|
||||
e.currentTarget.style.background = 'var(--rah-bg-base)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
{/* Icon */}
|
||||
@@ -82,7 +87,7 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#1a1a1a',
|
||||
background: 'var(--rah-bg-active)',
|
||||
borderRadius: '6px',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
@@ -93,9 +98,9 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{/* Title */}
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
color: '#e5e5e5',
|
||||
color: 'var(--rah-text-base)',
|
||||
marginBottom: '4px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
@@ -107,8 +112,8 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
{/* Description or Content Preview */}
|
||||
{(node.description || node.source) && (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
fontSize: '13px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
marginBottom: '8px',
|
||||
lineHeight: '1.4',
|
||||
display: '-webkit-box',
|
||||
@@ -138,11 +143,12 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
<span
|
||||
key={dim}
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
background: '#1a1a1a',
|
||||
borderRadius: '3px',
|
||||
fontSize: '10px',
|
||||
color: '#888'
|
||||
padding: '2px 8px',
|
||||
background: 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '11px',
|
||||
color: 'var(--rah-text-base)'
|
||||
}}
|
||||
>
|
||||
{dim}
|
||||
@@ -151,8 +157,8 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
{node.dimensions.length > 3 && (
|
||||
<span style={{
|
||||
padding: '2px 6px',
|
||||
fontSize: '10px',
|
||||
color: '#666'
|
||||
fontSize: '11px',
|
||||
color: 'var(--rah-text-muted)'
|
||||
}}>
|
||||
+{node.dimensions.length - 3}
|
||||
</span>
|
||||
@@ -162,17 +168,17 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
|
||||
{/* Date */}
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
color: '#555'
|
||||
fontSize: '11px',
|
||||
color: 'var(--rah-text-muted)'
|
||||
}}>
|
||||
{formatDate(node.updated_at || node.created_at)}
|
||||
{formatRelativeDate(node.updated_at || node.created_at)}
|
||||
</span>
|
||||
|
||||
{/* Edge count */}
|
||||
{node.edge_count !== undefined && node.edge_count > 0 && (
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
color: '#555'
|
||||
fontSize: '11px',
|
||||
color: 'var(--rah-text-muted)'
|
||||
}}>
|
||||
{node.edge_count} connections
|
||||
</span>
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Filter, ChevronDown, X, ArrowUpDown, GripVertical } from 'lucide-react';
|
||||
import { Filter, ChevronDown, X, ArrowUpDown, GripVertical, Inbox } 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';
|
||||
import { formatRelativeDate } from '@/utils/formatDate';
|
||||
|
||||
type SortOrder = 'updated' | 'edges' | 'created' | 'custom';
|
||||
|
||||
@@ -18,6 +19,8 @@ const SORT_LABELS: Record<SortOrder, string> = {
|
||||
custom: 'Custom',
|
||||
};
|
||||
|
||||
const DOCUMENT_MAX_WIDTH = '980px';
|
||||
|
||||
interface ColumnFilter {
|
||||
id: string;
|
||||
dimension: string;
|
||||
@@ -127,6 +130,39 @@ function PendingNodeCard({ pending, onDismiss }: { pending: PendingNode; onDismi
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonCard() {
|
||||
return (
|
||||
<div
|
||||
className="rah-skeleton"
|
||||
style={{
|
||||
padding: '12px 14px',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderLeft: '2px solid var(--rah-border)',
|
||||
borderRadius: '10px',
|
||||
background: 'var(--rah-bg-base)',
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--rah-bg-elevated)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ height: '14px', width: '58%', borderRadius: '4px', background: 'var(--rah-bg-elevated)', marginBottom: '8px' }} />
|
||||
<div style={{ height: '12px', width: '82%', borderRadius: '4px', background: 'var(--rah-bg-elevated)', marginBottom: '6px' }} />
|
||||
<div style={{ height: '11px', width: '28%', borderRadius: '4px', background: 'var(--rah-bg-elevated)' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ViewsOverlayProps {
|
||||
onNodeClick: (nodeId: number) => void;
|
||||
onNodeOpenInOtherPane?: (nodeId: number) => void;
|
||||
@@ -418,19 +454,26 @@ export default function ViewsOverlay({
|
||||
}}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
background: 'transparent',
|
||||
background: 'var(--rah-bg-base)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
borderBottom: '1px solid var(--rah-bg-panel)',
|
||||
borderLeft: '3px solid transparent',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderLeft: '2px solid var(--rah-border-stronger)',
|
||||
borderRadius: '10px',
|
||||
opacity: isDragSource ? 0.4 : 1,
|
||||
borderTop: isDropTarget ? '2px solid #22c55e' : '2px solid transparent',
|
||||
borderTop: isDropTarget ? '2px solid var(--rah-accent-green)' : '2px solid transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.02)';
|
||||
e.currentTarget.style.background = 'var(--rah-bg-surface)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = 'var(--rah-shadow-floating)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.background = 'var(--rah-bg-base)';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -460,13 +503,13 @@ export default function ViewsOverlay({
|
||||
justifyContent: 'center',
|
||||
width: '16px',
|
||||
cursor: 'grab',
|
||||
color: '#444',
|
||||
color: 'var(--rah-text-muted)',
|
||||
flexShrink: 0,
|
||||
alignSelf: 'center',
|
||||
transition: 'color 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#888'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#444'; }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--rah-text-soft)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
@@ -476,8 +519,8 @@ export default function ViewsOverlay({
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(var(--rah-bg-panel))',
|
||||
border: '1px solid var(--rah-bg-active)',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
border: '1px solid var(--rah-border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -493,9 +536,9 @@ export default function ViewsOverlay({
|
||||
marginBottom: descPreview ? '2px' : '4px'
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '13px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
color: 'var(var(--rah-text-active))',
|
||||
color: 'var(--rah-text-active)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
@@ -506,21 +549,27 @@ export default function ViewsOverlay({
|
||||
</span>
|
||||
{node.edge_count != null && node.edge_count > 0 && (
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
color: 'var(var(--rah-text-muted))',
|
||||
minWidth: '18px',
|
||||
height: '18px',
|
||||
padding: '0 5px',
|
||||
borderRadius: '999px',
|
||||
background: 'var(--rah-accent-green-soft)',
|
||||
border: '1px solid var(--rah-accent-green-soft-strong)',
|
||||
color: 'var(--rah-accent-green)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
<span style={{ fontSize: '9px' }}>🔗</span>
|
||||
{node.edge_count}
|
||||
</span>
|
||||
)}
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
color: '#444',
|
||||
background: 'var(var(--rah-bg-panel))',
|
||||
fontSize: '11px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'monospace',
|
||||
@@ -531,8 +580,8 @@ export default function ViewsOverlay({
|
||||
</div>
|
||||
{descPreview && (
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(var(--rah-text-muted))',
|
||||
fontSize: '13px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
lineHeight: '1.4',
|
||||
marginBottom: '4px',
|
||||
overflow: 'hidden',
|
||||
@@ -553,24 +602,36 @@ export default function ViewsOverlay({
|
||||
<span
|
||||
key={d}
|
||||
style={{
|
||||
fontSize: '9px',
|
||||
padding: '1px 5px',
|
||||
background: 'rgba(34, 197, 94, 0.08)',
|
||||
color: '#4a9',
|
||||
borderRadius: '3px'
|
||||
fontSize: '11px',
|
||||
padding: '2px 8px',
|
||||
background: 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
color: 'var(--rah-text-base)',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
>
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
{node.dimensions.length > 3 && (
|
||||
<span style={{ fontSize: '9px', color: 'var(var(--rah-text-muted))' }}>
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>
|
||||
+{node.dimensions.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginTop: '6px',
|
||||
fontSize: '11px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
}}>
|
||||
<span>{formatRelativeDate(node.updated_at || node.created_at)}</span>
|
||||
{node.edge_count != null && node.edge_count > 0 ? <span>{node.edge_count} connections</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -578,14 +639,15 @@ export default function ViewsOverlay({
|
||||
};
|
||||
|
||||
const toolbar = (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', flex: 1 }}>
|
||||
<div style={{ width: '100%', maxWidth: DOCUMENT_MAX_WIDTH, margin: '0 auto' }}>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', flex: 1 }}>
|
||||
{selectedFilters.map(filter => (
|
||||
<div
|
||||
key={filter}
|
||||
@@ -622,7 +684,7 @@ export default function ViewsOverlay({
|
||||
))}
|
||||
|
||||
{externalDimensionFilter && (
|
||||
<span style={{ fontSize: '11px', color: 'var(var(--rah-text-muted))' }}>
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>
|
||||
Sidebar filter
|
||||
</span>
|
||||
)}
|
||||
@@ -635,15 +697,16 @@ export default function ViewsOverlay({
|
||||
}
|
||||
}}
|
||||
disabled={!!externalDimensionFilter}
|
||||
title="Filter (⌘F)"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderRadius: '5px',
|
||||
color: externalDimensionFilter ? '#4b4b4b' : '#888',
|
||||
color: externalDimensionFilter ? 'var(--rah-text-muted)' : 'var(--rah-text-soft)',
|
||||
fontSize: '11px',
|
||||
cursor: externalDimensionFilter ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.15s ease'
|
||||
@@ -651,12 +714,12 @@ export default function ViewsOverlay({
|
||||
onMouseEnter={(e) => {
|
||||
if (!externalDimensionFilter) {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.04)';
|
||||
e.currentTarget.style.borderColor = '#333';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.borderColor = '#222';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border)';
|
||||
}}
|
||||
>
|
||||
<Filter size={11} />
|
||||
@@ -670,8 +733,8 @@ export default function ViewsOverlay({
|
||||
top: '100%',
|
||||
left: 0,
|
||||
marginTop: '4px',
|
||||
background: 'var(var(--rah-bg-panel))',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderRadius: '10px',
|
||||
padding: '6px',
|
||||
minWidth: '220px',
|
||||
@@ -689,23 +752,23 @@ export default function ViewsOverlay({
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '7px 10px',
|
||||
background: 'var(var(--rah-bg-surface))',
|
||||
background: 'var(--rah-bg-base)',
|
||||
border: '1px solid transparent',
|
||||
borderRadius: '6px',
|
||||
color: 'var(var(--rah-text-active))',
|
||||
color: 'var(--rah-text-active)',
|
||||
fontSize: '12px',
|
||||
marginBottom: '4px',
|
||||
outline: 'none',
|
||||
}}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = '#333'; }}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--rah-border-strong)'; }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
|
||||
/>
|
||||
{dimensionsLoading ? (
|
||||
<div style={{ padding: '12px', color: 'var(var(--rah-text-muted))', fontSize: '12px', textAlign: 'center' }}>
|
||||
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
|
||||
Loading dimensions...
|
||||
</div>
|
||||
) : filterPickerDimensions.length === 0 ? (
|
||||
<div style={{ padding: '12px', color: 'var(var(--rah-text-muted))', fontSize: '12px', textAlign: 'center' }}>
|
||||
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
|
||||
{filterSearchQuery ? 'No matching dimensions' : 'No dimensions available'}
|
||||
</div>
|
||||
) : (
|
||||
@@ -722,7 +785,7 @@ export default function ViewsOverlay({
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
color: 'var(var(--rah-text-secondary))',
|
||||
color: 'var(--rah-text-secondary)',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left'
|
||||
@@ -732,9 +795,9 @@ export default function ViewsOverlay({
|
||||
>
|
||||
<span>{d.dimension}</span>
|
||||
<span style={{
|
||||
color: 'var(var(--rah-text-muted))',
|
||||
color: 'var(--rah-text-muted)',
|
||||
fontSize: '10px',
|
||||
background: 'var(var(--rah-bg-active))',
|
||||
background: 'var(--rah-bg-active)',
|
||||
padding: '1px 6px',
|
||||
borderRadius: '10px',
|
||||
}}>
|
||||
@@ -754,12 +817,12 @@ export default function ViewsOverlay({
|
||||
padding: '4px 8px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(var(--rah-text-muted))',
|
||||
color: 'var(--rah-text-muted)',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
@@ -768,29 +831,29 @@ export default function ViewsOverlay({
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowSortDropdown(!showSortDropdown)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '5px 8px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
borderRadius: '5px',
|
||||
color: 'var(var(--rah-text-muted))',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
<button
|
||||
onClick={() => setShowSortDropdown(!showSortDropdown)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '5px 8px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderRadius: '5px',
|
||||
color: 'var(--rah-text-soft)',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.04)';
|
||||
e.currentTarget.style.borderColor = '#333';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.borderColor = '#222';
|
||||
e.currentTarget.style.borderColor = 'var(--rah-border)';
|
||||
}}
|
||||
>
|
||||
<ArrowUpDown size={11} />
|
||||
@@ -804,8 +867,8 @@ export default function ViewsOverlay({
|
||||
top: '100%',
|
||||
right: 0,
|
||||
marginTop: '4px',
|
||||
background: 'var(var(--rah-bg-panel))',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderRadius: '10px',
|
||||
padding: '4px',
|
||||
minWidth: '140px',
|
||||
@@ -828,7 +891,7 @@ export default function ViewsOverlay({
|
||||
background: sortOrder === key ? 'rgba(255,255,255,0.04)' : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
color: sortOrder === key ? '#f0f0f0' : '#999',
|
||||
color: sortOrder === key ? 'var(--rah-text-active)' : 'var(--rah-text-soft)',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
@@ -836,7 +899,7 @@ export default function ViewsOverlay({
|
||||
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>}
|
||||
{sortOrder === key && <span style={{ color: 'var(--rah-accent-green)', fontSize: '12px' }}>✓</span>}
|
||||
{SORT_LABELS[key]}
|
||||
</button>
|
||||
))}
|
||||
@@ -844,6 +907,7 @@ export default function ViewsOverlay({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -868,30 +932,55 @@ export default function ViewsOverlay({
|
||||
|
||||
{/* Content area — list view */}
|
||||
{filteredNodesLoading ? (
|
||||
<div style={{ padding: '40px', color: 'var(var(--rah-text-muted))', textAlign: 'center' }}>
|
||||
Loading...
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '16px',
|
||||
}}>
|
||||
<div style={{ width: '100%', maxWidth: DOCUMENT_MAX_WIDTH, margin: '0 auto', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<SkeletonCard key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : filteredNodes.length === 0 ? (
|
||||
<div style={{ padding: '40px', color: 'var(var(--rah-text-muted))', textAlign: 'center' }}>
|
||||
{selectedFilters.length > 0 ? 'No nodes match the selected filters.' : 'No nodes yet. Add some content to get started.'}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
padding: '40px 20px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ width: '100%', maxWidth: DOCUMENT_MAX_WIDTH, margin: '0 auto', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
|
||||
<Inbox size={28} strokeWidth={1.5} style={{ opacity: 0.4 }} />
|
||||
<span style={{ fontSize: '14px', color: 'var(--rah-text-secondary)' }}>
|
||||
{selectedFilters.length > 0 ? 'Nothing matches these filters' : 'Nothing here yet'}
|
||||
</span>
|
||||
<span style={{ fontSize: '12px', opacity: 0.7 }}>
|
||||
{selectedFilters.length > 0 ? 'Try adjusting your filters, or add a node with ⌘N' : 'Add a node with ⌘N to get started'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px'
|
||||
}}>
|
||||
{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 style={{ width: '100%', maxWidth: DOCUMENT_MAX_WIDTH, margin: '0 auto', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export function formatRelativeDate(dateString?: string | null): string {
|
||||
if (!dateString) return '';
|
||||
|
||||
const then = new Date(dateString).getTime();
|
||||
if (Number.isNaN(then)) return '';
|
||||
|
||||
const diff = Date.now() - then;
|
||||
if (diff < 0) return 'today';
|
||||
|
||||
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 === 1) return 'yesterday';
|
||||
if (days < 7) return `${days}d ago`;
|
||||
if (days < 30) return `${Math.floor(days / 7)}w ago`;
|
||||
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
|
||||
|
||||
return `${Math.floor(days / 365)}y ago`;
|
||||
}
|
||||
Reference in New Issue
Block a user