354 lines
9.4 KiB
TypeScript
354 lines
9.4 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
|
|
interface NodeConfig {
|
|
id: string;
|
|
label: string;
|
|
description: string;
|
|
icon: string;
|
|
color: string;
|
|
tech: string[];
|
|
x: number;
|
|
y: number;
|
|
connections: string[];
|
|
}
|
|
|
|
const nodes: NodeConfig[] = [
|
|
{
|
|
id: 'whymyrating',
|
|
label: 'whymyrating',
|
|
description: 'Web + Mobile Apps',
|
|
icon: '📱',
|
|
color: '#8b5cf6',
|
|
tech: ['React', 'Next.js', 'React Native', 'TypeScript'],
|
|
x: 50,
|
|
y: 5,
|
|
connections: ['whymyrating-engine', 'whymyrating-brand', 'whymyrating-templates'],
|
|
},
|
|
{
|
|
id: 'whymyrating-engine',
|
|
label: 'whymyrating-engine',
|
|
description: 'AI Pipelines & Backend API',
|
|
icon: '⚙️',
|
|
color: '#ef4444',
|
|
tech: ['Python', 'FastAPI', 'PostgreSQL', 'Docker'],
|
|
x: 10,
|
|
y: 40,
|
|
connections: ['nuc-server'],
|
|
},
|
|
{
|
|
id: 'whymyrating-brand',
|
|
label: 'whymyrating-brand',
|
|
description: 'Brand Assets & Guidelines',
|
|
icon: '🎨',
|
|
color: '#f59e0b',
|
|
tech: ['SVG', 'Figma', 'CSS'],
|
|
x: 50,
|
|
y: 40,
|
|
connections: [],
|
|
},
|
|
{
|
|
id: 'whymyrating-templates',
|
|
label: 'whymyrating-templates',
|
|
description: 'Email & Doc Templates',
|
|
icon: '📄',
|
|
color: '#10b981',
|
|
tech: ['Next.js', 'React Email', 'TypeScript'],
|
|
x: 90,
|
|
y: 40,
|
|
connections: [],
|
|
},
|
|
{
|
|
id: 'whyrating-hub',
|
|
label: 'whyrating-hub',
|
|
description: 'Internal Dashboard (You are here)',
|
|
icon: '🏠',
|
|
color: '#3b82f6',
|
|
tech: ['Next.js', 'Tailwind', 'TypeScript'],
|
|
x: 50,
|
|
y: 75,
|
|
connections: [],
|
|
},
|
|
{
|
|
id: 'nuc-server',
|
|
label: 'NUC Server',
|
|
description: 'Self-hosted Infrastructure',
|
|
icon: '🖥️',
|
|
color: '#6366f1',
|
|
tech: ['Docker', 'PostgreSQL', 'Nginx', 'Gitea'],
|
|
x: 10,
|
|
y: 75,
|
|
connections: ['whyrating-hub'],
|
|
},
|
|
];
|
|
|
|
const DiagramNode = ({
|
|
node,
|
|
index,
|
|
onHover,
|
|
isHighlighted,
|
|
}: {
|
|
node: NodeConfig;
|
|
index: number;
|
|
onHover: (id: string | null) => void;
|
|
isHighlighted: boolean;
|
|
}) => {
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setIsVisible(true), index * 100);
|
|
return () => clearTimeout(timer);
|
|
}, [index]);
|
|
|
|
return (
|
|
<div
|
|
className="absolute transform -translate-x-1/2 transition-all duration-500 cursor-pointer"
|
|
style={{
|
|
left: `${node.x}%`,
|
|
top: `${node.y}%`,
|
|
opacity: isVisible ? 1 : 0,
|
|
transform: `translate(-50%, ${isVisible ? '0' : '20px'}) scale(${isHovered ? 1.05 : 1})`,
|
|
zIndex: isHovered ? 10 : 1,
|
|
filter: isHighlighted || isHovered ? 'none' : isHighlighted === false ? 'opacity(0.4)' : 'none',
|
|
}}
|
|
onMouseEnter={() => {
|
|
setIsHovered(true);
|
|
onHover(node.id);
|
|
}}
|
|
onMouseLeave={() => {
|
|
setIsHovered(false);
|
|
onHover(null);
|
|
}}
|
|
>
|
|
<div
|
|
className="relative rounded-2xl p-4 min-w-[180px] max-w-[200px]"
|
|
style={{
|
|
background: `linear-gradient(135deg, ${node.color}, ${node.color}dd)`,
|
|
boxShadow: isHovered
|
|
? `0 20px 40px -10px ${node.color}66, 0 0 0 2px ${node.color}`
|
|
: '0 10px 30px -10px rgba(0,0,0,0.3)',
|
|
}}
|
|
>
|
|
{/* Pulse animation for hovered node */}
|
|
{isHovered && (
|
|
<div
|
|
className="absolute inset-0 rounded-2xl animate-ping"
|
|
style={{
|
|
background: node.color,
|
|
opacity: 0.3,
|
|
animationDuration: '1.5s',
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Icon */}
|
|
<div className="text-2xl mb-2 drop-shadow-md">{node.icon}</div>
|
|
|
|
{/* Label */}
|
|
<div className="text-white font-bold text-sm mb-1 drop-shadow-sm">
|
|
{node.label}
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div className="text-white/80 text-xs leading-relaxed">
|
|
{node.description}
|
|
</div>
|
|
|
|
{/* Tech stack - shown on hover */}
|
|
<div
|
|
className={`flex flex-wrap gap-1 mt-3 transition-all duration-300 overflow-hidden ${
|
|
isHovered ? 'max-h-20 opacity-100' : 'max-h-0 opacity-0'
|
|
}`}
|
|
>
|
|
{node.tech.map((t, i) => (
|
|
<span
|
|
key={i}
|
|
className="bg-white/20 text-white text-[10px] px-2 py-0.5 rounded-full backdrop-blur-sm"
|
|
>
|
|
{t}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ConnectionLine = ({
|
|
from,
|
|
to,
|
|
isHighlighted,
|
|
delay,
|
|
}: {
|
|
from: NodeConfig;
|
|
to: NodeConfig;
|
|
isHighlighted: boolean;
|
|
delay: number;
|
|
}) => {
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setIsVisible(true), delay);
|
|
return () => clearTimeout(timer);
|
|
}, [delay]);
|
|
|
|
// Calculate line positions (center of nodes)
|
|
const x1 = from.x;
|
|
const y1 = from.y + 8; // Offset for node height
|
|
const x2 = to.x;
|
|
const y2 = to.y;
|
|
|
|
// Calculate control points for curved line
|
|
const midY = (y1 + y2) / 2;
|
|
|
|
return (
|
|
<svg
|
|
className="absolute inset-0 w-full h-full pointer-events-none"
|
|
style={{ zIndex: 0 }}
|
|
>
|
|
<defs>
|
|
<linearGradient id={`gradient-${from.id}-${to.id}`} x1="0%" y1="0%" x2="100%" y2="100%">
|
|
<stop offset="0%" stopColor={from.color} stopOpacity="0.6" />
|
|
<stop offset="100%" stopColor={to.color} stopOpacity="0.6" />
|
|
</linearGradient>
|
|
</defs>
|
|
<path
|
|
d={`M ${x1}% ${y1}% Q ${x1}% ${midY}% ${(x1 + x2) / 2}% ${midY}% T ${x2}% ${y2}%`}
|
|
fill="none"
|
|
stroke={`url(#gradient-${from.id}-${to.id})`}
|
|
strokeWidth={isHighlighted ? 3 : 2}
|
|
strokeDasharray="8 4"
|
|
strokeLinecap="round"
|
|
className="transition-all duration-300"
|
|
style={{
|
|
opacity: isVisible ? (isHighlighted ? 1 : 0.4) : 0,
|
|
strokeDashoffset: isVisible ? 0 : 100,
|
|
animation: isVisible ? 'dash 20s linear infinite' : 'none',
|
|
}}
|
|
/>
|
|
{/* Animated dot along the path */}
|
|
{isHighlighted && isVisible && (
|
|
<circle r="4" fill={from.color}>
|
|
<animateMotion
|
|
dur="3s"
|
|
repeatCount="indefinite"
|
|
path={`M ${x1}% ${y1}% Q ${x1}% ${midY}% ${(x1 + x2) / 2}% ${midY}% T ${x2}% ${y2}%`}
|
|
/>
|
|
</circle>
|
|
)}
|
|
</svg>
|
|
);
|
|
};
|
|
|
|
export default function InfrastructureDiagram() {
|
|
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
if (!mounted) {
|
|
return (
|
|
<div className="flex items-center justify-center h-[500px]">
|
|
<div className="animate-pulse text-stone-500">Loading diagram...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Get all connections
|
|
const connections: { from: NodeConfig; to: NodeConfig }[] = [];
|
|
nodes.forEach((node) => {
|
|
node.connections.forEach((targetId) => {
|
|
const target = nodes.find((n) => n.id === targetId);
|
|
if (target) {
|
|
connections.push({ from: node, to: target });
|
|
}
|
|
});
|
|
});
|
|
|
|
// Determine which nodes/connections are highlighted
|
|
const getHighlightedNodes = () => {
|
|
if (!hoveredNode) return null;
|
|
const node = nodes.find((n) => n.id === hoveredNode);
|
|
if (!node) return null;
|
|
return new Set([hoveredNode, ...node.connections]);
|
|
};
|
|
|
|
const highlightedSet = getHighlightedNodes();
|
|
|
|
return (
|
|
<div className="relative">
|
|
{/* Background grid */}
|
|
<div
|
|
className="absolute inset-0 opacity-10 rounded-xl"
|
|
style={{
|
|
backgroundImage: `
|
|
radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)
|
|
`,
|
|
backgroundSize: '24px 24px',
|
|
}}
|
|
/>
|
|
|
|
{/* Diagram container */}
|
|
<div className="relative h-[500px] w-full">
|
|
{/* Connection lines */}
|
|
{connections.map(({ from, to }, i) => (
|
|
<ConnectionLine
|
|
key={`${from.id}-${to.id}`}
|
|
from={from}
|
|
to={to}
|
|
isHighlighted={
|
|
highlightedSet === null ||
|
|
(highlightedSet.has(from.id) && highlightedSet.has(to.id))
|
|
}
|
|
delay={600 + i * 100}
|
|
/>
|
|
))}
|
|
|
|
{/* Nodes */}
|
|
{nodes.map((node, i) => (
|
|
<DiagramNode
|
|
key={node.id}
|
|
node={node}
|
|
index={i}
|
|
onHover={setHoveredNode}
|
|
isHighlighted={highlightedSet === null || highlightedSet.has(node.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Legend */}
|
|
<div className="mt-8 flex flex-wrap gap-4 justify-center">
|
|
{[
|
|
{ color: '#8b5cf6', label: 'Main Apps' },
|
|
{ color: '#ef4444', label: 'Backend/AI' },
|
|
{ color: '#f59e0b', label: 'Brand' },
|
|
{ color: '#10b981', label: 'Templates' },
|
|
{ color: '#3b82f6', label: 'Dashboard' },
|
|
{ color: '#6366f1', label: 'Infrastructure' },
|
|
].map((item) => (
|
|
<div key={item.label} className="flex items-center gap-2">
|
|
<div
|
|
className="w-3 h-3 rounded-full"
|
|
style={{ background: item.color }}
|
|
/>
|
|
<span className="text-xs text-stone-500">{item.label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Global styles for animations */}
|
|
<style jsx global>{`
|
|
@keyframes dash {
|
|
to {
|
|
stroke-dashoffset: -100;
|
|
}
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|