update customizable stack

This commit is contained in:
Aman Varshney
2025-03-02 12:34:58 +05:30
parent aa4cde6a82
commit b3ae7e280b
3 changed files with 398 additions and 185 deletions

View File

@@ -14,11 +14,12 @@ import { initialNodes } from "@/lib/constant";
import { CommandDisplay } from "./CommandDisplay"; import { CommandDisplay } from "./CommandDisplay";
import { TechNodeComponent } from "./TechNodeComponent"; import { TechNodeComponent } from "./TechNodeComponent";
// Define initial edges with proper connections
const initialEdges = [ const initialEdges = [
{ id: "bun-hono", source: "bun", target: "hono", animated: true }, { id: "bun-hono", source: "bun", target: "hono", animated: true },
{ id: "bun-tanstack", source: "bun", target: "tanstack", animated: true }, { id: "bun-tanstack", source: "bun", target: "tanstack", animated: true },
{ id: "hono-libsql", source: "hono", target: "sqlite", animated: true }, { id: "hono-sqlite", source: "hono", target: "sqlite", animated: true },
{ id: "libsql-drizzle", source: "sqlite", target: "drizzle", animated: true }, { id: "sqlite-drizzle", source: "sqlite", target: "drizzle", animated: true },
{ {
id: "hono-better-auth", id: "hono-better-auth",
source: "hono", source: "hono",
@@ -38,16 +39,38 @@ const nodeTypes = {
techNode: TechNodeComponent, techNode: TechNodeComponent,
}; };
interface ActiveNodes {
backend: string;
database: string;
orm: string;
auth: string;
packageManager: string;
features: {
docker: boolean;
githubActions: boolean;
seo: boolean;
git: boolean;
};
}
const CustomizableStack = () => { const CustomizableStack = () => {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const [activeNodes, setActiveNodes] = useState({ const [activeNodes, setActiveNodes] = useState<ActiveNodes>({
backend: "hono", backend: "hono",
database: "sqlite", database: "sqlite",
orm: "drizzle", orm: "drizzle",
auth: "better-auth", auth: "better-auth",
packageManager: "npm",
features: {
docker: false,
githubActions: false,
seo: false,
git: true,
},
}); });
const [windowSize, setWindowSize] = useState("lg"); const [windowSize, setWindowSize] = useState("lg");
const [command, setCommand] = useState("npx create-better-t-stack my-app -y");
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
@@ -66,32 +89,54 @@ const CustomizableStack = () => {
}, []); }, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
const cleanupConnectionsByCategory = useCallback((category: string) => { useEffect(() => {
setEdges((eds) => // Generate command whenever activeNodes changes and update the command state
eds.filter((edge) => { setCommand(generateCommand());
if (category === "database") { }, [activeNodes]);
return !(
["postgres", "sqlite"].includes(edge.target) || // Function to remove connections related to specific category
["postgres", "sqlite"].includes(edge.source) || const removeConnectionsByCategory = useCallback(
edge.target === "drizzle" || (category: string) => {
edge.target === "prisma" setEdges((eds) => {
); return eds.filter((edge) => {
} // Find source and target nodes
if (category === "orm") { const sourceNode = nodes.find((n) => n.id === edge.source);
return !(edge.target === "drizzle" || edge.target === "prisma"); const targetNode = nodes.find((n) => n.id === edge.target);
}
if (category === "auth") { if (!sourceNode || !targetNode) return true;
return !["better-auth", "no-auth"].includes(edge.target);
} // Remove edges connected to the category being changed
return true; if (targetNode.data.category === category) return false;
}),
); // Remove edges that connect from the category being changed
}, []); if (sourceNode.data.category === category) return false;
// For database changes, also remove ORM connections
if (category === "database" && targetNode.data.category === "orm")
return false;
return true;
});
});
},
[nodes, setEdges],
);
const handleTechSelect = useCallback( const handleTechSelect = useCallback(
(category: string, techId: string) => { (category: string, techId: string) => {
setActiveNodes((prev) => ({ ...prev, [category]: techId })); // Update active nodes state
setActiveNodes((prev) => ({
...prev,
[category]: techId,
...(category === "features" && {
features: {
...prev.features,
[techId]: !prev.features[techId as keyof typeof prev.features],
},
}),
}));
// Update node active states
setNodes((nds) => setNodes((nds) =>
nds.map((node) => ({ nds.map((node) => ({
...node, ...node,
@@ -106,32 +151,84 @@ const CustomizableStack = () => {
})), })),
); );
cleanupConnectionsByCategory(category); // Remove old connections for this category
removeConnectionsByCategory(category);
if (category === "database") { // Create new connections based on the selected tech
const honoNode = nodes.find((n) => n.id === "hono"); if (category === "backend") {
const ormNode = nodes.find( // Connect backend to database, auth, and other core components
(n) => n.data.category === "orm" && n.data.isActive, const database = activeNodes.database;
); const auth = activeNodes.auth;
if (honoNode && ormNode) { setEdges((eds) => [
...eds,
{
id: `bun-${techId}`,
source: "bun",
target: techId,
animated: true,
},
{
id: `${techId}-${database}`,
source: techId,
target: database,
animated: true,
},
{
id: `${techId}-${auth}`,
source: techId,
target: auth,
animated: true,
},
{
id: `${techId}-tanstack`,
source: techId,
target: "tanstack",
animated: true,
},
]);
} else if (category === "database") {
// Connect backend to database and database to ORM
const orm = activeNodes.orm;
setEdges((eds) => [
...eds,
{
id: `hono-${techId}`,
source: "hono",
target: techId,
animated: true,
},
// Only add ORM connection if database is not "no-database"
...(techId !== "no-database"
? [
{
id: `${techId}-${orm}`,
source: techId,
target: orm,
animated: true,
},
]
: []),
]);
} else if (category === "orm") {
// Connect database to ORM
const database = activeNodes.database;
// Only add connection if database is not "no-database"
if (database !== "no-database") {
setEdges((eds) => [ setEdges((eds) => [
...eds, ...eds,
{ {
id: `hono-${techId}`, id: `${database}-${techId}`,
source: "hono", source: database,
target: techId, target: techId,
animated: true, animated: true,
}, },
{
id: `${techId}-${ormNode.id}`,
source: techId,
target: ormNode.id,
animated: true,
},
]); ]);
} }
} else if (category === "auth") { } else if (category === "auth") {
// Connect backend to auth
setEdges((eds) => [ setEdges((eds) => [
...eds, ...eds,
{ {
@@ -141,24 +238,9 @@ const CustomizableStack = () => {
animated: true, 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], [activeNodes, setNodes, setEdges, removeConnectionsByCategory],
); );
const isValidConnection = useCallback( const isValidConnection = useCallback(
@@ -168,8 +250,9 @@ const CustomizableStack = () => {
if (!sourceNode || !targetNode) return false; if (!sourceNode || !targetNode) return false;
// Define valid connection patterns
if (sourceNode.id === "hono" && targetNode.data.category === "database") { if (sourceNode.id === "hono" && targetNode.data.category === "database") {
return ["postgres", "sqlite"].includes(targetNode.id); return ["postgres", "sqlite", "no-database"].includes(targetNode.id);
} }
if (sourceNode.id === "hono" && targetNode.data.category === "auth") { if (sourceNode.id === "hono" && targetNode.data.category === "auth") {
@@ -180,7 +263,7 @@ const CustomizableStack = () => {
["postgres", "sqlite"].includes(sourceNode.id) && ["postgres", "sqlite"].includes(sourceNode.id) &&
targetNode.data.category === "orm" targetNode.data.category === "orm"
) { ) {
return true; return ["drizzle", "prisma"].includes(targetNode.id);
} }
return false; return false;
@@ -188,137 +271,196 @@ const CustomizableStack = () => {
[nodes], [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( const onConnect = useCallback(
(connection: Connection) => { (connection: Connection) => {
if (!isValidConnection(connection)) return; if (!isValidConnection(connection)) return;
cleanupPreviousConnections(connection);
const targetNode = nodes.find((n) => n.id === connection.target); const targetNode = nodes.find((n) => n.id === connection.target);
if (!targetNode) return; if (!targetNode || !targetNode.data.category) return;
setEdges((eds) => { // Remove existing connections for the category we're connecting to
const newEdges = [ removeConnectionsByCategory(targetNode.data.category);
...eds,
{ // Update active nodes state
id: `${connection.source}-${connection.target}`, setActiveNodes((prev) => ({
source: connection.source, ...prev,
target: connection.target, [targetNode.data.category]: connection.target,
animated: true, }));
// Update node active states
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,
}, },
]; })),
);
if (targetNode.data.category === "database") { // Add the new connection
const activeOrm = nodes.find( setEdges((eds) => [
(n) => n.data.category === "orm" && n.data.isActive, ...eds,
); {
if (activeOrm) { id: `${connection.source}-${connection.target}`,
newEdges.push({ source: connection.source,
target: connection.target,
animated: true,
},
]);
// If connecting to database, also connect to the active ORM
if (
targetNode.data.category === "database" &&
targetNode.id !== "no-database"
) {
const activeOrm = nodes.find(
(n) => n.data.category === "orm" && n.data.isActive,
);
if (activeOrm) {
setEdges((eds) => [
...eds,
{
id: `${connection.target}-${activeOrm.id}`, id: `${connection.target}-${activeOrm.id}`,
source: connection.target, source: connection.target,
target: activeOrm.id, target: activeOrm.id,
animated: true, 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], [nodes, setEdges, setNodes, removeConnectionsByCategory, isValidConnection],
); );
const generateCommand = useCallback(() => { const generateCommand = useCallback(() => {
const flags: string[] = ["-y"]; // Start with the base command
const command = "npx create-better-t-stack my-app";
const flags: string[] = [];
if (activeNodes.database !== "sqlite") { // Check if all defaults are being used
flags.splice(flags.indexOf("-y"), 1); const isAllDefaults =
flags.push(`--${activeNodes.database}`); activeNodes.database === "sqlite" &&
activeNodes.auth === "better-auth" &&
activeNodes.orm === "drizzle" &&
activeNodes.packageManager === "npm" &&
activeNodes.features.git === true &&
!activeNodes.features.docker &&
!activeNodes.features.githubActions &&
!activeNodes.features.seo;
// If using all defaults, just use -y flag
if (isAllDefaults) {
return `${command} -y`;
} }
if (activeNodes.auth !== "better-auth") { // Database options
if (flags.includes("-y")) { if (activeNodes.database === "postgres") {
flags.splice(flags.indexOf("-y"), 1); flags.push("--postgres");
} } else if (activeNodes.database === "sqlite") {
flags.push("--sqlite");
} else if (activeNodes.database === "no-database") {
flags.push("--no-database");
}
// Authentication options
if (activeNodes.auth === "better-auth") {
flags.push("--auth");
} else if (activeNodes.auth === "no-auth") {
flags.push("--no-auth"); flags.push("--no-auth");
} }
return `npx create-better-t-stack my-app ${flags.join(" ")}`; // ORM options
if (activeNodes.orm === "drizzle") {
flags.push("--drizzle");
} else if (activeNodes.orm === "prisma") {
flags.push("--prisma");
}
// Package manager options
if (activeNodes.packageManager !== "npm") {
flags.push(`--${activeNodes.packageManager}`);
}
// Feature flags
if (activeNodes.features.docker) {
flags.push("--docker");
}
if (activeNodes.features.githubActions) {
flags.push("--github-actions");
}
if (activeNodes.features.seo) {
flags.push("--seo");
}
if (!activeNodes.features.git) {
flags.push("--no-git");
}
return flags.length > 0 ? `${command} ${flags.join(" ")}` : command;
}, [activeNodes]); }, [activeNodes]);
return ( return (
<div className="relative w-full max-w-5xl mx-auto z-50 mt-24"> <div className="relative w-full max-w-5xl mx-auto z-50 mt-24">
<TechSelector onSelect={handleTechSelect} activeNodes={activeNodes} /> {/* Command Display - Fixed at top with proper centering */}
<div className="absolute -top-16 left-1/2 max-sm:left-[60%] -translate-x-1/2 z-50 w-96"> <div className="absolute -top-16 left-0 right-0 mx-auto flex justify-center z-50">
<CommandDisplay command={generateCommand()} /> <CommandDisplay command={command} />
</div> </div>
<div className="max-sm:hidden bg-gray-950/10 lg:p-4 p-1 absolute lg:top-4 top-2 lg:right-4 right-2 z-50 w-80 rounded-xl border border-gray-800 backdrop-blur-3xl">
<div className="lg:text-sm text-xs text-gray-300 text-center"> {/* Main container with proper layout */}
Select technologies from the left panel to customize your stack. The <div className="relative rounded-xl border border-gray-800 overflow-hidden">
graph will automatically update connections. <div className="absolute inset-0 backdrop-blur-3xl bg-gradient-to-r from-blue-500/10 via-purple-500/10 to-pink-500/10" />
{/* Tech selector fixed to the left side */}
<div className="absolute left-0 top-0 bottom-0 lg:w-52 md:w-44 w-36 z-50 bg-gray-950/30 border-r border-gray-800/50">
<TechSelector onSelect={handleTechSelect} activeNodes={activeNodes} />
</div>
{/* Help text */}
<div className="max-sm:hidden bg-gray-950/30 lg:p-4 p-1 absolute lg:top-4 top-2 lg:right-4 right-2 z-50 w-80 rounded-xl border border-gray-800 backdrop-blur-3xl">
<div className="lg:text-sm text-xs text-gray-300 text-center">
Select technologies from the left panel to customize your stack. The
graph will automatically update connections.
</div>
</div>
{/* Flow container with proper spacing from the selector */}
<div className="h-[600px] lg:pl-52 md:pl-44 pl-36 relative backdrop-blur-sm bg-gray-950/50">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
fitView
maxZoom={windowSize === "sm" ? 0.6 : windowSize === "md" ? 0.6 : 1}
zoomOnScroll={false}
zoomOnPinch={false}
preventScrolling={false}
nodesConnectable={true}
nodesDraggable={true}
connectOnClick={true}
deleteKeyCode="Delete"
selectionKeyCode="Shift"
proOptions={{
hideAttribution: true,
}}
>
<Background
className="bg-gray-950/5"
color="#1e293b"
gap={12}
size={1}
/>
</ReactFlow>
</div> </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] lg:pl-28 max-sm:pt-36 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={windowSize === "sm" ? 0.6 : windowSize === "md" ? 0.6 : 1}
zoomOnScroll={false}
zoomOnPinch={false}
preventScrolling={false}
nodesConnectable={true}
nodesDraggable={true}
connectOnClick={true}
deleteKeyCode="Delete"
selectionKeyCode="Shift"
proOptions={{
hideAttribution: true,
}}
>
<Background
className="bg-gray-950/5"
color="#1e293b"
gap={12}
size={1}
/>
</ReactFlow>
</div> </div>
</div> </div>
); );

View File

@@ -1,3 +1,17 @@
interface ActiveNodes {
backend: string;
database: string;
orm: string;
auth: string;
packageManager: string;
features: {
docker: boolean;
githubActions: boolean;
seo: boolean;
git: boolean;
};
}
type TechOption = { type TechOption = {
id: string; id: string;
label: string; label: string;
@@ -6,48 +20,89 @@ type TechOption = {
const techOptions: Record<string, TechOption[]> = { const techOptions: Record<string, TechOption[]> = {
database: [ database: [
{ id: "sqlite", label: "Sqlite", category: "database" }, { id: "sqlite", label: "SQLite", category: "database" },
{ id: "postgres", label: "PostgreSQL", category: "database" }, { id: "postgres", label: "PostgreSQL", category: "database" },
{ id: "no-database", label: "No DB", category: "database" },
], ],
orm: [ orm: [
{ id: "drizzle", label: "Drizzle", category: "orm" }, { id: "drizzle", label: "Drizzle", category: "orm" },
{ id: "prisma", label: "Prisma", category: "orm" }, { id: "prisma", label: "Prisma", category: "orm" },
], ],
auth: [ auth: [
{ id: "better-auth", label: "Better-Auth", category: "auth" }, { id: "better-auth", label: "Auth", category: "auth" },
{ id: "no-auth", label: "No Auth", category: "auth" }, { id: "no-auth", label: "No Auth", category: "auth" },
], ],
packageManager: [
{ id: "npm", label: "NPM", category: "packageManager" },
{ id: "pnpm", label: "PNPM", category: "packageManager" },
{ id: "yarn", label: "Yarn", category: "packageManager" },
{ id: "bun", label: "Bun", category: "packageManager" },
],
features: [
{ id: "docker", label: "Docker", category: "features" },
{ id: "githubActions", label: "GitHub Actions", category: "features" },
{ id: "seo", label: "SEO", category: "features" },
{ id: "git", label: "Git", category: "features" },
],
}; };
interface TechSelectorProps { interface TechSelectorProps {
onSelect: (category: string, techId: string) => void; onSelect: (category: string, techId: string) => void;
activeNodes: Record<string, string>; activeNodes: ActiveNodes;
} }
export function TechSelector({ onSelect, activeNodes }: TechSelectorProps) { export function TechSelector({ onSelect, activeNodes }: TechSelectorProps) {
return ( return (
<div className="absolute max-sm:w-11/12 top-4 left-4 z-50 sm:space-y-4 space-y-2 bg-gray-950/10 sm:p-4 px-4 py-2 rounded-xl border border-gray-800 backdrop-blur-3xl"> <div className="h-full overflow-y-auto p-3 space-y-5">
<div className="text-sm font-medium text-gray-200">Customize Stack</div> <div className="text-sm font-medium text-gray-200 border-b border-gray-700 pb-2">
{Object.entries(techOptions).map(([category, options]) => ( Options
<div key={category} className="space-y-2"> </div>
<div className="text-xs text-gray-400 capitalize">{category}</div>
<div className="flex gap-2"> {/* Regular tech options */}
{options.map((option) => ( {Object.entries(techOptions)
<Badge .filter(([category]) => category !== "features")
key={option.id} .map(([category, options]) => (
variant="secondary" <div key={category} className="space-y-2">
className={`cursor-pointer hover:bg-gray-700 ${ <div className="text-xs text-gray-400 capitalize">{category}</div>
activeNodes[category] === option.id && <div className="flex flex-wrap gap-1">
"bg-blue-600 text-white" {options.map((option) => (
}`} <Badge
onClick={() => onSelect(category, option.id)} key={option.id}
> variant="secondary"
{option.label} className={`cursor-pointer hover:bg-gray-700 ${
</Badge> activeNodes[
))} category as keyof Omit<ActiveNodes, "features">
] === option.id && "bg-blue-600 text-white"
}`}
onClick={() => onSelect(category, option.id)}
>
{option.label}
</Badge>
))}
</div>
</div> </div>
))}
{/* Feature toggles */}
<div className="space-y-2">
<div className="text-xs text-gray-400">Features</div>
<div className="flex flex-wrap gap-1">
{techOptions.features.map((option) => (
<Badge
key={option.id}
variant="secondary"
className={`cursor-pointer hover:bg-gray-700 ${
activeNodes.features[
option.id as keyof typeof activeNodes.features
] === true && "bg-blue-600 text-white"
}`}
onClick={() => onSelect("features", option.id)}
>
{option.label}
</Badge>
))}
</div> </div>
))} </div>
</div> </div>
); );
} }
@@ -65,7 +120,7 @@ const Badge = ({
return ( return (
<span <span
className={` className={`
px-2 rounded-full py-1 text-xs font-medium, px-2 rounded-full py-1 text-xs font-medium
${className} ${className}
`} `}
{...props} {...props}

View File

@@ -117,6 +117,7 @@ export const initialNodes: TechNode[] = [
description: "Fast all-in-one JavaScript runtime", description: "Fast all-in-one JavaScript runtime",
isDefault: true, isDefault: true,
isActive: true, isActive: true,
isStatic: true,
}, },
}, },
{ {
@@ -129,6 +130,7 @@ export const initialNodes: TechNode[] = [
description: "Type-safe routing", description: "Type-safe routing",
isDefault: true, isDefault: true,
isActive: true, isActive: true,
isStatic: true,
group: "router", group: "router",
}, },
}, },
@@ -168,6 +170,7 @@ export const initialNodes: TechNode[] = [
description: "Ultrafast web framework", description: "Ultrafast web framework",
isDefault: true, isDefault: true,
isActive: true, isActive: true,
isStatic: true,
group: "backend", group: "backend",
}, },
}, },
@@ -176,7 +179,7 @@ export const initialNodes: TechNode[] = [
type: "techNode", type: "techNode",
position: { x: 544, y: 532 }, position: { x: 544, y: 532 },
data: { data: {
label: "Sqlite", label: "SQLite",
category: "database", category: "database",
description: "SQLite-compatible database", description: "SQLite-compatible database",
isDefault: true, isDefault: true,
@@ -197,6 +200,19 @@ export const initialNodes: TechNode[] = [
group: "database", group: "database",
}, },
}, },
{
id: "no-database",
type: "techNode",
position: { x: 420, y: 710 },
data: {
label: "No Database",
category: "database",
description: "Skip database setup",
isDefault: false,
isActive: false,
group: "database",
},
},
{ {
id: "drizzle", id: "drizzle",
type: "techNode", type: "techNode",