Skip to content

Commit f239522

Browse files
committed
feat(dashboard): integrate shadcn radix primitives and @xyflow/react interactive schema canvas
1 parent 6f0dce4 commit f239522

13 files changed

Lines changed: 1991 additions & 535 deletions

File tree

‎apps/web-dashboard/package.json‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,17 @@
1515
"@dnd-kit/modifiers": "^9.0.0",
1616
"@dnd-kit/sortable": "^10.0.0",
1717
"@dnd-kit/utilities": "^3.2.2",
18+
"@radix-ui/react-dialog": "^1.1.23",
19+
"@radix-ui/react-select": "^2.3.7",
20+
"@radix-ui/react-tabs": "^1.1.21",
21+
"@radix-ui/react-tooltip": "^1.2.16",
1822
"@tailwindcss/vite": "^4.1.18",
1923
"@tanstack/react-table": "^8.21.3",
2024
"@vercel/analytics": "^2.0.1",
25+
"@xyflow/react": "^12.11.3",
2126
"axios": "^1.13.2",
27+
"class-variance-authority": "^0.7.1",
28+
"clsx": "^2.1.1",
2229
"framer-motion": "^12.35.0",
2330
"gsap": "^3.15.0",
2431
"lucide-react": "^0.554.0",
@@ -30,6 +37,7 @@
3037
"react-router-dom": "^7.9.6",
3138
"recharts": "^2.13.0",
3239
"remark-gfm": "^4.0.1",
40+
"tailwind-merge": "^3.6.0",
3341
"tailwindcss": "^4.1.18",
3442
"three": "^0.184.0"
3543
},
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import React, { memo } from 'react';
2+
import { Handle, Position } from '@xyflow/react';
3+
import { Database, Key, Link2, Layers, Calendar, Type, Hash, ToggleLeft, Brackets } from 'lucide-react';
4+
import { Badge } from '../ui/badge';
5+
6+
const getTypeIcon = (type) => {
7+
switch (type) {
8+
case 'String': return <Type size={11} className="text-emerald-500" />;
9+
case 'Number': return <Hash size={11} className="text-blue-500" />;
10+
case 'Boolean': return <ToggleLeft size={11} className="text-amber-500" />;
11+
case 'Date': return <Calendar size={11} className="text-purple-500" />;
12+
case 'Ref': return <Link2 size={11} className="text-cyan-500" />;
13+
case 'Object': return <Layers size={11} className="text-orange-500" />;
14+
case 'Array': return <Brackets size={11} className="text-pink-500" />;
15+
default: return <Type size={11} className="text-gray-400" />;
16+
}
17+
};
18+
19+
const getTypeVariant = (type) => {
20+
switch (type) {
21+
case 'String': return 'success';
22+
case 'Number': return 'blue';
23+
case 'Boolean': return 'warning';
24+
case 'Date': return 'purple';
25+
case 'Ref': return 'secondary';
26+
case 'Object': return 'outline';
27+
case 'Array': return 'outline';
28+
default: return 'outline';
29+
}
30+
};
31+
32+
function CollectionNode({ data, selected }) {
33+
const { name, fields = [], isLocked } = data;
34+
35+
return (
36+
<div
37+
className={`min-w-[240px] max-w-[320px] rounded-lg border bg-[var(--color-bg-card)] shadow-md transition-all duration-150 ${
38+
selected
39+
? 'border-[var(--color-primary)] ring-2 ring-[var(--color-primary)]/20 shadow-lg'
40+
: 'border-[var(--color-border)] hover:border-[var(--color-text-muted)]'
41+
}`}
42+
>
43+
{/* Target Handle for incoming Ref relations */}
44+
<Handle
45+
type="target"
46+
position={Position.Left}
47+
id={`${name}-target`}
48+
className="!w-2.5 !h-2.5 !bg-[var(--color-primary)] !border-2 !border-[var(--color-bg-card)] -left-[6px]"
49+
/>
50+
51+
{/* Node Header */}
52+
<div className="flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-bg-input)] px-3 py-2 rounded-t-lg">
53+
<div className="flex items-center gap-2 min-w-0">
54+
<Database size={14} className="text-[var(--color-primary)] shrink-0" />
55+
<span className="font-semibold text-xs text-[var(--color-text-main)] truncate font-mono">
56+
{name}
57+
</span>
58+
</div>
59+
<div className="flex items-center gap-1.5 shrink-0">
60+
{isLocked && (
61+
<Badge variant="warning" className="text-[9px] px-1 py-0 h-4">
62+
System
63+
</Badge>
64+
)}
65+
<span className="text-[10px] text-[var(--color-text-muted)] font-mono">
66+
{fields.length} field{fields.length !== 1 ? 's' : ''}
67+
</span>
68+
</div>
69+
</div>
70+
71+
{/* Fields List */}
72+
<div className="p-1.5 divide-y divide-[var(--color-border)]/40 max-h-[280px] overflow-y-auto custom-scrollbar">
73+
{/* Default _id field */}
74+
<div className="flex items-center justify-between py-1 px-1.5 text-xs text-[var(--color-text-muted)]">
75+
<div className="flex items-center gap-1.5 min-w-0">
76+
<Key size={11} className="text-amber-500 shrink-0" />
77+
<span className="font-mono text-[11px] text-[var(--color-text-main)] truncate">_id</span>
78+
</div>
79+
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 font-mono">
80+
ObjectId (PK)
81+
</Badge>
82+
</div>
83+
84+
{/* Dynamic Fields */}
85+
{fields.map((field) => {
86+
const isRef = field.type === 'Ref';
87+
return (
88+
<div
89+
key={field.key}
90+
className="relative flex items-center justify-between py-1 px-1.5 text-xs hover:bg-[var(--color-surface-hover)] rounded-sm group transition-colors"
91+
>
92+
<div className="flex items-center gap-1.5 min-w-0 flex-1">
93+
{getTypeIcon(field.type)}
94+
<span className="font-mono text-[11px] text-[var(--color-text-main)] truncate">
95+
{field.key}
96+
</span>
97+
{field.required && (
98+
<span className="text-red-400 font-bold text-[10px]" title="Required field">*</span>
99+
)}
100+
{field.unique && (
101+
<span className="text-[9px] text-amber-500 font-mono" title="Unique constraint">[U]</span>
102+
)}
103+
</div>
104+
105+
<div className="flex items-center gap-1 shrink-0 ml-1">
106+
<Badge variant={getTypeVariant(field.type)} className="text-[9px] px-1 py-0 h-4 font-mono">
107+
{field.type === 'Ref' && field.ref
108+
? `Ref → ${field.ref}`
109+
: field.type === 'Array' && field.items?.type
110+
? `[${field.items.type}]`
111+
: field.type}
112+
</Badge>
113+
114+
{/* Source Handle for outgoing Ref relations */}
115+
{isRef && (
116+
<Handle
117+
type="source"
118+
position={Position.Right}
119+
id={`${name}-${field.key}-ref`}
120+
className="!w-2 !h-2 !bg-cyan-500 !border-2 !border-[var(--color-bg-card)] -right-[5px]"
121+
/>
122+
)}
123+
</div>
124+
</div>
125+
);
126+
})}
127+
</div>
128+
</div>
129+
);
130+
}
131+
132+
export default memo(CollectionNode);
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import React, { useMemo } from 'react';
2+
import {
3+
ReactFlow,
4+
MiniMap,
5+
Controls,
6+
Background,
7+
BackgroundVariant,
8+
useNodesState,
9+
useEdgesState,
10+
MarkerType,
11+
} from '@xyflow/react';
12+
import '@xyflow/react/dist/style.css';
13+
import CollectionNode from './CollectionNode';
14+
import { Database, Link2, Sparkles } from 'lucide-react';
15+
import { Badge } from '../ui/badge';
16+
17+
const nodeTypes = {
18+
collectionNode: CollectionNode,
19+
};
20+
21+
export default function SchemaCanvas({ collections = [], isAiAssisted = false }) {
22+
// Convert collections list into React Flow Nodes and Edges with auto-layout
23+
const { initialNodes, initialEdges, totalFields, totalRelations } = useMemo(() => {
24+
const nodes = [];
25+
const edges = [];
26+
let fieldCount = 0;
27+
let relationCount = 0;
28+
29+
const COLS = collections.length > 4 ? 3 : 2;
30+
const X_GAP = 320;
31+
const Y_GAP = 300;
32+
33+
collections.forEach((col, idx) => {
34+
const colX = (idx % COLS) * X_GAP + 40;
35+
const colY = Math.floor(idx / COLS) * Y_GAP + 40;
36+
const fields = col.model || col.fields || [];
37+
fieldCount += fields.length;
38+
39+
nodes.push({
40+
id: col.name,
41+
type: 'collectionNode',
42+
position: { x: colX, y: colY },
43+
data: {
44+
name: col.name,
45+
fields,
46+
isLocked: col.name === 'users',
47+
},
48+
});
49+
50+
// Find Ref fields and create relation edges
51+
fields.forEach((field) => {
52+
const targetRef = field.ref || field.items?.ref;
53+
if ((field.type === 'Ref' || field.items?.type === 'Ref') && targetRef) {
54+
relationCount++;
55+
edges.push({
56+
id: `edge-${col.name}-${field.key}-${targetRef}`,
57+
source: col.name,
58+
target: targetRef,
59+
sourceHandle: `${col.name}-${field.key}-ref`,
60+
targetHandle: `${targetRef}-target`,
61+
type: 'smoothstep',
62+
animated: true,
63+
label: `${field.key} → ${targetRef}`,
64+
labelStyle: { fill: 'var(--color-text-muted)', fontSize: 10, fontFamily: 'monospace' },
65+
labelBgStyle: { fill: 'var(--color-bg-card)', fillOpacity: 0.85 },
66+
labelBgPadding: [4, 2],
67+
labelBgBorderRadius: 4,
68+
style: { stroke: 'var(--color-primary)', strokeWidth: 1.5 },
69+
markerEnd: {
70+
type: MarkerType.ArrowClosed,
71+
width: 14,
72+
height: 14,
73+
color: 'var(--color-primary)',
74+
},
75+
});
76+
}
77+
});
78+
});
79+
80+
return { initialNodes: nodes, initialEdges: edges, totalFields: fieldCount, totalRelations: relationCount };
81+
}, [collections]);
82+
83+
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
84+
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
85+
86+
React.useEffect(() => {
87+
setNodes(initialNodes);
88+
setEdges(initialEdges);
89+
}, [initialNodes, initialEdges, setNodes, setEdges]);
90+
91+
return (
92+
<div className="relative w-full h-full min-h-[480px] bg-[var(--color-bg-main)] rounded-lg overflow-hidden border border-[var(--color-border)] select-none">
93+
{/* Top Overlay Stats Bar */}
94+
<div className="absolute top-3 left-3 z-10 flex items-center gap-2 bg-[var(--color-bg-card)]/90 backdrop-blur-md px-3 py-1.5 rounded-md border border-[var(--color-border)] shadow-sm pointer-events-auto">
95+
<div className="flex items-center gap-1.5 text-xs text-[var(--color-text-main)] font-semibold">
96+
<Database size={13} className="text-[var(--color-primary)]" />
97+
<span>{collections.length} Collection{collections.length !== 1 ? 's' : ''}</span>
98+
</div>
99+
<span className="text-[var(--color-border)]">|</span>
100+
<div className="text-[11px] text-[var(--color-text-muted)] font-mono">
101+
{totalFields} Fields
102+
</div>
103+
{totalRelations > 0 && (
104+
<>
105+
<span className="text-[var(--color-border)]">|</span>
106+
<div className="flex items-center gap-1 text-[11px] text-cyan-400 font-mono">
107+
<Link2 size={11} />
108+
<span>{totalRelations} Relation{totalRelations !== 1 ? 's' : ''}</span>
109+
</div>
110+
</>
111+
)}
112+
{isAiAssisted && (
113+
<Badge variant="purple" className="text-[9px] px-1.5 py-0 h-4 ml-1 flex items-center gap-1">
114+
<Sparkles size={9} />
115+
<span>AI Visualizer</span>
116+
</Badge>
117+
)}
118+
</div>
119+
120+
<ReactFlow
121+
nodes={nodes}
122+
edges={edges}
123+
onNodesChange={onNodesChange}
124+
onEdgesChange={onEdgesChange}
125+
nodeTypes={nodeTypes}
126+
fitView
127+
fitViewOptions={{ padding: 0.25 }}
128+
minZoom={0.2}
129+
maxZoom={1.5}
130+
defaultEdgeOptions={{
131+
type: 'smoothstep',
132+
}}
133+
proOptions={{ hideAttribution: true }}
134+
>
135+
<Background
136+
variant={BackgroundVariant.Dots}
137+
gap={18}
138+
size={1}
139+
color="var(--color-border)"
140+
className="opacity-40"
141+
/>
142+
<Controls
143+
className="!bg-[var(--color-bg-card)] !border !border-[var(--color-border)] !rounded-md !shadow-md [&>button]:!bg-[var(--color-bg-card)] [&>button]:!border-b [&>button]:!border-[var(--color-border)] [&>button]:!text-[var(--color-text-main)] [&>button:hover]:!bg-[var(--color-surface-hover)]"
144+
/>
145+
<MiniMap
146+
nodeColor="var(--color-bg-input)"
147+
maskColor="rgba(0, 0, 0, 0.4)"
148+
className="!bg-[var(--color-bg-card)] !border !border-[var(--color-border)] !rounded-md"
149+
zoomable
150+
pannable
151+
/>
152+
</ReactFlow>
153+
154+
<style>{`
155+
.react-flow__node {
156+
cursor: grab;
157+
}
158+
.react-flow__node:active {
159+
cursor: grabbing;
160+
}
161+
.react-flow__edge-path {
162+
stroke: var(--color-primary);
163+
}
164+
`}</style>
165+
</div>
166+
);
167+
}

0 commit comments

Comments
 (0)