diff --git a/.gitignore b/.gitignore index 201ea51f16..202a4df834 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ dist/ # turbo .turbo + +# tanstack +.tanstack \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index be22763f17..ae80c88875 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,6 +17,12 @@ "files.associations": { "*.css": "tailwindcss" }, + "files.readonlyInclude": { + "**/routeTree.gen.ts": true + }, + "files.watcherExclude": { + "**/routeTree.gen.ts": true + }, "prettier.ignorePath": ".gitignore", "tailwindCSS.classFunctions": ["cva", "cx", "cn"], "typescript.enablePromptUseWorkspaceTsdk": true, diff --git a/README.md b/README.md index b066718adc..20657e129a 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,7 @@ > [!NOTE] > -> create-t3-turbo now uses better-auth for authentication! -> Look out for bugs as we're working through the last issues, -> especially, the oauth proxy might not play very nice with Expo -> so you might need to disable that in [`@acme/auth`](./packages/auth/src/index.ts) +> create-t3-turbo now includes the option to use Tanstack Start for the web app! ## Installation @@ -42,8 +39,13 @@ apps │ ├─ Navigation using Expo Router │ ├─ Tailwind CSS v4 using NativeWind v5 │ └─ Typesafe API calls using tRPC - └─ next.js - ├─ Next.js 15 + ├─ nextjs + │ ├─ Next.js 15 + │ ├─ React 19 + │ ├─ Tailwind CSS v4 + │ └─ E2E Typesafe API Server & Client + └─ tanstack-start + ├─ Tanstack Start v1 (rc) ├─ React 19 ├─ Tailwind CSS v4 └─ E2E Typesafe API Server & Client @@ -78,6 +80,10 @@ To get it running, follow the steps below: ### 1. Setup dependencies +> [!NOTE] +> +> While the repo does contain both a Next.js and Tanstack Start version of a web app, you can pick which one you like to use and delete the other folder before starting the setup. + ```bash # Install dependencies pnpm i diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 548dbddd5c..87a9663750 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -36,7 +36,7 @@ "@acme/prettier-config": "workspace:*", "@acme/tailwind-config": "workspace:*", "@acme/tsconfig": "workspace:*", - "@types/node": "^22.18.10", + "@types/node": "catalog:", "@types/react": "catalog:react19", "@types/react-dom": "catalog:react19", "eslint": "catalog:", diff --git a/apps/nextjs/src/app/layout.tsx b/apps/nextjs/src/app/layout.tsx index 7b8ba5a935..8985f5db05 100644 --- a/apps/nextjs/src/app/layout.tsx +++ b/apps/nextjs/src/app/layout.tsx @@ -8,7 +8,7 @@ import { Toaster } from "@acme/ui/toast"; import { env } from "~/env"; import { TRPCReactProvider } from "~/trpc/react"; -import "~/app/globals.css"; +import "~/app/styles.css"; export const metadata: Metadata = { metadataBase: new URL( @@ -57,7 +57,7 @@ export default function RootLayout(props: { children: React.ReactNode }) { geistMono.variable, )} > - + {props.children}
diff --git a/apps/nextjs/src/app/globals.css b/apps/nextjs/src/app/styles.css similarity index 83% rename from apps/nextjs/src/app/globals.css rename to apps/nextjs/src/app/styles.css index 3349ee93a9..25d002f017 100644 --- a/apps/nextjs/src/app/globals.css +++ b/apps/nextjs/src/app/styles.css @@ -5,6 +5,8 @@ @source "../../../../packages/ui/src/*.{ts,tsx}"; @custom-variant dark (&:where(.dark, .dark *)); +@custom-variant light (&:where(.light, .light *)); +@custom-variant auto (&:where(.auto, .auto *)); @utility container { margin-inline: auto; diff --git a/apps/tanstack-start/.prettierignore b/apps/tanstack-start/.prettierignore new file mode 100644 index 0000000000..2a0e6b73a9 --- /dev/null +++ b/apps/tanstack-start/.prettierignore @@ -0,0 +1 @@ +routeTree.gen.ts \ No newline at end of file diff --git a/apps/tanstack-start/eslint.config.ts b/apps/tanstack-start/eslint.config.ts new file mode 100644 index 0000000000..d69b9e42c2 --- /dev/null +++ b/apps/tanstack-start/eslint.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "eslint/config"; + +import { baseConfig, restrictEnvAccess } from "@acme/eslint-config/base"; +import { reactConfig } from "@acme/eslint-config/react"; + +export default defineConfig( + { + ignores: [".nitro/**", ".output/**", ".tanstack/**"], + }, + baseConfig, + reactConfig, + restrictEnvAccess, +); diff --git a/apps/tanstack-start/package.json b/apps/tanstack-start/package.json new file mode 100644 index 0000000000..f16340e0ac --- /dev/null +++ b/apps/tanstack-start/package.json @@ -0,0 +1,57 @@ +{ + "name": "@acme/tanstack-start", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "pnpm with-env vite dev", + "build": "vite build", + "start": "vite start", + "format": "prettier --check . --ignore-path ../../.gitignore --ignore-path .prettierignore", + "lint": "eslint --flag unstable_native_nodejs_ts_config", + "typecheck": "tsc --noEmit", + "with-env": "dotenv -e ../../.env --" + }, + "dependencies": { + "@acme/api": "workspace:*", + "@acme/auth": "workspace:*", + "@acme/db": "workspace:*", + "@acme/ui": "workspace:*", + "@fontsource-variable/geist": "^5.2.8", + "@fontsource-variable/geist-mono": "^5.2.7", + "@t3-oss/env-core": "^0.13.8", + "@tanstack/react-form": "catalog:", + "@tanstack/react-query": "catalog:", + "@tanstack/react-router": "^1.132.47", + "@tanstack/react-router-devtools": "^1.132.51", + "@tanstack/react-router-ssr-query": "^1.132.47", + "@tanstack/react-start": "^1.132.52", + "@trpc/client": "catalog:", + "@trpc/server": "catalog:", + "@trpc/tanstack-react-query": "catalog:", + "better-auth": "catalog:", + "nitro": "npm:nitro-nightly@latest", + "react": "catalog:react19", + "react-dom": "catalog:react19", + "superjson": "2.2.3", + "zod": "catalog:" + }, + "devDependencies": { + "@acme/eslint-config": "workspace:*", + "@acme/prettier-config": "workspace:*", + "@acme/tailwind-config": "workspace:*", + "@acme/tsconfig": "workspace:*", + "@tailwindcss/vite": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:react19", + "@types/react-dom": "catalog:react19", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "catalog:", + "prettier": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "^7.1.7", + "vite-tsconfig-paths": "^5.1.4" + }, + "prettier": "@acme/prettier-config" +} diff --git a/apps/tanstack-start/public/favicon.ico b/apps/tanstack-start/public/favicon.ico new file mode 100644 index 0000000000..f0058b404f Binary files /dev/null and b/apps/tanstack-start/public/favicon.ico differ diff --git a/apps/tanstack-start/src/auth/client.ts b/apps/tanstack-start/src/auth/client.ts new file mode 100644 index 0000000000..f1012dd4ac --- /dev/null +++ b/apps/tanstack-start/src/auth/client.ts @@ -0,0 +1,3 @@ +import { createAuthClient } from "better-auth/react"; + +export const authClient = createAuthClient(); diff --git a/apps/tanstack-start/src/auth/server.ts b/apps/tanstack-start/src/auth/server.ts new file mode 100644 index 0000000000..9f58c5f994 --- /dev/null +++ b/apps/tanstack-start/src/auth/server.ts @@ -0,0 +1,16 @@ +import { reactStartCookies } from "better-auth/react-start"; + +import { initAuth } from "@acme/auth"; + +import { env } from "~/env"; +import { getBaseUrl } from "~/lib/url"; + +export const auth = initAuth({ + baseUrl: getBaseUrl(), + productionUrl: `https://${env.VERCEL_PROJECT_PRODUCTION_URL ?? "turbo.t3.gg"}`, + secret: env.AUTH_SECRET, + discordClientId: env.AUTH_DISCORD_ID, + discordClientSecret: env.AUTH_DISCORD_SECRET, + + extraPlugins: [reactStartCookies()], +}); diff --git a/apps/tanstack-start/src/component/auth-showcase.tsx b/apps/tanstack-start/src/component/auth-showcase.tsx new file mode 100644 index 0000000000..5bd0adac0c --- /dev/null +++ b/apps/tanstack-start/src/component/auth-showcase.tsx @@ -0,0 +1,48 @@ +import { useNavigate } from "@tanstack/react-router"; + +import { Button } from "@acme/ui/button"; + +import { authClient } from "~/auth/client"; + +export function AuthShowcase() { + const { data: session } = authClient.useSession(); + const navigate = useNavigate(); + + if (!session) { + return ( + + ); + } + + return ( +
+

+ Logged in as {session.user.name} +

+ + +
+ ); +} diff --git a/apps/tanstack-start/src/env.ts b/apps/tanstack-start/src/env.ts new file mode 100644 index 0000000000..a595f54182 --- /dev/null +++ b/apps/tanstack-start/src/env.ts @@ -0,0 +1,36 @@ +import { createEnv } from "@t3-oss/env-core"; +import { vercel } from "@t3-oss/env-core/presets-zod"; +import { z } from "zod/v4"; + +import { authEnv } from "@acme/auth/env"; + +export const env = createEnv({ + clientPrefix: "VITE_", + extends: [authEnv(), vercel()], + shared: { + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), + }, + /** + * Specify your server-side environment variables schema here. + * This way you can ensure the app isn't built with invalid env vars. + */ + server: { + POSTGRES_URL: z.url(), + }, + + /** + * Specify your client-side environment variables schema here. + * For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`. + */ + client: { + // NEXT_PUBLIC_CLIENTVAR: z.string(), + }, + /** + * Destructure all variables from `process.env` to make sure they aren't tree-shaken away. + */ + runtimeEnv: process.env, + skipValidation: + !!process.env.CI || process.env.npm_lifecycle_event === "lint", +}); diff --git a/apps/tanstack-start/src/lib/trpc.ts b/apps/tanstack-start/src/lib/trpc.ts new file mode 100644 index 0000000000..a6e4189ce8 --- /dev/null +++ b/apps/tanstack-start/src/lib/trpc.ts @@ -0,0 +1,33 @@ +import { + createTRPCClient, + httpBatchStreamLink, + loggerLink, +} from "@trpc/client"; +import { createTRPCContext } from "@trpc/tanstack-react-query"; +import SuperJSON from "superjson"; + +import type { AppRouter } from "@acme/api"; + +import { env } from "~/env"; +import { getBaseUrl } from "~/lib/url"; + +export const trpcClient = createTRPCClient({ + links: [ + loggerLink({ + enabled: (op) => + env.NODE_ENV === "development" || + (op.direction === "down" && op.result instanceof Error), + }), + httpBatchStreamLink({ + transformer: SuperJSON, + url: getBaseUrl() + "/api/trpc", + headers() { + const headers = new Headers(); + headers.set("x-trpc-source", "nextjs-react"); + return headers; + }, + }), + ], +}); + +export const { useTRPC, TRPCProvider } = createTRPCContext(); diff --git a/apps/tanstack-start/src/lib/url.ts b/apps/tanstack-start/src/lib/url.ts new file mode 100644 index 0000000000..8df496d018 --- /dev/null +++ b/apps/tanstack-start/src/lib/url.ts @@ -0,0 +1,16 @@ +import { env } from "~/env"; + +export function getBaseUrl() { + if (typeof window !== "undefined") { + return window.location.origin; + } + if (env.VERCEL_ENV === "production") { + return `https://${env.VERCEL_PROJECT_PRODUCTION_URL}`; + } + if (env.VERCEL_ENV === "preview") { + return `https://${env.VERCEL_URL}`; + } + + // eslint-disable-next-line no-restricted-properties + return `http://localhost:${process.env.PORT ?? 3001}`; +} diff --git a/apps/tanstack-start/src/routeTree.gen.ts b/apps/tanstack-start/src/routeTree.gen.ts new file mode 100644 index 0000000000..14b20b81cd --- /dev/null +++ b/apps/tanstack-start/src/routeTree.gen.ts @@ -0,0 +1,104 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' +import { Route as ApiTrpcSplatRouteImport } from './routes/api/trpc.$' +import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const ApiTrpcSplatRoute = ApiTrpcSplatRouteImport.update({ + id: '/api/trpc/$', + path: '/api/trpc/$', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ + id: '/api/auth/$', + path: '/api/auth/$', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/api/auth/$': typeof ApiAuthSplatRoute + '/api/trpc/$': typeof ApiTrpcSplatRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/api/auth/$': typeof ApiAuthSplatRoute + '/api/trpc/$': typeof ApiTrpcSplatRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/api/auth/$': typeof ApiAuthSplatRoute + '/api/trpc/$': typeof ApiTrpcSplatRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/api/auth/$' | '/api/trpc/$' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/api/auth/$' | '/api/trpc/$' + id: '__root__' | '/' | '/api/auth/$' | '/api/trpc/$' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + ApiAuthSplatRoute: typeof ApiAuthSplatRoute + ApiTrpcSplatRoute: typeof ApiTrpcSplatRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/api/trpc/$': { + id: '/api/trpc/$' + path: '/api/trpc/$' + fullPath: '/api/trpc/$' + preLoaderRoute: typeof ApiTrpcSplatRouteImport + parentRoute: typeof rootRouteImport + } + '/api/auth/$': { + id: '/api/auth/$' + path: '/api/auth/$' + fullPath: '/api/auth/$' + preLoaderRoute: typeof ApiAuthSplatRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + ApiAuthSplatRoute: ApiAuthSplatRoute, + ApiTrpcSplatRoute: ApiTrpcSplatRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/apps/tanstack-start/src/router.tsx b/apps/tanstack-start/src/router.tsx new file mode 100644 index 0000000000..7a2554dba3 --- /dev/null +++ b/apps/tanstack-start/src/router.tsx @@ -0,0 +1,46 @@ +import { QueryClient } from "@tanstack/react-query"; +import { createRouter } from "@tanstack/react-router"; +import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query"; +import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query"; +import SuperJSON from "superjson"; + +import { trpcClient, TRPCProvider } from "~/lib/trpc"; +import { routeTree } from "./routeTree.gen"; + +export function getRouter() { + const queryClient = new QueryClient({ + defaultOptions: { + dehydrate: { serializeData: SuperJSON.serialize }, + hydrate: { deserializeData: SuperJSON.deserialize }, + }, + }); + const trpc = createTRPCOptionsProxy({ + client: trpcClient, + queryClient, + }); + + const router = createRouter({ + routeTree, + context: { queryClient, trpc }, + defaultPreload: "intent", + Wrap: (props) => ( + + ), + }); + setupRouterSsrQueryIntegration({ + router, + queryClient, + }); + + return router; +} + +declare module "@tanstack/react-router" { + interface Register { + router: ReturnType; + } +} diff --git a/apps/tanstack-start/src/routes/__root.tsx b/apps/tanstack-start/src/routes/__root.tsx new file mode 100644 index 0000000000..15a1cac3ca --- /dev/null +++ b/apps/tanstack-start/src/routes/__root.tsx @@ -0,0 +1,56 @@ +/// +import type { QueryClient } from "@tanstack/react-query"; +import type { TRPCOptionsProxy } from "@trpc/tanstack-react-query"; +import type * as React from "react"; +import { + createRootRouteWithContext, + HeadContent, + Outlet, + Scripts, +} from "@tanstack/react-router"; +import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; + +import type { AppRouter } from "@acme/api"; +import { ThemeProvider, ThemeToggle } from "@acme/ui/theme"; +import { Toaster } from "@acme/ui/toast"; + +import appCss from "~/styles.css?url"; + +export const Route = createRootRouteWithContext<{ + queryClient: QueryClient; + trpc: TRPCOptionsProxy; +}>()({ + head: () => ({ + links: [{ rel: "stylesheet", href: appCss }], + }), + component: RootComponent, +}); + +function RootComponent() { + return ( + + + + ); +} + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + + {children} +
+ +
+ + + + + +
+ ); +} diff --git a/apps/tanstack-start/src/routes/api/auth.$.ts b/apps/tanstack-start/src/routes/api/auth.$.ts new file mode 100644 index 0000000000..5c881139f2 --- /dev/null +++ b/apps/tanstack-start/src/routes/api/auth.$.ts @@ -0,0 +1,12 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { auth } from "~/auth/server"; + +export const Route = createFileRoute("/api/auth/$")({ + server: { + handlers: { + GET: ({ request }) => auth.handler(request), + POST: ({ request }) => auth.handler(request), + }, + }, +}); diff --git a/apps/tanstack-start/src/routes/api/trpc.$.ts b/apps/tanstack-start/src/routes/api/trpc.$.ts new file mode 100644 index 0000000000..c9f3d70351 --- /dev/null +++ b/apps/tanstack-start/src/routes/api/trpc.$.ts @@ -0,0 +1,30 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; + +import { appRouter, createTRPCContext } from "@acme/api"; + +import { auth } from "~/auth/server"; + +const handler = (req: Request) => + fetchRequestHandler({ + endpoint: "/api/trpc", + router: appRouter, + req, + createContext: () => + createTRPCContext({ + auth: auth, + headers: req.headers, + }), + onError({ error, path }) { + console.error(`>>> tRPC Error on '${path}'`, error); + }, + }); + +export const Route = createFileRoute("/api/trpc/$")({ + server: { + handlers: { + GET: ({ request }) => handler(request), + POST: ({ request }) => handler(request), + }, + }, +}); diff --git a/apps/tanstack-start/src/routes/index.tsx b/apps/tanstack-start/src/routes/index.tsx new file mode 100644 index 0000000000..1677a2f35f --- /dev/null +++ b/apps/tanstack-start/src/routes/index.tsx @@ -0,0 +1,245 @@ +import { Suspense } from "react"; +import { useForm } from "@tanstack/react-form"; +import { + useMutation, + useQueryClient, + useSuspenseQuery, +} from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; + +import type { RouterOutputs } from "@acme/api"; +import { CreatePostSchema } from "@acme/db/schema"; +import { cn } from "@acme/ui"; +import { Button } from "@acme/ui/button"; +import { + Field, + FieldContent, + FieldError, + FieldGroup, + FieldLabel, +} from "@acme/ui/field"; +import { Input } from "@acme/ui/input"; +import { toast } from "@acme/ui/toast"; + +import { AuthShowcase } from "~/component/auth-showcase"; +import { useTRPC } from "~/lib/trpc"; + +export const Route = createFileRoute("/")({ + loader: ({ context }) => { + const { trpc, queryClient } = context; + void queryClient.prefetchQuery(trpc.post.all.queryOptions()); + }, + component: RouteComponent, +}); + +function RouteComponent() { + return ( +
+
+

+ Create T3 Turbo +

+ + + +
+ + + + +
+ } + > + + +
+
+ + ); +} + +function CreatePostForm() { + const trpc = useTRPC(); + + const queryClient = useQueryClient(); + const createPost = useMutation( + trpc.post.create.mutationOptions({ + onSuccess: async () => { + form.reset(); + await queryClient.invalidateQueries(trpc.post.pathFilter()); + }, + onError: (err) => { + toast.error( + err.data?.code === "UNAUTHORIZED" + ? "You must be logged in to post" + : "Failed to create post", + ); + }, + }), + ); + + const form = useForm({ + defaultValues: { + content: "", + title: "", + }, + validators: { + onSubmit: CreatePostSchema, + }, + onSubmit: (data) => createPost.mutate(data.value), + }); + + return ( +
{ + event.preventDefault(); + void form.handleSubmit(); + }} + > + + { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + Bug Title + + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + placeholder="Title" + /> + {isInvalid && } + + ); + }} + /> + { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + Content + + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + placeholder="Content" + /> + {isInvalid && } + + ); + }} + /> + + +
+ ); +} + +function PostList() { + const trpc = useTRPC(); + const { data: posts } = useSuspenseQuery(trpc.post.all.queryOptions()); + + if (posts.length === 0) { + return ( +
+ + + + +
+

No posts yet

+
+
+ ); + } + + return ( +
+ {posts.map((p) => { + return ; + })} +
+ ); +} + +function PostCard(props: { post: RouterOutputs["post"]["all"][number] }) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const deletePost = useMutation( + trpc.post.delete.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries(trpc.post.pathFilter()); + }, + onError: (err) => { + toast.error( + err.data?.code === "UNAUTHORIZED" + ? "You must be logged in to delete a post" + : "Failed to delete post", + ); + }, + }), + ); + + return ( +
+
+

{props.post.title}

+

{props.post.content}

+
+
+ +
+
+ ); +} + +function PostCardSkeleton(props: { pulse?: boolean }) { + const { pulse = true } = props; + return ( +
+
+

+   +

+

+   +

+
+
+ ); +} diff --git a/apps/tanstack-start/src/styles.css b/apps/tanstack-start/src/styles.css new file mode 100644 index 0000000000..29867c4fde --- /dev/null +++ b/apps/tanstack-start/src/styles.css @@ -0,0 +1,35 @@ +@import "tailwindcss"; +@import "@acme/tailwind-config/theme"; + +@import "@fontsource-variable/geist"; +@import "@fontsource-variable/geist-mono"; + +@source "../../../packages/ui/src/*.{ts,tsx}"; + +@custom-variant dark (&:where(.dark, .dark *)); +@custom-variant light (&:where(.light, .light *)); +@custom-variant auto (&:where(.auto, .auto *)); + +@utility container { + margin-inline: auto; + padding-inline: 2rem; + @media (width >= --theme(--breakpoint-sm)) { + max-width: none; + } + @media (width >= 1400px) { + max-width: 1400px; + } +} + +@layer base { + :root { + --font-geist-sans: "Geist Variable"; + --font-geist-mono: "Geist Mono Variable"; + } + * { + @apply border-border; + } + body { + letter-spacing: var(--tracking-normal); + } +} diff --git a/apps/tanstack-start/tsconfig.json b/apps/tanstack-start/tsconfig.json new file mode 100644 index 0000000000..f618883454 --- /dev/null +++ b/apps/tanstack-start/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@acme/tsconfig/base.json", + "compilerOptions": { + "lib": ["ES2022", "dom", "dom.iterable"], + "jsx": "preserve", + "paths": { + "~/*": ["./src/*"] + } + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/apps/tanstack-start/turbo.json b/apps/tanstack-start/turbo.json new file mode 100644 index 0000000000..4c3f71fbc8 --- /dev/null +++ b/apps/tanstack-start/turbo.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://turborepo.com/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": [".nitro/**", ".output/**", ".tanstack/**"] + }, + "dev": { + "persistent": true + } + } +} diff --git a/apps/tanstack-start/vite.config.ts b/apps/tanstack-start/vite.config.ts new file mode 100644 index 0000000000..120867924a --- /dev/null +++ b/apps/tanstack-start/vite.config.ts @@ -0,0 +1,21 @@ +import tailwindcss from "@tailwindcss/vite"; +import { tanstackStart } from "@tanstack/react-start/plugin/vite"; +import viteReact from "@vitejs/plugin-react"; +import { nitro } from "nitro/vite"; +import { defineConfig } from "vite"; +import tsConfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + server: { + port: 3001, + }, + plugins: [ + tsConfigPaths({ + projects: ["./tsconfig.json"], + }), + nitro(), + tanstackStart(), + viteReact(), + tailwindcss(), + ], +}); diff --git a/packages/ui/package.json b/packages/ui/package.json index 9ac3a97849..ae4aba914c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -24,7 +24,6 @@ "dependencies": { "@radix-ui/react-icons": "^1.3.2", "class-variance-authority": "^0.7.1", - "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1" diff --git a/packages/ui/src/theme.tsx b/packages/ui/src/theme.tsx index 6f340a6f7a..8cc6f38877 100644 --- a/packages/ui/src/theme.tsx +++ b/packages/ui/src/theme.tsx @@ -1,7 +1,8 @@ "use client"; -import { MoonIcon, SunIcon } from "@radix-ui/react-icons"; -import { useTheme } from "next-themes"; +import * as React from "react"; +import { DesktopIcon, MoonIcon, SunIcon } from "@radix-ui/react-icons"; +import * as z from "zod/v4"; import { Button } from "./button"; import { @@ -11,7 +12,144 @@ import { DropdownMenuTrigger, } from "./dropdown-menu"; -export { ThemeProvider } from "next-themes"; +const ThemeModeSchema = z.enum(["light", "dark", "auto"]); + +const themeKey = "theme-mode"; + +export type ThemeMode = z.output; +export type ResolvedTheme = Exclude; + +const getStoredThemeMode = (): ThemeMode => { + if (typeof window === "undefined") return "auto"; + try { + const storedTheme = localStorage.getItem(themeKey); + return ThemeModeSchema.parse(storedTheme); + } catch { + return "auto"; + } +}; + +const setStoredThemeMode = (theme: ThemeMode) => { + try { + const parsedTheme = ThemeModeSchema.parse(theme); + localStorage.setItem(themeKey, parsedTheme); + } catch { + // Silently fail if localStorage is unavailable + } +}; + +const getSystemTheme = () => { + if (typeof window === "undefined") return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +}; + +const updateThemeClass = (themeMode: ThemeMode) => { + const root = document.documentElement; + root.classList.remove("light", "dark", "auto"); + const newTheme = themeMode === "auto" ? getSystemTheme() : themeMode; + root.classList.add(newTheme); + + if (themeMode === "auto") { + root.classList.add("auto"); + } +}; + +const setupPreferredListener = () => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const handler = () => updateThemeClass("auto"); + mediaQuery.addEventListener("change", handler); + return () => mediaQuery.removeEventListener("change", handler); +}; + +const getNextTheme = (current: ThemeMode): ThemeMode => { + const themes: ThemeMode[] = + getSystemTheme() === "dark" + ? ["auto", "light", "dark"] + : ["auto", "dark", "light"]; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return themes[(themes.indexOf(current) + 1) % themes.length]!; +}; + +export const themeDetectorScript = (function () { + function themeFn() { + const isValidTheme = (theme: string): theme is ThemeMode => { + const validThemes = ["light", "dark", "auto"] as const; + return validThemes.includes(theme as ThemeMode); + }; + + const storedTheme = localStorage.getItem("theme-mode") ?? "auto"; + const validTheme = isValidTheme(storedTheme) ? storedTheme : "auto"; + + if (validTheme === "auto") { + const autoTheme = window.matchMedia("(prefers-color-scheme: dark)") + .matches + ? "dark" + : "light"; + document.documentElement.classList.add(autoTheme, "auto"); + } else { + document.documentElement.classList.add(validTheme); + } + } + return `(${themeFn.toString()})();`; +})(); + +interface ThemeContextProps { + themeMode: ThemeMode; + resolvedTheme: ResolvedTheme; + setTheme: (theme: ThemeMode) => void; + toggleMode: () => void; +} +const ThemeContext = React.createContext( + undefined, +); + +export function ThemeProvider({ children }: React.PropsWithChildren) { + const [themeMode, setThemeMode] = React.useState(getStoredThemeMode); + + React.useEffect(() => { + if (themeMode !== "auto") return; + return setupPreferredListener(); + }, [themeMode]); + + const resolvedTheme = themeMode === "auto" ? getSystemTheme() : themeMode; + + const setTheme = (newTheme: ThemeMode) => { + setThemeMode(newTheme); + setStoredThemeMode(newTheme); + updateThemeClass(newTheme); + }; + + const toggleMode = () => { + setTheme(getNextTheme(themeMode)); + }; + + return ( + +