From dafefb8449a7a461d13cbaadd01884757130f142 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 2 Apr 2025 19:50:38 +0530 Subject: [PATCH] add react router --- .changeset/nine-bobcats-admire.md | 5 + apps/cli/package.json | 5 +- apps/cli/src/constants.ts | 2 +- apps/cli/src/helpers/addons-setup.ts | 78 +++++- apps/cli/src/helpers/create-project.ts | 1 + apps/cli/src/helpers/create-readme.ts | 60 ++++- apps/cli/src/helpers/env-setup.ts | 45 +++- apps/cli/src/helpers/examples-setup.ts | 203 ++++++++------ apps/cli/src/helpers/post-installation.ts | 18 +- apps/cli/src/helpers/tauri-setup.ts | 12 +- apps/cli/src/helpers/template-manager.ts | 152 +++++++++-- apps/cli/src/index.ts | 62 ++++- apps/cli/src/prompts/addons.ts | 4 +- apps/cli/src/prompts/auth.ts | 4 +- apps/cli/src/prompts/examples.ts | 5 +- apps/cli/src/prompts/frontend-option.ts | 60 ++++- apps/cli/src/types.ts | 2 +- .../base/apps/{web => web-base}/_gitignore | 3 + .../apps/{web => web-base}/components.json | 4 +- .../src/components/loader.tsx | 0 .../src/components/mode-toggle.tsx | 2 +- .../src/components/theme-provider.tsx | 2 +- .../web-base/src/components/ui/button.tsx | 59 ++++ .../src/components/ui/card.tsx | 20 +- .../src/components/ui/checkbox.tsx | 12 +- .../src/components/ui/dropdown-menu.tsx | 255 ++++++++++++++++++ .../apps/web-base/src/components/ui/input.tsx | 21 ++ .../apps/web-base/src/components/ui/label.tsx | 24 ++ .../web-base/src/components/ui/skeleton.tsx | 13 + .../web-base/src/components/ui/sonner.tsx | 23 ++ .../template/base/apps/web-base/src/index.css | 134 +++++++++ .../apps/{web => web-base}/src/lib/utils.ts | 0 .../apps/{web => web-base}/src/utils/trpc.ts | 0 .../base/apps/web-react-router/package.json | 49 ++++ .../apps/web-react-router/public/favicon.ico | Bin 0 -> 15086 bytes .../web-react-router/react-router.config.ts | 6 + .../src/components/header.tsx | 31 +++ .../base/apps/web-react-router/src/root.tsx | 91 +++++++ .../base/apps/web-react-router/src/routes.ts | 4 + .../web-react-router/src/routes/_index.tsx | 89 ++++++ .../base/apps/web-react-router/tsconfig.json | 27 ++ .../base/apps/web-react-router/vite.config.ts | 8 + .../{web => web-tanstack-router}/index.html | 0 .../{web => web-tanstack-router}/package.json | 4 +- .../src/components/header.tsx | 0 .../{web => web-tanstack-router}/src/main.tsx | 0 .../src/routes/__root.tsx | 0 .../src/routes/index.tsx | 5 +- .../tsconfig.json | 0 .../vite.config.ts | 0 .../apps/web/src/components/ui/button.tsx | 57 ---- .../web/src/components/ui/dropdown-menu.tsx | 199 -------------- .../base/apps/web/src/components/ui/input.tsx | 22 -- .../base/apps/web/src/components/ui/label.tsx | 24 -- .../apps/web/src/components/ui/skeleton.tsx | 15 -- .../apps/web/src/components/ui/sonner.tsx | 29 -- apps/cli/template/base/apps/web/src/index.css | 119 -------- .../apps/web-react-router/src/routes/ai.tsx | 64 +++++ .../src/routes/ai.tsx | 0 .../web-react-router/src/routes/todos.tsx | 130 +++++++++ .../src/routes/todos.tsx | 14 +- .../{web => web-base}/src/lib/auth-client.ts | 0 .../apps/{web => web-base}/src/utils/trpc.ts | 0 .../src/components/header.tsx | 34 +++ .../src/components/sign-in-form.tsx | 135 ++++++++++ .../src/components/sign-up-form.tsx | 160 +++++++++++ .../src/components/user-menu.tsx | 60 +++++ .../web-react-router/src/routes/dashboard.tsx | 30 +++ .../web-react-router/src/routes/login.tsx | 13 + .../src/components/header.tsx | 0 .../src/components/sign-in-form.tsx | 0 .../src/components/sign-up-form.tsx | 0 .../src/components/user-menu.tsx | 0 .../src/routes/dashboard.tsx | 0 .../src/routes/login.tsx | 0 .../template/with-pwa/apps/web/vite.config.ts | 35 --- .../web/src/app/(home)/_components/Navbar.tsx | 2 +- .../app/(home)/_components/StackArchitech.tsx | 162 +++++++---- 78 files changed, 2160 insertions(+), 748 deletions(-) create mode 100644 .changeset/nine-bobcats-admire.md rename apps/cli/template/base/apps/{web => web-base}/_gitignore (87%) rename apps/cli/template/base/apps/{web => web-base}/components.json (91%) rename apps/cli/template/base/apps/{web => web-base}/src/components/loader.tsx (100%) rename apps/cli/template/base/apps/{web => web-base}/src/components/mode-toggle.tsx (96%) rename apps/cli/template/base/apps/{web => web-base}/src/components/theme-provider.tsx (99%) create mode 100644 apps/cli/template/base/apps/web-base/src/components/ui/button.tsx rename apps/cli/template/base/apps/{web => web-base}/src/components/ui/card.tsx (94%) rename apps/cli/template/base/apps/{web => web-base}/src/components/ui/checkbox.tsx (83%) create mode 100644 apps/cli/template/base/apps/web-base/src/components/ui/dropdown-menu.tsx create mode 100644 apps/cli/template/base/apps/web-base/src/components/ui/input.tsx create mode 100644 apps/cli/template/base/apps/web-base/src/components/ui/label.tsx create mode 100644 apps/cli/template/base/apps/web-base/src/components/ui/skeleton.tsx create mode 100644 apps/cli/template/base/apps/web-base/src/components/ui/sonner.tsx create mode 100644 apps/cli/template/base/apps/web-base/src/index.css rename apps/cli/template/base/apps/{web => web-base}/src/lib/utils.ts (100%) rename apps/cli/template/base/apps/{web => web-base}/src/utils/trpc.ts (100%) create mode 100644 apps/cli/template/base/apps/web-react-router/package.json create mode 100644 apps/cli/template/base/apps/web-react-router/public/favicon.ico create mode 100644 apps/cli/template/base/apps/web-react-router/react-router.config.ts create mode 100644 apps/cli/template/base/apps/web-react-router/src/components/header.tsx create mode 100644 apps/cli/template/base/apps/web-react-router/src/root.tsx create mode 100644 apps/cli/template/base/apps/web-react-router/src/routes.ts create mode 100644 apps/cli/template/base/apps/web-react-router/src/routes/_index.tsx create mode 100644 apps/cli/template/base/apps/web-react-router/tsconfig.json create mode 100644 apps/cli/template/base/apps/web-react-router/vite.config.ts rename apps/cli/template/base/apps/{web => web-tanstack-router}/index.html (100%) rename apps/cli/template/base/apps/{web => web-tanstack-router}/package.json (95%) rename apps/cli/template/base/apps/{web => web-tanstack-router}/src/components/header.tsx (100%) rename apps/cli/template/base/apps/{web => web-tanstack-router}/src/main.tsx (100%) rename apps/cli/template/base/apps/{web => web-tanstack-router}/src/routes/__root.tsx (100%) rename apps/cli/template/base/apps/{web => web-tanstack-router}/src/routes/index.tsx (95%) rename apps/cli/template/base/apps/{web => web-tanstack-router}/tsconfig.json (100%) rename apps/cli/template/base/apps/{web => web-tanstack-router}/vite.config.ts (100%) delete mode 100644 apps/cli/template/base/apps/web/src/components/ui/button.tsx delete mode 100644 apps/cli/template/base/apps/web/src/components/ui/dropdown-menu.tsx delete mode 100644 apps/cli/template/base/apps/web/src/components/ui/input.tsx delete mode 100644 apps/cli/template/base/apps/web/src/components/ui/label.tsx delete mode 100644 apps/cli/template/base/apps/web/src/components/ui/skeleton.tsx delete mode 100644 apps/cli/template/base/apps/web/src/components/ui/sonner.tsx delete mode 100644 apps/cli/template/base/apps/web/src/index.css create mode 100644 apps/cli/template/examples/ai/apps/web-react-router/src/routes/ai.tsx rename apps/cli/template/examples/ai/apps/{web => web-tanstack-router}/src/routes/ai.tsx (100%) create mode 100644 apps/cli/template/examples/todo/apps/web-react-router/src/routes/todos.tsx rename apps/cli/template/examples/todo/apps/{web => web-tanstack-router}/src/routes/todos.tsx (92%) rename apps/cli/template/with-auth/apps/{web => web-base}/src/lib/auth-client.ts (100%) rename apps/cli/template/with-auth/apps/{web => web-base}/src/utils/trpc.ts (100%) create mode 100644 apps/cli/template/with-auth/apps/web-react-router/src/components/header.tsx create mode 100644 apps/cli/template/with-auth/apps/web-react-router/src/components/sign-in-form.tsx create mode 100644 apps/cli/template/with-auth/apps/web-react-router/src/components/sign-up-form.tsx create mode 100644 apps/cli/template/with-auth/apps/web-react-router/src/components/user-menu.tsx create mode 100644 apps/cli/template/with-auth/apps/web-react-router/src/routes/dashboard.tsx create mode 100644 apps/cli/template/with-auth/apps/web-react-router/src/routes/login.tsx rename apps/cli/template/with-auth/apps/{web => web-tanstack}/src/components/header.tsx (100%) rename apps/cli/template/with-auth/apps/{web => web-tanstack}/src/components/sign-in-form.tsx (100%) rename apps/cli/template/with-auth/apps/{web => web-tanstack}/src/components/sign-up-form.tsx (100%) rename apps/cli/template/with-auth/apps/{web => web-tanstack}/src/components/user-menu.tsx (100%) rename apps/cli/template/with-auth/apps/{web => web-tanstack}/src/routes/dashboard.tsx (100%) rename apps/cli/template/with-auth/apps/{web => web-tanstack}/src/routes/login.tsx (100%) delete mode 100644 apps/cli/template/with-pwa/apps/web/vite.config.ts diff --git a/.changeset/nine-bobcats-admire.md b/.changeset/nine-bobcats-admire.md new file mode 100644 index 0000000..b4d10c4 --- /dev/null +++ b/.changeset/nine-bobcats-admire.md @@ -0,0 +1,5 @@ +--- +"create-better-t-stack": minor +--- + +Add React Router diff --git a/apps/cli/package.json b/apps/cli/package.json index 9e92b14..fa272b5 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -7,10 +7,7 @@ "bin": { "create-better-t-stack": "dist/index.js" }, - "files": [ - "dist", - "template" - ], + "files": ["dist", "template"], "keywords": [ "typescript", "scaffold", diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts index 45ed5a5..e3334aa 100644 --- a/apps/cli/src/constants.ts +++ b/apps/cli/src/constants.ts @@ -9,7 +9,7 @@ export const PKG_ROOT = path.join(distPath, "../"); export const DEFAULT_CONFIG: ProjectConfig = { projectName: "my-better-t-app", - frontend: ["web"], + frontend: ["tanstack-router"], database: "sqlite", orm: "drizzle", auth: true, diff --git a/apps/cli/src/helpers/addons-setup.ts b/apps/cli/src/helpers/addons-setup.ts index 1989804..e1d808f 100644 --- a/apps/cli/src/helpers/addons-setup.ts +++ b/apps/cli/src/helpers/addons-setup.ts @@ -15,16 +15,17 @@ export async function setupAddons( packageManager: ProjectPackageManager, frontends: ProjectFrontend[], ) { - const hasWebFrontend = frontends.includes("web"); + const hasWebFrontend = + frontends.includes("react-router") || frontends.includes("tanstack-router"); // if (addons.includes("docker")) { - // await setupDocker(projectDir); + // await setupDocker(projectDir); // } if (addons.includes("pwa") && hasWebFrontend) { - await setupPwa(projectDir); + await setupPwa(projectDir, frontends); } if (addons.includes("tauri") && hasWebFrontend) { - await setupTauri(projectDir, packageManager); + await setupTauri(projectDir, packageManager, frontends); } if (addons.includes("biome")) { await setupBiome(projectDir); @@ -34,6 +35,13 @@ export async function setupAddons( } } +export function getWebAppDir( + projectDir: string, + frontends: ProjectFrontend[], +): string { + return path.join(projectDir, "apps/web"); +} + async function setupBiome(projectDir: string) { const biomeTemplateDir = path.join(PKG_ROOT, "template/with-biome"); if (await fs.pathExists(biomeTemplateDir)) { @@ -88,13 +96,13 @@ async function setupHusky(projectDir: string) { } } -async function setupPwa(projectDir: string) { +async function setupPwa(projectDir: string, frontends: ProjectFrontend[]) { const pwaTemplateDir = path.join(PKG_ROOT, "template/with-pwa"); if (await fs.pathExists(pwaTemplateDir)) { await fs.copy(pwaTemplateDir, projectDir, { overwrite: true }); } - const clientPackageDir = path.join(projectDir, "apps/web"); + const clientPackageDir = getWebAppDir(projectDir, frontends); if (!(await fs.pathExists(clientPackageDir))) { return; @@ -106,6 +114,64 @@ async function setupPwa(projectDir: string) { projectDir: clientPackageDir, }); + const viteConfigPath = path.join(clientPackageDir, "vite.config.ts"); + if (await fs.pathExists(viteConfigPath)) { + let viteConfig = await fs.readFile(viteConfigPath, "utf8"); + + if (!viteConfig.includes("vite-plugin-pwa")) { + const firstImportMatch = viteConfig.match( + /^import .* from ['"](.*)['"]/m, + ); + + if (firstImportMatch) { + viteConfig = viteConfig.replace( + firstImportMatch[0], + `import { VitePWA } from "vite-plugin-pwa";\n${firstImportMatch[0]}`, + ); + } else { + viteConfig = `import { VitePWA } from "vite-plugin-pwa";\n${viteConfig}`; + } + } + + const pwaPluginCode = `VitePWA({ + registerType: "autoUpdate", + manifest: { + name: "My App", + short_name: "My App", + description: "My App", + theme_color: "#0c0c0c", + }, + pwaAssets: { + disabled: false, + config: true, + }, + devOptions: { + enabled: true, + }, + })`; + + if (!viteConfig.includes("VitePWA(")) { + if (frontends.includes("react-router")) { + viteConfig = viteConfig.replace( + /plugins: \[\s*tailwindcss\(\)/, + `plugins: [\n tailwindcss(),\n ${pwaPluginCode}`, + ); + } else if (frontends.includes("tanstack-router")) { + viteConfig = viteConfig.replace( + /plugins: \[\s*tailwindcss\(\)/, + `plugins: [\n tailwindcss(),\n ${pwaPluginCode}`, + ); + } else { + viteConfig = viteConfig.replace( + /plugins: \[/, + `plugins: [\n ${pwaPluginCode},`, + ); + } + } + + await fs.writeFile(viteConfigPath, viteConfig); + } + const clientPackageJsonPath = path.join(clientPackageDir, "package.json"); if (await fs.pathExists(clientPackageJsonPath)) { const packageJson = await fs.readJson(clientPackageJsonPath); diff --git a/apps/cli/src/helpers/create-project.ts b/apps/cli/src/helpers/create-project.ts index 5affabe..cb2e068 100644 --- a/apps/cli/src/helpers/create-project.ts +++ b/apps/cli/src/helpers/create-project.ts @@ -62,6 +62,7 @@ export async function createProject(options: ProjectConfig): Promise { options.backend, options.orm, options.database, + options.frontend, ); await setupAuth(projectDir, options.auth); diff --git a/apps/cli/src/helpers/create-readme.ts b/apps/cli/src/helpers/create-readme.ts index 20cfb2a..9fea16b 100644 --- a/apps/cli/src/helpers/create-readme.ts +++ b/apps/cli/src/helpers/create-readme.ts @@ -4,6 +4,7 @@ import type { ProjectAddons, ProjectConfig, ProjectDatabase, + ProjectFrontend, ProjectOrm, ProjectRuntime, } from "../types"; @@ -28,18 +29,25 @@ function generateReadmeContent(options: ProjectConfig): string { addons = [], orm = "drizzle", runtime = "bun", + frontend = ["tanstack-router"], } = options; + const hasReactRouter = frontend.includes("react-router"); + const hasTanstackRouter = frontend.includes("tanstack-router"); + const hasNative = frontend.includes("native"); + const packageManagerRunCmd = packageManager === "npm" ? "npm run" : packageManager; + const port = hasReactRouter ? "5173" : "3001"; + return `# ${projectName} -This project was created with [Better-T-Stack](https://github.com/better-t-stack/Better-T-Stack), a modern TypeScript stack that combines React, TanStack Router, Hono, tRPC, and more. +This project was created with [Better-T-Stack](https://github.com/better-t-stack/Better-T-Stack), a modern TypeScript stack that combines React, ${hasTanstackRouter ? "TanStack Router" : "React Router"}, Hono, tRPC, and more. ## Features -${generateFeaturesList(database, auth, addons, orm, runtime)} +${generateFeaturesList(database, auth, addons, orm, runtime, frontend)} ## Getting Started @@ -57,21 +65,31 @@ Then, run the development server: ${packageManagerRunCmd} dev \`\`\` -Open [http://localhost:3001](http://localhost:3001) in your browser to see the web application. +${ + hasTanstackRouter || hasReactRouter + ? `Open [http://localhost:${port}](http://localhost:${port}) in your browser to see the web application.` + : "" +} +${hasNative ? "Use the Expo Go app to run the mobile application.\n" : ""} The API is running at [http://localhost:3000](http://localhost:3000). +${ + addons.includes("pwa") && hasReactRouter + ? "\n## PWA Support with React Router v7\n\nThere is a known compatibility issue between VitePWA and React Router v7.\nSee: https://github.com/vite-pwa/vite-plugin-pwa/issues/809\n\nIf you encounter problems with the PWA functionality, you may need to manually modify\nthe service worker registration or consider waiting for a fix from VitePWA.\n" + : "" +} + ## Project Structure \`\`\` ${projectName}/ ├── apps/ -│ ├── web/ # Frontend application (React, TanStack Router) -│ └── server/ # Backend API (Hono, tRPC) +${hasTanstackRouter || hasReactRouter ? `│ ├── web/ # Frontend application (React, ${hasTanstackRouter ? "TanStack Router" : "React Router"})\n` : ""}${hasNative ? "│ ├── native/ # Mobile application (React Native, Expo)\n" : ""}│ └── server/ # Backend API (Hono, tRPC) \`\`\` ## Available Scripts -${generateScriptsList(packageManagerRunCmd, database, orm, auth)} +${generateScriptsList(packageManagerRunCmd, database, orm, auth, hasNative)} `; } @@ -81,16 +99,36 @@ function generateFeaturesList( addons: ProjectAddons[], orm: ProjectOrm, runtime: ProjectRuntime, + frontend: ProjectFrontend[], ): string { + const hasTanstackRouter = frontend.includes("tanstack-router"); + const hasReactRouter = frontend.includes("react-router"); + const hasNative = frontend.includes("native"); + const addonsList = [ "- **TypeScript** - For type safety and improved developer experience", - "- **TanStack Router** - File-based routing with full type safety", + ]; + + if (hasTanstackRouter) { + addonsList.push( + "- **TanStack Router** - File-based routing with full type safety", + ); + } else if (hasReactRouter) { + addonsList.push("- **React Router** - Declarative routing for React"); + } + + if (hasNative) { + addonsList.push("- **React Native** - Build mobile apps using React"); + addonsList.push("- **Expo** - Tools for React Native development"); + } + + addonsList.push( "- **TailwindCSS** - Utility-first CSS for rapid UI development", "- **shadcn/ui** - Reusable UI components", "- **Hono** - Lightweight, performant server framework", "- **tRPC** - End-to-end type-safe APIs", `- **${runtime === "bun" ? "Bun" : "Node.js"}** - Runtime environment`, - ]; + ); if (database !== "none") { addonsList.push( @@ -172,6 +210,7 @@ function generateScriptsList( database: ProjectDatabase, orm: ProjectOrm, auth: boolean, + hasNative: boolean, ): string { let scripts = `- \`${packageManagerRunCmd} dev\`: Start both web and server in development mode - \`${packageManagerRunCmd} build\`: Build both web and server @@ -179,6 +218,11 @@ function generateScriptsList( - \`${packageManagerRunCmd} dev:server\`: Start only the server - \`${packageManagerRunCmd} check-types\`: Check TypeScript types across all apps`; + if (hasNative) { + scripts += ` +- \`${packageManagerRunCmd} dev:native\`: Start the React Native/Expo development server`; + } + if (database !== "none") { scripts += ` - \`${packageManagerRunCmd} db:push\`: Push schema changes to database diff --git a/apps/cli/src/helpers/env-setup.ts b/apps/cli/src/helpers/env-setup.ts index 13b358a..42ca562 100644 --- a/apps/cli/src/helpers/env-setup.ts +++ b/apps/cli/src/helpers/env-setup.ts @@ -17,7 +17,18 @@ export async function setupEnvironmentVariables( } if (!envContent.includes("CORS_ORIGIN")) { - envContent += "\nCORS_ORIGIN=http://localhost:3001"; + const hasReactRouter = options.frontend.includes("react-router"); + const hasTanStackRouter = options.frontend.includes("tanstack-router"); + + let corsOrigin = "http://localhost:3000"; + + if (hasReactRouter) { + corsOrigin = "http://localhost:5173"; + } else if (hasTanStackRouter) { + corsOrigin = "http://localhost:3001"; + } + + envContent += `\nCORS_ORIGIN=${corsOrigin}`; } if (options.auth) { @@ -55,20 +66,13 @@ export async function setupEnvironmentVariables( await fs.writeFile(envPath, envContent.trim()); - if (options.frontend.includes("web")) { + const hasReactRouter = options.frontend.includes("react-router"); + const hasTanStackRouter = options.frontend.includes("tanstack-router"); + + if (hasReactRouter || hasTanStackRouter) { const clientDir = path.join(projectDir, "apps/web"); - const clientEnvPath = path.join(clientDir, ".env"); - let clientEnvContent = ""; - if (await fs.pathExists(clientEnvPath)) { - clientEnvContent = await fs.readFile(clientEnvPath, "utf8"); - } - - if (!clientEnvContent.includes("VITE_SERVER_URL")) { - clientEnvContent += "VITE_SERVER_URL=http://localhost:3000\n"; - } - - await fs.writeFile(clientEnvPath, clientEnvContent.trim()); + await setupClientEnvFile(clientDir); } if (options.frontend.includes("native")) { @@ -87,3 +91,18 @@ export async function setupEnvironmentVariables( await fs.writeFile(nativeEnvPath, nativeEnvContent.trim()); } } + +async function setupClientEnvFile(clientDir: string) { + const clientEnvPath = path.join(clientDir, ".env"); + let clientEnvContent = ""; + + if (await fs.pathExists(clientEnvPath)) { + clientEnvContent = await fs.readFile(clientEnvPath, "utf8"); + } + + if (!clientEnvContent.includes("VITE_SERVER_URL")) { + clientEnvContent += "VITE_SERVER_URL=http://localhost:3000\n"; + } + + await fs.writeFile(clientEnvPath, clientEnvContent.trim()); +} diff --git a/apps/cli/src/helpers/examples-setup.ts b/apps/cli/src/helpers/examples-setup.ts index 667d8fd..81dedad 100644 --- a/apps/cli/src/helpers/examples-setup.ts +++ b/apps/cli/src/helpers/examples-setup.ts @@ -10,13 +10,20 @@ export async function setupExamples( orm: ProjectOrm, auth: boolean, backend: ProjectBackend, - frontend: ProjectFrontend[] = ["web"], + frontend: ProjectFrontend[] = ["tanstack-router"], ): Promise { - const hasWebFrontend = frontend.includes("web"); + const hasTanstackRouter = frontend.includes("tanstack-router"); + const hasReactRouter = frontend.includes("react-router"); + const hasWebFrontend = hasTanstackRouter || hasReactRouter; + + const routerType = hasTanstackRouter + ? "web-tanstack-router" + : "web-react-router"; + const webAppExists = await fs.pathExists(path.join(projectDir, "apps/web")); if (examples.includes("todo") && hasWebFrontend && webAppExists) { - await setupTodoExample(projectDir, orm, auth); + await setupTodoExample(projectDir, orm, auth, routerType); } else { await cleanupTodoFiles(projectDir, orm); } @@ -27,17 +34,31 @@ export async function setupExamples( hasWebFrontend && webAppExists ) { - await setupAIExample(projectDir); + await setupAIExample(projectDir, routerType); } } -async function setupAIExample(projectDir: string): Promise { +async function setupAIExample( + projectDir: string, + routerType: string, +): Promise { const aiExampleDir = path.join(PKG_ROOT, "template/examples/ai"); if (await fs.pathExists(aiExampleDir)) { - await fs.copy(aiExampleDir, projectDir); + const aiRouteSourcePath = path.join( + aiExampleDir, + `apps/${routerType}/src/routes/ai.tsx`, + ); + const aiRouteTargetPath = path.join( + projectDir, + "apps/web/src/routes/ai.tsx", + ); - await updateHeaderWithAILink(projectDir); + if (await fs.pathExists(aiRouteSourcePath)) { + await fs.copy(aiRouteSourcePath, aiRouteTargetPath, { overwrite: true }); + } + + await updateHeaderWithAILink(projectDir, routerType); const clientDir = path.join(projectDir, "apps/web"); addPackageDependency({ @@ -66,27 +87,27 @@ async function updateServerIndexWithAIRoute(projectDir: string): Promise { const importSection = `import { streamText } from "ai";\nimport { google } from "@ai-sdk/google";\nimport { stream } from "hono/streaming";`; const aiRouteHandler = ` -app.post("/ai", async (c) => { - const body = await c.req.json(); - const messages = body.messages || []; + app.post("/ai", async (c) => { + const body = await c.req.json(); + const messages = body.messages || []; - const result = streamText({ - model: google("gemini-2.0-flash-exp"), - messages, - }); + const result = streamText({ + model: google("gemini-2.0-flash-exp"), + messages, + }); - c.header("X-Vercel-AI-Data-Stream", "v1"); - c.header("Content-Type", "text/plain; charset=utf-8"); + c.header("X-Vercel-AI-Data-Stream", "v1"); + c.header("Content-Type", "text/plain; charset=utf-8"); - return stream(c, (stream) => stream.pipe(result.toDataStream())); -});`; + return stream(c, (stream) => stream.pipe(result.toDataStream())); + });`; if (indexContent.includes("import {")) { const lastImportIndex = indexContent.lastIndexOf("import"); const endOfLastImport = indexContent.indexOf("\n", lastImportIndex); indexContent = `${indexContent.substring(0, endOfLastImport + 1)} -${importSection} -${indexContent.substring(endOfLastImport + 1)}`; + ${importSection} + ${indexContent.substring(endOfLastImport + 1)}`; } else { indexContent = `${importSection} @@ -118,7 +139,10 @@ ${aiRouteHandler}`; } } -async function updateHeaderWithAILink(projectDir: string): Promise { +async function updateHeaderWithAILink( + projectDir: string, + routerType: string, +): Promise { const headerPath = path.join( projectDir, "apps/web/src/components/header.tsx", @@ -127,24 +151,20 @@ async function updateHeaderWithAILink(projectDir: string): Promise { if (await fs.pathExists(headerPath)) { let headerContent = await fs.readFile(headerPath, "utf8"); - if (headerContent.includes('{ to: "/todos"')) { - headerContent = headerContent.replace( - /{ to: "\/todos", label: "Todos" },/, - `{ to: "/todos", label: "Todos" },\n { to: "/ai", label: "AI Chat" },`, - ); - } else if (headerContent.includes('{ to: "/dashboard"')) { - headerContent = headerContent.replace( - /{ to: "\/dashboard", label: "Dashboard" },/, - `{ to: "/dashboard", label: "Dashboard" },\n { to: "/ai", label: "AI Chat" },`, - ); - } else { - headerContent = headerContent.replace( - /const links = \[\s*{ to: "\/", label: "Home" },/, - `const links = [\n { to: "/", label: "Home" },\n { to: "/ai", label: "AI Chat" },`, - ); - } + const linksPattern = /const links = \[\s*([^;]*?)\s*\];/s; + const linksMatch = headerContent.match(linksPattern); - await fs.writeFile(headerPath, headerContent); + if (linksMatch) { + const linksContent = linksMatch[1]; + if (!linksContent.includes('"/ai"')) { + const updatedLinks = `const links = [\n ${linksContent}${ + linksContent.trim().endsWith(",") ? "" : "," + }\n { to: "/ai", label: "AI Chat" },\n ];`; + + headerContent = headerContent.replace(linksPattern, updatedLinks); + await fs.writeFile(headerPath, headerContent); + } + } } } @@ -152,12 +172,25 @@ async function setupTodoExample( projectDir: string, orm: ProjectOrm, auth: boolean, + routerType: string, ): Promise { const todoExampleDir = path.join(PKG_ROOT, "template/examples/todo"); + if (await fs.pathExists(todoExampleDir)) { - const todoRouteDir = path.join(todoExampleDir, "apps/web/src/routes"); - const targetRouteDir = path.join(projectDir, "apps/web/src/routes"); - await fs.copy(todoRouteDir, targetRouteDir, { overwrite: true }); + const todoRouteSourceDir = path.join( + todoExampleDir, + `apps/${routerType}/src/routes/todos.tsx`, + ); + const todoRouteTargetPath = path.join( + projectDir, + "apps/web/src/routes/todos.tsx", + ); + + if (await fs.pathExists(todoRouteSourceDir)) { + await fs.copy(todoRouteSourceDir, todoRouteTargetPath, { + overwrite: true, + }); + } if (orm !== "none") { const todoRouterSourceFile = path.join( @@ -174,16 +207,51 @@ async function setupTodoExample( overwrite: true, }); } + + await updateRouterIndexToIncludeTodo(projectDir); } - await updateHeaderWithTodoLink(projectDir, auth); - await addTodoButtonToHomepage(projectDir); + await updateHeaderWithTodoLink(projectDir, routerType); + } +} + +async function updateRouterIndexToIncludeTodo( + projectDir: string, +): Promise { + const routerFile = path.join(projectDir, "apps/server/src/routers/index.ts"); + + if (await fs.pathExists(routerFile)) { + let routerContent = await fs.readFile(routerFile, "utf8"); + + if (!routerContent.includes("import { todoRouter }")) { + const lastImportIndex = routerContent.lastIndexOf("import"); + const endOfImports = routerContent.indexOf("\n\n", lastImportIndex); + + if (endOfImports !== -1) { + routerContent = `${routerContent.slice(0, endOfImports)} +import { todoRouter } from "./todo";${routerContent.slice(endOfImports)}`; + } else { + routerContent = `import { todoRouter } from "./todo";\n${routerContent}`; + } + + const routerDefIndex = routerContent.indexOf( + "export const appRouter = router({", + ); + if (routerDefIndex !== -1) { + const routerContentStart = + routerContent.indexOf("{", routerDefIndex) + 1; + routerContent = `${routerContent.slice(0, routerContentStart)} + todo: todoRouter,${routerContent.slice(routerContentStart)}`; + } + + await fs.writeFile(routerFile, routerContent); + } } } async function updateHeaderWithTodoLink( projectDir: string, - auth: boolean, + routerType: string, ): Promise { const headerPath = path.join( projectDir, @@ -193,19 +261,20 @@ async function updateHeaderWithTodoLink( if (await fs.pathExists(headerPath)) { let headerContent = await fs.readFile(headerPath, "utf8"); - if (auth) { - headerContent = headerContent.replace( - /const links = \[\s*{ to: "\/", label: "Home" },\s*{ to: "\/dashboard", label: "Dashboard" },/, - `const links = [\n { to: "/", label: "Home" },\n { to: "/dashboard", label: "Dashboard" },\n { to: "/todos", label: "Todos" },`, - ); - } else { - headerContent = headerContent.replace( - /const links = \[\s*{ to: "\/", label: "Home" },/, - `const links = [\n { to: "/", label: "Home" },\n { to: "/todos", label: "Todos" },`, - ); - } + const linksPattern = /const links = \[\s*([^;]*?)\s*\];/s; + const linksMatch = headerContent.match(linksPattern); - await fs.writeFile(headerPath, headerContent); + if (linksMatch) { + const linksContent = linksMatch[1]; + if (!linksContent.includes('"/todos"')) { + const updatedLinks = `const links = [\n ${linksContent}${ + linksContent.trim().endsWith(",") ? "" : "," + }\n { to: "/todos", label: "Todos" },\n ];`; + + headerContent = headerContent.replace(linksPattern, updatedLinks); + await fs.writeFile(headerPath, headerContent); + } + } } } @@ -255,25 +324,3 @@ async function updateRouterIndex(projectDir: string): Promise { await fs.writeFile(routerFile, routerContent); } } - -async function addTodoButtonToHomepage(projectDir: string): Promise { - const indexPath = path.join(projectDir, "apps/web/src/routes/index.tsx"); - - if (await fs.pathExists(indexPath)) { - let indexContent = await fs.readFile(indexPath, "utf8"); - - indexContent = indexContent.replace( - /
<\/div>/, - `
- -
`, - ); - - await fs.writeFile(indexPath, indexContent); - } -} diff --git a/apps/cli/src/helpers/post-installation.ts b/apps/cli/src/helpers/post-installation.ts index 4081b53..5b6306e 100644 --- a/apps/cli/src/helpers/post-installation.ts +++ b/apps/cli/src/helpers/post-installation.ts @@ -37,8 +37,14 @@ export function displayPostInstallInstructions( const nativeInstructions = frontends?.includes("native") ? getNativeInstructions() : ""; + const pwaInstructions = + addons?.includes("pwa") && frontends?.includes("react-router") + ? getPwaInstructions() + : ""; - const hasWebFrontend = frontends?.includes("web"); + const hasTanstackRouter = frontends?.includes("tanstack-router"); + const hasReactRouter = frontends?.includes("react-router"); + const hasWebFrontend = hasTanstackRouter || hasReactRouter; const hasNativeFrontend = frontends?.includes("native"); const hasFrontend = hasWebFrontend || hasNativeFrontend; @@ -49,10 +55,10 @@ ${!depsInstalled ? `${pc.cyan("2.")} ${packageManager} install\n` : ""}${pc.cyan ${pc.bold("Your project will be available at:")} ${ hasFrontend - ? `${hasWebFrontend ? `${pc.cyan("•")} Frontend: http://localhost:3001\n` : ""}` + ? `${hasWebFrontend ? `${pc.cyan("•")} Frontend: http://localhost:${hasReactRouter ? "5173" : "3001"}\n` : ""}` : `${pc.yellow("NOTE:")} You are creating a backend-only app (no frontend selected)\n` }${pc.cyan("•")} API: http://localhost:3000 -${nativeInstructions ? `\n${nativeInstructions.trim()}` : ""}${databaseInstructions ? `\n${databaseInstructions.trim()}` : ""}${tauriInstructions ? `\n${tauriInstructions.trim()}` : ""}${lintingInstructions ? `\n${lintingInstructions.trim()}` : ""}`, +${nativeInstructions ? `\n${nativeInstructions.trim()}` : ""}${databaseInstructions ? `\n${databaseInstructions.trim()}` : ""}${tauriInstructions ? `\n${tauriInstructions.trim()}` : ""}${lintingInstructions ? `\n${lintingInstructions.trim()}` : ""}${pwaInstructions ? `\n${pwaInstructions.trim()}` : ""}`, "Next steps", ); } @@ -105,5 +111,9 @@ function getDatabaseInstructions( } function getTauriInstructions(runCmd?: string): string { - return `${pc.bold("Desktop app with Tauri:")}\n${pc.cyan("•")} Start desktop app: ${`cd apps/web && ${runCmd} desktop:dev`}\n${pc.cyan("•")} Build desktop app: ${`cd apps/web && ${runCmd} desktop:build`}\n${pc.yellow("NOTE:")} Tauri requires Rust and platform-specific dependencies. See: ${"https://v2.tauri.app/start/prerequisites/"}\n\n`; + return `\n${pc.bold("Desktop app with Tauri:")}\n${pc.cyan("•")} Start desktop app: ${`cd apps/web && ${runCmd} desktop:dev`}\n${pc.cyan("•")} Build desktop app: ${`cd apps/web && ${runCmd} desktop:build`}\n${pc.yellow("NOTE:")} Tauri requires Rust and platform-specific dependencies.\nSee: ${"https://v2.tauri.app/start/prerequisites/"}\n\n`; +} + +function getPwaInstructions(): string { + return `${pc.bold("PWA with React Router v7:")}\n${pc.yellow("NOTE:")} There is a known compatibility issue between VitePWA and React Router v7.\nSee: https://github.com/vite-pwa/vite-plugin-pwa/issues/809\n`; } diff --git a/apps/cli/src/helpers/tauri-setup.ts b/apps/cli/src/helpers/tauri-setup.ts index dc595d6..7d29ac0 100644 --- a/apps/cli/src/helpers/tauri-setup.ts +++ b/apps/cli/src/helpers/tauri-setup.ts @@ -1,14 +1,15 @@ import path from "node:path"; import { log, spinner } from "@clack/prompts"; -import { $, execa } from "execa"; +import { execa } from "execa"; import fs from "fs-extra"; import pc from "picocolors"; -import type { ProjectPackageManager } from "../types"; +import type { ProjectFrontend, ProjectPackageManager } from "../types"; import { addPackageDependency } from "../utils/add-package-deps"; export async function setupTauri( projectDir: string, packageManager: ProjectPackageManager, + frontends: ProjectFrontend[], ): Promise { const s = spinner(); const clientPackageDir = path.join(projectDir, "apps/web"); @@ -60,13 +61,18 @@ export async function setupTauri( args = ["@tauri-apps/cli@latest"]; } + const hasReactRouter = frontends.includes("react-router"); + const devUrl = hasReactRouter + ? "http://localhost:5173" + : "http://localhost:3001"; + args = [ ...args, "init", `--app-name=${path.basename(projectDir)}`, `--window-title=${path.basename(projectDir)}`, "--frontend-dist=dist", - "--dev-url=http://localhost:3001", + `--dev-url=${devUrl}`, `--before-dev-command=${packageManager} run dev`, `--before-build-command=${packageManager} run build`, ]; diff --git a/apps/cli/src/helpers/template-manager.ts b/apps/cli/src/helpers/template-manager.ts index b31a230..d80641e 100644 --- a/apps/cli/src/helpers/template-manager.ts +++ b/apps/cli/src/helpers/template-manager.ts @@ -8,31 +8,86 @@ import type { ProjectOrm, } from "../types"; +/** + * Copy base template structure but exclude app-specific folders that will be added based on options + */ export async function copyBaseTemplate(projectDir: string): Promise { const templateDir = path.join(PKG_ROOT, "template/base"); + if (!(await fs.pathExists(templateDir))) { throw new Error(`Template directory not found: ${templateDir}`); } - await fs.copy(templateDir, projectDir); + + await fs.ensureDir(projectDir); + + const rootFiles = await fs.readdir(templateDir); + for (const file of rootFiles) { + const srcPath = path.join(templateDir, file); + const destPath = path.join(projectDir, file); + + if (file === "apps") continue; + + if (await fs.stat(srcPath).then((stat) => stat.isDirectory())) { + await fs.copy(srcPath, destPath); + } else { + await fs.copy(srcPath, destPath); + } + } + + await fs.ensureDir(path.join(projectDir, "apps")); + + const serverSrcDir = path.join(templateDir, "apps/server"); + const serverDestDir = path.join(projectDir, "apps/server"); + if (await fs.pathExists(serverSrcDir)) { + await fs.copy(serverSrcDir, serverDestDir); + } } export async function setupFrontendTemplates( projectDir: string, frontends: ProjectFrontend[], ): Promise { - if (!frontends.includes("web")) { + const hasTanstackWeb = frontends.includes("tanstack-router"); + const hasReactRouterWeb = frontends.includes("react-router"); + const hasNative = frontends.includes("native"); + + if (hasTanstackWeb || hasReactRouterWeb) { const webDir = path.join(projectDir, "apps/web"); - if (await fs.pathExists(webDir)) { - await fs.remove(webDir); + await fs.ensureDir(webDir); + + const webBaseDir = path.join(PKG_ROOT, "template/base/apps/web-base"); + if (await fs.pathExists(webBaseDir)) { + await fs.copy(webBaseDir, webDir); + } + + const frameworkName = hasTanstackWeb + ? "web-tanstack-router" + : "web-react-router"; + const webFrameworkDir = path.join( + PKG_ROOT, + `template/base/apps/${frameworkName}`, + ); + + if (await fs.pathExists(webFrameworkDir)) { + await fs.copy(webFrameworkDir, webDir, { overwrite: true }); + } + + const packageJsonPath = path.join(webDir, "package.json"); + if (await fs.pathExists(packageJsonPath)) { + const packageJson = await fs.readJson(packageJsonPath); + packageJson.name = "web"; + await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); } } - if (!frontends.includes("native")) { - const nativeDir = path.join(projectDir, "apps/native"); - if (await fs.pathExists(nativeDir)) { - await fs.remove(nativeDir); + if (hasNative) { + const nativeSrcDir = path.join(PKG_ROOT, "template/base/apps/native"); + const nativeDestDir = path.join(projectDir, "apps/native"); + + if (await fs.pathExists(nativeSrcDir)) { + await fs.copy(nativeSrcDir, nativeDestDir); } - } else { + await fs.writeFile( path.join(projectDir, ".npmrc"), "node-linker=hoisted\n", @@ -91,14 +146,43 @@ export async function setupAuthTemplate( framework: ProjectBackend, orm: ProjectOrm, database: ProjectDatabase, + frontends: ProjectFrontend[], ): Promise { if (!auth) return; const authTemplateDir = path.join(PKG_ROOT, "template/with-auth"); if (await fs.pathExists(authTemplateDir)) { - const clientAuthDir = path.join(authTemplateDir, "apps/web"); - const projectClientDir = path.join(projectDir, "apps/web"); - await fs.copy(clientAuthDir, projectClientDir, { overwrite: true }); + const hasReactRouter = frontends.includes("react-router"); + const hasTanStackRouter = frontends.includes("tanstack-router"); + + if (hasReactRouter || hasTanStackRouter) { + const webDir = path.join(projectDir, "apps/web"); + + const webBaseAuthDir = path.join(authTemplateDir, "apps/web-base"); + if (await fs.pathExists(webBaseAuthDir)) { + await fs.copy(webBaseAuthDir, webDir, { overwrite: true }); + } + + if (hasReactRouter) { + const reactRouterAuthDir = path.join( + authTemplateDir, + "apps/web-react-router", + ); + if (await fs.pathExists(reactRouterAuthDir)) { + await fs.copy(reactRouterAuthDir, webDir, { overwrite: true }); + } + } + + if (hasTanStackRouter) { + const tanstackAuthDir = path.join( + authTemplateDir, + "apps/web-tanstack-router", + ); + if (await fs.pathExists(tanstackAuthDir)) { + await fs.copy(tanstackAuthDir, webDir, { overwrite: true }); + } + } + } const serverAuthDir = path.join(authTemplateDir, "apps/server/src"); const projectServerDir = path.join(projectDir, "apps/server/src"); @@ -141,25 +225,55 @@ export async function setupAuthTemplate( ); } } + + if (frontends.includes("native")) { + const nativeAuthDir = path.join(authTemplateDir, "apps/native"); + const projectNativeDir = path.join(projectDir, "apps/native"); + + if (await fs.pathExists(nativeAuthDir)) { + await fs.copy(nativeAuthDir, projectNativeDir, { overwrite: true }); + } + } } } export async function fixGitignoreFiles(projectDir: string): Promise { - const gitignorePaths = [ - path.join(projectDir, "_gitignore"), - path.join(projectDir, "apps/web/_gitignore"), - path.join(projectDir, "apps/native/_gitignore"), - path.join(projectDir, "apps/server/_gitignore"), - ]; + const gitignorePaths = await findGitignoreFiles(projectDir); for (const gitignorePath of gitignorePaths) { if (await fs.pathExists(gitignorePath)) { const targetPath = path.join(path.dirname(gitignorePath), ".gitignore"); - await fs.move(gitignorePath, targetPath); + await fs.move(gitignorePath, targetPath, { overwrite: true }); } } } +/** + * Find all _gitignore files in the project recursively + */ +async function findGitignoreFiles(dir: string): Promise { + const gitignoreFiles: string[] = []; + + const gitignorePath = path.join(dir, "_gitignore"); + if (await fs.pathExists(gitignorePath)) { + gitignoreFiles.push(gitignorePath); + } + + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory() && entry.name !== "node_modules") { + const subDirPath = path.join(dir, entry.name); + const subDirFiles = await findGitignoreFiles(subDirPath); + gitignoreFiles.push(...subDirFiles); + } + } + } catch (error) {} + + return gitignoreFiles; +} + function getOrmTemplateDir(orm: ProjectOrm, database: ProjectDatabase): string { if (orm === "drizzle") { return database === "sqlite" diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 9848dbb..2274e91 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -41,7 +41,10 @@ async function main() { .option("--orm ", "ORM type (none, drizzle, prisma)") .option("--auth", "Include authentication") .option("--no-auth", "Exclude authentication") - .option("--frontend ", "Frontend types (web, native, none)") + .option( + "--frontend ", + "Frontend types (tanstack-router, react-router, native, none)", + ) .option( "--addons ", "Additional addons (pwa, tauri, biome, husky, none)", @@ -251,7 +254,9 @@ function validateOptions(options: CLIOptions): void { if ( options.frontend && - !options.frontend.includes("web") && + !options.frontend.some((f) => + ["tanstack-router", "react-router"].includes(f), + ) && !options.frontend.includes("none") ) { cancel( @@ -264,7 +269,12 @@ function validateOptions(options: CLIOptions): void { } if (options.frontend && options.frontend.length > 0) { - const validFrontends = ["web", "native", "none"]; + const validFrontends = [ + "tanstack-router", + "react-router", + "native", + "none", + ]; const invalidFrontends = options.frontend.filter( (frontend: string) => !validFrontends.includes(frontend), ); @@ -278,6 +288,19 @@ function validateOptions(options: CLIOptions): void { process.exit(1); } + const webFrontends = options.frontend.filter( + (f) => f === "tanstack-router" || f === "react-router", + ); + + if (webFrontends.length > 1) { + cancel( + pc.red( + "Cannot select multiple web frameworks. Choose only one of: tanstack-router, react-router", + ), + ); + process.exit(1); + } + if (options.frontend.includes("none") && options.frontend.length > 1) { cancel(pc.red(`Cannot combine 'none' with other frontend options.`)); process.exit(1); @@ -316,7 +339,9 @@ function validateOptions(options: CLIOptions): void { if ( hasWebSpecificAddons && options.frontend && - !options.frontend.includes("web") && + !options.frontend.some((f) => + ["tanstack-router", "react-router"].includes(f), + ) && !options.frontend.includes("none") ) { cancel( @@ -336,13 +361,26 @@ function processFlags( projectDirectory?: string, ): Partial { let frontend: ProjectFrontend[] | undefined = undefined; + if (options.frontend) { if (options.frontend.includes("none")) { frontend = []; } else { frontend = options.frontend.filter( - (f): f is ProjectFrontend => f === "web" || f === "native", + (f): f is ProjectFrontend => + f === "tanstack-router" || f === "react-router" || f === "native", ); + + const webFrontends = frontend.filter( + (f) => f === "tanstack-router" || f === "react-router", + ); + + if (webFrontends.length > 1) { + const firstWebFrontend = webFrontends[0]; + frontend = frontend.filter( + (f) => f === "native" || f === firstWebFrontend, + ); + } } } @@ -371,7 +409,12 @@ function processFlags( (ex): ex is ProjectExamples => ex === "todo" || ex === "ai", ); - if (frontend && frontend.length > 0 && !frontend.includes("web")) { + if ( + frontend && + frontend.length > 0 && + !frontend.includes("tanstack-router") && + !frontend.includes("react-router") + ) { examples = []; log.warn( pc.yellow("Examples require web frontend - ignoring examples flag"), @@ -402,7 +445,12 @@ function processFlags( addon === "husky", ); - if (frontend && frontend.length > 0 && !frontend.includes("web")) { + if ( + frontend && + frontend.length > 0 && + !frontend.includes("tanstack-router") && + !frontend.includes("react-router") + ) { addons = addons.filter((addon) => !["pwa", "tauri"].includes(addon)); if (addons.length !== options.addons.length) { log.warn( diff --git a/apps/cli/src/prompts/addons.ts b/apps/cli/src/prompts/addons.ts index fc7f0d7..77fb0e6 100644 --- a/apps/cli/src/prompts/addons.ts +++ b/apps/cli/src/prompts/addons.ts @@ -9,7 +9,9 @@ export async function getAddonsChoice( ): Promise { if (Addons !== undefined) return Addons; - const hasWeb = frontends?.includes("web"); + const hasWeb = + frontends?.includes("react-router") || + frontends?.includes("tanstack-router"); const addonOptions = [ { diff --git a/apps/cli/src/prompts/auth.ts b/apps/cli/src/prompts/auth.ts index bf2b2f6..18c411b 100644 --- a/apps/cli/src/prompts/auth.ts +++ b/apps/cli/src/prompts/auth.ts @@ -11,7 +11,9 @@ export async function getAuthChoice( if (!hasDatabase) return false; const hasNative = frontends?.includes("native"); - const hasWeb = frontends?.includes("web"); + const hasWeb = + frontends?.includes("tanstack-router") || + frontends?.includes("react-router"); if (hasNative) { log.warn( diff --git a/apps/cli/src/prompts/examples.ts b/apps/cli/src/prompts/examples.ts index c2e0b39..6319b2a 100644 --- a/apps/cli/src/prompts/examples.ts +++ b/apps/cli/src/prompts/examples.ts @@ -18,7 +18,10 @@ export async function getExamplesChoice( if (database === "none") return []; - const hasWebFrontend = frontends?.includes("web"); + const hasWebFrontend = + frontends?.includes("react-router") || + frontends?.includes("tanstack-router"); + if (!hasWebFrontend) return []; let response: ProjectExamples[] | symbol = []; diff --git a/apps/cli/src/prompts/frontend-option.ts b/apps/cli/src/prompts/frontend-option.ts index 3c4a3f8..d8c4318 100644 --- a/apps/cli/src/prompts/frontend-option.ts +++ b/apps/cli/src/prompts/frontend-option.ts @@ -1,4 +1,4 @@ -import { cancel, isCancel, multiselect } from "@clack/prompts"; +import { cancel, isCancel, multiselect, select } from "@clack/prompts"; import pc from "picocolors"; import { DEFAULT_CONFIG } from "../constants"; import type { ProjectFrontend } from "../types"; @@ -8,28 +8,66 @@ export async function getFrontendChoice( ): Promise { if (frontendOptions !== undefined) return frontendOptions; - const response = await multiselect({ - message: "Choose frontends", + const frontendTypes = await multiselect({ + message: "Select platforms to develop for", options: [ { value: "web", - label: "Web App", - hint: "React + TanStack Router web application", + label: "Web", + hint: "React Web Application", }, { value: "native", - label: "Native App", - hint: "React Native + Expo application", + label: "Native", + hint: "Create a React Native/Expo app", }, ], - initialValues: DEFAULT_CONFIG.frontend, - required: false, + initialValues: DEFAULT_CONFIG.frontend.some( + (f) => f === "tanstack-router" || f === "react-router", + ) + ? ["web"] + : [], }); - if (isCancel(response)) { + if (isCancel(frontendTypes)) { cancel(pc.red("Operation cancelled")); process.exit(0); } - return response; + const result: ProjectFrontend[] = []; + + if (frontendTypes.includes("web")) { + const webFramework = await select({ + message: "Choose frontend framework", + options: [ + { + value: "tanstack-router", + label: "TanStack Router", + hint: "Modern and scalable routing for React Applications", + }, + { + value: "react-router", + label: "React Router", + hint: "A user‑obsessed, standards‑focused, multi‑strategy router you can deploy anywhere.", + }, + ], + initialValue: + DEFAULT_CONFIG.frontend.find( + (f) => f === "tanstack-router" || f === "react-router", + ) || "tanstack-router", + }); + + if (isCancel(webFramework)) { + cancel(pc.red("Operation cancelled")); + process.exit(0); + } + + result.push(webFramework); + } + + if (frontendTypes.includes("native")) { + result.push("native"); + } + + return result; } diff --git a/apps/cli/src/types.ts b/apps/cli/src/types.ts index fd62bc5..59ff438 100644 --- a/apps/cli/src/types.ts +++ b/apps/cli/src/types.ts @@ -5,7 +5,7 @@ export type ProjectAddons = "pwa" | "biome" | "tauri" | "husky"; export type ProjectBackend = "hono" | "elysia"; export type ProjectRuntime = "node" | "bun"; export type ProjectExamples = "todo" | "ai"; -export type ProjectFrontend = "web" | "native"; +export type ProjectFrontend = "react-router" | "tanstack-router" | "native"; export interface ProjectConfig { projectName: string; diff --git a/apps/cli/template/base/apps/web/_gitignore b/apps/cli/template/base/apps/web-base/_gitignore similarity index 87% rename from apps/cli/template/base/apps/web/_gitignore rename to apps/cli/template/base/apps/web-base/_gitignore index b61b002..5e931e4 100644 --- a/apps/cli/template/base/apps/web/_gitignore +++ b/apps/cli/template/base/apps/web-base/_gitignore @@ -21,3 +21,6 @@ dist/ !.env.example dev-dist + +/.react-router/ +/build/ diff --git a/apps/cli/template/base/apps/web/components.json b/apps/cli/template/base/apps/web-base/components.json similarity index 91% rename from apps/cli/template/base/apps/web/components.json rename to apps/cli/template/base/apps/web-base/components.json index 1d282e6..13e1db0 100644 --- a/apps/cli/template/base/apps/web/components.json +++ b/apps/cli/template/base/apps/web-base/components.json @@ -4,7 +4,7 @@ "rsc": false, "tsx": true, "tailwind": { - "config": "tailwind.config.js", + "config": "", "css": "src/index.css", "baseColor": "neutral", "cssVariables": true, @@ -18,4 +18,4 @@ "hooks": "@/hooks" }, "iconLibrary": "lucide" -} \ No newline at end of file +} diff --git a/apps/cli/template/base/apps/web/src/components/loader.tsx b/apps/cli/template/base/apps/web-base/src/components/loader.tsx similarity index 100% rename from apps/cli/template/base/apps/web/src/components/loader.tsx rename to apps/cli/template/base/apps/web-base/src/components/loader.tsx diff --git a/apps/cli/template/base/apps/web/src/components/mode-toggle.tsx b/apps/cli/template/base/apps/web-base/src/components/mode-toggle.tsx similarity index 96% rename from apps/cli/template/base/apps/web/src/components/mode-toggle.tsx rename to apps/cli/template/base/apps/web-base/src/components/mode-toggle.tsx index ebd44a4..199d09f 100644 --- a/apps/cli/template/base/apps/web/src/components/mode-toggle.tsx +++ b/apps/cli/template/base/apps/web-base/src/components/mode-toggle.tsx @@ -10,7 +10,7 @@ import { import { useTheme } from "@/components/theme-provider"; export function ModeToggle() { - const { setTheme, theme } = useTheme(); + const { setTheme } = useTheme(); return ( diff --git a/apps/cli/template/base/apps/web/src/components/theme-provider.tsx b/apps/cli/template/base/apps/web-base/src/components/theme-provider.tsx similarity index 99% rename from apps/cli/template/base/apps/web/src/components/theme-provider.tsx rename to apps/cli/template/base/apps/web-base/src/components/theme-provider.tsx index e18440d..7b9eeb2 100644 --- a/apps/cli/template/base/apps/web/src/components/theme-provider.tsx +++ b/apps/cli/template/base/apps/web-base/src/components/theme-provider.tsx @@ -27,7 +27,7 @@ export function ThemeProvider({ ...props }: ThemeProviderProps) { const [theme, setTheme] = useState( - () => (localStorage.getItem(storageKey) as Theme) || defaultTheme, + () => (localStorage.getItem(storageKey) as Theme) || defaultTheme ); useEffect(() => { diff --git a/apps/cli/template/base/apps/web-base/src/components/ui/button.tsx b/apps/cli/template/base/apps/web-base/src/components/ui/button.tsx new file mode 100644 index 0000000..66ab90e --- /dev/null +++ b/apps/cli/template/base/apps/web-base/src/components/ui/button.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : "button"; + + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/apps/cli/template/base/apps/web/src/components/ui/card.tsx b/apps/cli/template/base/apps/web-base/src/components/ui/card.tsx similarity index 94% rename from apps/cli/template/base/apps/web/src/components/ui/card.tsx rename to apps/cli/template/base/apps/web-base/src/components/ui/card.tsx index d05bbc6..93a82d9 100644 --- a/apps/cli/template/base/apps/web/src/components/ui/card.tsx +++ b/apps/cli/template/base/apps/web-base/src/components/ui/card.tsx @@ -1,6 +1,6 @@ -import * as React from "react" +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Card({ className, ...props }: React.ComponentProps<"div">) { return ( @@ -12,7 +12,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) { )} {...props} /> - ) + ); } function CardHeader({ className, ...props }: React.ComponentProps<"div">) { @@ -25,7 +25,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) { )} {...props} /> - ) + ); } function CardTitle({ className, ...props }: React.ComponentProps<"div">) { @@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) { className={cn("leading-none font-semibold", className)} {...props} /> - ) + ); } function CardDescription({ className, ...props }: React.ComponentProps<"div">) { @@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) { className={cn("text-muted-foreground text-sm", className)} {...props} /> - ) + ); } function CardAction({ className, ...props }: React.ComponentProps<"div">) { @@ -58,7 +58,7 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) { )} {...props} /> - ) + ); } function CardContent({ className, ...props }: React.ComponentProps<"div">) { @@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) { className={cn("px-6", className)} {...props} /> - ) + ); } function CardFooter({ className, ...props }: React.ComponentProps<"div">) { @@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) { className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} /> - ) + ); } export { @@ -89,4 +89,4 @@ export { CardAction, CardDescription, CardContent, -} +}; diff --git a/apps/cli/template/base/apps/web/src/components/ui/checkbox.tsx b/apps/cli/template/base/apps/web-base/src/components/ui/checkbox.tsx similarity index 83% rename from apps/cli/template/base/apps/web/src/components/ui/checkbox.tsx rename to apps/cli/template/base/apps/web-base/src/components/ui/checkbox.tsx index defeb01..6afea5d 100644 --- a/apps/cli/template/base/apps/web/src/components/ui/checkbox.tsx +++ b/apps/cli/template/base/apps/web-base/src/components/ui/checkbox.tsx @@ -1,8 +1,8 @@ -import * as React from "react" -import * as CheckboxPrimitive from "@radix-ui/react-checkbox" -import { CheckIcon } from "lucide-react" +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { CheckIcon } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Checkbox({ className, @@ -24,7 +24,7 @@ function Checkbox({ - ) + ); } -export { Checkbox } +export { Checkbox }; diff --git a/apps/cli/template/base/apps/web-base/src/components/ui/dropdown-menu.tsx b/apps/cli/template/base/apps/web-base/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..5de13d8 --- /dev/null +++ b/apps/cli/template/base/apps/web-base/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,255 @@ +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/apps/cli/template/base/apps/web-base/src/components/ui/input.tsx b/apps/cli/template/base/apps/web-base/src/components/ui/input.tsx new file mode 100644 index 0000000..0316cc4 --- /dev/null +++ b/apps/cli/template/base/apps/web-base/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/apps/cli/template/base/apps/web-base/src/components/ui/label.tsx b/apps/cli/template/base/apps/web-base/src/components/ui/label.tsx new file mode 100644 index 0000000..747d8eb --- /dev/null +++ b/apps/cli/template/base/apps/web-base/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +"use client"; + +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; + +import { cn } from "@/lib/utils"; + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Label }; diff --git a/apps/cli/template/base/apps/web-base/src/components/ui/skeleton.tsx b/apps/cli/template/base/apps/web-base/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..0168998 --- /dev/null +++ b/apps/cli/template/base/apps/web-base/src/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/lib/utils"; + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Skeleton }; diff --git a/apps/cli/template/base/apps/web-base/src/components/ui/sonner.tsx b/apps/cli/template/base/apps/web-base/src/components/ui/sonner.tsx new file mode 100644 index 0000000..7264637 --- /dev/null +++ b/apps/cli/template/base/apps/web-base/src/components/ui/sonner.tsx @@ -0,0 +1,23 @@ +import { useTheme } from "next-themes"; +import { Toaster as Sonner, type ToasterProps } from "sonner"; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + + ); +}; + +export { Toaster }; diff --git a/apps/cli/template/base/apps/web-base/src/index.css b/apps/cli/template/base/apps/web-base/src/index.css new file mode 100644 index 0000000..1c9e00a --- /dev/null +++ b/apps/cli/template/base/apps/web-base/src/index.css @@ -0,0 +1,134 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme { + --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; +} + +html, +body { + @apply bg-white dark:bg-gray-950; + + @media (prefers-color-scheme: dark) { + color-scheme: dark; + } +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/apps/cli/template/base/apps/web/src/lib/utils.ts b/apps/cli/template/base/apps/web-base/src/lib/utils.ts similarity index 100% rename from apps/cli/template/base/apps/web/src/lib/utils.ts rename to apps/cli/template/base/apps/web-base/src/lib/utils.ts diff --git a/apps/cli/template/base/apps/web/src/utils/trpc.ts b/apps/cli/template/base/apps/web-base/src/utils/trpc.ts similarity index 100% rename from apps/cli/template/base/apps/web/src/utils/trpc.ts rename to apps/cli/template/base/apps/web-base/src/utils/trpc.ts diff --git a/apps/cli/template/base/apps/web-react-router/package.json b/apps/cli/template/base/apps/web-react-router/package.json new file mode 100644 index 0000000..c45c276 --- /dev/null +++ b/apps/cli/template/base/apps/web-react-router/package.json @@ -0,0 +1,49 @@ +{ + "name": "web", + "private": true, + "type": "module", + "scripts": { + "build": "react-router build", + "dev": "react-router dev", + "start": "react-router-serve ./build/server/index.js", + "typecheck": "react-router typegen && tsc" + }, + "dependencies": { + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-slot": "^1.1.2", + "@react-router/fs-routes": "^7.4.1", + "@react-router/node": "^7.4.1", + "@react-router/serve": "^7.4.1", + "@tanstack/react-form": "^1.2.3", + "@tanstack/react-query": "^5.71.3", + "@trpc/client": "^11.0.1", + "@trpc/server": "^11.0.1", + "@trpc/tanstack-react-query": "^11.0.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "isbot": "^5.1.17", + "lucide-react": "^0.487.0", + "next-themes": "^0.4.6", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router": "^7.4.1", + "sonner": "^2.0.3", + "tailwind-merge": "^3.1.0", + "tw-animate-css": "^1.2.5" + }, + "devDependencies": { + "@react-router/dev": "^7.4.1", + "@tailwindcss/vite": "^4.0.0", + "@tanstack/react-query-devtools": "^5.71.3", + "@types/node": "^20", + "@types/react": "^19.0.1", + "@types/react-dom": "^19.0.1", + "react-router-devtools": "^1.1.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.2", + "vite": "^5.4.11", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/apps/cli/template/base/apps/web-react-router/public/favicon.ico b/apps/cli/template/base/apps/web-react-router/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5dbdfcddcb14182535f6d32d1c900681321b1aa3 GIT binary patch literal 15086 zcmeI33v3ic7{|AFEmuJ-;v>ep_G*NPi6KM`qNryCe1PIJ8siIN1WZ(7qVa)RVtmC% z)Ch?tN+afMKm;5@rvorJk zcXnoOc4q51HBQnQH_jn!cAg&XI1?PlX>Kl^k8qq0;zkha`kY$Fxt#=KNJAE9CMdpW zqr4#g8`nTw191(+H4xW8Tmyru2I^3=J1G3emPxkPXA=3{vvuvse_WWSshqaqls^-m zgB7q8&Vk*aYRe?sn$n53dGH#%3y%^vxv{pL*-h0Z4bmb_(k6{FL7HWIz(V*HT#IcS z-wE{)+0x1U!RUPt3gB97%p}@oHxF4|6S*+Yw=_tLtxZ~`S=z6J?O^AfU>7qOX`JNBbV&8+bO0%@fhQitKIJ^O^ zpgIa__qD_y07t@DFlBJ)8SP_#^j{6jpaXt{U%=dx!qu=4u7^21lWEYHPPY5U3TcoQ zX_7W+lvZi>TapNk_X>k-KO%MC9iZp>1E`N34gHKd9tK&){jq2~7OsJ>!G0FzxQFw6G zm&Vb(2#-T|rM|n3>uAsG_hnbvUKFf3#ay@u4uTzia~NY%XgCHfx4^To4BDU@)HlV? z@EN=g^ymETa1sQK{kRwyE4Ax8?wT&GvaG@ASO}{&a17&^v`y z!oPdiSiia^oov(Z)QhG2&|FgE{M9_4hJROGbnj>#$~ZF$-G^|zPj*QApltKe?;u;uKHJ~-V!=VLkg7Kgct)l7u39f@%VG8e3f$N-B zAu3a4%ZGf)r+jPAYCSLt73m_J3}p>}6Tx0j(wg4vvKhP!DzgiWANiE;Ppvp}P2W@m z-VbYn+NXFF?6ngef5CfY6ZwKnWvNV4z6s^~yMXw2i5mv}jC$6$46g?G|CPAu{W5qF zDobS=zb2ILX9D827g*NtGe5w;>frjanY{f)hrBP_2ehBt1?`~ypvg_Ot4x1V+43P@Ve8>qd)9NX_jWdLo`Zfy zoeam9)@Dpym{4m@+LNxXBPjPKA7{3a&H+~xQvr>C_A;7=JrfK~$M2pCh>|xLz>W6SCs4qC|#V`)# z)0C|?$o>jzh<|-cpf

K7osU{Xp5PG4-K+L2G=)c3f&}H&M3wo7TlO_UJjQ-Oq&_ zjAc9=nNIYz{c3zxOiS5UfcE1}8#iI4@uy;$Q7>}u`j+OU0N<*Ezx$k{x_27+{s2Eg z`^=rhtIzCm!_UcJ?Db~Lh-=_))PT3{Q0{Mwdq;0>ZL%l3+;B&4!&xm#%HYAK|;b456Iv&&f$VQHf` z>$*K9w8T+paVwc7fLfMlhQ4)*zL_SG{~v4QR;IuX-(oRtYAhWOlh`NLoX0k$RUYMi z2Y!bqpdN}wz8q`-%>&Le@q|jFw92ErW-hma-le?S z-@OZt2EEUm4wLsuEMkt4zlyy29_3S50JAcQHTtgTC{P~%-mvCTzrjXOc|{}N`Cz`W zSj7CrXfa7lcsU0J(0uSX6G`54t^7}+OLM0n(|g4waOQ}bd3%!XLh?NX9|8G_|06Ie zD5F1)w5I~!et7lA{G^;uf7aqT`KE&2qx9|~O;s6t!gb`+zVLJyT2T)l*8l(j literal 0 HcmV?d00001 diff --git a/apps/cli/template/base/apps/web-react-router/react-router.config.ts b/apps/cli/template/base/apps/web-react-router/react-router.config.ts new file mode 100644 index 0000000..4af1600 --- /dev/null +++ b/apps/cli/template/base/apps/web-react-router/react-router.config.ts @@ -0,0 +1,6 @@ +import type { Config } from "@react-router/dev/config"; + +export default { + ssr: false, + appDirectory: "src", +} satisfies Config; diff --git a/apps/cli/template/base/apps/web-react-router/src/components/header.tsx b/apps/cli/template/base/apps/web-react-router/src/components/header.tsx new file mode 100644 index 0000000..98a48e7 --- /dev/null +++ b/apps/cli/template/base/apps/web-react-router/src/components/header.tsx @@ -0,0 +1,31 @@ +import { NavLink } from "react-router"; +import { ModeToggle } from "./mode-toggle"; + +export default function Header() { + const links = [ + { to: "/", label: "Home" }, + ]; + + return ( +

+
+ +
+ +
+
+
+
+ ); +} diff --git a/apps/cli/template/base/apps/web-react-router/src/root.tsx b/apps/cli/template/base/apps/web-react-router/src/root.tsx new file mode 100644 index 0000000..39a43b2 --- /dev/null +++ b/apps/cli/template/base/apps/web-react-router/src/root.tsx @@ -0,0 +1,91 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { + isRouteErrorResponse, + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "react-router"; +import type { Route } from "./+types/root"; +import "./index.css"; +import Header from "./components/header"; +import { ThemeProvider } from "./components/theme-provider"; +import { queryClient } from "./utils/trpc"; +import { Toaster } from "./components/ui/sonner"; + +export const links: Route.LinksFunction = () => [ + { rel: "preconnect", href: "https://fonts.googleapis.com" }, + { + rel: "preconnect", + href: "https://fonts.gstatic.com", + crossOrigin: "anonymous", + }, + { + rel: "stylesheet", + href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", + }, +]; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +} + +export default function App() { + return ( + + +
+
+ +
+ +
+ +
+ ); +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = "Oops!"; + let details = "An unexpected error occurred."; + let stack: string | undefined; + + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? "404" : "Error"; + details = + error.status === 404 + ? "The requested page could not be found." + : error.statusText || details; + } else if (import.meta.env.DEV && error && error instanceof Error) { + details = error.message; + stack = error.stack; + } + + return ( +
+

{message}

+

{details}

+ {stack && ( +
+          {stack}
+        
+ )} +
+ ); +} diff --git a/apps/cli/template/base/apps/web-react-router/src/routes.ts b/apps/cli/template/base/apps/web-react-router/src/routes.ts new file mode 100644 index 0000000..4c05936 --- /dev/null +++ b/apps/cli/template/base/apps/web-react-router/src/routes.ts @@ -0,0 +1,4 @@ +import { type RouteConfig } from "@react-router/dev/routes"; +import { flatRoutes } from "@react-router/fs-routes"; + +export default flatRoutes() satisfies RouteConfig; diff --git a/apps/cli/template/base/apps/web-react-router/src/routes/_index.tsx b/apps/cli/template/base/apps/web-react-router/src/routes/_index.tsx new file mode 100644 index 0000000..b779085 --- /dev/null +++ b/apps/cli/template/base/apps/web-react-router/src/routes/_index.tsx @@ -0,0 +1,89 @@ +import type { Route } from "./+types/_index"; +import { trpc } from "@/utils/trpc"; +import { useQuery } from "@tanstack/react-query"; + +const TITLE_TEXT = ` + ██████╗ ███████╗████████╗████████╗███████╗██████╗ + ██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗ + ██████╔╝█████╗ ██║ ██║ █████╗ ██████╔╝ + ██╔══██╗██╔══╝ ██║ ██║ ██╔══╝ ██╔══██╗ + ██████╔╝███████╗ ██║ ██║ ███████╗██║ ██║ + ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ + + ████████╗ ███████╗████████╗ █████╗ ██████╗██╗ ██╗ + ╚══██╔══╝ ██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝ + ██║ ███████╗ ██║ ███████║██║ █████╔╝ + ██║ ╚════██║ ██║ ██╔══██║██║ ██╔═██╗ + ██║ ███████║ ██║ ██║ ██║╚██████╗██║ ██╗ + ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ + `; + +export function meta({}: Route.MetaArgs) { + return [{ title: "My App" }, { name: "description", content: "My App" }]; +} + +export default function Home() { + const healthCheck = useQuery(trpc.healthCheck.queryOptions()); + + return ( +
+
{TITLE_TEXT}
+
+
+

API Status

+
+
+ + {healthCheck.isLoading + ? "Checking..." + : healthCheck.data + ? "Connected" + : "Disconnected"} + +
+
+ +
+

Core Features

+
    + + + + +
+
+
+
+ ); +} + +function FeatureItem({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( +
  • +

    {title}

    +

    {description}

    +
  • + ); +} diff --git a/apps/cli/template/base/apps/web-react-router/tsconfig.json b/apps/cli/template/base/apps/web-react-router/tsconfig.json new file mode 100644 index 0000000..61235c3 --- /dev/null +++ b/apps/cli/template/base/apps/web-react-router/tsconfig.json @@ -0,0 +1,27 @@ +{ + "include": [ + "**/*", + "**/.server/**/*", + "**/.client/**/*", + ".react-router/types/**/*" + ], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["node", "vite/client"], + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "rootDirs": [".", "./.react-router/types"], + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true + } +} diff --git a/apps/cli/template/base/apps/web-react-router/vite.config.ts b/apps/cli/template/base/apps/web-react-router/vite.config.ts new file mode 100644 index 0000000..4a88d58 --- /dev/null +++ b/apps/cli/template/base/apps/web-react-router/vite.config.ts @@ -0,0 +1,8 @@ +import { reactRouter } from "@react-router/dev/vite"; +import tailwindcss from "@tailwindcss/vite"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], +}); diff --git a/apps/cli/template/base/apps/web/index.html b/apps/cli/template/base/apps/web-tanstack-router/index.html similarity index 100% rename from apps/cli/template/base/apps/web/index.html rename to apps/cli/template/base/apps/web-tanstack-router/index.html diff --git a/apps/cli/template/base/apps/web/package.json b/apps/cli/template/base/apps/web-tanstack-router/package.json similarity index 95% rename from apps/cli/template/base/apps/web/package.json rename to apps/cli/template/base/apps/web-tanstack-router/package.json index 7acd706..0010c0d 100644 --- a/apps/cli/template/base/apps/web/package.json +++ b/apps/cli/template/base/apps/web-tanstack-router/package.json @@ -39,12 +39,12 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.473.0", - "next-themes": "^0.4.6", + "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", "sonner": "^1.7.4", "tailwind-merge": "^2.6.0", - "tailwindcss-animate": "^1.0.7", + "tw-animate-css": "^1.2.5", "zod": "^3.24.2" } } diff --git a/apps/cli/template/base/apps/web/src/components/header.tsx b/apps/cli/template/base/apps/web-tanstack-router/src/components/header.tsx similarity index 100% rename from apps/cli/template/base/apps/web/src/components/header.tsx rename to apps/cli/template/base/apps/web-tanstack-router/src/components/header.tsx diff --git a/apps/cli/template/base/apps/web/src/main.tsx b/apps/cli/template/base/apps/web-tanstack-router/src/main.tsx similarity index 100% rename from apps/cli/template/base/apps/web/src/main.tsx rename to apps/cli/template/base/apps/web-tanstack-router/src/main.tsx diff --git a/apps/cli/template/base/apps/web/src/routes/__root.tsx b/apps/cli/template/base/apps/web-tanstack-router/src/routes/__root.tsx similarity index 100% rename from apps/cli/template/base/apps/web/src/routes/__root.tsx rename to apps/cli/template/base/apps/web-tanstack-router/src/routes/__root.tsx diff --git a/apps/cli/template/base/apps/web/src/routes/index.tsx b/apps/cli/template/base/apps/web-tanstack-router/src/routes/index.tsx similarity index 95% rename from apps/cli/template/base/apps/web/src/routes/index.tsx rename to apps/cli/template/base/apps/web-tanstack-router/src/routes/index.tsx index a742556..c5a33ee 100644 --- a/apps/cli/template/base/apps/web/src/routes/index.tsx +++ b/apps/cli/template/base/apps/web-tanstack-router/src/routes/index.tsx @@ -1,8 +1,6 @@ -import { Button } from "@/components/ui/button"; import { trpc } from "@/utils/trpc"; import { useQuery } from "@tanstack/react-query"; -import { Link, createFileRoute } from "@tanstack/react-router"; -import { ArrowRight } from "lucide-react"; +import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/")({ component: HomeComponent, @@ -68,7 +66,6 @@ function HomeComponent() { /> -
    ); diff --git a/apps/cli/template/base/apps/web/tsconfig.json b/apps/cli/template/base/apps/web-tanstack-router/tsconfig.json similarity index 100% rename from apps/cli/template/base/apps/web/tsconfig.json rename to apps/cli/template/base/apps/web-tanstack-router/tsconfig.json diff --git a/apps/cli/template/base/apps/web/vite.config.ts b/apps/cli/template/base/apps/web-tanstack-router/vite.config.ts similarity index 100% rename from apps/cli/template/base/apps/web/vite.config.ts rename to apps/cli/template/base/apps/web-tanstack-router/vite.config.ts diff --git a/apps/cli/template/base/apps/web/src/components/ui/button.tsx b/apps/cli/template/base/apps/web/src/components/ui/button.tsx deleted file mode 100644 index 7ce98b3..0000000 --- a/apps/cli/template/base/apps/web/src/components/ui/button.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", - { - variants: { - variant: { - default: - "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90", - destructive: - "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90", - outline: - "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", - secondary: - "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2", - sm: "h-8 rounded-md px-3 text-xs", - lg: "h-10 rounded-md px-8", - icon: "h-9 w-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { - asChild?: boolean -} - -const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button" - return ( - - ) - } -) -Button.displayName = "Button" - -export { Button, buttonVariants } diff --git a/apps/cli/template/base/apps/web/src/components/ui/dropdown-menu.tsx b/apps/cli/template/base/apps/web/src/components/ui/dropdown-menu.tsx deleted file mode 100644 index 0342df4..0000000 --- a/apps/cli/template/base/apps/web/src/components/ui/dropdown-menu.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import * as React from "react" -import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" -import { Check, ChevronRight, Circle } from "lucide-react" - -import { cn } from "@/lib/utils" - -const DropdownMenu = DropdownMenuPrimitive.Root - -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger - -const DropdownMenuGroup = DropdownMenuPrimitive.Group - -const DropdownMenuPortal = DropdownMenuPrimitive.Portal - -const DropdownMenuSub = DropdownMenuPrimitive.Sub - -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup - -const DropdownMenuSubTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean - } ->(({ className, inset, children, ...props }, ref) => ( - - {children} - - -)) -DropdownMenuSubTrigger.displayName = - DropdownMenuPrimitive.SubTrigger.displayName - -const DropdownMenuSubContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DropdownMenuSubContent.displayName = - DropdownMenuPrimitive.SubContent.displayName - -const DropdownMenuContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - - - -)) -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName - -const DropdownMenuItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean - } ->(({ className, inset, ...props }, ref) => ( - svg]:size-4 [&>svg]:shrink-0", - inset && "pl-8", - className - )} - {...props} - /> -)) -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName - -const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, checked, ...props }, ref) => ( - - - - - - - {children} - -)) -DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName - -const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - - - - {children} - -)) -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName - -const DropdownMenuLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean - } ->(({ className, inset, ...props }, ref) => ( - -)) -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName - -const DropdownMenuSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName - -const DropdownMenuShortcut = ({ - className, - ...props -}: React.HTMLAttributes) => { - return ( - - ) -} -DropdownMenuShortcut.displayName = "DropdownMenuShortcut" - -export { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuRadioGroup, -} diff --git a/apps/cli/template/base/apps/web/src/components/ui/input.tsx b/apps/cli/template/base/apps/web/src/components/ui/input.tsx deleted file mode 100644 index 6f3073a..0000000 --- a/apps/cli/template/base/apps/web/src/components/ui/input.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import * as React from "react" - -import { cn } from "@/lib/utils" - -const Input = React.forwardRef>( - ({ className, type, ...props }, ref) => { - return ( - - ) - } -) -Input.displayName = "Input" - -export { Input } diff --git a/apps/cli/template/base/apps/web/src/components/ui/label.tsx b/apps/cli/template/base/apps/web/src/components/ui/label.tsx deleted file mode 100644 index 683faa7..0000000 --- a/apps/cli/template/base/apps/web/src/components/ui/label.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from "react" -import * as LabelPrimitive from "@radix-ui/react-label" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const labelVariants = cva( - "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" -) - -const Label = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & - VariantProps ->(({ className, ...props }, ref) => ( - -)) -Label.displayName = LabelPrimitive.Root.displayName - -export { Label } diff --git a/apps/cli/template/base/apps/web/src/components/ui/skeleton.tsx b/apps/cli/template/base/apps/web/src/components/ui/skeleton.tsx deleted file mode 100644 index d7e45f7..0000000 --- a/apps/cli/template/base/apps/web/src/components/ui/skeleton.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { cn } from "@/lib/utils" - -function Skeleton({ - className, - ...props -}: React.HTMLAttributes) { - return ( -
    - ) -} - -export { Skeleton } diff --git a/apps/cli/template/base/apps/web/src/components/ui/sonner.tsx b/apps/cli/template/base/apps/web/src/components/ui/sonner.tsx deleted file mode 100644 index 1128edf..0000000 --- a/apps/cli/template/base/apps/web/src/components/ui/sonner.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useTheme } from "next-themes" -import { Toaster as Sonner } from "sonner" - -type ToasterProps = React.ComponentProps - -const Toaster = ({ ...props }: ToasterProps) => { - const { theme = "system" } = useTheme() - - return ( - - ) -} - -export { Toaster } diff --git a/apps/cli/template/base/apps/web/src/index.css b/apps/cli/template/base/apps/web/src/index.css deleted file mode 100644 index 334e9db..0000000 --- a/apps/cli/template/base/apps/web/src/index.css +++ /dev/null @@ -1,119 +0,0 @@ -@import 'tailwindcss'; - -@plugin 'tailwindcss-animate'; - -@custom-variant dark (&:is(.dark *)); - -@theme { - --radius-lg: var(--radius); - --radius-md: calc(var(--radius) - 2px); - --radius-sm: calc(var(--radius) - 4px); - - --color-background: hsl(var(--background)); - --color-foreground: hsl(var(--foreground)); - - --color-card: hsl(var(--card)); - --color-card-foreground: hsl(var(--card-foreground)); - - --color-popover: hsl(var(--popover)); - --color-popover-foreground: hsl(var(--popover-foreground)); - - --color-primary: hsl(var(--primary)); - --color-primary-foreground: hsl(var(--primary-foreground)); - - --color-secondary: hsl(var(--secondary)); - --color-secondary-foreground: hsl(var(--secondary-foreground)); - - --color-muted: hsl(var(--muted)); - --color-muted-foreground: hsl(var(--muted-foreground)); - - --color-accent: hsl(var(--accent)); - --color-accent-foreground: hsl(var(--accent-foreground)); - - --color-destructive: hsl(var(--destructive)); - --color-destructive-foreground: hsl(var(--destructive-foreground)); - - --color-border: hsl(var(--border)); - --color-input: hsl(var(--input)); - --color-ring: hsl(var(--ring)); - - --color-chart-1: hsl(var(--chart-1)); - --color-chart-2: hsl(var(--chart-2)); - --color-chart-3: hsl(var(--chart-3)); - --color-chart-4: hsl(var(--chart-4)); - --color-chart-5: hsl(var(--chart-5)); -} - -@layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentColor); - } -} - -@layer base { - :root { - --background: 0 0% 100%; - --foreground: 0 0% 3.9%; - --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 0 0% 9%; - --primary-foreground: 0 0% 98%; - --secondary: 0 0% 96.1%; - --secondary-foreground: 0 0% 9%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 0 0% 3.9%; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - --radius: 0.5rem; - } - .dark { - --background: 0 0% 3.9%; - --foreground: 0 0% 98%; - --card: 0 0% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 0 0% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; - --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 14.9%; - --input: 0 0% 14.9%; - --ring: 0 0% 83.1%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - } -} -@layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } -} diff --git a/apps/cli/template/examples/ai/apps/web-react-router/src/routes/ai.tsx b/apps/cli/template/examples/ai/apps/web-react-router/src/routes/ai.tsx new file mode 100644 index 0000000..e5b8995 --- /dev/null +++ b/apps/cli/template/examples/ai/apps/web-react-router/src/routes/ai.tsx @@ -0,0 +1,64 @@ +import { useChat } from "@ai-sdk/react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Send } from "lucide-react"; +import { useRef, useEffect } from "react"; + +export default function AI() { + const { messages, input, handleInputChange, handleSubmit } = useChat({ + api: `${import.meta.env.VITE_SERVER_URL}/ai`, + }); + + const messagesEndRef = useRef(null); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + return ( +
    +
    + {messages.length === 0 ? ( +
    + Ask me anything to get started! +
    + ) : ( + messages.map((message) => ( +
    +

    + {message.role === "user" ? "You" : "AI Assistant"} +

    +
    {message.content}
    +
    + )) + )} +
    +
    + +
    + + +
    +
    + ); +} diff --git a/apps/cli/template/examples/ai/apps/web/src/routes/ai.tsx b/apps/cli/template/examples/ai/apps/web-tanstack-router/src/routes/ai.tsx similarity index 100% rename from apps/cli/template/examples/ai/apps/web/src/routes/ai.tsx rename to apps/cli/template/examples/ai/apps/web-tanstack-router/src/routes/ai.tsx diff --git a/apps/cli/template/examples/todo/apps/web-react-router/src/routes/todos.tsx b/apps/cli/template/examples/todo/apps/web-react-router/src/routes/todos.tsx new file mode 100644 index 0000000..d1b22e9 --- /dev/null +++ b/apps/cli/template/examples/todo/apps/web-react-router/src/routes/todos.tsx @@ -0,0 +1,130 @@ +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { trpc } from "@/utils/trpc"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Loader2, Trash2 } from "lucide-react"; +import { useState } from "react"; + +export default function Todos() { + const [newTodoText, setNewTodoText] = useState(""); + + const todos = useQuery(trpc.todo.getAll.queryOptions()); + const createMutation = useMutation( + trpc.todo.create.mutationOptions({ + onSuccess: () => { + todos.refetch(); + setNewTodoText(""); + }, + }) + ); + const toggleMutation = useMutation( + trpc.todo.toggle.mutationOptions({ + onSuccess: () => todos.refetch(), + }) + ); + const deleteMutation = useMutation( + trpc.todo.delete.mutationOptions({ + onSuccess: () => todos.refetch(), + }) + ); + + const handleAddTodo = (e: React.FormEvent) => { + e.preventDefault(); + if (newTodoText.trim()) { + createMutation.mutate({ text: newTodoText }); + } + }; + + const handleToggleTodo = (id: number, completed: boolean) => { + toggleMutation.mutate({ id, completed: !completed }); + }; + + const handleDeleteTodo = (id: number) => { + deleteMutation.mutate({ id }); + }; + + return ( +
    + + + Todo List + Manage your tasks efficiently + + +
    + setNewTodoText(e.target.value)} + placeholder="Add a new task..." + disabled={createMutation.isPending} + /> + +
    + + {todos.isLoading ? ( +
    + +
    + ) : todos.data?.length === 0 ? ( +

    + No todos yet. Add one above! +

    + ) : ( +
      + {todos.data?.map((todo) => ( +
    • +
      + + handleToggleTodo(todo.id, todo.completed) + } + id={`todo-${todo.id}`} + /> + +
      + +
    • + ))} +
    + )} +
    +
    +
    + ); +} diff --git a/apps/cli/template/examples/todo/apps/web/src/routes/todos.tsx b/apps/cli/template/examples/todo/apps/web-tanstack-router/src/routes/todos.tsx similarity index 92% rename from apps/cli/template/examples/todo/apps/web/src/routes/todos.tsx rename to apps/cli/template/examples/todo/apps/web-tanstack-router/src/routes/todos.tsx index 43ea6b5..8265414 100644 --- a/apps/cli/template/examples/todo/apps/web/src/routes/todos.tsx +++ b/apps/cli/template/examples/todo/apps/web-tanstack-router/src/routes/todos.tsx @@ -28,17 +28,17 @@ function TodosRoute() { todos.refetch(); setNewTodoText(""); }, - }) + }), ); const toggleMutation = useMutation( trpc.todo.toggle.mutationOptions({ onSuccess: () => todos.refetch(), - }) + }), ); const deleteMutation = useMutation( trpc.todo.delete.mutationOptions({ onSuccess: () => todos.refetch(), - }) + }), ); const handleAddTodo = (e: React.FormEvent) => { @@ -57,7 +57,7 @@ function TodosRoute() { }; return ( -
    +
    Todo List @@ -91,9 +91,7 @@ function TodosRoute() {
    ) : todos.data?.length === 0 ? ( -

    - No todos yet. Add one above! -

    +

    No todos yet. Add one above!

    ) : (
      {todos.data?.map((todo) => ( @@ -111,7 +109,7 @@ function TodosRoute() { /> diff --git a/apps/cli/template/with-auth/apps/web/src/lib/auth-client.ts b/apps/cli/template/with-auth/apps/web-base/src/lib/auth-client.ts similarity index 100% rename from apps/cli/template/with-auth/apps/web/src/lib/auth-client.ts rename to apps/cli/template/with-auth/apps/web-base/src/lib/auth-client.ts diff --git a/apps/cli/template/with-auth/apps/web/src/utils/trpc.ts b/apps/cli/template/with-auth/apps/web-base/src/utils/trpc.ts similarity index 100% rename from apps/cli/template/with-auth/apps/web/src/utils/trpc.ts rename to apps/cli/template/with-auth/apps/web-base/src/utils/trpc.ts diff --git a/apps/cli/template/with-auth/apps/web-react-router/src/components/header.tsx b/apps/cli/template/with-auth/apps/web-react-router/src/components/header.tsx new file mode 100644 index 0000000..6e0c10d --- /dev/null +++ b/apps/cli/template/with-auth/apps/web-react-router/src/components/header.tsx @@ -0,0 +1,34 @@ +import { NavLink } from "react-router"; +import { ModeToggle } from "./mode-toggle"; +import UserMenu from "./user-menu"; + +export default function Header() { + const links = [ + { to: "/", label: "Home" }, + { to: "/dashboard", label: "Dashboard" }, + ]; + + return ( +
      +
      + +
      + + +
      +
      +
      +
      + ); +} diff --git a/apps/cli/template/with-auth/apps/web-react-router/src/components/sign-in-form.tsx b/apps/cli/template/with-auth/apps/web-react-router/src/components/sign-in-form.tsx new file mode 100644 index 0000000..643cfb1 --- /dev/null +++ b/apps/cli/template/with-auth/apps/web-react-router/src/components/sign-in-form.tsx @@ -0,0 +1,135 @@ +import { authClient } from "@/lib/auth-client"; +import { useForm } from "@tanstack/react-form"; +import { useNavigate } from "react-router"; +import { toast } from "sonner"; +import { z } from "zod"; +import Loader from "./loader"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; + +export default function SignInForm({ + onSwitchToSignUp, +}: { + onSwitchToSignUp: () => void; +}) { + const navigate = useNavigate(); + const { isPending } = authClient.useSession(); + + const form = useForm({ + defaultValues: { + email: "", + password: "", + }, + onSubmit: async ({ value }) => { + await authClient.signIn.email( + { + email: value.email, + password: value.password, + }, + { + onSuccess: () => { + navigate("/dashboard"); + toast.success("Sign in successful"); + }, + onError: (error) => { + toast.error(error.error.message); + }, + } + ); + }, + validators: { + onSubmit: z.object({ + email: z.string().email("Invalid email address"), + password: z.string().min(6, "Password must be at least 6 characters"), + }), + }, + }); + + if (isPending) { + return ; + } + + return ( +
      +

      Welcome Back

      + +
      { + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }} + className="space-y-4" + > +
      + + {(field) => ( +
      + + field.handleChange(e.target.value)} + /> + {field.state.meta.errors.map((error) => ( +

      + {error?.message} +

      + ))} +
      + )} +
      +
      + +
      + + {(field) => ( +
      + + field.handleChange(e.target.value)} + /> + {field.state.meta.errors.map((error) => ( +

      + {error?.message} +

      + ))} +
      + )} +
      +
      + + + {(state) => ( + + )} + +
      + +
      + +
      +
      + ); +} diff --git a/apps/cli/template/with-auth/apps/web-react-router/src/components/sign-up-form.tsx b/apps/cli/template/with-auth/apps/web-react-router/src/components/sign-up-form.tsx new file mode 100644 index 0000000..1d55361 --- /dev/null +++ b/apps/cli/template/with-auth/apps/web-react-router/src/components/sign-up-form.tsx @@ -0,0 +1,160 @@ +import { authClient } from "@/lib/auth-client"; +import { useForm } from "@tanstack/react-form"; +import { useNavigate } from "react-router"; +import { toast } from "sonner"; +import { z } from "zod"; +import Loader from "./loader"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; + +export default function SignUpForm({ + onSwitchToSignIn, +}: { + onSwitchToSignIn: () => void; +}) { + const navigate = useNavigate(); + const { isPending } = authClient.useSession(); + + const form = useForm({ + defaultValues: { + email: "", + password: "", + name: "", + }, + onSubmit: async ({ value }) => { + await authClient.signUp.email( + { + email: value.email, + password: value.password, + name: value.name, + }, + { + onSuccess: () => { + navigate("/dashboard"); + toast.success("Sign up successful"); + }, + onError: (error) => { + toast.error(error.error.message); + }, + } + ); + }, + validators: { + onSubmit: z.object({ + name: z.string().min(2, "Name must be at least 2 characters"), + email: z.string().email("Invalid email address"), + password: z.string().min(6, "Password must be at least 6 characters"), + }), + }, + }); + + if (isPending) { + return ; + } + + return ( +
      +

      Create Account

      + +
      { + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }} + className="space-y-4" + > +
      + + {(field) => ( +
      + + field.handleChange(e.target.value)} + /> + {field.state.meta.errors.map((error) => ( +

      + {error?.message} +

      + ))} +
      + )} +
      +
      + +
      + + {(field) => ( +
      + + field.handleChange(e.target.value)} + /> + {field.state.meta.errors.map((error) => ( +

      + {error?.message} +

      + ))} +
      + )} +
      +
      + +
      + + {(field) => ( +
      + + field.handleChange(e.target.value)} + /> + {field.state.meta.errors.map((error) => ( +

      + {error?.message} +

      + ))} +
      + )} +
      +
      + + + {(state) => ( + + )} + +
      + +
      + +
      +
      + ); +} diff --git a/apps/cli/template/with-auth/apps/web-react-router/src/components/user-menu.tsx b/apps/cli/template/with-auth/apps/web-react-router/src/components/user-menu.tsx new file mode 100644 index 0000000..2fef376 --- /dev/null +++ b/apps/cli/template/with-auth/apps/web-react-router/src/components/user-menu.tsx @@ -0,0 +1,60 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { authClient } from "@/lib/auth-client"; +import { useNavigate } from "react-router"; +import { Button } from "./ui/button"; +import { Skeleton } from "./ui/skeleton"; +import { Link } from "react-router"; + +export default function UserMenu() { + const navigate = useNavigate(); + const { data: session, isPending } = authClient.useSession(); + + if (isPending) { + return ; + } + + if (!session) { + return ( + + ); + } + + return ( + + + + + + My Account + + {session.user.email} + + + + + + ); +} diff --git a/apps/cli/template/with-auth/apps/web-react-router/src/routes/dashboard.tsx b/apps/cli/template/with-auth/apps/web-react-router/src/routes/dashboard.tsx new file mode 100644 index 0000000..b46efc1 --- /dev/null +++ b/apps/cli/template/with-auth/apps/web-react-router/src/routes/dashboard.tsx @@ -0,0 +1,30 @@ +import { authClient } from "@/lib/auth-client"; +import { trpc } from "@/utils/trpc"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { useNavigate } from "react-router"; + +export default function Dashboard() { + const { data: session, isPending } = authClient.useSession(); + const navigate = useNavigate(); + + const privateData = useQuery(trpc.privateData.queryOptions()); + + useEffect(() => { + if (!session && !isPending) { + navigate("/login"); + } + }, [session, isPending]); + + if (isPending) { + return
      Loading...
      ; + } + + return ( +
      +

      Dashboard

      +

      Welcome {session?.user.name}

      +

      privateData: {privateData.data?.message}

      +
      + ); +} diff --git a/apps/cli/template/with-auth/apps/web-react-router/src/routes/login.tsx b/apps/cli/template/with-auth/apps/web-react-router/src/routes/login.tsx new file mode 100644 index 0000000..d578764 --- /dev/null +++ b/apps/cli/template/with-auth/apps/web-react-router/src/routes/login.tsx @@ -0,0 +1,13 @@ +import SignInForm from "@/components/sign-in-form"; +import SignUpForm from "@/components/sign-up-form"; +import { useState } from "react"; + +export default function Login() { + const [showSignIn, setShowSignIn] = useState(false); + + return showSignIn ? ( + setShowSignIn(false)} /> + ) : ( + setShowSignIn(true)} /> + ); +} diff --git a/apps/cli/template/with-auth/apps/web/src/components/header.tsx b/apps/cli/template/with-auth/apps/web-tanstack/src/components/header.tsx similarity index 100% rename from apps/cli/template/with-auth/apps/web/src/components/header.tsx rename to apps/cli/template/with-auth/apps/web-tanstack/src/components/header.tsx diff --git a/apps/cli/template/with-auth/apps/web/src/components/sign-in-form.tsx b/apps/cli/template/with-auth/apps/web-tanstack/src/components/sign-in-form.tsx similarity index 100% rename from apps/cli/template/with-auth/apps/web/src/components/sign-in-form.tsx rename to apps/cli/template/with-auth/apps/web-tanstack/src/components/sign-in-form.tsx diff --git a/apps/cli/template/with-auth/apps/web/src/components/sign-up-form.tsx b/apps/cli/template/with-auth/apps/web-tanstack/src/components/sign-up-form.tsx similarity index 100% rename from apps/cli/template/with-auth/apps/web/src/components/sign-up-form.tsx rename to apps/cli/template/with-auth/apps/web-tanstack/src/components/sign-up-form.tsx diff --git a/apps/cli/template/with-auth/apps/web/src/components/user-menu.tsx b/apps/cli/template/with-auth/apps/web-tanstack/src/components/user-menu.tsx similarity index 100% rename from apps/cli/template/with-auth/apps/web/src/components/user-menu.tsx rename to apps/cli/template/with-auth/apps/web-tanstack/src/components/user-menu.tsx diff --git a/apps/cli/template/with-auth/apps/web/src/routes/dashboard.tsx b/apps/cli/template/with-auth/apps/web-tanstack/src/routes/dashboard.tsx similarity index 100% rename from apps/cli/template/with-auth/apps/web/src/routes/dashboard.tsx rename to apps/cli/template/with-auth/apps/web-tanstack/src/routes/dashboard.tsx diff --git a/apps/cli/template/with-auth/apps/web/src/routes/login.tsx b/apps/cli/template/with-auth/apps/web-tanstack/src/routes/login.tsx similarity index 100% rename from apps/cli/template/with-auth/apps/web/src/routes/login.tsx rename to apps/cli/template/with-auth/apps/web-tanstack/src/routes/login.tsx diff --git a/apps/cli/template/with-pwa/apps/web/vite.config.ts b/apps/cli/template/with-pwa/apps/web/vite.config.ts deleted file mode 100644 index b196ecf..0000000 --- a/apps/cli/template/with-pwa/apps/web/vite.config.ts +++ /dev/null @@ -1,35 +0,0 @@ -import tailwindcss from "@tailwindcss/vite"; -import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; -import react from "@vitejs/plugin-react"; -import path from "node:path"; -import { defineConfig } from "vite"; -import { VitePWA } from "vite-plugin-pwa"; - -export default defineConfig({ - plugins: [ - tailwindcss(), - TanStackRouterVite({}), - react(), - VitePWA({ - registerType: "autoUpdate", - manifest: { - name: "My App", - short_name: "My App ", - description: "My App", - theme_color: "#0c0c0c", - }, - pwaAssets: { - disabled: false, - config: true, - }, - devOptions: { - enabled: true, - }, - }), - ], - resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), - }, - }, -}); diff --git a/apps/web/src/app/(home)/_components/Navbar.tsx b/apps/web/src/app/(home)/_components/Navbar.tsx index b332efa..debc942 100644 --- a/apps/web/src/app/(home)/_components/Navbar.tsx +++ b/apps/web/src/app/(home)/_components/Navbar.tsx @@ -66,7 +66,7 @@ const Navbar = () => {
    -
    +
    { const TECH_OPTIONS = { frontend: [ { - id: "web", - name: "React Web", - description: "React with TanStack Router", + id: "tanstack-router", + name: "TanStack Router", + description: "Modern type-safe router for React", icon: "🌐", color: "from-blue-400 to-blue-600", default: true, }, + { + id: "react-router", + name: "React Router", + description: "Declarative routing for React", + icon: "🧭", + color: "from-cyan-400 to-cyan-600", + default: false, + }, { id: "native", name: "React Native", @@ -303,7 +311,7 @@ interface StackState { const DEFAULT_STACK: StackState = { projectName: "my-better-t-app", - frontend: ["web"], + frontend: ["tanstack-router"], runtime: "bun", backendFramework: "hono", database: "sqlite", @@ -330,7 +338,10 @@ const StackArchitect = () => { ); useEffect(() => { - if (!stack.frontend.includes("web") && stack.auth === "true") { + const hasWebFrontend = + stack.frontend.includes("tanstack-router") || + stack.frontend.includes("react-router"); + if (!hasWebFrontend && stack.auth === "true") { setStack((prev) => ({ ...prev, auth: "false", @@ -343,16 +354,21 @@ const StackArchitect = () => { setCommand(cmd); const notes: Record = {}; + const hasWebFrontend = + stack.frontend.includes("tanstack-router") || + stack.frontend.includes("react-router"); notes.frontend = []; notes.auth = []; - if (!stack.frontend.includes("web") && stack.auth === "true") { - notes.auth.push("Authentication is only available with React Web."); + if (!hasWebFrontend && stack.auth === "true") { + notes.auth.push( + "Authentication is only available with React Web (TanStack Router or React Router).", + ); } notes.addons = []; - if (!stack.frontend.includes("web")) { + if (!hasWebFrontend) { notes.addons.push("PWA and Tauri are only available with React Web."); } @@ -373,7 +389,7 @@ const StackArchitect = () => { } notes.examples = []; - if (!stack.frontend.includes("web")) { + if (!hasWebFrontend) { notes.examples.push( "Todo and Ai example are only available with React Web.", ); @@ -398,7 +414,10 @@ const StackArchitect = () => { if (stackState.frontend.length === 1 && stackState.frontend[0] === "none") { flags.push("--frontend none"); } else if ( - !(stackState.frontend.length === 1 && stackState.frontend[0] === "web") + !( + stackState.frontend.length === 1 && + stackState.frontend[0] === "tanstack-router" + ) ) { flags.push(`--frontend ${stackState.frontend.join(" ")}`); } @@ -455,6 +474,7 @@ const StackArchitect = () => { setStack((prev) => { if (category === "frontend") { const currentSelection = [...prev.frontend]; + const webTypes = ["tanstack-router", "react-router"]; if (techId === "none") { return { @@ -470,50 +490,81 @@ const StackArchitect = () => { }; } - if (currentSelection.includes(techId)) { - if (currentSelection.length === 1) { + // Handle web router types (tanstack-router or react-router) + if (webTypes.includes(techId)) { + // If clicking on an already selected web router, do nothing + if ( + currentSelection.includes(techId) && + currentSelection.length === 1 + ) { return prev; } - const newFrontend = currentSelection.filter((id) => id !== techId); - - if (techId === "web") { + // If selecting a web router while another one is active, replace it + if (currentSelection.some((id) => webTypes.includes(id))) { + const nonWebSelections = currentSelection.filter( + (id) => !webTypes.includes(id), + ); return { ...prev, - frontend: newFrontend, - auth: "false", - examples: prev.examples.filter( - (ex) => ex !== "todo" && ex !== "ai", - ), - addons: prev.addons.filter( - (addon) => addon !== "pwa" && addon !== "tauri", - ), + frontend: [...nonWebSelections, techId], + auth: prev.auth, // Keep existing auth setting + }; + } + + // If no web router was selected before + if (currentSelection.includes("none")) { + return { + ...prev, + frontend: [techId], + auth: "true", }; } return { ...prev, - frontend: newFrontend, + frontend: [ + ...currentSelection.filter((id) => id !== "none"), + techId, + ], + auth: "true", }; } - if (currentSelection.includes("none")) { + // Handle native selection + if (techId === "native") { + if (currentSelection.includes(techId)) { + if (currentSelection.length === 1) { + return prev; // Don't allow removing the last frontend + } + return { + ...prev, + frontend: currentSelection.filter((id) => id !== techId), + }; + } + + if (currentSelection.includes("none")) { + return { + ...prev, + frontend: [techId], + }; + } + return { ...prev, - frontend: [techId], - ...(techId === "web" && { auth: "true" }), + frontend: [...currentSelection, techId], }; } - return { - ...prev, - frontend: [...currentSelection, techId], - ...(techId === "web" && { auth: "true" }), - }; + + return prev; } if (category === "addons" || category === "examples") { const currentArray = [...(prev[category] || [])]; const index = currentArray.indexOf(techId); + const hasWebFrontend = + prev.frontend.includes("tanstack-router") || + prev.frontend.includes("react-router"); if (index >= 0) { currentArray.splice(index, 1); @@ -521,14 +572,14 @@ const StackArchitect = () => { if ( category === "examples" && techId === "todo" && - !prev.frontend.includes("web") + !hasWebFrontend ) { return prev; } if ( category === "addons" && (techId === "pwa" || techId === "tauri") && - !prev.frontend.includes("web") + !hasWebFrontend ) { return prev; } @@ -691,31 +742,32 @@ const StackArchitect = () => { stack[activeTab as keyof StackState] === tech.id; } + const hasWebFrontend = + stack.frontend.includes("tanstack-router") || + stack.frontend.includes("react-router"); const isDisabled = (activeTab === "orm" && stack.database === "none") || (activeTab === "turso" && stack.database !== "sqlite") || - (activeTab === "auth" && !stack.frontend.includes("web")) || + (activeTab === "auth" && !hasWebFrontend) || (activeTab === "examples" && - ((tech.id === "todo" && - !stack.frontend.includes("web")) || - (tech.id === "ai" && - !stack.frontend.includes("web")))) || + ((tech.id === "todo" && !hasWebFrontend) || + (tech.id === "ai" && !hasWebFrontend))) || (activeTab === "addons" && (tech.id === "pwa" || tech.id === "tauri") && - !stack.frontend.includes("web")); + !hasWebFrontend); return ( @@ -873,13 +925,13 @@ const StackArchitect = () => { type="button" key={category} className={` - py-2 px-4 text-xs font-mono whitespace-nowrap transition-colors - ${ - activeTab === category - ? "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border-t-2 border-blue-500" - : "text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-800" - } - `} + py-2 px-4 text-xs font-mono whitespace-nowrap transition-colors + ${ + activeTab === category + ? "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border-t-2 border-blue-500" + : "text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-800" + } + `} onClick={() => setActiveTab(category)} > {category}