Skip to content

Commit 1dc1430

Browse files
committed
feat: Add interactive GitHub Repository Dependency Graph
1 parent a7f376b commit 1dc1430

7 files changed

Lines changed: 838 additions & 11 deletions

File tree

app/(root)/dashboard/[username]/page.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ describe('DashboardPage', () => {
9191
insights: [],
9292
achievements: [],
9393
commitClock: [],
94+
graphData: { nodes: [], links: [] },
9495
};
9596

9697
beforeEach(() => {

components/dashboard/DashboardClient.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ const mockInitialData = {
134134
insights: [{ id: '1', icon: 'zap', text: 'Highly active in mornings' }],
135135
achievements: [],
136136
commitClock: [],
137+
graphData: { nodes: [], links: [] },
137138
};
138139

139140
const mockSecondData = {
@@ -170,6 +171,7 @@ const mockSecondData = {
170171
insights: [{ id: '1', icon: 'zap', text: 'Hard worker' }],
171172
achievements: [],
172173
commitClock: [],
174+
graphData: { nodes: [], links: [] },
173175
};
174176

175177
const initialDataWithHigherStreak = {

components/dashboard/DashboardClient.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import CommitClock from './CommitClock';
1616
import Heatmap from './Heatmap';
1717
import AIInsights from './AIInsights';
1818
import StatsCard from './StatsCard';
19+
import RepositoryGraph from './RepositoryGraph';
1920
import ComparisonStatsCard from './ComparisonStatsCard';
2021
import RadarChart from './RadarChart';
2122
import GrowthTrendChart from './GrowthTrendChart';
@@ -63,6 +64,10 @@ interface DashboardData {
6364
day: string;
6465
commits: number;
6566
}>;
67+
graphData: {
68+
nodes: Record<string, unknown>[];
69+
links: Record<string, unknown>[];
70+
};
6671
}
6772

6873
interface DashboardClientProps {
@@ -505,7 +510,7 @@ export default function DashboardClient({ initialData, username }: DashboardClie
505510
/* Standard Single Profile View */
506511
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr_320px] gap-6 lg:gap-8">
507512
{/* Left Sidebar */}
508-
<aside className="flex flex-col gap-6">
513+
<aside className="flex flex-col gap-6 lg:row-span-2">
509514
<ProfileCard
510515
user={initialData.profile}
511516
exportData={{
@@ -562,6 +567,11 @@ export default function DashboardClient({ initialData, username }: DashboardClie
562567

563568
<AIInsights insights={initialData.insights} />
564569
</aside>
570+
571+
{/* Full Width Bottom Section */}
572+
<div className="col-span-1 lg:col-span-2 lg:col-start-2">
573+
<RepositoryGraph data={initialData.graphData} />
574+
</div>
565575
</div>
566576
) : (
567577
/* Compare Mode Split-Dashboard View */
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
'use client';
2+
3+
import dynamic from 'next/dynamic';
4+
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
5+
import { X, Star, GitFork, Clock, Box } from 'lucide-react';
6+
7+
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false });
8+
9+
const FILTER_COLORS = {
10+
Personal: '#3B82F6', // Blue
11+
Contributions: '#22C55E', // Green
12+
Forks: '#F97316', // Orange
13+
};
14+
15+
interface GraphNode {
16+
id: string;
17+
name: string;
18+
type: 'User' | 'Repo' | 'Contribution' | 'Fork';
19+
val: number;
20+
color: string;
21+
stats?: {
22+
stars?: number;
23+
forks?: number;
24+
language?: string | null;
25+
updatedAt?: string;
26+
description?: string | null;
27+
};
28+
x?: number;
29+
y?: number;
30+
}
31+
32+
interface GraphLink {
33+
source: string | GraphNode;
34+
target: string | GraphNode;
35+
}
36+
37+
interface RepositoryGraphProps {
38+
data: {
39+
nodes: GraphNode[];
40+
links: GraphLink[];
41+
};
42+
}
43+
44+
export default function RepositoryGraph({ data }: RepositoryGraphProps) {
45+
const [filters, setFilters] = useState({
46+
Personal: true,
47+
Contributions: true,
48+
Forks: true,
49+
});
50+
51+
const [hoverNode, setHoverNode] = useState<GraphNode | null>(null);
52+
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
53+
const containerRef = useRef<HTMLDivElement>(null);
54+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
55+
const fgRef = useRef<any>(null);
56+
57+
useEffect(() => {
58+
if (containerRef.current) {
59+
setDimensions({
60+
width: containerRef.current.clientWidth,
61+
height: Math.max(400, window.innerHeight * 0.45),
62+
});
63+
64+
const handleResize = () => {
65+
if (containerRef.current) {
66+
setDimensions({
67+
width: containerRef.current.clientWidth,
68+
height: Math.max(400, window.innerHeight * 0.45),
69+
});
70+
}
71+
};
72+
window.addEventListener('resize', handleResize);
73+
return () => window.removeEventListener('resize', handleResize);
74+
}
75+
}, []);
76+
77+
// Filter Data
78+
const graphData = useMemo(() => {
79+
const activeTypes = new Set<string>(['User']);
80+
if (filters.Personal) activeTypes.add('Repo');
81+
if (filters.Contributions) activeTypes.add('Contribution');
82+
if (filters.Forks) activeTypes.add('Fork');
83+
84+
const filteredNodes = data.nodes.filter((node) => activeTypes.has(node.type));
85+
const validNodeIds = new Set(filteredNodes.map((n) => n.id));
86+
87+
const filteredLinks = data.links.filter(
88+
(link) =>
89+
validNodeIds.has(
90+
typeof link.source === 'object' ? link.source.id : (link.source as string)
91+
) &&
92+
validNodeIds.has(typeof link.target === 'object' ? link.target.id : (link.target as string))
93+
);
94+
95+
return { nodes: filteredNodes, links: filteredLinks };
96+
}, [data, filters]);
97+
98+
// Insights
99+
const insights = useMemo(() => {
100+
if (!data.nodes || data.nodes.length === 0) return null;
101+
102+
let mostStarred = null;
103+
let highestContribution = null;
104+
let reposCount = 0;
105+
106+
for (const node of data.nodes) {
107+
if (node.type === 'Repo' || node.type === 'Fork') reposCount++;
108+
if (!mostStarred || (node.stats?.stars || 0) > (mostStarred.stats?.stars || 0)) {
109+
if (node.type !== 'User') mostStarred = node;
110+
}
111+
if (
112+
node.type === 'Contribution' &&
113+
(!highestContribution || (node.stats?.stars || 0) > (highestContribution.stats?.stars || 0))
114+
) {
115+
highestContribution = node;
116+
}
117+
}
118+
119+
return {
120+
reposCount,
121+
mostStarred,
122+
highestContribution,
123+
};
124+
}, [data]);
125+
126+
const handleNodeClick = useCallback((node: GraphNode) => {
127+
if (fgRef.current) {
128+
fgRef.current.centerAt(node.x, node.y, 1000);
129+
fgRef.current.zoom(2, 1000);
130+
}
131+
}, []);
132+
133+
const toggleFilter = (key: keyof typeof filters) => {
134+
setFilters((prev) => ({ ...prev, [key]: !prev[key] }));
135+
};
136+
137+
if (!data || data.nodes.length <= 1) {
138+
return (
139+
<div className="flex flex-col items-center justify-center p-12 border border-dashed border-gray-300 dark:border-zinc-800 rounded-xl text-gray-500">
140+
<Box className="w-12 h-12 mb-4 opacity-50" />
141+
<p>No repository relationship data available yet.</p>
142+
</div>
143+
);
144+
}
145+
146+
return (
147+
<div className="flex flex-col gap-6" id="repository-graph">
148+
{/* Header & Filters */}
149+
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
150+
<div>
151+
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
152+
🌐 Repository Dependency Graph
153+
</h2>
154+
<p className="text-sm text-gray-500 dark:text-zinc-400 mt-1">
155+
Visualize your GitHub ecosystem and contribution network.
156+
</p>
157+
</div>
158+
159+
<div className="flex flex-wrap gap-2">
160+
{Object.entries(filters).map(([key, value]) => (
161+
<button
162+
key={key}
163+
onClick={() => toggleFilter(key as keyof typeof filters)}
164+
className={`px-3 py-1.5 text-xs font-semibold rounded-full border transition-all ${
165+
value
166+
? 'text-black border-transparent shadow-sm'
167+
: 'bg-transparent text-gray-500 border-gray-300 dark:border-zinc-700 hover:border-gray-500'
168+
}`}
169+
style={{
170+
backgroundColor: value
171+
? FILTER_COLORS[key as keyof typeof FILTER_COLORS]
172+
: undefined,
173+
}}
174+
>
175+
{key}
176+
</button>
177+
))}
178+
</div>
179+
</div>
180+
181+
{/* Main Layout */}
182+
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8 relative">
183+
{/* Graph Container */}
184+
<div
185+
ref={containerRef}
186+
className="flex-grow bg-white dark:bg-[#0a0a0a] border border-black/10 dark:border-[rgba(255,255,255,0.08)] rounded-xl overflow-hidden relative shadow-sm"
187+
style={{ height: dimensions.height }}
188+
>
189+
<ForceGraph2D
190+
ref={fgRef}
191+
width={dimensions.width}
192+
height={dimensions.height}
193+
graphData={graphData}
194+
nodeLabel="name"
195+
nodeColor={(node: Record<string, unknown>) => String(node.color || '')}
196+
nodeRelSize={1}
197+
nodeVal={(node: Record<string, unknown>) => Number(node.val || 0)}
198+
onNodeClick={(node: Record<string, unknown>) =>
199+
handleNodeClick(node as unknown as GraphNode)
200+
}
201+
onNodeHover={(node: Record<string, unknown> | null) =>
202+
setHoverNode((node as unknown as GraphNode) || null)
203+
}
204+
linkDirectionalParticles={2}
205+
linkDirectionalParticleSpeed={() => 0.005}
206+
linkColor={() => 'rgba(150, 150, 150, 0.2)'}
207+
backgroundColor="transparent"
208+
/>
209+
210+
{/* Custom Hover Tooltip overlay to keep it out of canvas for better styling */}
211+
{hoverNode && (
212+
<div className="absolute top-4 left-4 pointer-events-none bg-black/90 dark:bg-white/90 backdrop-blur-sm text-white dark:text-black p-4 rounded-xl shadow-xl z-10 w-64 border border-white/10 dark:border-black/10 transition-opacity">
213+
<h4 className="font-bold text-base mb-1 truncate">{hoverNode.name}</h4>
214+
<p className="text-xs uppercase tracking-wider opacity-70 mb-3">{hoverNode.type}</p>
215+
216+
{hoverNode.stats && (
217+
<div className="space-y-2 text-sm">
218+
{hoverNode.stats.stars !== undefined && (
219+
<div className="flex items-center gap-2">
220+
<Star className="w-4 h-4 text-yellow-400" />
221+
<span>{hoverNode.stats.stars} Stars</span>
222+
</div>
223+
)}
224+
{hoverNode.stats.forks !== undefined && (
225+
<div className="flex items-center gap-2">
226+
<GitFork className="w-4 h-4 opacity-70" />
227+
<span>{hoverNode.stats.forks} Forks</span>
228+
</div>
229+
)}
230+
{hoverNode.stats.language && (
231+
<div className="flex items-center gap-2">
232+
<div className="w-3 h-3 rounded-full bg-blue-400" />
233+
<span>{hoverNode.stats.language}</span>
234+
</div>
235+
)}
236+
{hoverNode.stats.updatedAt && (
237+
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-white/20 dark:border-black/20 text-xs opacity-70">
238+
<Clock className="w-3 h-3" />
239+
<span>
240+
Updated: {new Date(hoverNode.stats.updatedAt).toLocaleDateString()}
241+
</span>
242+
</div>
243+
)}
244+
</div>
245+
)}
246+
</div>
247+
)}
248+
</div>
249+
250+
{/* Side Panel (Desktop) */}
251+
<div className="lg:w-80 flex flex-col gap-6 hidden lg:flex">
252+
<div className="bg-white dark:bg-[#0a0a0a] border border-black/10 dark:border-[rgba(255,255,255,0.08)] rounded-xl p-6 shadow-sm hidden lg:block">
253+
<div className="flex items-center gap-3 mb-6">
254+
<div className="p-2 bg-blue-500/10 text-blue-500 rounded-lg">
255+
<Box className="w-5 h-5" />
256+
</div>
257+
<h3 className="font-semibold text-gray-900 dark:text-white">Graph Insights</h3>
258+
</div>
259+
260+
<div className="space-y-5">
261+
<div>
262+
<p className="text-xs text-gray-500 uppercase tracking-wider font-semibold mb-1">
263+
Ecosystem Size
264+
</p>
265+
<p className="text-sm font-medium text-gray-900 dark:text-white">
266+
{insights?.reposCount} Repositories connected
267+
</p>
268+
</div>
269+
270+
{insights?.mostStarred && (
271+
<div>
272+
<p className="text-xs text-gray-500 uppercase tracking-wider font-semibold mb-1">
273+
🌟 Most Starred
274+
</p>
275+
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
276+
{insights.mostStarred.name} ({insights.mostStarred.stats?.stars} stars)
277+
</p>
278+
</div>
279+
)}
280+
281+
{insights?.highestContribution && (
282+
<div>
283+
<p className="text-xs text-gray-500 uppercase tracking-wider font-semibold mb-1">
284+
⚡ Top Contribution
285+
</p>
286+
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
287+
{insights.highestContribution.name}
288+
</p>
289+
</div>
290+
)}
291+
292+
<div className="pt-4 mt-2 border-t border-gray-100 dark:border-zinc-800">
293+
<p className="text-xs text-gray-500 text-center">
294+
Hover over any node to view detailed statistics, and click to zoom.
295+
</p>
296+
</div>
297+
</div>
298+
</div>
299+
</div>
300+
</div>
301+
</div>
302+
);
303+
}

0 commit comments

Comments
 (0)