diff --git a/.changeset/few-walls-prove.md b/.changeset/few-walls-prove.md new file mode 100644 index 0000000..2581508 --- /dev/null +++ b/.changeset/few-walls-prove.md @@ -0,0 +1,5 @@ +--- +"create-better-t-stack": minor +--- + +Add Better Auth in Native diff --git a/apps/cli/package.json b/apps/cli/package.json index 7821c78..da1acf1 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -7,10 +7,7 @@ "bin": { "create-better-t-stack": "dist/index.js" }, - "files": [ - "template", - "dist" - ], + "files": ["template", "dist"], "keywords": [ "typescript", "scaffold", diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts index 0f55cb4..52f300e 100644 --- a/apps/cli/src/constants.ts +++ b/apps/cli/src/constants.ts @@ -26,6 +26,7 @@ export const DEFAULT_CONFIG: ProjectConfig = { export const dependencyVersionMap = { "better-auth": "^1.2.4", + "@better-auth/expo": "^1.2.5", "drizzle-orm": "^0.38.4", "drizzle-kit": "^0.30.5", diff --git a/apps/cli/src/helpers/auth-setup.ts b/apps/cli/src/helpers/auth-setup.ts index 0de0215..b20d8dc 100644 --- a/apps/cli/src/helpers/auth-setup.ts +++ b/apps/cli/src/helpers/auth-setup.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { log } from "@clack/prompts"; import pc from "picocolors"; +import type { ProjectFrontend } from "../types"; import { addPackageDependency } from "../utils/add-package-deps"; export function generateAuthSecret(length = 32): string { @@ -17,6 +18,7 @@ export function generateAuthSecret(length = 32): string { export async function setupAuth( projectDir: string, enableAuth: boolean, + frontends: ProjectFrontend[] = [], ): Promise { if (!enableAuth) { return; @@ -24,16 +26,33 @@ export async function setupAuth( const serverDir = path.join(projectDir, "apps/server"); const clientDir = path.join(projectDir, "apps/web"); + const nativeDir = path.join(projectDir, "apps/native"); try { - addPackageDependency({ - dependencies: ["better-auth"], - projectDir: serverDir, - }); - addPackageDependency({ - dependencies: ["better-auth"], - projectDir: clientDir, - }); + if ( + frontends.includes("react-router") || + frontends.includes("tanstack-router") + ) { + addPackageDependency({ + dependencies: ["better-auth"], + projectDir: serverDir, + }); + addPackageDependency({ + dependencies: ["better-auth"], + projectDir: clientDir, + }); + } + + if (frontends.includes("native")) { + addPackageDependency({ + dependencies: ["better-auth", "@better-auth/expo"], + projectDir: nativeDir, + }); + addPackageDependency({ + dependencies: ["better-auth", "@better-auth/expo"], + projectDir: serverDir, + }); + } } catch (error) { log.error(pc.red("Failed to configure authentication")); if (error instanceof Error) { diff --git a/apps/cli/src/helpers/create-project.ts b/apps/cli/src/helpers/create-project.ts index 179b501..61e2193 100644 --- a/apps/cli/src/helpers/create-project.ts +++ b/apps/cli/src/helpers/create-project.ts @@ -66,7 +66,7 @@ export async function createProject(options: ProjectConfig): Promise { options.database, options.frontend, ); - await setupAuth(projectDir, options.auth); + await setupAuth(projectDir, options.auth, options.frontend); await setupRuntime(projectDir, options.runtime, options.backend); diff --git a/apps/cli/src/helpers/template-manager.ts b/apps/cli/src/helpers/template-manager.ts index d80641e..cfba254 100644 --- a/apps/cli/src/helpers/template-manager.ts +++ b/apps/cli/src/helpers/template-manager.ts @@ -7,6 +7,7 @@ import type { ProjectFrontend, ProjectOrm, } from "../types"; +import { addPackageDependency } from "../utils/add-package-deps"; /** * Copy base template structure but exclude app-specific folders that will be added based on options @@ -233,10 +234,83 @@ export async function setupAuthTemplate( if (await fs.pathExists(nativeAuthDir)) { await fs.copy(nativeAuthDir, projectNativeDir, { overwrite: true }); } + + addPackageDependency({ + dependencies: ["@better-auth/expo"], + projectDir: path.join(projectDir, "apps/server"), + }); + + await updateAuthConfigWithExpoPlugin(projectDir, orm, database); } } } +// Need to find a better way to handle this +async function updateAuthConfigWithExpoPlugin( + projectDir: string, + orm: ProjectOrm, + database: ProjectDatabase, +): Promise { + const serverDir = path.join(projectDir, "apps/server"); + + let authFilePath: string | undefined; + if (orm === "drizzle") { + if (database === "sqlite") { + authFilePath = path.join(serverDir, "src/lib/auth.ts"); + } else if (database === "postgres") { + authFilePath = path.join(serverDir, "src/lib/auth.ts"); + } + } else if (orm === "prisma") { + if (database === "sqlite") { + authFilePath = path.join(serverDir, "src/lib/auth.ts"); + } else if (database === "postgres") { + authFilePath = path.join(serverDir, "src/lib/auth.ts"); + } + } + + if (authFilePath && (await fs.pathExists(authFilePath))) { + let authFileContent = await fs.readFile(authFilePath, "utf8"); + + if (!authFileContent.includes("@better-auth/expo")) { + const importLine = 'import { expo } from "@better-auth/expo";\n'; + + const lastImportIndex = authFileContent.lastIndexOf("import"); + const afterLastImport = + authFileContent.indexOf("\n", lastImportIndex) + 1; + + authFileContent = + authFileContent.substring(0, afterLastImport) + + importLine + + authFileContent.substring(afterLastImport); + } + + if (!authFileContent.includes("plugins:")) { + authFileContent = authFileContent.replace( + /}\);/, + " plugins: [expo()],\n});", + ); + } else if (!authFileContent.includes("expo()")) { + authFileContent = authFileContent.replace( + /plugins: \[(.*?)\]/s, + (match, plugins) => { + return `plugins: [${plugins}${plugins.trim() ? ", " : ""}expo()]`; + }, + ); + } + + if (!authFileContent.includes("my-better-t-app://")) { + authFileContent = authFileContent.replace( + /trustedOrigins: \[(.*?)\]/s, + (match, origins) => { + return `trustedOrigins: [${origins}${origins.trim() ? ", " : ""}"my-better-t-app://"]`; + }, + ); + } + + await fs.writeFile(authFilePath, authFileContent); + } +} + export async function fixGitignoreFiles(projectDir: string): Promise { const gitignorePaths = await findGitignoreFiles(projectDir); diff --git a/apps/cli/src/prompts/auth.ts b/apps/cli/src/prompts/auth.ts index 18c411b..b713275 100644 --- a/apps/cli/src/prompts/auth.ts +++ b/apps/cli/src/prompts/auth.ts @@ -10,19 +10,6 @@ export async function getAuthChoice( ): Promise { if (!hasDatabase) return false; - const hasNative = frontends?.includes("native"); - const hasWeb = - frontends?.includes("tanstack-router") || - frontends?.includes("react-router"); - - if (hasNative) { - log.warn( - pc.yellow("Note: Authentication is not yet available with native"), - ); - } - - if (!hasWeb) return false; - if (auth !== undefined) return auth; const response = await confirm({ diff --git a/apps/cli/src/prompts/package-manager.ts b/apps/cli/src/prompts/package-manager.ts index cc85da0..e9e0d90 100644 --- a/apps/cli/src/prompts/package-manager.ts +++ b/apps/cli/src/prompts/package-manager.ts @@ -14,16 +14,16 @@ export async function getPackageManagerChoice( message: "Choose package manager", options: [ { value: "npm", label: "npm", hint: "Node Package Manager" }, - { - value: "bun", - label: "bun", - hint: "All-in-one JavaScript runtime & toolkit", - }, { value: "pnpm", label: "pnpm", hint: "Fast, disk space efficient package manager", }, + { + value: "bun", + label: "bun", + hint: "All-in-one JavaScript runtime & toolkit", + }, ], initialValue: detectedPackageManager, }); diff --git a/apps/cli/template/base/apps/native/app.json b/apps/cli/template/base/apps/native/app.json index cbb5b11..ecb7165 100644 --- a/apps/cli/template/base/apps/native/app.json +++ b/apps/cli/template/base/apps/native/app.json @@ -9,7 +9,10 @@ "output": "static", "favicon": "./assets/favicon.png" }, - "plugins": ["expo-router"], + "plugins": [ + "expo-router", + "expo-secure-store" + ], "experiments": { "typedRoutes": true, "tsconfigPaths": true diff --git a/apps/cli/template/base/apps/native/app/(drawer)/(tabs)/index.tsx b/apps/cli/template/base/apps/native/app/(drawer)/(tabs)/index.tsx index e8ce549..3d5b8ba 100644 --- a/apps/cli/template/base/apps/native/app/(drawer)/(tabs)/index.tsx +++ b/apps/cli/template/base/apps/native/app/(drawer)/(tabs)/index.tsx @@ -1,9 +1,17 @@ -import { View, Text } from "react-native"; +import { Container } from "@/components/container"; +import { Text, View } from "react-native"; -export default function App() { - return ( - - Hello, World! - - ); +export default function TabOne() { + return ( + + + + Tab One + + + This is the first tab of the application. + + + + ); } diff --git a/apps/cli/template/base/apps/native/app/(drawer)/(tabs)/two.tsx b/apps/cli/template/base/apps/native/app/(drawer)/(tabs)/two.tsx index f1ab919..5e13d51 100644 --- a/apps/cli/template/base/apps/native/app/(drawer)/(tabs)/two.tsx +++ b/apps/cli/template/base/apps/native/app/(drawer)/(tabs)/two.tsx @@ -1,16 +1,17 @@ -import { Stack } from "expo-router"; -import { View, Text } from "react-native"; - import { Container } from "@/components/container"; +import { Text, View } from "react-native"; -export default function Home() { - return ( - <> - - - Tab Two - - - - ); +export default function TabTwo() { + return ( + + + + Tab Two + + + This is the second tab of the application. + + + + ); } diff --git a/apps/cli/template/base/apps/native/app/modal.tsx b/apps/cli/template/base/apps/native/app/modal.tsx index 6d6e946..9a6f4c5 100644 --- a/apps/cli/template/base/apps/native/app/modal.tsx +++ b/apps/cli/template/base/apps/native/app/modal.tsx @@ -1,15 +1,12 @@ +import { Container } from "@/components/container"; import { Text, View } from "react-native"; -import { Container } from "@/components/container"; - export default function Modal() { - return ( - <> - - - HI MODAL - - - - ); + return ( + + + Modal View + + + ); } diff --git a/apps/cli/template/base/apps/native/metro.config.js b/apps/cli/template/base/apps/native/metro.config.js index a5b17c9..9107334 100644 --- a/apps/cli/template/base/apps/native/metro.config.js +++ b/apps/cli/template/base/apps/native/metro.config.js @@ -15,6 +15,8 @@ const config = withTurborepoManagedCache( config.resolver.unstable_enablePackageExports = true; +config.resolver.disableHierarchicalLookup = true; + module.exports = config; /** diff --git a/apps/cli/template/base/apps/native/package.json b/apps/cli/template/base/apps/native/package.json index 663ce14..a511656 100644 --- a/apps/cli/template/base/apps/native/package.json +++ b/apps/cli/template/base/apps/native/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "main": "expo-router/entry", "scripts": { - "dev": "expo start", + "dev": "expo start --clear", "android": "expo run:android", "ios": "expo run:ios", "prebuild": "expo prebuild", @@ -19,18 +19,19 @@ "@trpc/client": "^11.0.0", "@trpc/server": "^11.0.0", "@trpc/tanstack-react-query": "^11.0.0", - "expo": "^52.0.41", + "expo": "^52.0.44", "expo-constants": "~17.0.8", "expo-linking": "~7.0.5", "expo-navigation-bar": "~4.0.8", "expo-router": "~4.0.19", + "expo-secure-store": "~14.0.1", "expo-status-bar": "~2.0.1", "expo-system-ui": "~4.0.8", "expo-web-browser": "~14.0.2", "nativewind": "^4.1.23", "react": "18.3.1", "react-dom": "18.3.1", - "react-native": "0.76.7", + "react-native": "0.76.9", "react-native-gesture-handler": "~2.20.2", "react-native-reanimated": "3.16.2", "react-native-safe-area-context": "4.12.0", diff --git a/apps/cli/template/base/apps/web-tanstack-router/src/main.tsx b/apps/cli/template/base/apps/web-tanstack-router/src/main.tsx index 32d74b9..75af1de 100644 --- a/apps/cli/template/base/apps/web-tanstack-router/src/main.tsx +++ b/apps/cli/template/base/apps/web-tanstack-router/src/main.tsx @@ -3,13 +3,13 @@ import { RouterProvider, createRouter } from "@tanstack/react-router"; import ReactDOM from "react-dom/client"; import Loader from "./components/loader"; import { routeTree } from "./routeTree.gen"; -import { queryClient, trpcClient } from "./utils/trpc"; +import { queryClient, trpc } from "./utils/trpc"; const router = createRouter({ routeTree, defaultPreload: "intent", defaultPendingComponent: () => , - context: { trpcClient }, + context: { trpc, queryClient }, Wrap: function WrapComponent({ children }) { return ( {children} diff --git a/apps/cli/template/base/apps/web-tanstack-router/src/routes/__root.tsx b/apps/cli/template/base/apps/web-tanstack-router/src/routes/__root.tsx index 2b60910..9787deb 100644 --- a/apps/cli/template/base/apps/web-tanstack-router/src/routes/__root.tsx +++ b/apps/cli/template/base/apps/web-tanstack-router/src/routes/__root.tsx @@ -2,7 +2,8 @@ import Header from "@/components/header"; import Loader from "@/components/loader"; import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "@/components/ui/sonner"; -import type { trpcClient } from "@/utils/trpc"; +import type { trpc } from "@/utils/trpc"; +import type { QueryClient } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { HeadContent, @@ -14,7 +15,8 @@ import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import "../index.css"; export interface RouterAppContext { - trpcClient: typeof trpcClient; + trpc: typeof trpc; + queryClient: QueryClient; } export const Route = createRootRouteWithContext()({ diff --git a/apps/cli/template/with-auth/apps/native/app/(drawer)/index.tsx b/apps/cli/template/with-auth/apps/native/app/(drawer)/index.tsx new file mode 100644 index 0000000..cba09c1 --- /dev/null +++ b/apps/cli/template/with-auth/apps/native/app/(drawer)/index.tsx @@ -0,0 +1,84 @@ +import { authClient } from "@/lib/auth-client"; +import { useQuery } from "@tanstack/react-query"; +import { ScrollView, Text, TouchableOpacity, View } from "react-native"; + +import { Container } from "@/components/container"; +import { SignIn } from "@/components/sign-in"; +import { SignUp } from "@/components/sign-up"; +import { queryClient, trpc } from "@/utils/trpc"; + +export default function Home() { + const healthCheck = useQuery(trpc.healthCheck.queryOptions()); + const privateData = useQuery(trpc.privateData.queryOptions()); + const { data: session } = authClient.useSession(); + + return ( + + + + + BETTER T STACK + + {session?.user ? ( + + + + Welcome,{" "} + {session.user.name} + + + + {session.user.email} + + + { + authClient.signOut(); + queryClient.invalidateQueries(); + }} + > + Sign Out + + + ) : null} + + API Status + + + + {healthCheck.isLoading + ? "Checking..." + : healthCheck.data + ? "Connected to API" + : "API Disconnected"} + + + + + + Private Data + + {privateData && ( + + + {privateData.data?.message} + + + )} + + {!session?.user && ( + <> + + + + )} + + + + ); +} diff --git a/apps/cli/template/with-auth/apps/native/components/sign-in.tsx b/apps/cli/template/with-auth/apps/native/components/sign-in.tsx new file mode 100644 index 0000000..d5e6369 --- /dev/null +++ b/apps/cli/template/with-auth/apps/native/components/sign-in.tsx @@ -0,0 +1,88 @@ +import { authClient } from "@/lib/auth-client"; +import { queryClient } from "@/utils/trpc"; +import { useState } from "react"; +import { + ActivityIndicator, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; + +export function SignIn() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleLogin = async () => { + setIsLoading(true); + setError(null); + + await authClient.signIn.email( + { + email, + password, + }, + { + onError: (error) => { + setError(error.error?.message || "Failed to sign in"); + setIsLoading(false); + }, + onSuccess: () => { + setEmail(""); + setPassword(""); + queryClient.refetchQueries(); + }, + onFinished: () => { + setIsLoading(false); + }, + }, + ); + }; + + return ( + + + Sign In + + + {error && ( + + {error} + + )} + + + + + + + {isLoading ? ( + + ) : ( + Sign In + )} + + + ); +} diff --git a/apps/cli/template/with-auth/apps/native/components/sign-up.tsx b/apps/cli/template/with-auth/apps/native/components/sign-up.tsx new file mode 100644 index 0000000..7c1d315 --- /dev/null +++ b/apps/cli/template/with-auth/apps/native/components/sign-up.tsx @@ -0,0 +1,99 @@ +import { authClient } from "@/lib/auth-client"; +import { queryClient } from "@/utils/trpc"; +import { useState } from "react"; +import { + ActivityIndicator, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; + +export function SignUp() { + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSignUp = async () => { + setIsLoading(true); + setError(null); + + await authClient.signUp.email( + { + name, + email, + password, + }, + { + onError: (error) => { + setError(error.error?.message || "Failed to sign up"); + setIsLoading(false); + }, + onSuccess: () => { + setName(""); + setEmail(""); + setPassword(""); + queryClient.refetchQueries(); + }, + onFinished: () => { + setIsLoading(false); + }, + }, + ); + }; + + return ( + + + Create Account + + + {error && ( + + {error} + + )} + + + + + + + + + {isLoading ? ( + + ) : ( + Sign Up + )} + + + ); +} diff --git a/apps/cli/template/with-auth/apps/native/lib/auth-client.ts b/apps/cli/template/with-auth/apps/native/lib/auth-client.ts new file mode 100644 index 0000000..8d1b7bd --- /dev/null +++ b/apps/cli/template/with-auth/apps/native/lib/auth-client.ts @@ -0,0 +1,13 @@ +import { createAuthClient } from "better-auth/react"; +import { expoClient } from "@better-auth/expo/client"; +import * as SecureStore from "expo-secure-store"; + +export const authClient = createAuthClient({ + baseURL: process.env.EXPO_PUBLIC_SERVER_URL, + plugins: [ + expoClient({ + storagePrefix: "my-better-t-app", + storage: SecureStore, + }), + ], +}); diff --git a/apps/cli/template/with-auth/apps/native/utils/trpc.ts b/apps/cli/template/with-auth/apps/native/utils/trpc.ts new file mode 100644 index 0000000..abfbe3a --- /dev/null +++ b/apps/cli/template/with-auth/apps/native/utils/trpc.ts @@ -0,0 +1,28 @@ +import { authClient } from "@/lib/auth-client"; +import { QueryClient } from "@tanstack/react-query"; +import { createTRPCClient, httpBatchLink } from "@trpc/client"; +import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query"; +import type { AppRouter } from "../../server/src/routers"; + +export const queryClient = new QueryClient(); + +const trpcClient = createTRPCClient({ + links: [ + httpBatchLink({ + url: `${process.env.EXPO_PUBLIC_SERVER_URL}/trpc`, + headers() { + const headers = new Map(); + const cookies = authClient.getCookie(); + if (cookies) { + headers.set("Cookie", cookies); + } + return Object.fromEntries(headers); + }, + }), + ], +}); + +export const trpc = createTRPCOptionsProxy({ + client: trpcClient, + queryClient, +}); diff --git a/apps/web/src/app/(home)/_components/Navbar.tsx b/apps/web/src/app/(home)/_components/Navbar.tsx index bb370b8..8f6c5bb 100644 --- a/apps/web/src/app/(home)/_components/Navbar.tsx +++ b/apps/web/src/app/(home)/_components/Navbar.tsx @@ -82,7 +82,7 @@ const Navbar = () => { linkRefs.current.home = ref; }} onMouseOver={() => setActiveLink("home")} - className="relative rounded-md px-4 py-2 font-mono text-gray-700 transition-colors hover:text-blue-600 dark:text-gray-300 dark:hover:text-blue-300 flex gap-1 items-center" + className="relative flex items-center gap-1 rounded-md px-4 py-2 font-mono text-gray-700 transition-colors hover:text-blue-600 dark:text-gray-300 dark:hover:text-blue-300" > ~/ home @@ -131,7 +131,7 @@ const Navbar = () => { > GitHub - {" "} + Github diff --git a/apps/web/src/app/(home)/_components/StackArchitech.tsx b/apps/web/src/app/(home)/_components/StackArchitech.tsx index 30f47d0..4358804 100644 --- a/apps/web/src/app/(home)/_components/StackArchitech.tsx +++ b/apps/web/src/app/(home)/_components/StackArchitech.tsx @@ -1,12 +1,22 @@ "use client"; +import { + DEFAULT_STACK, + PRESET_TEMPLATES, + type StackState, + TECH_OPTIONS, +} from "@/lib/constant"; import { motion } from "framer-motion"; import { Check, Circle, CircleCheck, ClipboardCopy, + HelpCircle, InfoIcon, + RefreshCw, + Settings, + Star, Terminal, } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; @@ -36,315 +46,6 @@ const validateProjectName = (name: string): string | undefined => { return undefined; }; -const TECH_OPTIONS = { - frontend: [ - { - 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", - description: "Expo with NativeWind", - icon: "📱", - color: "from-purple-400 to-purple-600", - default: false, - }, - { - id: "none", - name: "No Frontend", - description: "API-only backend", - icon: "⚙️", - color: "from-gray-400 to-gray-600", - default: false, - }, - ], - runtime: [ - { - id: "bun", - name: "Bun", - description: "Fast JavaScript runtime & toolkit", - icon: "🥟", - color: "from-amber-400 to-amber-600", - default: true, - }, - { - id: "node", - name: "Node.js", - description: "JavaScript runtime environment", - icon: "🟩", - color: "from-green-400 to-green-600", - }, - ], - backendFramework: [ - { - id: "hono", - name: "Hono", - description: "Ultrafast web framework", - icon: "⚡", - color: "from-blue-500 to-blue-700", - default: true, - }, - { - id: "elysia", - name: "Elysia", - description: "TypeScript web framework", - icon: "🦊", - color: "from-purple-500 to-purple-700", - }, - ], - database: [ - { - id: "sqlite", - name: "SQLite", - description: "File-based SQL database", - icon: "🗃️", - color: "from-blue-400 to-cyan-500", - default: true, - }, - { - id: "postgres", - name: "PostgreSQL", - description: "Advanced SQL database", - icon: "🐘", - color: "from-indigo-400 to-indigo-600", - }, - { - id: "none", - name: "No Database", - description: "Skip database integration", - icon: "🚫", - color: "from-gray-400 to-gray-600", - }, - ], - orm: [ - { - id: "drizzle", - name: "Drizzle", - description: "TypeScript ORM", - icon: "💧", - color: "from-cyan-400 to-cyan-600", - default: true, - }, - { - id: "prisma", - name: "Prisma", - description: "Next-gen ORM", - icon: "◮", - color: "from-purple-400 to-purple-600", - }, - ], - auth: [ - { - id: "true", - name: "Better Auth", - description: "Simple authentication", - icon: "🔐", - color: "from-green-400 to-green-600", - default: true, - }, - { - id: "false", - name: "No Auth", - description: "Skip authentication", - icon: "🔓", - color: "from-red-400 to-red-600", - }, - ], - turso: [ - { - id: "true", - name: "Turso", - description: "SQLite cloud database", - icon: "☁️", - color: "from-pink-400 to-pink-600", - default: false, - }, - { - id: "false", - name: "No Turso", - description: "Skip Turso integration", - icon: "🚫", - color: "from-gray-400 to-gray-600", - default: true, - }, - ], - prismaPostgres: [ - { - id: "true", - name: "Prisma PostgreSQL", - description: "Set up PostgreSQL with Prisma", - icon: "🐘", - color: "from-indigo-400 to-indigo-600", - default: false, - }, - { - id: "false", - name: "Skip Prisma PostgreSQL", - description: "Basic Prisma setup", - icon: "🚫", - color: "from-gray-400 to-gray-600", - default: true, - }, - ], - packageManager: [ - { - id: "npm", - name: "npm", - description: "Default package manager", - icon: "📦", - color: "from-red-500 to-red-700", - }, - { - id: "pnpm", - name: "pnpm", - description: "Fast, disk space efficient", - icon: "🚀", - color: "from-orange-500 to-orange-700", - }, - { - id: "bun", - name: "bun", - description: "All-in-one toolkit", - icon: "🥟", - color: "from-amber-500 to-amber-700", - default: true, - }, - ], - addons: [ - { - id: "pwa", - name: "PWA", - description: "Progressive Web App", - icon: "📱", - color: "from-blue-500 to-blue-700", - default: false, - }, - { - id: "tauri", - name: "Tauri", - description: "Desktop app support", - icon: "🖥️", - color: "from-amber-500 to-amber-700", - default: false, - }, - { - id: "biome", - name: "Biome", - description: "Linting & formatting", - icon: "🌿", - color: "from-green-500 to-green-700", - default: false, - }, - { - id: "husky", - name: "Husky", - description: "Git hooks & lint-staged", - icon: "🐶", - color: "from-purple-500 to-purple-700", - default: false, - }, - ], - examples: [ - { - id: "todo", - name: "Todo Example", - description: "Simple todo application", - icon: "✅", - color: "from-indigo-500 to-indigo-700", - default: false, - }, - { - id: "ai", - name: "AI Example", - description: "AI integration example using AI SDK", - icon: "🤖", - color: "from-purple-500 to-purple-700", - default: false, - }, - ], - git: [ - { - id: "true", - name: "Git", - description: "Initialize Git repository", - icon: "📝", - color: "from-gray-500 to-gray-700", - default: true, - }, - { - id: "false", - name: "No Git", - description: "Skip Git initialization", - icon: "🚫", - color: "from-red-400 to-red-600", - }, - ], - install: [ - { - id: "true", - name: "Install Dependencies", - description: "Install packages automatically", - icon: "📥", - color: "from-green-400 to-green-600", - default: true, - }, - { - id: "false", - name: "Skip Install", - description: "Skip dependency installation", - icon: "⏭️", - color: "from-yellow-400 to-yellow-600", - }, - ], -}; - -interface StackState { - projectName: string; - frontend: string[]; - runtime: string; - backendFramework: string; - database: string; - orm: string | null; - auth: string; - turso: string; - prismaPostgres: string; - packageManager: string; - addons: string[]; - examples: string[]; - git: string; - install: string; -} - -const DEFAULT_STACK: StackState = { - projectName: "my-better-t-app", - frontend: ["tanstack-router"], - runtime: "bun", - backendFramework: "hono", - database: "sqlite", - orm: "drizzle", - auth: "true", - turso: "false", - prismaPostgres: "false", - packageManager: "bun", - addons: [], - examples: [], - git: "true", - install: "true", -}; - const StackArchitect = () => { const [stack, setStack] = useState(DEFAULT_STACK); const [command, setCommand] = useState( @@ -356,18 +57,21 @@ const StackArchitect = () => { const [projectNameError, setProjectNameError] = useState( undefined, ); + const [showPresets, setShowPresets] = useState(false); + const [showHelp, setShowHelp] = useState(false); + const [lastSavedStack, setLastSavedStack] = useState(null); useEffect(() => { - const hasWebFrontend = - stack.frontend.includes("tanstack-router") || - stack.frontend.includes("react-router"); - if (!hasWebFrontend && stack.auth === "true") { - setStack((prev) => ({ - ...prev, - auth: "false", - })); + const savedStack = localStorage.getItem("betterTStackPreference"); + if (savedStack) { + try { + const parsedStack = JSON.parse(savedStack); + setLastSavedStack(parsedStack); + } catch (e) { + console.error("Failed to parse saved stack", e); + } } - }, [stack.frontend, stack.auth]); + }, []); useEffect(() => { if (stack.database === "none" && stack.orm !== "none") { @@ -385,7 +89,17 @@ const StackArchitect = () => { setStack((prev) => ({ ...prev, turso: "false" })); } } - }, [stack.database, stack.orm, stack.prismaPostgres, stack.turso]); + + if (stack.database === "none" && stack.auth === "true") { + setStack((prev) => ({ ...prev, auth: "false" })); + } + }, [ + stack.database, + stack.orm, + stack.prismaPostgres, + stack.turso, + stack.auth, + ]); useEffect(() => { const cmd = generateCommand(stack); @@ -398,13 +112,6 @@ const StackArchitect = () => { notes.frontend = []; - notes.auth = []; - if (!hasWebFrontend && stack.auth === "true") { - notes.auth.push( - "Authentication is only available with React Web (TanStack Router or React Router).", - ); - } - notes.addons = []; if (!hasWebFrontend) { notes.addons.push("PWA and Tauri are only available with React Web."); @@ -419,6 +126,11 @@ const StackArchitect = () => { ); } + notes.auth = []; + if (stack.database === "none") { + notes.auth.push("Authentication requires a database."); + } + notes.turso = []; if (stack.database !== "sqlite") { notes.turso.push( @@ -535,7 +247,6 @@ const StackArchitect = () => { return { ...prev, frontend: ["none"], - auth: "false", examples: [], addons: prev.addons.filter( (addon) => addon !== "pwa" && addon !== "tauri", @@ -543,68 +254,33 @@ const StackArchitect = () => { }; } - if (webTypes.includes(techId)) { - if ( - currentSelection.includes(techId) && - currentSelection.length === 1 - ) { + if (currentSelection.includes(techId)) { + if (currentSelection.length === 1) { return prev; } - if (currentSelection.some((id) => webTypes.includes(id))) { - const nonWebSelections = currentSelection.filter( - (id) => !webTypes.includes(id), - ); - return { - ...prev, - frontend: [...nonWebSelections, techId], - auth: prev.auth, - }; - } - - if (currentSelection.includes("none")) { - return { - ...prev, - frontend: [techId], - auth: "true", - }; - } - return { ...prev, - frontend: [ - ...currentSelection.filter((id) => id !== "none"), - techId, - ], - auth: "true", + frontend: currentSelection.filter((id) => id !== techId), }; } - if (techId === "native") { - if (currentSelection.includes(techId)) { - if (currentSelection.length === 1) { - return prev; - } - return { - ...prev, - frontend: currentSelection.filter((id) => id !== techId), - }; - } + let newSelection = [...currentSelection]; - if (currentSelection.includes("none")) { - return { - ...prev, - frontend: [techId], - }; - } - - return { - ...prev, - frontend: [...currentSelection, techId], - }; + if (newSelection.includes("none")) { + newSelection = []; } - return prev; + if (webTypes.includes(techId)) { + newSelection = newSelection.filter((id) => !webTypes.includes(id)); + } + + newSelection.push(techId); + + return { + ...prev, + frontend: newSelection, + }; } if (category === "addons" || category === "examples") { @@ -666,7 +342,7 @@ const StackArchitect = () => { orm: "none", turso: "false", prismaPostgres: "false", - auth: hasWebFrontend(prev.frontend) ? prev.auth : "false", + auth: "false", }; } @@ -680,6 +356,11 @@ const StackArchitect = () => { techId === "postgres" && prev.orm === "prisma" ? prev.prismaPostgres : "false", + auth: + hasWebFrontend(prev.frontend) || + prev.frontend.includes("native") + ? "true" + : "false", }; } @@ -735,6 +416,14 @@ const StackArchitect = () => { return prev; } + if ( + category === "auth" && + prev.database === "none" && + techId === "true" + ) { + return prev; + } + return { ...prev, [category]: techId, @@ -747,7 +436,8 @@ const StackArchitect = () => { const hasWebFrontend = useCallback((frontendOptions: string[]) => { return ( frontendOptions.includes("tanstack-router") || - frontendOptions.includes("react-router") + frontendOptions.includes("react-router") || + frontendOptions.includes("native") ); }, []); @@ -757,6 +447,40 @@ const StackArchitect = () => { setTimeout(() => setCopied(false), 2000); }, [command]); + const resetStack = useCallback(() => { + setStack(DEFAULT_STACK); + setActiveTab("frontend"); + }, []); + + const saveCurrentStack = useCallback(() => { + localStorage.setItem("betterTStackPreference", JSON.stringify(stack)); + setLastSavedStack(stack); + const saveMessage = document.createElement("div"); + saveMessage.textContent = "Stack preferences saved!"; + saveMessage.className = + "fixed bottom-4 right-4 bg-green-500 text-white py-2 px-4 rounded-md shadow-lg z-50"; + document.body.appendChild(saveMessage); + setTimeout(() => { + document.body.removeChild(saveMessage); + }, 3000); + }, [stack]); + + const loadSavedStack = useCallback(() => { + if (lastSavedStack) { + setStack(lastSavedStack); + } + }, [lastSavedStack]); + + const applyPreset = useCallback((presetId: string) => { + const preset = PRESET_TEMPLATES.find( + (template) => template.id === presetId, + ); + if (preset) { + setStack(preset.stack); + setShowPresets(false); + } + }, []); + return (
@@ -769,7 +493,23 @@ const StackArchitect = () => {
Stack Architect Terminal
-
+
+ +
+ + {showHelp && ( +
+

+ How to Use Stack Architect +

+
    +
  • + Select your preferred technologies from each category using the + tabs below +
  • +
  • + The command will automatically update based on your selections +
  • +
  • + Click the copy button to copy the command to your clipboard +
  • +
  • + You can reset to defaults or choose from presets for quick setup +
  • +
  • Save your preferences to load them later when you return
  • +
+
+ )} + + {showPresets && ( +
+

+ Quick Start Presets +

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