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
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; 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,15 +151,46 @@ 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;
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;
if (honoNode && ormNode) {
setEdges((eds) => [ setEdges((eds) => [
...eds, ...eds,
{ {
@@ -123,15 +199,36 @@ const CustomizableStack = () => {
target: techId, target: techId,
animated: true, animated: true,
}, },
// Only add ORM connection if database is not "no-database"
...(techId !== "no-database"
? [
{ {
id: `${techId}-${ormNode.id}`, id: `${techId}-${orm}`,
source: techId, source: techId,
target: ormNode.id, 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) => [
...eds,
{
id: `${database}-${techId}`,
source: database,
target: techId,
animated: true, 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,58 +271,23 @@ 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,
{
id: `${connection.source}-${connection.target}`,
source: connection.source,
target: connection.target,
animated: true,
},
];
if (targetNode.data.category === "database") { // Update active nodes state
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) => ({ setActiveNodes((prev) => ({
...prev, ...prev,
[targetNode.data.category]: connection.target, [targetNode.data.category]: connection.target,
})); }));
// Update node active states
setNodes((nds) => setNodes((nds) =>
nds.map((node) => ({ nds.map((node) => ({
...node, ...node,
@@ -253,43 +301,137 @@ const CustomizableStack = () => {
}, },
})), })),
); );
// Add the new connection
setEdges((eds) => [
...eds,
{
id: `${connection.source}-${connection.target}`,
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}`,
source: connection.target,
target: activeOrm.id,
animated: true,
},
]);
}
} }
}, },
[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">
{/* Main container with proper layout */}
<div className="relative rounded-xl border border-gray-800 overflow-hidden">
<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"> <div className="lg:text-sm text-xs text-gray-300 text-center">
Select technologies from the left panel to customize your stack. The Select technologies from the left panel to customize your stack. The
graph will automatically update connections. graph will automatically update connections.
</div> </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"> {/* 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 <ReactFlow
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
@@ -298,7 +440,6 @@ const CustomizableStack = () => {
onConnect={onConnect} onConnect={onConnect}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
fitView fitView
// minZoom={1}
maxZoom={windowSize === "sm" ? 0.6 : windowSize === "md" ? 0.6 : 1} maxZoom={windowSize === "sm" ? 0.6 : windowSize === "md" ? 0.6 : 1}
zoomOnScroll={false} zoomOnScroll={false}
zoomOnPinch={false} zoomOnPinch={false}
@@ -321,6 +462,7 @@ const CustomizableStack = () => {
</ReactFlow> </ReactFlow>
</div> </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,39 +20,59 @@ 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>
{/* Regular tech options */}
{Object.entries(techOptions)
.filter(([category]) => category !== "features")
.map(([category, options]) => (
<div key={category} className="space-y-2"> <div key={category} className="space-y-2">
<div className="text-xs text-gray-400 capitalize">{category}</div> <div className="text-xs text-gray-400 capitalize">{category}</div>
<div className="flex gap-2"> <div className="flex flex-wrap gap-1">
{options.map((option) => ( {options.map((option) => (
<Badge <Badge
key={option.id} key={option.id}
variant="secondary" variant="secondary"
className={`cursor-pointer hover:bg-gray-700 ${ className={`cursor-pointer hover:bg-gray-700 ${
activeNodes[category] === option.id && activeNodes[
"bg-blue-600 text-white" category as keyof Omit<ActiveNodes, "features">
] === option.id && "bg-blue-600 text-white"
}`} }`}
onClick={() => onSelect(category, option.id)} onClick={() => onSelect(category, option.id)}
> >
@@ -48,6 +82,27 @@ export function TechSelector({ onSelect, activeNodes }: TechSelectorProps) {
</div> </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>
); );
} }
@@ -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",