Replace dark hex values with CSS custom properties across all panel, card, modal, and focus components so light mode renders correctly. Backgrounds, borders, and text colors now respond to data-theme attribute. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
533 lines
20 KiB
TypeScript
533 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useState, type ChangeEvent, type KeyboardEvent } from 'react';
|
||
import { Folder, Link as LinkIcon } from 'lucide-react';
|
||
import { Node } from '@/types/database';
|
||
import { openExternalUrl } from '@/utils/openExternalUrl';
|
||
|
||
interface NodeWithMetrics extends Node {
|
||
edge_count?: number;
|
||
}
|
||
|
||
interface AppliedFilters {
|
||
search?: string;
|
||
dimensions?: string[];
|
||
sortBy: 'updated' | 'edges';
|
||
}
|
||
|
||
interface PopularDimension {
|
||
dimension: string;
|
||
count: number;
|
||
isPriority: boolean;
|
||
}
|
||
|
||
const LIMIT = 50;
|
||
|
||
export default function DatabaseViewer() {
|
||
const [nodes, setNodes] = useState<NodeWithMetrics[]>([]);
|
||
const [page, setPage] = useState(1);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const [searchInput, setSearchInput] = useState('');
|
||
const [dimensionInput, setDimensionInput] = useState('');
|
||
const [dimensionFilters, setDimensionFilters] = useState<string[]>([]);
|
||
const [sortBy, setSortBy] = useState<'updated' | 'edges'>('updated');
|
||
|
||
const [appliedFilters, setAppliedFilters] = useState<AppliedFilters>({ sortBy: 'updated' });
|
||
const [lockedDimensionSet, setLockedDimensionSet] = useState<Set<string>>(new Set());
|
||
const [contextHubIds, setContextHubIds] = useState<Set<number>>(new Set());
|
||
|
||
const filtersActive = useMemo(
|
||
() => Boolean(appliedFilters.search || (appliedFilters.dimensions && appliedFilters.dimensions.length > 0)),
|
||
[appliedFilters]
|
||
);
|
||
|
||
const fetchNodes = useCallback(
|
||
async (pageNumber: number, filters: AppliedFilters) => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const params = new URLSearchParams();
|
||
params.set('limit', LIMIT.toString());
|
||
params.set('offset', ((pageNumber - 1) * LIMIT).toString());
|
||
params.set('sortBy', filters.sortBy);
|
||
if (filters.search) params.set('search', filters.search);
|
||
if (filters.dimensions && filters.dimensions.length > 0) {
|
||
params.set('dimensions', filters.dimensions.join(','));
|
||
}
|
||
|
||
const response = await fetch(`/api/nodes?${params.toString()}`);
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch nodes');
|
||
}
|
||
const data = await response.json();
|
||
setNodes(data.data || []);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||
setNodes([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
},
|
||
[]
|
||
);
|
||
|
||
useEffect(() => {
|
||
fetchNodes(page, appliedFilters);
|
||
}, [page, appliedFilters, fetchNodes]);
|
||
|
||
useEffect(() => {
|
||
const loadLockedDimensions = async () => {
|
||
try {
|
||
const response = await fetch('/api/dimensions/popular');
|
||
if (!response.ok) return;
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
const priorityDimensions: PopularDimension[] = result.data;
|
||
setLockedDimensionSet(new Set(priorityDimensions.filter((d) => d.isPriority).map((d) => d.dimension)));
|
||
}
|
||
} catch (err) {
|
||
console.warn('Failed to load locked dimensions', err);
|
||
}
|
||
};
|
||
|
||
loadLockedDimensions();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const loadContextHubs = async () => {
|
||
try {
|
||
const response = await fetch('/api/nodes?sortBy=edges&limit=10');
|
||
if (!response.ok) return;
|
||
const payload = await response.json();
|
||
const ids = new Set<number>((payload.data || []).map((node: Node) => node.id));
|
||
setContextHubIds(ids);
|
||
} catch (err) {
|
||
console.warn('Failed to load auto-context hubs', err);
|
||
}
|
||
};
|
||
|
||
loadContextHubs();
|
||
}, []);
|
||
|
||
const handleApplyFilters = () => {
|
||
const payload: AppliedFilters = {
|
||
sortBy,
|
||
};
|
||
|
||
if (searchInput.trim()) payload.search = searchInput.trim();
|
||
if (dimensionFilters.length > 0) payload.dimensions = dimensionFilters;
|
||
|
||
setAppliedFilters(payload);
|
||
setPage(1);
|
||
};
|
||
|
||
const handleClearFilters = () => {
|
||
setSearchInput('');
|
||
setDimensionInput('');
|
||
setDimensionFilters([]);
|
||
setSortBy('updated');
|
||
setAppliedFilters({ sortBy: 'updated' });
|
||
setPage(1);
|
||
};
|
||
|
||
const handleAddDimension = () => {
|
||
const next = dimensionInput.trim();
|
||
if (!next) return;
|
||
setDimensionFilters((prev) => (prev.includes(next) ? prev : [...prev, next]));
|
||
setDimensionInput('');
|
||
};
|
||
|
||
const handleDimensionKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||
if (event.key === 'Enter') {
|
||
event.preventDefault();
|
||
handleAddDimension();
|
||
}
|
||
};
|
||
|
||
const handleRemoveDimension = (dimension: string) => {
|
||
setDimensionFilters((prev) => prev.filter((dim) => dim !== dimension));
|
||
};
|
||
|
||
const handleSortChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
||
const value = event.target.value === 'edges' ? 'edges' : 'updated';
|
||
setSortBy(value);
|
||
setAppliedFilters((prev) => ({ ...prev, sortBy: value }));
|
||
};
|
||
|
||
const handlePrevious = () => {
|
||
setPage((prev) => (prev > 1 ? prev - 1 : prev));
|
||
};
|
||
|
||
const handleNext = () => {
|
||
if (nodes.length === LIMIT) {
|
||
setPage((prev) => prev + 1);
|
||
}
|
||
};
|
||
|
||
const isFirstPage = page === 1;
|
||
const isLastPage = nodes.length < LIMIT;
|
||
const filterStatus = filtersActive
|
||
? 'Filtered results'
|
||
: `Showing ${(page - 1) * LIMIT + 1}-${(page - 1) * LIMIT + nodes.length}`;
|
||
|
||
const formatTimestamp = (value?: string) => {
|
||
if (!value) return '—';
|
||
const date = new Date(value);
|
||
return date.toLocaleString(undefined, {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
};
|
||
|
||
const formatRelative = (value?: string) => {
|
||
if (!value) return '';
|
||
const diffMs = Date.now() - new Date(value).getTime();
|
||
const diffMinutes = Math.round(diffMs / (1000 * 60));
|
||
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
||
const diffHours = Math.round(diffMinutes / 60);
|
||
if (diffHours < 24) return `${diffHours}h ago`;
|
||
const diffDays = Math.round(diffHours / 24);
|
||
return `${diffDays}d ago`;
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div style={{ padding: '40px', textAlign: 'center', color: 'var(--rah-text-muted)' }}>
|
||
Loading database...
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div style={{ padding: '40px', textAlign: 'center', color: '#ef4444' }}>
|
||
Error: {error}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (nodes.length === 0) {
|
||
return (
|
||
<div style={{ padding: '40px', textAlign: 'center', color: 'var(--rah-text-muted)' }}>
|
||
No nodes found
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||
<div
|
||
style={{
|
||
padding: '16px 24px',
|
||
borderBottom: '1px solid var(--rah-border-strong)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '12px',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: 'var(--rah-text-muted)', gap: '4px' }}>
|
||
Search
|
||
<input
|
||
value={searchInput}
|
||
onChange={(e) => setSearchInput(e.target.value)}
|
||
placeholder="title or content"
|
||
style={{
|
||
background: 'var(--rah-bg-surface)',
|
||
border: '1px solid var(--rah-border-strong)',
|
||
color: 'var(--rah-text-base)',
|
||
padding: '6px 10px',
|
||
borderRadius: '4px',
|
||
minWidth: '220px',
|
||
}}
|
||
/>
|
||
</label>
|
||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: 'var(--rah-text-muted)', gap: '4px' }}>
|
||
Dimension
|
||
<div style={{ display: 'flex', gap: '8px' }}>
|
||
<input
|
||
value={dimensionInput}
|
||
onChange={(e) => setDimensionInput(e.target.value)}
|
||
onKeyDown={handleDimensionKeyDown}
|
||
placeholder="e.g. research"
|
||
style={{
|
||
background: 'var(var(--rah-bg-surface))',
|
||
border: '1px solid var(--rah-border-strong)',
|
||
color: 'var(var(--rah-text-base))',
|
||
padding: '6px 10px',
|
||
borderRadius: '4px',
|
||
minWidth: '180px',
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={handleAddDimension}
|
||
style={{
|
||
padding: '6px 10px',
|
||
background: '#1f3529',
|
||
border: '1px solid #264333',
|
||
borderRadius: '4px',
|
||
color: '#c4f5d2',
|
||
fontSize: '12px',
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
Add
|
||
</button>
|
||
</div>
|
||
</label>
|
||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: '11px', color: 'var(--rah-text-muted)', gap: '4px' }}>
|
||
Sort by
|
||
<select
|
||
value={sortBy}
|
||
onChange={handleSortChange}
|
||
style={{
|
||
background: 'var(--rah-bg-surface)',
|
||
border: '1px solid var(--rah-border-strong)',
|
||
color: 'var(--rah-text-base)',
|
||
padding: '6px 10px',
|
||
borderRadius: '4px',
|
||
}}
|
||
>
|
||
<option value="updated">Recently updated</option>
|
||
<option value="edges">Most connected</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
{dimensionFilters.length > 0 && (
|
||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||
{dimensionFilters.map((dimension) => (
|
||
<span
|
||
key={dimension}
|
||
style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: '6px',
|
||
padding: '3px 10px',
|
||
borderRadius: '999px',
|
||
background: '#142817',
|
||
color: '#c4f5d2',
|
||
fontSize: '11px',
|
||
border: '1px solid #1f3b23',
|
||
}}
|
||
>
|
||
<Folder size={12} />
|
||
{dimension}
|
||
<button
|
||
onClick={() => handleRemoveDimension(dimension)}
|
||
style={{
|
||
marginLeft: '2px',
|
||
background: 'transparent',
|
||
border: 'none',
|
||
color: '#7de8a5',
|
||
cursor: 'pointer',
|
||
fontSize: '11px',
|
||
}}
|
||
aria-label={`Remove ${dimension}`}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<div style={{ fontSize: '12px', color: 'var(--rah-text-muted)' }}>{filterStatus}</div>
|
||
<div style={{ display: 'flex', gap: '8px' }}>
|
||
<button
|
||
onClick={handleApplyFilters}
|
||
style={{
|
||
padding: '8px 16px',
|
||
background: '#22c55e33',
|
||
border: '1px solid #22c55e66',
|
||
borderRadius: '4px',
|
||
color: '#22c55e',
|
||
cursor: 'pointer',
|
||
fontSize: '12px',
|
||
}}
|
||
>
|
||
Apply
|
||
</button>
|
||
<button
|
||
onClick={handleClearFilters}
|
||
style={{
|
||
padding: '8px 16px',
|
||
background: 'var(--rah-bg-active)',
|
||
border: '1px solid var(--rah-border-stronger)',
|
||
borderRadius: '4px',
|
||
color: 'var(--rah-text-secondary)',
|
||
cursor: 'pointer',
|
||
fontSize: '12px',
|
||
}}
|
||
>
|
||
Clear
|
||
</button>
|
||
<button
|
||
onClick={handlePrevious}
|
||
disabled={isFirstPage || filtersActive}
|
||
style={{
|
||
padding: '8px 12px',
|
||
background: isFirstPage || filtersActive ? 'var(--rah-bg-active)' : 'var(--rah-bg-active)',
|
||
border: '1px solid var(--rah-border-stronger)',
|
||
borderRadius: '4px',
|
||
color: isFirstPage || filtersActive ? 'var(--rah-text-muted)' : 'var(--rah-text-secondary)',
|
||
cursor: isFirstPage || filtersActive ? 'not-allowed' : 'pointer',
|
||
fontSize: '12px',
|
||
}}
|
||
>
|
||
Previous
|
||
</button>
|
||
<button
|
||
onClick={handleNext}
|
||
disabled={isLastPage || filtersActive}
|
||
style={{
|
||
padding: '8px 12px',
|
||
background: isLastPage || filtersActive ? 'var(--rah-bg-active)' : 'var(--rah-bg-active)',
|
||
border: '1px solid var(--rah-border-stronger)',
|
||
borderRadius: '4px',
|
||
color: isLastPage || filtersActive ? 'var(--rah-text-muted)' : 'var(--rah-text-secondary)',
|
||
cursor: isLastPage || filtersActive ? 'not-allowed' : 'pointer',
|
||
fontSize: '12px',
|
||
}}
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||
<thead style={{ position: 'sticky', top: 0, background: 'var(--rah-bg-active)', zIndex: 1 }}>
|
||
<tr>
|
||
{['Node', 'Categories', 'Edges', 'Highlights', 'Updated', 'Created'].map((column) => (
|
||
<th
|
||
key={column}
|
||
style={{
|
||
padding: '12px 16px',
|
||
textAlign: 'left',
|
||
fontSize: '11px',
|
||
color: 'var(--rah-text-muted)',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.5px',
|
||
fontWeight: 'normal',
|
||
borderBottom: '1px solid var(--rah-border-strong)',
|
||
}}
|
||
>
|
||
{column}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{nodes.map((node, index) => {
|
||
const belongsToLocked = node.dimensions?.some((dimension) => lockedDimensionSet.has(dimension));
|
||
return (
|
||
<tr
|
||
key={node.id}
|
||
style={{
|
||
background: index % 2 === 0 ? 'var(--rah-bg-base)' : 'var(--rah-bg-surface)',
|
||
borderBottom: '1px solid var(--rah-bg-surface)',
|
||
}}
|
||
>
|
||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '28%' }}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||
<div style={{ fontWeight: 600, color: 'var(--rah-text-base)', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
{node.title || 'Untitled node'}
|
||
{node.link && (
|
||
<button
|
||
onClick={() => {
|
||
void openExternalUrl(node.link!).catch((error) => {
|
||
console.error('[DatabaseViewer] Failed to open node link', error);
|
||
window.alert(`Unable to open ${node.link}`);
|
||
});
|
||
}}
|
||
title="Open original link"
|
||
style={{
|
||
background: 'transparent',
|
||
border: 'none',
|
||
color: '#7de8a5',
|
||
cursor: 'pointer',
|
||
padding: 0,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
}}
|
||
>
|
||
<LinkIcon size={14} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)', fontFamily: 'JetBrains Mono, monospace' }}>ID: {node.id}</span>
|
||
</div>
|
||
</td>
|
||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '24%' }}>
|
||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||
{node.dimensions && node.dimensions.length > 0 ? (
|
||
node.dimensions.slice(0, 3).map((dimension) => (
|
||
<span
|
||
key={`${node.id}-${dimension}`}
|
||
style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: '4px',
|
||
padding: '2px 8px',
|
||
borderRadius: '999px',
|
||
background: '#111914',
|
||
border: '1px solid #1f2f24',
|
||
fontSize: '11px',
|
||
color: '#bbf7d0',
|
||
}}
|
||
>
|
||
<Folder size={11} />
|
||
{dimension}
|
||
</span>
|
||
))
|
||
) : (
|
||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>No categories</span>
|
||
)}
|
||
{node.dimensions && node.dimensions.length > 3 && (
|
||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>+{node.dimensions.length - 3} more</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '10%' }}>
|
||
<div style={{ fontWeight: 600, color: 'var(--rah-text-base)' }}>{node.edge_count ?? 0}</div>
|
||
<div style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>connections</div>
|
||
</td>
|
||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '14%' }}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||
{contextHubIds.has(node.id) ? (
|
||
<span style={{ fontSize: '11px', color: '#facc15', fontWeight: 600 }}>
|
||
Auto-context hub
|
||
</span>
|
||
) : (
|
||
<span style={{ fontSize: '11px', color: '#475569' }}>—</span>
|
||
)}
|
||
{belongsToLocked && (
|
||
<span style={{ fontSize: '11px', color: '#7de8a5' }}>Priority dimension</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
|
||
<div style={{ fontSize: '12px', color: 'var(--rah-text-base)' }}>{formatTimestamp(node.updated_at)}</div>
|
||
<div style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>{formatRelative(node.updated_at)}</div>
|
||
</td>
|
||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '12%' }}>
|
||
<div style={{ fontSize: '12px', color: 'var(--rah-text-base)' }}>{formatTimestamp(node.created_at)}</div>
|
||
<div style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>{formatRelative(node.created_at)}</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|