Files
create-better-t-stack/apps/web/src/app/(home)/_components/CustomizableStack.tsx
2025-02-18 14:16:32 +05:30

308 lines
7.9 KiB
TypeScript

"use client";
import {
Background,
type Connection,
ReactFlow,
useEdgesState,
useNodesState,
} from "@xyflow/react";
import { useCallback, useState } from "react";
import { TechSelector } from "./TechSelector";
import "@xyflow/react/dist/style.css";
import { initialNodes } from "@/lib/constant";
import { CommandDisplay } from "./CommandDisplay";
import { TechNodeComponent } from "./TechNodeComponent";
const initialEdges = [
{ id: "bun-hono", source: "bun", target: "hono", animated: true },
{ id: "bun-tanstack", source: "bun", target: "tanstack", animated: true },
{ id: "hono-libsql", source: "hono", target: "sqlite", animated: true },
{ id: "libsql-drizzle", source: "sqlite", target: "drizzle", animated: true },
{
id: "hono-better-auth",
source: "hono",
target: "better-auth",
animated: true,
},
{ id: "bun-tailwind", source: "bun", target: "tailwind", animated: true },
{
id: "tailwind-shadcn",
source: "tailwind",
target: "shadcn",
animated: true,
},
];
const nodeTypes = {
techNode: TechNodeComponent,
};
const CustomizableStack = () => {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const [activeNodes, setActiveNodes] = useState({
backend: "hono",
database: "sqlite",
orm: "drizzle",
auth: "better-auth",
});
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
const cleanupConnectionsByCategory = useCallback((category: string) => {
setEdges((eds) =>
eds.filter((edge) => {
if (category === "database") {
return !(
["postgres", "sqlite"].includes(edge.target) ||
["postgres", "sqlite"].includes(edge.source) ||
edge.target === "drizzle" ||
edge.target === "prisma"
);
}
if (category === "orm") {
return !(edge.target === "drizzle" || edge.target === "prisma");
}
if (category === "auth") {
return !["better-auth", "no-auth"].includes(edge.target);
}
return true;
}),
);
}, []);
const handleTechSelect = useCallback(
(category: string, techId: string) => {
setActiveNodes((prev) => ({ ...prev, [category]: techId }));
setNodes((nds) =>
nds.map((node) => ({
...node,
data: {
...node.data,
isActive: node.data.isStatic
? true
: node.data.category === category
? node.id === techId
: node.data.isActive,
},
})),
);
cleanupConnectionsByCategory(category);
if (category === "database") {
const honoNode = nodes.find((n) => n.id === "hono");
const ormNode = nodes.find(
(n) => n.data.category === "orm" && n.data.isActive,
);
if (honoNode && ormNode) {
setEdges((eds) => [
...eds,
{
id: `hono-${techId}`,
source: "hono",
target: techId,
animated: true,
},
{
id: `${techId}-${ormNode.id}`,
source: techId,
target: ormNode.id,
animated: true,
},
]);
}
} else if (category === "auth") {
setEdges((eds) => [
...eds,
{
id: `hono-${techId}`,
source: "hono",
target: techId,
animated: true,
},
]);
} else if (category === "orm") {
const dbNode = nodes.find(
(n) => n.data.category === "database" && n.data.isActive,
);
if (dbNode) {
setEdges((eds) => [
...eds,
{
id: `${dbNode.id}-${techId}`,
source: dbNode.id,
target: techId,
animated: true,
},
]);
}
}
},
[nodes, setNodes, setEdges, cleanupConnectionsByCategory],
);
const isValidConnection = useCallback(
(connection: Connection) => {
const sourceNode = nodes.find((n) => n.id === connection.source);
const targetNode = nodes.find((n) => n.id === connection.target);
if (!sourceNode || !targetNode) return false;
if (sourceNode.id === "hono" && targetNode.data.category === "database") {
return ["postgres", "sqlite"].includes(targetNode.id);
}
if (sourceNode.id === "hono" && targetNode.data.category === "auth") {
return ["better-auth", "no-auth"].includes(targetNode.id);
}
if (
["postgres", "sqlite"].includes(sourceNode.id) &&
targetNode.data.category === "orm"
) {
return true;
}
return false;
},
[nodes],
);
const cleanupPreviousConnections = useCallback(
(connection: Connection) => {
const sourceNode = nodes.find((n) => n.id === connection.source);
const targetNode = nodes.find((n) => n.id === connection.target);
if (!targetNode || !sourceNode) return;
cleanupConnectionsByCategory(targetNode.data.category);
},
[nodes, cleanupConnectionsByCategory],
);
const onConnect = useCallback(
(connection: Connection) => {
if (!isValidConnection(connection)) return;
cleanupPreviousConnections(connection);
const targetNode = nodes.find((n) => n.id === connection.target);
if (!targetNode) return;
setEdges((eds) => {
const newEdges = [
...eds,
{
id: `${connection.source}-${connection.target}`,
source: connection.source,
target: connection.target,
animated: true,
},
];
if (targetNode.data.category === "database") {
const activeOrm = nodes.find(
(n) => n.data.category === "orm" && n.data.isActive,
);
if (activeOrm) {
newEdges.push({
id: `${connection.target}-${activeOrm.id}`,
source: connection.target,
target: activeOrm.id,
animated: true,
});
}
}
return newEdges;
});
if (targetNode.data.category) {
setActiveNodes((prev) => ({
...prev,
[targetNode.data.category]: connection.target,
}));
setNodes((nds) =>
nds.map((node) => ({
...node,
data: {
...node.data,
isActive: node.data.isStatic
? true
: node.data.category === targetNode.data.category
? node.id === connection.target
: node.data.isActive,
},
})),
);
}
},
[nodes, setEdges, setNodes, cleanupPreviousConnections, isValidConnection],
);
const generateCommand = useCallback(() => {
const flags: string[] = ["-y"];
if (activeNodes.database !== "sqlite") {
flags.splice(flags.indexOf("-y"), 1);
flags.push(`--${activeNodes.database}`);
}
if (activeNodes.auth !== "better-auth") {
if (flags.includes("-y")) {
flags.splice(flags.indexOf("-y"), 1);
}
flags.push("--no-auth");
}
return `npx create-better-t-stack my-app ${flags.join(" ")}`;
}, [activeNodes]);
return (
<div className="relative w-full max-w-5xl mx-auto z-50 mt-24">
<TechSelector onSelect={handleTechSelect} activeNodes={activeNodes} />
<div className="absolute -top-16 left-1/2 -translate-x-1/2 z-50 w-96">
<CommandDisplay command={generateCommand()} />
</div>
<div className="bg-gray-950/10 p-4 absolute top-4 right-4 z-50 w-80 rounded-xl border border-gray-800 backdrop-blur-3xl">
<div className="text-sm text-gray-300 text-center">
Select technologies from the left panel to customize your stack. The
graph will automatically update connections.
</div>
</div>
<div className="absolute inset-0 backdrop-blur-3xl bg-gradient-to-r from-blue-500/10 via-purple-500/10 to-pink-500/10 rounded-xl" />
<div className="h-[600px] pl-28 relative backdrop-blur-sm bg-gray-950/50 rounded-xl overflow-hidden border border-gray-800">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
fitView
minZoom={1}
maxZoom={1}
zoomOnScroll={false}
zoomOnPinch={false}
preventScrolling={false}
nodesConnectable={true}
nodesDraggable={false}
connectOnClick={true}
deleteKeyCode="Delete"
selectionKeyCode="Shift"
>
<Background
className="bg-gray-950/5"
color="#1e293b"
gap={12}
size={1}
/>
</ReactFlow>
</div>
</div>
);
};
export default CustomizableStack;