diff --git a/.changeset/slick-cameras-attack.md b/.changeset/slick-cameras-attack.md new file mode 100644 index 0000000..a1acb3e --- /dev/null +++ b/.changeset/slick-cameras-attack.md @@ -0,0 +1,5 @@ +--- +"create-better-t-stack": patch +--- + +fix database none flags validation diff --git a/apps/cli/src/helpers/create-readme.ts b/apps/cli/src/helpers/create-readme.ts index 8de0934..2bf5073 100644 --- a/apps/cli/src/helpers/create-readme.ts +++ b/apps/cli/src/helpers/create-readme.ts @@ -43,8 +43,7 @@ function generateReadmeContent(options: ProjectConfig): string { const packageManagerRunCmd = packageManager === "npm" ? "npm run" : packageManager; - // Determine the web port based on the frontend framework - let webPort = "3001"; // Default for TanStack Router and TanStack Start + let webPort = "3001"; if (hasReactRouter) { webPort = "5173"; } else if (hasNext) { diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 571eb6e..fc4c1ec 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -64,7 +64,7 @@ async function main() { }) .option("auth", { type: "boolean", - describe: "Include authentication", + describe: "Include authentication (use --no-auth to exclude)", }) .option("frontend", { type: "array", @@ -101,7 +101,7 @@ async function main() { }) .option("git", { type: "boolean", - describe: "Initialize git repository", + describe: "Initialize git repository (use --no-git to skip)", }) .option("package-manager", { alias: "pm", @@ -111,7 +111,7 @@ async function main() { }) .option("install", { type: "boolean", - describe: "Install dependencies", + describe: "Install dependencies (use --no-install to skip)", }) .option("db-setup", { type: "string", @@ -165,6 +165,14 @@ async function main() { projectName: projectDirectory ?? DEFAULT_CONFIG.projectName, ...flagConfig, }; + + if (config.database === "none") { + config.orm = "none"; + config.auth = false; + config.dbSetup = "none"; + config.examples = config.examples.filter((ex) => ex !== "todo"); + } + log.info(pc.yellow("Using these default/flag options:")); log.message(displayConfig(config)); log.message(""); @@ -227,122 +235,23 @@ function processAndValidateFlags( config.database = options.database as ProjectDatabase; } if (options.orm) { - if (options.orm === "none") { - config.orm = "none"; - } else { - config.orm = options.orm as ProjectOrm; - } + config.orm = options.orm as ProjectOrm; } - if ( - (config.database ?? options.database) === "mongodb" && - (config.orm ?? options.orm) === "drizzle" - ) { - consola.fatal( - "MongoDB is only available with Prisma. Cannot use --database mongodb with --orm drizzle", - ); - process.exit(1); - } - - if (options.dbSetup) { - const dbSetup = options.dbSetup as ProjectDBSetup | "none"; - - if (dbSetup !== "none") { - config.dbSetup = dbSetup; - - if (dbSetup === "turso") { - if (options.database && options.database !== "sqlite") { - consola.fatal( - `Turso setup requires a SQLite database. Cannot use --db-setup turso with --database ${options.database}`, - ); - process.exit(1); - } - config.database = "sqlite"; - - if (options.orm === "prisma") { - consola.fatal( - "Turso setup is not compatible with Prisma. Cannot use --db-setup turso with --orm prisma", - ); - process.exit(1); - } - config.orm = "drizzle"; - } else if (dbSetup === "prisma-postgres") { - if (options.database && options.database !== "postgres") { - consola.fatal( - "Prisma PostgreSQL setup requires PostgreSQL database. Cannot use --db-setup prisma-postgres with a different database type.", - ); - process.exit(1); - } - config.database = "postgres"; - - if (options.orm && options.orm !== "prisma" && options.orm !== "none") { - consola.fatal( - "Prisma PostgreSQL setup requires Prisma ORM. Cannot use --db-setup prisma-postgres with a different ORM.", - ); - process.exit(1); - } - config.orm = "prisma"; - } else if (dbSetup === "mongodb-atlas") { - if (options.database && options.database !== "mongodb") { - consola.fatal( - "MongoDB Atlas setup requires MongoDB database. Cannot use --db-setup mongodb-atlas with a different database type.", - ); - process.exit(1); - } - config.database = "mongodb"; - config.orm = "prisma"; - } else if (dbSetup === "neon") { - if (options.database && options.database !== "postgres") { - consola.fatal( - "Neon PostgreSQL setup requires PostgreSQL database. Cannot use --db-setup neon with a different database type.", - ); - process.exit(1); - } - config.database = "postgres"; - } - } else { - config.dbSetup = "none"; - } - } - - const effectiveDatabase = config.database ?? options.database; - if (effectiveDatabase === "none") { - if (options.auth === true) { - consola.fatal( - "Authentication requires a database. Cannot use --auth with --database none.", - ); - process.exit(1); - } - - const effectiveOrm = config.orm ?? options.orm; - if (effectiveOrm && effectiveOrm !== "none") { - consola.fatal( - `Cannot use ORM with no database. Cannot use --orm ${effectiveOrm} with --database none.`, - ); - process.exit(1); - } - config.orm = "none"; - - const effectiveDbSetup = config.dbSetup ?? options.dbSetup; - if (effectiveDbSetup && effectiveDbSetup !== "none") { - consola.fatal( - `Database setup requires a database. Cannot use --db-setup ${effectiveDbSetup} with --database none.`, - ); - process.exit(1); - } - config.dbSetup = "none"; - } - if (options.auth !== undefined) { config.auth = options.auth; } - + if (options.git !== undefined) { + config.git = options.git; + } + if (options.install !== undefined) { + config.install = options.install; + } if (options.backend) { config.backend = options.backend as ProjectBackend; } if (options.runtime) { config.runtime = options.runtime as ProjectRuntime; } - if (options.frontend && options.frontend.length > 0) { if (options.frontend.includes("none")) { if (options.frontend.length > 1) { @@ -354,7 +263,6 @@ function processAndValidateFlags( const validOptions = options.frontend.filter( (f): f is ProjectFrontend => f !== "none", ); - const webFrontends = validOptions.filter( (f) => f === "tanstack-router" || @@ -362,7 +270,6 @@ function processAndValidateFlags( f === "tanstack-start" || f === "next", ); - if (webFrontends.length > 1) { consola.fatal( "Cannot select multiple web frameworks. Choose only one of: tanstack-router, tanstack-start, react-router, next", @@ -372,34 +279,9 @@ function processAndValidateFlags( config.frontend = validOptions; } } - if (options.api) { config.api = options.api as ProjectApi; } - - const effectiveFrontend = - config.frontend ?? - (options.frontend?.filter((f) => f !== "none") as ProjectFrontend[]) ?? - (options.yes ? DEFAULT_CONFIG.frontend : undefined); - - const includesNative = effectiveFrontend?.includes("native"); - - const effectiveApi = - config.api ?? (options.yes ? DEFAULT_CONFIG.api : undefined); - - if (includesNative && effectiveApi === "orpc") { - consola.fatal( - `oRPC API is not supported when using the 'native' frontend. Please use --api trpc or remove 'native' from --frontend.`, - ); - process.exit(1); - } - - if (includesNative && effectiveApi !== "trpc") { - if (!options.api || (options.yes && options.api !== "orpc")) { - config.api = "trpc"; - } - } - if (options.addons && options.addons.length > 0) { if (options.addons.includes("none")) { if (options.addons.length > 1) { @@ -408,42 +290,11 @@ function processAndValidateFlags( } config.addons = []; } else { - const validOptions = options.addons.filter( + config.addons = options.addons.filter( (addon): addon is ProjectAddons => addon !== "none", ); - - const webSpecificAddons = ["pwa", "tauri"]; - const hasWebSpecificAddons = validOptions.some((addon) => - webSpecificAddons.includes(addon), - ); - - const hasCompatibleWebFrontend = effectiveFrontend?.some( - (f) => f === "tanstack-router" || f === "react-router", - ); - - if (hasWebSpecificAddons && !hasCompatibleWebFrontend) { - if (options.frontend) { - consola.fatal( - "PWA and Tauri addons require tanstack-router or react-router. Cannot use these addons with your frontend selection.", - ); - process.exit(1); - } else if (!options.yes) { - } else { - consola.fatal( - "PWA and Tauri addons require tanstack-router or react-router (default frontend incompatible).", - ); - process.exit(1); - } - } - - if (validOptions.includes("husky") && !validOptions.includes("biome")) { - validOptions.push("biome"); - } - - config.addons = [...new Set(validOptions)]; } } - if (options.examples && options.examples.length > 0) { if (options.examples.includes("none")) { if (options.examples.length > 1) { @@ -452,59 +303,202 @@ function processAndValidateFlags( } config.examples = []; } else { - const validExamples = options.examples.filter( + config.examples = options.examples.filter( (ex): ex is ProjectExamples => ex !== "none", ); - - const effectiveBackend = config.backend ?? options.backend; - if ( - validExamples.includes("ai") && - effectiveBackend === "elysia" && - !(options.yes && DEFAULT_CONFIG.backend !== "elysia") - ) { - consola.fatal( - "AI example is only compatible with Hono backend. Cannot use --examples ai with --backend elysia", - ); - process.exit(1); - } - - const hasWebFrontendForExamples = effectiveFrontend?.some((f) => - ["tanstack-router", "react-router", "tanstack-start", "next"].includes( - f, - ), - ); - - if (!hasWebFrontendForExamples) { - if (options.frontend) { - consola.fatal( - "Examples require a web frontend (tanstack-router, react-router, tanstack-start, or next). Cannot use --examples with your frontend selection.", - ); - process.exit(1); - } else if (!options.yes) { - } else { - consola.fatal( - "Examples require a web frontend (tanstack-router, react-router, tanstack-start, or next) (default frontend incompatible).", - ); - process.exit(1); - } - } - - config.examples = validExamples; } } - if (options.packageManager) { config.packageManager = options.packageManager as ProjectPackageManager; } - if (options.git !== undefined) { - config.git = options.git; - } - if (options.install !== undefined) { - config.install = options.install; - } if (projectDirectory) { config.projectName = projectDirectory; } + if (options.dbSetup) { + config.dbSetup = options.dbSetup as ProjectDBSetup; + } + + const effectiveDatabase = + config.database ?? (options.yes ? DEFAULT_CONFIG.database : undefined); + const effectiveOrm = + config.orm ?? (options.yes ? DEFAULT_CONFIG.orm : undefined); + const effectiveAuth = + config.auth ?? (options.yes ? DEFAULT_CONFIG.auth : undefined); + const effectiveDbSetup = + config.dbSetup ?? (options.yes ? DEFAULT_CONFIG.dbSetup : undefined); + const effectiveExamples = + config.examples ?? (options.yes ? DEFAULT_CONFIG.examples : undefined); + const effectiveFrontend = + config.frontend ?? (options.yes ? DEFAULT_CONFIG.frontend : undefined); + const effectiveApi = + config.api ?? (options.yes ? DEFAULT_CONFIG.api : undefined); + const effectiveBackend = + config.backend ?? (options.yes ? DEFAULT_CONFIG.backend : undefined); + + if (effectiveDatabase === "none") { + if (effectiveOrm && effectiveOrm !== "none") { + consola.fatal( + `Cannot use ORM '--orm ${effectiveOrm}' when database is 'none'.`, + ); + process.exit(1); + } + config.orm = "none"; + + if (effectiveAuth === true) { + consola.fatal( + "Authentication requires a database. Cannot use --auth when database is 'none'.", + ); + process.exit(1); + } + config.auth = false; + + if (effectiveDbSetup && effectiveDbSetup !== "none") { + consola.fatal( + `Database setup '--db-setup ${effectiveDbSetup}' requires a database. Cannot use when database is 'none'.`, + ); + process.exit(1); + } + config.dbSetup = "none"; + + if (effectiveExamples?.includes("todo")) { + consola.fatal( + "The 'todo' example requires a database. Cannot use --examples todo when database is 'none'.", + ); + process.exit(1); + } + if (config.examples) { + config.examples = config.examples.filter((ex) => ex !== "todo"); + } + } + + if (effectiveDatabase === "mongodb" && effectiveOrm === "drizzle") { + consola.fatal( + "MongoDB is only available with Prisma. Cannot use --database mongodb with --orm drizzle", + ); + process.exit(1); + } + + if (config.dbSetup && config.dbSetup !== "none") { + const dbSetup = config.dbSetup; + if (dbSetup === "turso") { + if (effectiveDatabase && effectiveDatabase !== "sqlite") { + consola.fatal( + `Turso setup requires SQLite. Cannot use --db-setup turso with --database ${effectiveDatabase}`, + ); + process.exit(1); + } + if (effectiveOrm === "prisma") { + consola.fatal( + "Turso setup is not compatible with Prisma. Cannot use --db-setup turso with --orm prisma", + ); + process.exit(1); + } + config.database = "sqlite"; + config.orm = "drizzle"; + } else if (dbSetup === "prisma-postgres") { + if (effectiveDatabase && effectiveDatabase !== "postgres") { + consola.fatal( + `Prisma PostgreSQL setup requires PostgreSQL. Cannot use --db-setup prisma-postgres with --database ${effectiveDatabase}.`, + ); + process.exit(1); + } + if ( + effectiveOrm && + effectiveOrm !== "prisma" && + effectiveOrm !== "none" + ) { + consola.fatal( + `Prisma PostgreSQL setup requires Prisma ORM. Cannot use --db-setup prisma-postgres with --orm ${effectiveOrm}.`, + ); + process.exit(1); + } + config.database = "postgres"; + config.orm = "prisma"; + } else if (dbSetup === "mongodb-atlas") { + if (effectiveDatabase && effectiveDatabase !== "mongodb") { + consola.fatal( + `MongoDB Atlas setup requires MongoDB. Cannot use --db-setup mongodb-atlas with --database ${effectiveDatabase}.`, + ); + process.exit(1); + } + if ( + effectiveOrm && + effectiveOrm !== "prisma" && + effectiveOrm !== "none" + ) { + consola.fatal( + `MongoDB Atlas setup requires Prisma ORM. Cannot use --db-setup mongodb-atlas with --orm ${effectiveOrm}.`, + ); + process.exit(1); + } + config.database = "mongodb"; + config.orm = "prisma"; + } else if (dbSetup === "neon") { + if (effectiveDatabase && effectiveDatabase !== "postgres") { + consola.fatal( + `Neon PostgreSQL setup requires PostgreSQL. Cannot use --db-setup neon with --database ${effectiveDatabase}.`, + ); + process.exit(1); + } + config.database = "postgres"; + } + } + + const includesNative = effectiveFrontend?.includes("native"); + if (includesNative && effectiveApi === "orpc") { + consola.fatal( + `oRPC API is not supported with 'native' frontend. Please use --api trpc or remove 'native' from --frontend.`, + ); + process.exit(1); + } + if ( + includesNative && + effectiveApi !== "trpc" && + (!options.api || (options.yes && options.api !== "orpc")) + ) { + config.api = "trpc"; + } + + if (config.addons && config.addons.length > 0) { + const webSpecificAddons = ["pwa", "tauri"]; + const hasWebSpecificAddons = config.addons.some((addon) => + webSpecificAddons.includes(addon), + ); + const hasCompatibleWebFrontend = effectiveFrontend?.some( + (f) => f === "tanstack-router" || f === "react-router", + ); + + if (hasWebSpecificAddons && !hasCompatibleWebFrontend) { + consola.fatal( + "PWA and Tauri addons require tanstack-router or react-router. Cannot use these addons with your frontend selection.", + ); + process.exit(1); + } + + if (config.addons.includes("husky") && !config.addons.includes("biome")) { + config.addons.push("biome"); + } + config.addons = [...new Set(config.addons)]; + } + + if (config.examples && config.examples.length > 0) { + if (config.examples.includes("ai") && effectiveBackend === "elysia") { + consola.fatal( + "AI example is not compatible with Elysia backend. Cannot use --examples ai with --backend elysia", + ); + process.exit(1); + } + + const hasWebFrontendForExamples = effectiveFrontend?.some((f) => + ["tanstack-router", "react-router", "tanstack-start", "next"].includes(f), + ); + if (!hasWebFrontendForExamples) { + consola.fatal( + "Examples require a web frontend (tanstack-router, react-router, tanstack-start, or next). Cannot use --examples with your frontend selection.", + ); + process.exit(1); + } + } return config; } diff --git a/apps/cli/templates/api/trpc/server/base/src/lib/context.ts.hbs b/apps/cli/templates/api/trpc/server/base/src/lib/context.ts.hbs index eb1cf8f..8ba9e4d 100644 --- a/apps/cli/templates/api/trpc/server/base/src/lib/context.ts.hbs +++ b/apps/cli/templates/api/trpc/server/base/src/lib/context.ts.hbs @@ -96,8 +96,6 @@ export async function createContext(opts: CreateExpressContextOptions) { } {{else}} -// Default or fallback context if backend is not recognized or none -// This might need adjustment based on your default behavior export async function createContext() { return { session: null, diff --git a/apps/cli/templates/backend/elysia/src/index.ts.hbs b/apps/cli/templates/backend/elysia/src/index.ts.hbs index 3e87b32..dfdf3c8 100644 --- a/apps/cli/templates/backend/elysia/src/index.ts.hbs +++ b/apps/cli/templates/backend/elysia/src/index.ts.hbs @@ -68,5 +68,5 @@ const app = new Elysia() {{/if}} .get("/", () => "OK") .listen(3000, () => { - console.log(`Server is running on http://localhost:3000`); + console.log("Server is running on http://localhost:3000"); }); diff --git a/apps/cli/templates/backend/hono/src/index.ts.hbs b/apps/cli/templates/backend/hono/src/index.ts.hbs index 7241eaf..8834b8d 100644 --- a/apps/cli/templates/backend/hono/src/index.ts.hbs +++ b/apps/cli/templates/backend/hono/src/index.ts.hbs @@ -70,7 +70,6 @@ app.use("/trpc/*", trpcServer({ {{/if}} {{#if (includes examples "ai")}} -// AI chat endpoint app.post("/ai", async (c) => { const body = await c.req.json(); const messages = body.messages || []; diff --git a/apps/web/src/app/(home)/_components/StackArchitech.tsx b/apps/web/src/app/(home)/_components/StackArchitech.tsx index e1d8de5..3e479a5 100644 --- a/apps/web/src/app/(home)/_components/StackArchitech.tsx +++ b/apps/web/src/app/(home)/_components/StackArchitech.tsx @@ -13,15 +13,13 @@ import { ClipboardCopy, HelpCircle, InfoIcon, - Maximize2, RefreshCw, Settings, Star, Terminal, } from "lucide-react"; import { motion } from "motion/react"; -import Link from "next/link"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; const validateProjectName = (name: string): string | undefined => { const INVALID_CHARS = ["<", ">", ":", '"', "|", "?", "*"]; @@ -48,43 +46,62 @@ const validateProjectName = (name: string): string | undefined => { return undefined; }; -const StackArchitect = ({ - fullscreen = false, -}: { - fullscreen?: boolean; -}) => { - const [stack, setStack] = useState(DEFAULT_STACK); - const [command, setCommand] = useState( - "npx create-better-t-stack@latest my-better-t-app --yes", +const CATEGORY_ORDER: Array = [ + "frontend", + "runtime", + "backendFramework", + "api", + "database", + "orm", + "dbSetup", + "auth", + "packageManager", + "addons", + "examples", + "git", + "install", +]; + +const hasWebFrontend = (frontend: string[]) => + frontend.some((f) => + ["tanstack-router", "react-router", "tanstack-start", "next"].includes(f), ); - const [activeTab, setActiveTab] = useState("frontend"); + +const hasPWACompatibleFrontend = (frontend: string[]) => + frontend.some((f) => ["tanstack-router", "react-router"].includes(f)); + +const hasNativeFrontend = (frontend: string[]) => frontend.includes("native"); + +const StackArchitect = () => { + const [stack, setStack] = useState(DEFAULT_STACK); + const [command, setCommand] = useState(""); const [copied, setCopied] = useState(false); - const [compatNotes, setCompatNotes] = useState>({}); + const [compatNotes, setCompatNotes] = useState< + Record + >({}); const [projectNameError, setProjectNameError] = useState( undefined, ); const [showPresets, setShowPresets] = useState(false); const [showHelp, setShowHelp] = useState(false); const [lastSavedStack, setLastSavedStack] = useState(null); + const [activeCategory, setActiveCategory] = useState( + CATEGORY_ORDER[0], + ); - const hasNativeFrontend = useMemo( - () => stack.frontend.includes("native"), + const sectionRefs = useRef>({}); + const contentRef = useRef(null); + + const currentHasWebFrontend = useMemo( + () => hasWebFrontend(stack.frontend), [stack.frontend], ); - const hasWebFrontend = useMemo( - () => - stack.frontend.some((f) => - ["tanstack-router", "react-router", "tanstack-start", "next"].includes( - f, - ), - ), + const currentHasPWACompatibleFrontend = useMemo( + () => hasPWACompatibleFrontend(stack.frontend), [stack.frontend], ); - const hasPWACompatibleFrontend = useMemo( - () => - stack.frontend.some((f) => - ["tanstack-router", "react-router"].includes(f), - ), + const currentHasNativeFrontend = useMemo( + () => hasNativeFrontend(stack.frontend), [stack.frontend], ); @@ -92,638 +109,495 @@ const StackArchitect = ({ const savedStack = localStorage.getItem("betterTStackPreference"); if (savedStack) { try { - const parsedStack = JSON.parse(savedStack); + const parsedStack = JSON.parse(savedStack) as StackState; setLastSavedStack(parsedStack); } catch (e) { console.error("Failed to parse saved stack", e); + localStorage.removeItem("betterTStackPreference"); } } }, []); - // biome-ignore lint/correctness/useExhaustiveDependencies: Intentionally managing complex dependencies + // biome-ignore lint/correctness/useExhaustiveDependencies: Dependencies are logically required for validation inside updater useEffect(() => { - let changed = false; - const nextStack = { ...stack }; - const originalAuth = stack.auth; + setStack((currentStack) => { + const nextStack = { ...currentStack }; + let changed = false; - if (nextStack.database === "none") { - if (nextStack.orm !== "none") { - nextStack.orm = "none"; + const isWeb = hasWebFrontend(nextStack.frontend); + const isPWACompat = hasPWACompatibleFrontend(nextStack.frontend); + const isNative = hasNativeFrontend(nextStack.frontend); + + if (nextStack.database === "none") { + if (nextStack.orm !== "none") { + nextStack.orm = "none"; + changed = true; + } + if (nextStack.auth === "true") { + nextStack.auth = "false"; + changed = true; + } + if (nextStack.dbSetup !== "none") { + nextStack.dbSetup = "none"; + changed = true; + } + } else if (nextStack.database === "mongodb") { + if (nextStack.orm !== "prisma") { + nextStack.orm = "prisma"; + changed = true; + } + } + + if (nextStack.dbSetup === "turso") { + if (nextStack.database !== "sqlite") { + nextStack.database = "sqlite"; + changed = true; + } + if (nextStack.orm !== "drizzle") { + nextStack.orm = "drizzle"; + changed = true; + } + } else if (nextStack.dbSetup === "prisma-postgres") { + if (nextStack.database !== "postgres") { + nextStack.database = "postgres"; + changed = true; + } + if (nextStack.orm !== "prisma") { + nextStack.orm = "prisma"; + changed = true; + } + } else if (nextStack.dbSetup === "mongodb-atlas") { + if (nextStack.database !== "mongodb") { + nextStack.database = "mongodb"; + changed = true; + } + if (nextStack.orm !== "prisma") { + nextStack.orm = "prisma"; + changed = true; + } + } else if (nextStack.dbSetup === "neon") { + if (nextStack.database !== "postgres") { + nextStack.database = "postgres"; + changed = true; + } + } + + if (isNative && nextStack.api !== "trpc") { + nextStack.api = "trpc"; changed = true; } - if (nextStack.auth === "true") { - nextStack.auth = "false"; + + const incompatibleAddons: string[] = []; + if (!isPWACompat) { + incompatibleAddons.push("pwa", "tauri"); + } + const originalAddonsLength = nextStack.addons.length; + nextStack.addons = nextStack.addons.filter( + (addon) => !incompatibleAddons.includes(addon), + ); + if (nextStack.addons.length !== originalAddonsLength) { changed = true; } - if (nextStack.dbSetup !== "none") { - nextStack.dbSetup = "none"; - changed = true; - } - } else { if ( - nextStack.auth === "false" && - (hasWebFrontend || hasNativeFrontend) && - originalAuth === "false" + nextStack.addons.includes("husky") && + !nextStack.addons.includes("biome") ) { + nextStack.addons.push("biome"); + nextStack.addons = [...new Set(nextStack.addons)]; + changed = true; } - } - if (nextStack.database === "mongodb" && nextStack.orm === "drizzle") { - nextStack.orm = "prisma"; - changed = true; - } + const incompatibleExamples: string[] = []; + if (!isWeb) { + incompatibleExamples.push("todo", "ai"); + } + if (nextStack.database === "none") { + incompatibleExamples.push("todo"); + } + if (nextStack.backendFramework === "elysia") { + incompatibleExamples.push("ai"); + } - if (nextStack.dbSetup === "turso") { - if (nextStack.database !== "sqlite") { - nextStack.database = "sqlite"; + const originalExamplesLength = nextStack.examples.length; + nextStack.examples = nextStack.examples.filter( + (ex) => !incompatibleExamples.includes(ex), + ); + if (nextStack.examples.length !== originalExamplesLength) { changed = true; } - if (nextStack.orm === "prisma") { - nextStack.orm = "drizzle"; - changed = true; - } - } else if (nextStack.dbSetup === "prisma-postgres") { - if (nextStack.database !== "postgres") { - nextStack.database = "postgres"; - changed = true; - } - if (nextStack.orm !== "prisma") { - nextStack.orm = "prisma"; - changed = true; - } - } else if (nextStack.dbSetup === "mongodb-atlas") { - if (nextStack.database !== "mongodb") { - nextStack.database = "mongodb"; - changed = true; - } - if (nextStack.orm !== "prisma") { - nextStack.orm = "prisma"; - changed = true; - } - } else if (nextStack.dbSetup === "neon") { - if (nextStack.database !== "postgres") { - nextStack.database = "postgres"; - changed = true; - } - } - if (changed) { - setStack((currentStack) => ({ - ...currentStack, - database: nextStack.database, - orm: nextStack.orm, - auth: nextStack.auth, - dbSetup: nextStack.dbSetup, - })); - } + return changed ? nextStack : currentStack; + }); }, [ stack.database, stack.orm, - stack.dbSetup, stack.auth, - hasWebFrontend, - hasNativeFrontend, - ]); - - useEffect(() => { - let addonsChanged = false; - let examplesChanged = false; - let apiChanged = false; - - const currentAddons = stack.addons; - const currentExamples = stack.examples; - const currentApi = stack.api; - const currentBackend = stack.backendFramework; - - let nextAddons = [...currentAddons]; - let nextExamples = [...currentExamples]; - let nextApi = currentApi; - - if (!hasPWACompatibleFrontend) { - const incompatibleAddons = ["pwa", "tauri"]; - const originalLength = nextAddons.length; - nextAddons = nextAddons.filter( - (addon) => !incompatibleAddons.includes(addon), - ); - if (nextAddons.length !== originalLength) { - addonsChanged = true; - } - } - - if (!hasWebFrontend) { - const incompatibleExamples = ["todo", "ai"]; - const originalLength = nextExamples.length; - nextExamples = nextExamples.filter( - (example) => !incompatibleExamples.includes(example), - ); - if (nextExamples.length !== originalLength) { - examplesChanged = true; - } - } - - if (currentBackend === "elysia") { - const originalLength = nextExamples.length; - nextExamples = nextExamples.filter((example) => example !== "ai"); - if (nextExamples.length !== originalLength) { - examplesChanged = true; - } - } - - if (hasNativeFrontend && currentApi !== "trpc") { - nextApi = "trpc"; - apiChanged = true; - } - - if (addonsChanged || examplesChanged || apiChanged) { - setStack((prev) => ({ - ...prev, - addons: addonsChanged ? nextAddons : prev.addons, - examples: examplesChanged ? nextExamples : prev.examples, - api: apiChanged ? nextApi : prev.api, - })); - } - }, [ + stack.dbSetup, + stack.frontend, + stack.api, stack.addons, stack.examples, - stack.api, stack.backendFramework, - hasPWACompatibleFrontend, - hasWebFrontend, - hasNativeFrontend, ]); const generateCommand = useCallback((stackState: StackState) => { let base: string; - if (stackState.packageManager === "npm") { - base = "npx create-better-t-stack@latest"; - } else if (stackState.packageManager === "pnpm") { - base = "pnpm create better-t-stack@latest"; - } else { - base = "bun create better-t-stack@latest"; + switch (stackState.packageManager) { + case "npm": + base = "npx create-better-t-stack@latest"; + break; + case "pnpm": + base = "pnpm create better-t-stack@latest"; + break; + default: + base = "bun create better-t-stack@latest"; + break; } const projectName = stackState.projectName || "my-better-t-app"; - const flags: string[] = ["--yes"]; + const flags: string[] = []; - if ( - stackState.frontend.length === 0 || - (stackState.frontend.length === 1 && stackState.frontend[0] === "none") - ) { - flags.push("--frontend none"); - } else if ( - !( - stackState.frontend.length === 1 && - stackState.frontend[0] === "tanstack-router" - ) - ) { - flags.push(`--frontend ${stackState.frontend.join(" ")}`); + const isDefault = ( + key: K, + value: StackState[K], + ) => { + const defaultValue = DEFAULT_STACK[key]; + + if (Array.isArray(defaultValue) && Array.isArray(value)) { + return ( + defaultValue.length === value.length && + defaultValue.every((item) => value.includes(item)) && + value.every((item) => defaultValue.includes(item)) + ); + } + return defaultValue === value; + }; + + if (!isDefault("frontend", stackState.frontend)) { + if ( + stackState.frontend.length === 0 || + stackState.frontend[0] === "none" + ) { + flags.push("--frontend none"); + } else { + flags.push(`--frontend ${stackState.frontend.join(" ")}`); + } } - if (stackState.database !== "sqlite") { + if (!isDefault("database", stackState.database)) { flags.push(`--database ${stackState.database}`); } - if (stackState.database !== "none" && stackState.orm !== "drizzle") { + if (stackState.database !== "none" && !isDefault("orm", stackState.orm)) { flags.push(`--orm ${stackState.orm}`); } - if (stackState.auth === "false") { - flags.push("--no-auth"); + if (!isDefault("auth", stackState.auth)) { + if (stackState.auth === "false") { + flags.push("--no-auth"); + } } - if (stackState.dbSetup !== "none") { + if (!isDefault("dbSetup", stackState.dbSetup)) { flags.push(`--db-setup ${stackState.dbSetup}`); } - if (stackState.backendFramework !== "hono") { + if (!isDefault("backendFramework", stackState.backendFramework)) { flags.push(`--backend ${stackState.backendFramework}`); } - if (stackState.runtime !== "bun") { + if (!isDefault("runtime", stackState.runtime)) { flags.push(`--runtime ${stackState.runtime}`); } - if (stackState.packageManager !== "bun") { - flags.push(`--package-manager ${stackState.packageManager}`); - } - - if (stackState.git === "false") { - flags.push("--no-git"); - } - - if (stackState.install === "false") { - flags.push("--no-install"); - } - - if (stackState.addons.length > 0) { - flags.push(`--addons ${stackState.addons.join(" ")}`); - } - - if (stackState.examples.length > 0) { - flags.push(`--examples ${stackState.examples.join(" ")}`); - } - - if (stackState.api && stackState.api !== "trpc") { + if (!isDefault("api", stackState.api)) { flags.push(`--api ${stackState.api}`); } - return `${base} ${projectName} ${flags.join(" ")}`; + if (!isDefault("packageManager", stackState.packageManager)) { + flags.push(`--package-manager ${stackState.packageManager}`); + } + + if (!isDefault("git", stackState.git)) { + if (stackState.git === "false") { + flags.push("--no-git"); + } + } + + if (!isDefault("install", stackState.install)) { + if (stackState.install === "false") { + flags.push("--no-install"); + } + } + + if (!isDefault("addons", stackState.addons)) { + if (stackState.addons.length > 0) { + flags.push(`--addons ${stackState.addons.join(" ")}`); + } + } + + if (!isDefault("examples", stackState.examples)) { + if (stackState.examples.length > 0) { + flags.push(`--examples ${stackState.examples.join(" ")}`); + } + } + + return `${base} ${projectName}${flags.length > 0 ? ` ${flags.join(" ")}` : ""}`; }, []); useEffect(() => { const cmd = generateCommand(stack); setCommand(cmd); - const notes: Record = {}; - - notes.frontend = []; - if (stack.frontend.includes("native") && stack.frontend.length > 1) { - notes.frontend.push( - "When using React Native alongside a web frontend, only the tRPC API option is available.", - ); + const notes: Record = {}; + for (const cat of CATEGORY_ORDER) { + notes[cat] = { notes: [], hasIssue: false }; } - notes.dbSetup = []; - if (stack.database === "none") { - notes.dbSetup.push("Database setup requires a database to be selected."); - } else { - if (stack.dbSetup === "turso") { - if (stack.database !== "sqlite") { - notes.dbSetup.push("Turso setup requires the SQLite database."); - } - if (stack.orm === "prisma") { - notes.dbSetup.push("Turso is not compatible with the Prisma ORM."); - } - } else if (stack.dbSetup === "prisma-postgres") { - if (stack.database !== "postgres") { - notes.dbSetup.push( - "Prisma PostgreSQL setup requires the PostgreSQL database.", - ); - } - if (stack.orm !== "prisma") { - notes.dbSetup.push( - "Prisma PostgreSQL setup requires the Prisma ORM.", - ); - } - } else if (stack.dbSetup === "mongodb-atlas") { - if (stack.database !== "mongodb") { - notes.dbSetup.push( - "MongoDB Atlas setup requires the MongoDB database.", - ); - } + const isWeb = currentHasWebFrontend; + const isPWACompat = currentHasPWACompatibleFrontend; + const isNative = currentHasNativeFrontend; - if (stack.orm !== "prisma") { - notes.dbSetup.push( - "MongoDB Atlas setup requires the Prisma ORM (implicitly selected).", - ); - } - } else if (stack.dbSetup === "neon") { - if (stack.database !== "postgres") { - notes.dbSetup.push("Neon setup requires the PostgreSQL database."); - } + if (isNative && stack.frontend.length > 1) { + notes.frontend.notes.push( + "React Native requires the tRPC API when used with other frontends. oRPC will be disabled.", + ); + if (stack.api !== "trpc") notes.frontend.hasIssue = true; + } + if ( + !isPWACompat && + stack.addons.some((a) => ["pwa", "tauri"].includes(a)) + ) { + notes.frontend.notes.push( + "PWA/Tauri addons require TanStack or React Router.", + ); + notes.frontend.hasIssue = true; + notes.addons.hasIssue = true; + } + if (!isWeb && stack.examples.length > 0) { + notes.frontend.notes.push("Examples require a web frontend."); + notes.frontend.hasIssue = true; + notes.examples.hasIssue = true; + } + + if (isNative && stack.api !== "trpc") { + notes.api.notes.push( + "React Native requires tRPC. It will be selected automatically.", + ); + notes.api.hasIssue = true; + } + + if (stack.database === "mongodb" && stack.orm !== "prisma") { + notes.database.notes.push( + "MongoDB requires Prisma ORM. It will be selected automatically.", + ); + notes.database.hasIssue = true; + notes.orm.hasIssue = true; + } + if (stack.database === "none") { + if (stack.orm !== "none") notes.database.hasIssue = true; + if (stack.auth === "true") notes.database.hasIssue = true; + if (stack.dbSetup !== "none") notes.database.hasIssue = true; + if (stack.examples.includes("todo")) notes.database.hasIssue = true; + } + + if (stack.database === "none" && stack.orm !== "none") { + notes.orm.notes.push( + "ORM requires a database. It will be set to 'None'.", + ); + notes.orm.hasIssue = true; + } + if (stack.database === "mongodb" && stack.orm !== "prisma") { + notes.orm.notes.push("MongoDB requires Prisma ORM. It will be selected."); + notes.orm.hasIssue = true; + } + if (stack.dbSetup === "turso" && stack.orm !== "drizzle") { + notes.orm.notes.push( + "Turso DB setup requires Drizzle ORM. It will be selected.", + ); + notes.orm.hasIssue = true; + } + if (stack.dbSetup === "prisma-postgres" && stack.orm !== "prisma") { + notes.orm.notes.push( + "Prisma PostgreSQL setup requires Prisma ORM. It will be selected.", + ); + notes.orm.hasIssue = true; + } + if (stack.dbSetup === "mongodb-atlas" && stack.orm !== "prisma") { + notes.orm.notes.push( + "MongoDB Atlas setup requires Prisma ORM. It will be selected.", + ); + notes.orm.hasIssue = true; + } + + if (stack.database === "none" && stack.dbSetup !== "none") { + notes.dbSetup.notes.push( + "DB Setup requires a database. It will be set to 'Basic Setup'.", + ); + notes.dbSetup.hasIssue = true; + } else if (stack.dbSetup !== "none") { + const db = stack.database; + const orm = stack.orm; + if (stack.dbSetup === "turso" && (db !== "sqlite" || orm !== "drizzle")) { + notes.dbSetup.notes.push( + "Turso requires SQLite & Drizzle. They will be selected.", + ); + notes.dbSetup.hasIssue = true; + if (db !== "sqlite") notes.database.hasIssue = true; + if (orm !== "drizzle") notes.orm.hasIssue = true; + } else if ( + stack.dbSetup === "prisma-postgres" && + (db !== "postgres" || orm !== "prisma") + ) { + notes.dbSetup.notes.push( + "Requires PostgreSQL & Prisma. They will be selected.", + ); + notes.dbSetup.hasIssue = true; + if (db !== "postgres") notes.database.hasIssue = true; + if (orm !== "prisma") notes.orm.hasIssue = true; + } else if ( + stack.dbSetup === "mongodb-atlas" && + (db !== "mongodb" || orm !== "prisma") + ) { + notes.dbSetup.notes.push( + "Requires MongoDB & Prisma. They will be selected.", + ); + notes.dbSetup.hasIssue = true; + if (db !== "mongodb") notes.database.hasIssue = true; + if (orm !== "prisma") notes.orm.hasIssue = true; + } else if (stack.dbSetup === "neon" && db !== "postgres") { + notes.dbSetup.notes.push( + "Neon requires PostgreSQL. It will be selected.", + ); + notes.dbSetup.hasIssue = true; + if (db !== "postgres") notes.database.hasIssue = true; } } - notes.addons = []; - if (!hasPWACompatibleFrontend) { - notes.addons.push( - "PWA and Tauri addons require TanStack Router or React Router.", + if (stack.database === "none" && stack.auth === "true") { + notes.auth.notes.push( + "Authentication requires a database. It will be disabled.", ); + notes.auth.hasIssue = true; + } + + if ( + !isPWACompat && + stack.addons.some((a) => ["pwa", "tauri"].includes(a)) + ) { + notes.addons.notes.push( + "PWA/Tauri require TanStack/React Router. They will be removed.", + ); + notes.addons.hasIssue = true; } if (stack.addons.includes("husky") && !stack.addons.includes("biome")) { - notes.addons.push( - "Husky addon automatically enables Biome for lint-staged.", + notes.addons.notes.push( + "Husky automatically enables Biome. It will be added.", ); } - notes.database = []; - if (stack.database === "mongodb" && stack.orm === "drizzle") { - notes.database.push("MongoDB is only compatible with the Prisma ORM."); - } - if (stack.dbSetup !== "none") { - notes.database.push( - `Changing the database might reset the '${TECH_OPTIONS.dbSetup.find((db) => db.id === stack.dbSetup)?.name}' setup if it becomes incompatible.`, + if (!isWeb && stack.examples.length > 0) { + notes.examples.notes.push( + "Examples require a web frontend. They will be removed.", ); + notes.examples.hasIssue = true; } - - notes.orm = []; - if (stack.database === "none") { - notes.orm.push("ORM options require a database to be selected."); - } else if (stack.database === "mongodb" && stack.orm === "drizzle") { - notes.orm.push("MongoDB is only compatible with the Prisma ORM."); - } else if ( - stack.database === "sqlite" && - stack.orm === "prisma" && - stack.dbSetup === "turso" - ) { - notes.orm.push("Prisma ORM is not compatible with the Turso DB setup."); - } - - notes.auth = []; - if (stack.database === "none") { - notes.auth.push("Authentication requires a database."); - } - - notes.examples = []; - if (!hasWebFrontend) { - notes.examples.push( - "Todo and AI examples require a web frontend (TanStack Router, React Router, TanStack Start, or Next.js).", + if (stack.database === "none" && stack.examples.includes("todo")) { + notes.examples.notes.push( + "Todo example requires a database. It will be removed.", ); + notes.examples.hasIssue = true; } if (stack.backendFramework === "elysia" && stack.examples.includes("ai")) { - notes.examples.push( - "The AI example is currently only compatible with the Hono backend.", + notes.examples.notes.push( + "AI example is not compatible with Elysia. It will be removed.", ); - } - - notes.api = []; - if (hasNativeFrontend && stack.api !== "trpc") { - notes.api.push("React Native frontend requires the tRPC API option."); + notes.examples.hasIssue = true; + notes.backendFramework.hasIssue = true; } setCompatNotes(notes); }, [ stack, - hasWebFrontend, - hasPWACompatibleFrontend, - hasNativeFrontend, generateCommand, + currentHasWebFrontend, + currentHasPWACompatibleFrontend, + currentHasNativeFrontend, ]); const handleTechSelect = useCallback( (category: keyof typeof TECH_OPTIONS, techId: string) => { setStack((prev) => { const currentStack = { ...prev }; - - if (category === "frontend") { - const currentSelection = [...currentStack.frontend]; - const webTypes = [ - "tanstack-router", - "react-router", - "tanstack-start", - "next", - ]; - - if (techId === "none") { - return { ...currentStack, frontend: ["none"] }; - } - - if ( - currentSelection.includes(techId) && - currentSelection.length === 1 - ) { - return prev; - } - - let newSelection = [...currentSelection]; - - if (newSelection.includes("none")) { - newSelection = newSelection.filter((id) => id !== "none"); - } - - if (newSelection.includes(techId)) { - newSelection = newSelection.filter((id) => id !== techId); - - if (newSelection.length === 0) { - } - } else { - if (webTypes.includes(techId)) { - newSelection = newSelection.filter( - (id) => !webTypes.includes(id), - ); - } - newSelection.push(techId); - } - - return { ...currentStack, frontend: newSelection }; - } - - if (category === "addons" || category === "examples") { - const currentArray = [...(currentStack[category] || [])]; - const index = currentArray.indexOf(techId); - const nextArray = [...currentArray]; - - if (index >= 0) { - nextArray.splice(index, 1); - if (techId === "biome" && nextArray.includes("husky")) { - } - } else { - if (category === "examples") { - if (!hasWebFrontend && (techId === "todo" || techId === "ai")) - return prev; - if (techId === "ai" && currentStack.backendFramework === "elysia") - return prev; - } - if (category === "addons") { - if ( - !hasPWACompatibleFrontend && - (techId === "pwa" || techId === "tauri") - ) - return prev; - if (techId === "husky" && !nextArray.includes("biome")) { - nextArray.push("biome"); - } - } - nextArray.push(techId); - } - return { ...currentStack, [category]: nextArray }; - } - - if (category === "database") { - if (currentStack.database === techId) return prev; - - const updatedState: Partial = { database: techId }; - const currentDbSetup = currentStack.dbSetup; - - let resetDbSetup = false; - if (currentDbSetup === "turso" && techId !== "sqlite") - resetDbSetup = true; - if (currentDbSetup === "prisma-postgres" && techId !== "postgres") - resetDbSetup = true; - if (currentDbSetup === "mongodb-atlas" && techId !== "mongodb") - resetDbSetup = true; - if (currentDbSetup === "neon" && techId !== "postgres") - resetDbSetup = true; - if (techId === "none") resetDbSetup = true; - - if (resetDbSetup && currentDbSetup !== "none") { - updatedState.dbSetup = "none"; - } - - if (techId === "none") { - updatedState.orm = "none"; - updatedState.auth = "false"; - } else { - if (prev.database === "none") { - updatedState.orm = techId === "mongodb" ? "prisma" : "drizzle"; - } else { - if (techId === "mongodb" && currentStack.orm === "drizzle") { - updatedState.orm = "prisma"; - } else if ( - techId !== "mongodb" && - currentStack.orm === "prisma" && - currentDbSetup === "turso" && - techId === "sqlite" - ) { - } - } - } - - return { ...currentStack, ...updatedState }; - } - - if (category === "orm") { - if (currentStack.database === "none") return prev; - if (currentStack.database === "mongodb" && techId === "drizzle") - return prev; - if ( - currentStack.database === "sqlite" && - techId === "prisma" && - currentStack.dbSetup === "turso" - ) - return prev; - - if (currentStack.orm === techId) return prev; - return { ...currentStack, orm: techId }; - } - - if (category === "dbSetup") { - if (currentStack.database === "none" && techId !== "none") - return prev; - - if (techId === "turso") { - if (currentStack.database !== "sqlite") return prev; - if (currentStack.orm === "prisma") return prev; - } else if (techId === "prisma-postgres") { - if (currentStack.database !== "postgres") return prev; - if (currentStack.orm !== "prisma") return prev; - } else if (techId === "mongodb-atlas") { - if (currentStack.database !== "mongodb") return prev; - } else if (techId === "neon") { - if (currentStack.database !== "postgres") return prev; - } - - if (currentStack.dbSetup === techId) return prev; - return { ...currentStack, dbSetup: techId }; - } - - if (category === "auth") { - if (currentStack.database === "none" && techId === "true") - return prev; - if (currentStack.auth === techId) return prev; - return { ...currentStack, auth: techId }; - } - - if (category === "api") { - if (hasNativeFrontend && techId !== "trpc") return prev; - if (currentStack.api === techId) return prev; - return { ...currentStack, api: techId }; - } + const catKey = category as keyof StackState; if ( - category === "runtime" || - category === "backendFramework" || - category === "packageManager" || - category === "git" || - category === "install" + catKey === "frontend" || + catKey === "addons" || + catKey === "examples" ) { - if (currentStack[category] === techId) return prev; + const currentArray = [...(currentStack[catKey] as string[])]; + let nextArray = [...currentArray]; + const isSelected = currentArray.includes(techId); - const updatedState: Partial = { [category]: techId }; - if (category === "backendFramework" && techId === "elysia") { - const currentExamples = currentStack.examples || []; - if (currentExamples.includes("ai")) { - updatedState.examples = currentExamples.filter( - (ex) => ex !== "ai", - ); + if (catKey === "frontend") { + const webTypes = [ + "tanstack-router", + "react-router", + "tanstack-start", + "next", + ]; + if (techId === "none") { + nextArray = ["none"]; + } else if (isSelected) { + nextArray = nextArray.filter((id) => id !== techId); + if (nextArray.length === 0) { + return prev; + } + } else { + nextArray = nextArray.filter((id) => id !== "none"); + if (webTypes.includes(techId)) { + nextArray = nextArray.filter((id) => !webTypes.includes(id)); + } + nextArray.push(techId); + } + } else { + if (isSelected) { + nextArray = nextArray.filter((id) => id !== techId); + } else { + nextArray.push(techId); } - } else if (category === "backendFramework" && techId === "hono") { } - - return { ...currentStack, ...updatedState }; + return { ...currentStack, [catKey]: [...new Set(nextArray)] }; } - return prev; + if (currentStack[catKey] === techId) { + return prev; + } + return { ...currentStack, [catKey]: techId }; }); }, - [hasWebFrontend, hasPWACompatibleFrontend, hasNativeFrontend], + [], ); - const copyToClipboard = useCallback(() => { - navigator.clipboard.writeText(command); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }, [command]); - - const resetStack = useCallback(() => { - setStack(DEFAULT_STACK); - setActiveTab("frontend"); - setProjectNameError(undefined); - }, []); - - const saveCurrentStack = useCallback(() => { - localStorage.setItem("betterTStackPreference", JSON.stringify(stack)); - setLastSavedStack(stack); - setCopied(false); - const saveButton = document.getElementById("save-stack-button"); - const saveTextSpan = saveButton?.querySelector("span"); - - if (saveButton && saveTextSpan) { - const originalText = saveTextSpan.textContent; - saveTextSpan.textContent = "Saved!"; - saveButton.classList.add("bg-green-200", "dark:bg-green-800/70"); - saveButton.classList.remove("bg-green-100", "dark:bg-green-900/50"); - - setTimeout(() => { - if (saveTextSpan.textContent === "Saved!") { - saveTextSpan.textContent = originalText; - saveButton.classList.remove("bg-green-200", "dark:bg-green-800/70"); - saveButton.classList.add("bg-green-100", "dark:bg-green-900/50"); - } - }, 2000); - } - }, [stack]); - - const loadSavedStack = useCallback(() => { - if (lastSavedStack) { - setStack(lastSavedStack); - setProjectNameError( - validateProjectName(lastSavedStack.projectName || ""), - ); - setActiveTab("frontend"); - } - }, [lastSavedStack]); - - const applyPreset = useCallback((presetId: string) => { - const preset = PRESET_TEMPLATES.find( - (template) => template.id === presetId, - ); - if (preset) { - setStack(preset.stack); - setProjectNameError(validateProjectName(preset.stack.projectName || "")); - setShowPresets(false); - setActiveTab("frontend"); - } - }, []); - const getDisabledReason = useCallback( (category: keyof typeof TECH_OPTIONS, techId: string): string | null => { - if (category === "api" && techId !== "trpc" && hasNativeFrontend) { - return "Only tRPC API is supported when React Native is selected."; + const catKey = category as keyof StackState; + + if (catKey === "api" && techId !== "trpc" && currentHasNativeFrontend) { + return "Only tRPC API is supported with React Native."; } - if (category === "orm") { + + if (catKey === "orm") { if (stack.database === "none") return "Select a database to enable ORM options."; if (stack.database === "mongodb" && techId === "drizzle") return "MongoDB requires the Prisma ORM."; - if ( - stack.database === "sqlite" && - techId === "prisma" && - stack.dbSetup === "turso" - ) - return "Prisma ORM is not compatible with Turso DB setup (requires Drizzle)."; + if (stack.dbSetup === "turso" && techId === "prisma") + return "Turso DB setup requires the Drizzle ORM."; } - if (category === "dbSetup" && techId !== "none") { + + if (catKey === "dbSetup" && techId !== "none") { if (stack.database === "none") return "Select a database before choosing a cloud setup."; @@ -737,30 +611,33 @@ const StackArchitect = ({ if (stack.orm !== "prisma") return "Requires Prisma ORM."; } else if (techId === "mongodb-atlas") { if (stack.database !== "mongodb") return "Requires MongoDB database."; + if (stack.orm !== "prisma") return "Requires Prisma ORM."; } else if (techId === "neon") { if (stack.database !== "postgres") return "Requires PostgreSQL database."; } } - if ( - category === "auth" && - techId === "true" && - stack.database === "none" - ) { + + if (catKey === "auth" && techId === "true" && stack.database === "none") { return "Authentication requires a database."; } - if (category === "addons") { + + if (catKey === "addons") { if ( (techId === "pwa" || techId === "tauri") && - !hasPWACompatibleFrontend + !currentHasPWACompatibleFrontend ) { return "Requires TanStack Router or React Router frontend."; } } - if (category === "examples") { - if ((techId === "todo" || techId === "ai") && !hasWebFrontend) { + + if (catKey === "examples") { + if ((techId === "todo" || techId === "ai") && !currentHasWebFrontend) { return "Requires a web frontend (TanStack Router, React Router, etc.)."; } + if (techId === "todo" && stack.database === "none") { + return "Todo example requires a database."; + } if (techId === "ai" && stack.backendFramework === "elysia") { return "AI example is not compatible with Elysia backend."; } @@ -773,408 +650,476 @@ const StackArchitect = ({ stack.orm, stack.dbSetup, stack.backendFramework, - hasNativeFrontend, - hasPWACompatibleFrontend, - hasWebFrontend, + currentHasNativeFrontend, + currentHasPWACompatibleFrontend, + currentHasWebFrontend, ], ); + const copyToClipboard = useCallback(() => { + navigator.clipboard.writeText(command); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [command]); + + const resetStack = useCallback(() => { + setStack(DEFAULT_STACK); + setProjectNameError(validateProjectName(DEFAULT_STACK.projectName || "")); + setShowHelp(false); + setShowPresets(false); + setActiveCategory(CATEGORY_ORDER[0]); + contentRef.current?.scrollTo(0, 0); + }, []); + + useEffect(() => { + setProjectNameError(validateProjectName(stack.projectName || "")); + }, [stack.projectName]); + + const saveCurrentStack = useCallback(() => { + localStorage.setItem("betterTStackPreference", JSON.stringify(stack)); + setLastSavedStack(stack); + }, [stack]); + + const loadSavedStack = useCallback(() => { + if (lastSavedStack) { + setStack(lastSavedStack); + setProjectNameError( + validateProjectName(lastSavedStack.projectName || ""), + ); + setShowHelp(false); + setShowPresets(false); + setActiveCategory(CATEGORY_ORDER[0]); + contentRef.current?.scrollTo(0, 0); + } + }, [lastSavedStack]); + + const applyPreset = useCallback((presetId: string) => { + const preset = PRESET_TEMPLATES.find( + (template) => template.id === presetId, + ); + if (preset) { + setStack(preset.stack); + setProjectNameError(validateProjectName(preset.stack.projectName || "")); + setShowPresets(false); + setShowHelp(false); + setActiveCategory(CATEGORY_ORDER[0]); + contentRef.current?.scrollTo(0, 0); + } + }, []); + + const handleSidebarClick = (category: string) => { + setActiveCategory(category); + const element = sectionRefs.current[category]; + if (element) { + element.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }; + + const getCategoryDisplayName = (categoryKey: string): string => { + const result = categoryKey.replace(/([A-Z])/g, " $1"); + return result.charAt(0).toUpperCase() + result.slice(1); + }; + return ( -
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ Stack Architect Terminal +
+
+ + +
+
-
- Stack Architect Terminal -
+ {showHelp && ( +
+

+ How to Use Stack Architect +

+
    +
  • Use the sidebar to navigate between configuration sections.
  • +
  • Select your preferred technologies in the main area.
  • +
  • + Some selections may disable or automatically change other options + based on compatibility (check notes within each section!). +
  • +
  • + The command below updates automatically based on your selections. +
  • +
  • + Click the copy button ( + ) next to the command + to copy it. +
  • +
  • + Use presets () for quick setup + or reset () to defaults. +
  • +
  • + Save () your preferences to + load () them later. +
  • +
+
+ )} -
+ {showPresets && ( +
+

+ Quick Start Presets +

+
+ {PRESET_TEMPLATES.map((preset) => ( + ))} +
+
+ )} + +
+
+ +
+ + {lastSavedStack && ( - {!fullscreen && ( - - - - )} - -
+ )} +
- {showHelp && ( -
-

- How to Use Stack Architect -

-
    -
  • - Select your preferred technologies from each category using the - tabs below. -
  • -
  • - Some selections may disable or automatically change other - options based on compatibility (check notes!). -
  • -
  • - The command will automatically update based on your selections. -
  • -
  • - Click the copy button ( - ) - to copy the command. -
  • -
  • - Use presets ( - ) for quick - setup or reset ( - ) to - defaults. -
  • -
  • - Save () - your preferences to load ( - ) them - later. -
  • -
+
+
+ + $ + + + {command} +
- )} + +
- {showPresets && ( -
-

- Quick Start Presets -

-
- {PRESET_TEMPLATES.map((preset) => ( - - ))} -
-
- )} +
+

+ Selected Stack Summary +

+
+ {CATEGORY_ORDER.flatMap((category) => { + const categoryKey = category as keyof StackState; + const options = + TECH_OPTIONS[category as keyof typeof TECH_OPTIONS]; + const selectedValue = stack[categoryKey]; -
-
- -
- + if (!options) return []; - {lastSavedStack && ( - - )} + if (Array.isArray(selectedValue)) { + if (selectedValue.length === 0 || selectedValue[0] === "none") + return []; - -
-
- -
-
- - $ - - - {command} - -
-
- - {compatNotes[activeTab] && compatNotes[activeTab].length > 0 && ( -
-
- - - Compatibility Notes for{" "} - {activeTab.charAt(0).toUpperCase() + activeTab.slice(1)} - -
-
    - {compatNotes[activeTab].map((note) => ( -
  • {note}
  • - ))} -
-
- )} - -
-
- - - Configure{" "} - {activeTab.charAt(0).toUpperCase() + - activeTab.slice(1).replace(/([A-Z])/g, " $1")} - -
-
- {(TECH_OPTIONS[activeTab as keyof typeof TECH_OPTIONS] || []).map( - (tech) => { - let isSelected = false; - if (activeTab === "addons" || activeTab === "examples") { - isSelected = ( - stack[activeTab as "addons" | "examples"] || [] - ).includes(tech.id); - } else if (activeTab === "frontend") { - isSelected = (stack.frontend || []).includes(tech.id); - } else { - isSelected = - stack[activeTab as keyof StackState] === tech.id; - } - - const disabledReason = getDisabledReason( - activeTab as keyof typeof TECH_OPTIONS, - tech.id, - ); - const isDisabled = !!disabledReason; - - return ( - - !isDisabled && - handleTechSelect( - activeTab as keyof typeof TECH_OPTIONS, - tech.id, - ) - } + return selectedValue + .map((id) => options.find((opt) => opt.id === id)) + .filter((tech): tech is NonNullable => + Boolean(tech), + ) + .map((tech) => ( + -
-
- {isSelected ? ( - - ) : ( - - )} -
-
-
- - {tech.icon} - - - {tech.name} - + {tech.icon} {tech.name} + + )); + } + const tech = options.find((opt) => opt.id === selectedValue); + + if ( + !tech || + tech.id === "none" || + tech.id === "false" || + ((category === "git" || + category === "install" || + category === "auth") && + tech.id === "true") + ) { + return []; + } + + return ( + + {tech.icon} {tech.name} + + ); + })} +
+
+
+ +
+ + +
+ {CATEGORY_ORDER.map((categoryKey) => { + const categoryOptions = + TECH_OPTIONS[categoryKey as keyof typeof TECH_OPTIONS] || []; + const categoryDisplayName = getCategoryDisplayName(categoryKey); + const notesInfo = compatNotes[categoryKey]; + + return ( +
{ + sectionRefs.current[categoryKey] = el; + }} + key={categoryKey} + id={`section-${categoryKey}`} + className="mb-8 scroll-mt-4" + > +
+ +

+ {categoryDisplayName} +

+
+ + {notesInfo?.notes && notesInfo.notes.length > 0 && ( +
+
+ + + {notesInfo.hasIssue + ? "Compatibility Issues / Auto-Adjustments" + : "Notes"} + +
+
    + {notesInfo.notes.map((note, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: Static notes per render +
  • {note}
  • + ))} +
+
+ )} + +
+ {categoryOptions.map((tech) => { + let isSelected = false; + const category = categoryKey as keyof StackState; + + if ( + category === "addons" || + category === "examples" || + category === "frontend" + ) { + isSelected = ( + (stack[category] as string[]) || [] + ).includes(tech.id); + } else { + isSelected = stack[category] === tech.id; + } + + const disabledReason = getDisabledReason( + categoryKey as keyof typeof TECH_OPTIONS, + tech.id, + ); + const isDisabled = !!disabledReason; + + return ( + + !isDisabled && + handleTechSelect( + categoryKey as keyof typeof TECH_OPTIONS, + tech.id, + ) + } + > +
+
+ {isSelected ? ( + + ) : ( + + )} +
+
+
+
+ + {tech.icon} + + + {tech.name} + +
+
+

+ {tech.description} +

-

- {tech.description} -

{tech.default && !isSelected && !isDisabled && ( - + Default )} -
- - ); - }, - )} -
- -
-
-
- Selected Stack -
-
-
- {Object.entries(TECH_OPTIONS).flatMap(([category, options]) => { - const categoryKey = category as keyof StackState; - const selectedValue = stack[categoryKey]; - - if (Array.isArray(selectedValue)) { - return selectedValue - .map((id) => options.find((opt) => opt.id === id)) - .filter(Boolean) - .map((tech) => ( - - {tech?.icon} {tech?.name} - - )); - } - const tech = options.find((opt) => opt.id === selectedValue); - - if (tech && tech.id !== "none" && tech.id !== "false") { - return ( - - {tech.icon} {tech.name} - + ); - } + })} +
+ + ); + })} - return []; - })} -
-
-
-
- -
- {[ - "frontend", - "runtime", - "backendFramework", - "api", - "database", - "orm", - "dbSetup", - "auth", - "packageManager", - "addons", - "examples", - "git", - "install", - ].map((category) => ( - - ))} -
+
+
); @@ -1187,11 +1132,11 @@ const getBadgeColors = (category: string): string => { case "runtime": return "border-amber-300 bg-amber-100 text-amber-800 dark:border-amber-700/30 dark:bg-amber-900/30 dark:text-amber-300"; case "backendFramework": - return "border-blue-300 bg-blue-100 text-blue-800 dark:border-blue-700/30 dark:bg-blue-900/30 dark:text-blue-300"; + return "border-sky-300 bg-sky-100 text-sky-800 dark:border-sky-700/30 dark:bg-sky-900/30 dark:text-sky-300"; case "api": return "border-indigo-300 bg-indigo-100 text-indigo-800 dark:border-indigo-700/30 dark:bg-indigo-900/30 dark:text-indigo-300"; case "database": - return "border-indigo-300 bg-indigo-100 text-indigo-800 dark:border-indigo-700/30 dark:bg-indigo-900/30 dark:text-indigo-300"; + return "border-emerald-300 bg-emerald-100 text-emerald-800 dark:border-emerald-700/30 dark:bg-emerald-900/30 dark:text-emerald-300"; case "orm": return "border-cyan-300 bg-cyan-100 text-cyan-800 dark:border-cyan-700/30 dark:bg-cyan-900/30 dark:text-cyan-300"; case "auth": @@ -1205,9 +1150,8 @@ const getBadgeColors = (category: string): string => { case "packageManager": return "border-orange-300 bg-orange-100 text-orange-800 dark:border-orange-700/30 dark:bg-orange-900/30 dark:text-orange-300"; case "git": - return "border-gray-300 bg-gray-100 text-gray-800 dark:border-gray-700/30 dark:bg-gray-900/30 dark:text-gray-300"; case "install": - return "border-lime-300 bg-lime-100 text-lime-800 dark:border-lime-700/30 dark:bg-lime-900/30 dark:text-lime-300"; + return "border-gray-300 bg-gray-100 text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400"; default: return "border-gray-300 bg-gray-100 text-gray-800 dark:border-gray-700/30 dark:bg-gray-900/30 dark:text-gray-300"; } diff --git a/apps/web/src/app/(home)/new/page.tsx b/apps/web/src/app/(home)/new/page.tsx index 759b3ff..d26050b 100644 --- a/apps/web/src/app/(home)/new/page.tsx +++ b/apps/web/src/app/(home)/new/page.tsx @@ -1,7 +1,6 @@ "use client"; import { motion } from "motion/react"; -import Link from "next/link"; import StackArchitect from "../_components/StackArchitech"; export default function FullScreenStackArchitect() { @@ -13,18 +12,8 @@ export default function FullScreenStackArchitect() { transition={{ duration: 0.3 }} className="w-full flex-1" > - + - -
-

- Tip: Use presets (⭐), save (💾), reset (🔄), or copy (📋) the - command.{" "} - - Exit Fullscreen - -

-
); }