├── .dockerignore
├── .gitignore
├── LICENSE
├── README.md
├── apps
├── server
│ ├── .env.example
│ ├── .gitignore
│ ├── Dockerfile
│ ├── eslint.config.js
│ ├── package.json
│ ├── src
│ │ ├── env.ts
│ │ └── index.ts
│ ├── tsconfig.json
│ ├── tsup.config.ts
│ └── turbo.json
└── web
│ ├── .env.example
│ ├── .gitignore
│ ├── .prettierignore
│ ├── Dockerfile
│ ├── eslint.config.js
│ ├── index.html
│ ├── nginx.conf
│ ├── package.json
│ ├── public
│ ├── favicon.png
│ └── healthcheck
│ ├── src
│ ├── clients
│ │ ├── authClient.ts
│ │ ├── queryClient.ts
│ │ └── trpcClient.ts
│ ├── env.ts
│ ├── main.tsx
│ ├── routeTree.gen.ts
│ ├── router.tsx
│ ├── routes
│ │ ├── -components
│ │ │ ├── common
│ │ │ │ ├── form-field-info.tsx
│ │ │ │ └── spinner.tsx
│ │ │ └── layout
│ │ │ │ └── nav
│ │ │ │ ├── nav-container.tsx
│ │ │ │ ├── navbar.tsx
│ │ │ │ └── user-avatar.tsx
│ │ ├── __root.tsx
│ │ ├── _protected
│ │ │ ├── layout.tsx
│ │ │ └── posts
│ │ │ │ ├── $postid
│ │ │ │ └── index.tsx
│ │ │ │ ├── -components
│ │ │ │ ├── create-post.tsx
│ │ │ │ └── delete-post.tsx
│ │ │ │ ├── -validations
│ │ │ │ └── posts-link-options.ts
│ │ │ │ └── index.tsx
│ │ ├── _public
│ │ │ ├── -components
│ │ │ │ ├── login-form.tsx
│ │ │ │ └── register-form.tsx
│ │ │ ├── layout.tsx
│ │ │ ├── login.tsx
│ │ │ └── register.tsx
│ │ └── index.tsx
│ ├── style.css
│ └── vite-env.d.ts
│ ├── tsconfig.app.json
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ ├── turbo.json
│ └── vite.config.ts
├── compose.yaml
├── package.json
├── packages
├── api
│ ├── eslint.config.js
│ ├── package.json
│ ├── src
│ │ ├── client
│ │ │ └── index.ts
│ │ └── server
│ │ │ ├── index.ts
│ │ │ ├── router
│ │ │ └── post.ts
│ │ │ └── trpc.ts
│ └── tsconfig.json
├── auth
│ ├── eslint.config.js
│ ├── package.json
│ ├── src
│ │ ├── cli-config.ts
│ │ ├── client.ts
│ │ └── server.ts
│ └── tsconfig.json
├── db
│ ├── .env.example
│ ├── Dockerfile
│ ├── drizzle.config.ts
│ ├── eslint.config.js
│ ├── package.json
│ ├── src
│ │ ├── client.ts
│ │ ├── index.ts
│ │ ├── schema.ts
│ │ └── schemas
│ │ │ ├── auth.ts
│ │ │ └── posts.ts
│ ├── tsconfig.drizzlekit.json
│ ├── tsconfig.json
│ ├── tsconfig.package.json
│ └── turbo.json
└── ui
│ ├── components.json
│ ├── eslint.config.js
│ ├── package.json
│ ├── src
│ ├── components
│ │ ├── avatar.tsx
│ │ ├── button.tsx
│ │ ├── dialog.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── sonner.tsx
│ │ ├── textarea.tsx
│ │ └── tooltip.tsx
│ └── lib
│ │ └── utils.ts
│ └── tsconfig.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── tools
├── eslint
│ ├── base.js
│ ├── package.json
│ ├── react.js
│ ├── tsconfig.json
│ └── types.d.ts
├── prettier
│ ├── index.js
│ ├── package.json
│ └── tsconfig.json
├── tailwind
│ ├── eslint.config.js
│ ├── package.json
│ └── style.css
└── typescript
│ ├── base.json
│ ├── internal-package.json
│ ├── package.json
│ └── vite.json
└── turbo.json
/.dockerignore:
--------------------------------------------------------------------------------
1 | **/.cache/
2 | **/.turbo/
3 | **/.DS_Store
4 |
5 | **/.env*
6 |
7 | **/dist/
8 | **/node_modules/
9 | **/out/
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 | out/
4 |
5 | .cache/
6 | .turbo/
7 |
8 | .env*
9 | !.env.example
10 |
11 | .DS_Store
12 | .vscode
13 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2025 Khiet Tam Nguyen
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of
4 | this software and associated documentation files (the “Software”), to deal in
5 | the Software without restriction, including without limitation the rights to
6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
7 | the Software, and to permit persons to whom the Software is furnished to do so,
8 | subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
19 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
12 |
13 |
14 |
15 |
16 | A modern & lightweight [turborepo](https://turbo.build/repo/docs) template for
17 | fullstack projects with modular components, shared configs, containerised
18 | deployments and 100% type-safety.
19 |
20 | - [About](#about)
21 | - [Stack overview](#stack-overview)
22 | - [Base Functionalities](#base-functionalities)
23 | - [Inspirations & Goals](#inspirations--goals)
24 | - [Quick Start](#quick-start)
25 | - [Prerequisites](#prerequisites)
26 | - [Setup](#setup)
27 | - [Using an External Database](#using-an-external-database)
28 | - [Developing](#developing)
29 | - [Working with a single package](#working-with-a-single-package)
30 | - [Adding new shadcn components](#adding-new-shadcn-components)
31 | - [Adding new better-auth plugins](#adding-new-better-auth-plugins)
32 | - [Tooling Scripts](#tooling-scripts)
33 | - [Containerisation (Docker/Podman)](#containerisation-dockerpodman)
34 | - [Deployment](#deployment)
35 | - [Using Containers](#using-containers)
36 | - [Using Major Platforms](#using-major-platforms)
37 | - [Other Notes](#other-notes)
38 | - [Tanstack Router](#tanstack-router)
39 | - [Server API Artificial Delays](#server-api-artificial-delays)
40 | - [Environment Variables](#environment-variables)
41 | - [Extensions to existing template](#extensions-to-existing-template)
42 |
43 | ## About
44 |
45 | ### Stack overview
46 |
47 | Below is an overview of all the components in the stack:
48 |
49 | ```
50 | apps
51 | ├─ web
52 | | ├─ react (vite)
53 | | ├─ tanstack (router, query, form)
54 | | └─ tailwindcss
55 | ├─ server
56 | | └─ hono (wrapper for api & auth)
57 | packages
58 | ├─ api
59 | | └─ trpc with valibot
60 | ├─ auth
61 | | └─ better-auth
62 | ├─ db
63 | | └─ drizzle-orm (postgres database)
64 | ├─ ui
65 | | ├─ tailwindcss
66 | | └─ shadcn & radix ui
67 | tools
68 | ├─ eslint
69 | ├─ prettier
70 | ├─ tailwind
71 | └─ typescript
72 | ```
73 |
74 | View all catalog dependencies in [pnpm-workspace.yaml](pnpm-workspace.yaml).
75 |
76 | ### Base Functionalities
77 |
78 | The following features are implemented out-of-the-box:
79 |
80 | - login/register (using [better-auth email/password](https://www.better-auth.com/docs/authentication/email-password)) credentials provider
81 | - themes (dark/light mode using [next-themes](github.com/pacocoursey/next-themes))
82 | - web/server integration ([trpc](https://trpc.io/docs/quickstart) API example for creating/listing posts)
83 |
84 | You can visit the [live demo](https://rtstack.nktnet.uk) to see these features in action.
85 |
86 | ### Inspirations & Goals
87 |
88 | Many aspects of the RT Stack were derived from the
89 | [t3-oss/create-t3-turbo](https://github.com/t3-oss/create-t3-turbo). However,
90 | there is a preference for:
91 |
92 | - [tanstack router](https://tanstack.com/router/latest) (web) + [hono](https://hono.dev) (server) instead of [nextjs](https://nextjs.org) (fullstack)
93 | - [better auth](https://www.better-auth.com) for authentication instead [auth.js (next auth)](https://authjs.dev)
94 | - [valibot](https://valibot.dev) for input validation instead of [zod](https://zod.dev)
95 | - [tanstack form](https://tanstack.com/form/latest) instead of [react-hook-form](https://react-hook-form.com)
96 | - using `.env` in each application/package instead of globally, as per [turborepo's recommendations](https://turbo.build/repo/docs/crafting-your-repository/using-environment-variables#best-practices)
97 |
98 | This project also aims to consistently adopt the latest releases of dependencies and tools. For example:
99 |
100 | - react v19
101 | - tailwindcss v4 & shadcn-ui (canary)
102 | - trpc v11
103 | - eslint v9
104 | - pnpm v10
105 |
106 | ## Quick Start
107 |
108 | ### Prerequisites
109 |
110 | Ensure the following tools are available on your system:
111 |
112 | 1. [node](https://nodejs.org/en/download) (version 22+)
113 | 1. [pnpm](https://pnpm.io/installation) (version 10+)
114 | 1. [postgres](https://www.postgresql.org) database, which you can easily run using tools like:
115 | - [docker](https://docs.docker.com/engine/install) and [docker-compose](https://docs.docker.com/compose)
116 | - [podman](https://podman.io/docs/installation) and [podman-compose](https://github.com/containers/podman-compose)
117 | - [supabase](https://supabase.com)'s free tier cloud database
118 |
119 | ### Setup
120 |
121 | ```bash
122 | # Create a repository using the rt-stack template (replace YOUR_PROJECT)
123 | pnpm dlx create-turbo@latest -m pnpm -e https://github.com/nktnet1/rt-stack YOUR_PROJECT
124 |
125 | # Enter the directory or open in your IDE (replace YOUR_PROJECT)
126 | cd YOUR_PROJECT
127 |
128 | # Install all dependencies for apps and packages
129 | pnpm install
130 |
131 | # Copy .env.example to .env for all applications and the @repo/db package
132 | pnpm env:copy-example
133 |
134 | # Start a local postgres instance in the background (e.g. using docker)
135 | docker compose up db --detach
136 |
137 | # Push the drizzle schema to your database
138 | pnpm db:push
139 | ```
140 |
141 | You can then start all applications with
142 |
143 | ```bash
144 | pnpm dev
145 | ```
146 |
147 | By default the following URLs will be accessible:
148 |
149 | - web application: http://localhost:8085
150 | - backend server: http://localhost:3035
151 |
152 | ### Using an External Database
153 |
154 | When using an external postgres database (e.g. from [supabase](https://supabase.com)), you can skip the step that spins up a local postgres instance with docker.
155 |
156 | Instead, you will need to modify the following environment variables:
157 |
158 | 1. `SERVER_POSTGRES_URL` in the file `apps/server/.env`
159 |
160 | - used at runtime by the backend server in `pnpm dev`
161 |
162 | 1. `DB_POSTGRES_URL` in the file `packages/db/.env`
163 | - used in database schema migrations with `pnpm db:push`
164 |
165 | ## Developing
166 |
167 | ### Working with a single package
168 |
169 | Use [`pnpm --filter=`](https://pnpm.io/filtering) (where `` is
170 | defined in the `package.json` of each package).
171 |
172 | Example usage:
173 |
174 | ```bash
175 | # Install the nuqs package for our web application:
176 | pnpm --filter=web install nuqs
177 |
178 | # Format only the ui package:
179 | pnpm --filter=@repo/ui format
180 | ```
181 |
182 | You can get a list of all package names using the command below:
183 |
184 | ```bash
185 | find . -maxdepth 3 -name "package.json" -exec grep '"name":' {} \;
186 | ```
187 |
188 | ### Adding new shadcn components
189 |
190 | To install a single Shadcn/UI component, e.g. `button`, use the command
191 |
192 | ```bash
193 | pnpm ui-add button
194 | ```
195 |
196 | You can also open an interactive session to select components using a TUI by not passing any arguments
197 |
198 | ```bash
199 | pnpm ui-add
200 | ```
201 |
202 | - press `i` to enter interactive mode on startup
203 | - use `j/k` (or arrow keys) to navigate up and down.
204 | - use `` to toggle select your desired component(s)
205 | - hit `` to install all selected components
206 |
207 | ### Adding new better-auth plugins
208 |
209 | When integrating more better-auth plugins, e.g.
210 |
211 | - [admin](https://better-auth.vercel.app/docs/plugins/admin)
212 | - [organization](https://better-auth.vercel.app/docs/plugins/organization)
213 |
214 | You should
215 |
216 | 1. Modify the auth package server and client files in accordance with the plugin's
217 | respective documentations.
218 |
219 | 2. Run the interactive command:
220 |
221 | ```bash
222 | pnpm auth:schema:generate
223 | ```
224 |
225 | Press `i` to enter interactive mode, then `y` to overwrite [packages/db/src/schemas/auth.ts](packages/db/src/schemas/auth.ts).
226 |
227 | 3. Format and fix all linting issues, e.g. with
228 |
229 | ```bash
230 | pnpm format:fix
231 | pnpm lint:fix
232 | ```
233 |
234 | 4. Push your new schema to the database
235 |
236 | ```bash
237 | pnpm db:push
238 | ```
239 |
240 | 5. Occasionally, the type inference will not work immediately in your IDE (e.g. in VSCode).
241 | This can be resolved by running
242 |
243 | ```bash
244 | pnpm clean && pnpm install
245 | ```
246 |
247 | followed by a restarting your TS Server or reloading VSCode.
248 |
249 | You can find an example in the [better-auth-admin-organization-plugins](https://github.com/nktnet1/rt-stack/tree/better-auth-admin-organization-plugins) branch.
250 |
251 | ### Tooling Scripts
252 |
253 | All scripts are defined in [package.json](package.json) and
254 | [turbo.json](turbo.json):
255 |
256 | ```bash
257 | pnpm clean # remove all .cache, .turbo, dist, node_modules
258 |
259 | pnpm typecheck # report typescript issues
260 |
261 | pnpm format # report prettier issues
262 | pnpm format:fix # auto-fix prettier issues
263 |
264 | pnpm lint # report eslint issues
265 | pnpm lint:fix # auto-fix eslint issues
266 |
267 | pnpx codemod pnpm/catalog # migrate dependencies to pnpm-workspace.yaml
268 | ```
269 |
270 | ## Containerisation (Docker/Podman)
271 |
272 | Both the `web` and `server` applications have been containerised. You can start
273 | see this in action by running the commands:
274 |
275 | ```bash
276 | # Start all applications
277 | docker compose up --build
278 |
279 | # Push the drizzle schema to your database. While you can use `pnpm db:push` on
280 | # the host machine if you have installed all the required dependencies, it is
281 | # also possible to do everything within docker alone.
282 | # Open a second terminal and run the command:
283 | docker compose run --build --rm drizzle
284 |
285 | # Upon completion, you will be inside the `drizzle` docker container instead
286 | # of the host machine. It is now possible to push the schema with:
287 | pnpm db:push
288 | ```
289 |
290 | You can then open the web link below in your browser:
291 |
292 | - http://localhost:8085
293 |
294 | Please note that these containers are run in production mode. For further
295 | details, see
296 |
297 | - [compose.yaml](compose.yaml)
298 | - [apps/server/Dockerfile](apps/server/Dockerfile)
299 | - [apps/web/Dockerfile](apps/web/Dockerfile)
300 | - [apps/web/nginx.conf](apps/web/nginx.conf)
301 |
302 | ## Deployment
303 |
304 | > [!TIP]
305 | > The [live demo](https://rtstack.nktnet.uk) of RT Stack is currently deployed to
306 | >
307 | > - vercel for the web frontend
308 | > - fly.io for the server backend and postgres database
309 |
310 | ### Using Containers
311 |
312 | You can deploy applications to any services that supports docker deployment.
313 |
314 | Using docker compose (see [compose.yaml](compose.yaml)) is also an option,
315 | although this alone may not be production-ready at scale. However, it can be
316 | paired with
317 |
318 | - reverse proxies and load balancers offered by tools like
319 | [Traefik](https://github.com/traefik/traefik) or
320 | [Caddy](https://github.com/caddyserver/caddy)
321 | - container orchestration platforms like [Docker Swarm](https://docs.docker.com/engine/swarm) and [Kubernetes](https://kubernetes.io)
322 |
323 | Personally, I recommend setting up a Virtual Private Server (e.g. on [Hetzner](https://www.hetzner.com))
324 | and make use of self-hostable PaaS software which automatically handles the complexity of deployment
325 | mentioned above for you - these includes:
326 |
327 | - Coolify
328 | - https://github.com/coollabsio/coolify
329 | - https://www.coolify.io
330 | - Dokploy
331 | - https://github.com/Dokploy/dokploy
332 | - http://dokploy.com
333 |
334 | Do note that for the **web** application, the `PUBLIC_SERVER_URL` variable
335 | available at build time (as a docker build argument), rather than an environment
336 | variable at runtime.
337 |
338 | Also, both the **server** application's `PUBLIC_WEB_URL` and the **web**
339 | application's `PUBLIC_SERVER_URL` needs to be set as internet-accessible URLs
340 | when deployed, e.g. `https://mycompany.com` and `https://api.mycompany.com`,
341 | rather than referencing `http://localhost:8085` like in development.
342 |
343 | ### Using Major Platforms
344 |
345 | The **web** application is a simple React static site powered by Vite, which is
346 | easily deployed to platforms such as GitHub/GitLab pages, Vercel and Netlify.
347 | You can refer to the [vite documentation](https://vite.dev/guide/static-deploy)
348 | for deployment guides on all major platforms.
349 |
350 | The **server** application uses the [hono](https://hono.dev) web framework with
351 | the [NodeJS runtime](https://hono.dev/docs/getting-started/nodejs). However,
352 | this can be exchanged with other runtimes before deploying to your chosen
353 | platforms. For example, deploying to Netlify is covered within
354 | [Hono's documentations](https://hono.dev/docs/getting-started/netlify#_4-deploy).
355 |
356 | Note that when deploying your web frontend and server backend to two different
357 | domains, you will need to [tweak your better-auth configurations](https://www.better-auth.com/docs/integrations/hono#cross-domain-cookies).
358 | Apple's Safari browser also does not support third party cookies, so auth will
359 | not function as expected without any proxy workarounds.
360 |
361 | To keep things simple, it is recommended that you host your frontend and
362 | backend on the same root domain and differ by subdomains. For example, the
363 | frontend can be served at either `example.com` or `web.example.com`, and the
364 | backend hosted at `api.example.com`.
365 |
366 | ## Other Notes
367 |
368 | ### Tanstack Router
369 |
370 | The following is configured in [vite.config.ts](apps/web/vite.config.ts) web
371 | application:
372 |
373 | ```ts
374 | TanStackRouterVite({
375 | routeToken: 'layout',
376 | }),
377 | ```
378 |
379 | This enables the use of a `layout.tsx` file in each directory similar to NextJS.
380 | You can read more about this
381 | [here](https://github.com/TanStack/router/discussions/1102#discussioncomment-10946603).
382 |
383 | Also, it is recommended that you exclude the `routerTree.gen.ts` from your IDE.
384 | For example, in VSCode, you can add the following `.vscode/settings.json` at the
385 | root of your turborepo:
386 |
387 | ```json
388 | {
389 | "files.readonlyInclude": {
390 | "**/routeTree.gen.ts": true
391 | },
392 | "files.watcherExclude": {
393 | "**/routeTree.gen.ts": true
394 | },
395 | "search.exclude": {
396 | "**/routeTree.gen.ts": true
397 | }
398 | }
399 | ```
400 |
401 | ### Server API Artificial Delays
402 |
403 | There is an artificial delay added in development mode to simulate API usage in
404 | real-world environments. You can disable this by removing the `timingMiddleware`
405 | in [./packages/api/src/server/trpc.ts](./packages/api/src/server/trpc.ts)
406 |
407 | ### Environment Variables
408 |
409 | This template was made to follow the the recommendation of
410 |
411 | - @tyleralbee in [this turborepo's GitHub discussion](https://github.com/vercel/turborepo/discussions/9458#discussioncomment-11443969)
412 | - @cjkihl in [create-t3-turbo issue #397](https://github.com/t3-oss/create-t3-turbo/issues/397#issuecomment-1630028405)
413 | - turborepo official docs on [environment variables best practices](https://turbo.build/repo/docs/crafting-your-repository/using-environment-variables#best-practices)
414 |
415 | In using this template, it is recommended that
416 |
417 | 1. each application has a local `.env` file instead of a global `.env` at the
418 | root of your repository
419 | 1. packages should be pure, i.e. rely on factory methods and receiving inputs to
420 | instantiate rather than consuming environment variables directly
421 | - one exception is the `@repo/db` package, which requires the
422 | `DB_POSTGRES_URL` variable for schema migration with `pnpm db:push`
423 | 1. environment variables are prefixed, e.g. `SERVER_AUTH_SECRET` instead of
424 | `AUTH_SECRET`. Caching in the app's `turbo.json` can then be configured to
425 | use wildcards such as:
426 | ```json
427 | "tasks": {
428 | "build": {
429 | "env": ["SERVER_*"],
430 | }
431 | }
432 | ```
433 |
434 | There is also a script that creates a `.env` from `.env.example` of each
435 | app/package, which can be run with:
436 |
437 | ```bash
438 | # NOTE: This will not overwrite existing local .env files
439 | pnpm env:copy-example
440 |
441 | # To reset any modifications to your .env and restore the examples, run:
442 | pnpm env:remove
443 | pnpm env:copy-example
444 | ```
445 |
446 | It is recommended that any new apps that uses environment variables follow the
447 | example script set in [apps/server/package.json](apps/server/package.json).
448 |
449 | Extensions to Existing Template
450 | The table below demonstrates how you can build and extend upon the existing RT Stack template:
451 |
452 |
453 |
454 |
455 | Feature
456 | Description
457 | Branch Link
458 |
459 |
460 |
461 |
462 | NextJS
463 | Adds a docs
application that uses NextJS and Fumadocs, along with the workspace @repo/ui
package.
464 | nextjs-fumadocs
465 |
466 |
467 | Multi-language support
468 | Implements internationalisation support, e.g. switching between English and Vietnamese.
469 | i18n
470 |
471 |
472 | Better-auth Plugins
473 | Demonstrates how to integrate better-auth plugins in a type-safe and CLI-compatible manner.
474 | better-auth-admin-organization-plugins
475 |
476 |
477 |
478 |
--------------------------------------------------------------------------------
/apps/server/.env.example:
--------------------------------------------------------------------------------
1 | SERVER_AUTH_SECRET=please_change_this_in_production
2 | SERVER_POSTGRES_URL=postgres://postgres:postgres@localhost:5432/postgres
3 | SERVER_HOST=localhost
4 | SERVER_PORT=3035
5 |
6 | # Frontend URL, used to configure trusted origin (cors)
7 | PUBLIC_WEB_URL=http://localhost:8085
8 |
--------------------------------------------------------------------------------
/apps/server/.gitignore:
--------------------------------------------------------------------------------
1 | .cache/
2 | .turbo/
3 | .DS_Store
4 |
5 | .env*
6 | !.env.example
7 |
8 | dist/
9 | node_modules/
--------------------------------------------------------------------------------
/apps/server/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:22-alpine AS base
2 |
3 | ENV NODE_ENV=production
4 |
5 | WORKDIR /app
6 |
7 | # =========================================================================== #
8 |
9 | FROM base AS builder-base
10 |
11 | ENV TURBO_TELEMETRY_DISABLED=1
12 | ENV PNPM_HOME="/pnpm"
13 | ENV PATH="$PNPM_HOME:$PATH"
14 | ENV CI=1
15 |
16 | RUN corepack enable pnpm
17 |
18 | # =========================================================================== #
19 |
20 | FROM builder-base AS builder
21 |
22 | RUN pnpm install --global turbo@^2
23 |
24 | COPY . .
25 |
26 | # https://turbo.build/repo/docs/guides/tools/docker#the-solution
27 | RUN turbo prune server --docker
28 |
29 | # =========================================================================== #
30 |
31 | FROM builder-base AS installer
32 |
33 | COPY --from=builder /app/out/json/ .
34 | RUN pnpm install --frozen-lockfile
35 |
36 | COPY --from=builder /app/out/full/ .
37 | RUN pnpm build
38 |
39 | # =========================================================================== #
40 |
41 | FROM base AS production
42 |
43 | RUN addgroup --system --gid 1001 nodejs \
44 | && adduser --system --uid 1001 hono
45 |
46 | COPY --from=installer --chown=hono:nodejs /app/apps/server/dist /app/dist
47 |
48 | USER hono
49 |
50 | HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
51 | CMD wget --quiet --spider http://${SERVER_HOST}:${SERVER_PORT}/healthcheck || exit 1
52 |
53 | CMD ["node", "/app/dist/index.js"]
54 |
--------------------------------------------------------------------------------
/apps/server/eslint.config.js:
--------------------------------------------------------------------------------
1 | import baseConfig from '@repo/eslint-config/base';
2 |
3 | /** @type {import('typescript-eslint').Config} */
4 | export default [...baseConfig];
5 |
--------------------------------------------------------------------------------
/apps/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server",
3 | "type": "module",
4 | "scripts": {
5 | "build": "tsup",
6 | "clean": "git clean -xdf .cache .turbo dist node_modules",
7 | "dev": "pnpm env:run tsx watch src/index.ts",
8 | "env:copy-example": "shx -- cp -n .env.example .env",
9 | "env:remove": "shx -- rm -f .env",
10 | "env:run": "dotenv --",
11 | "format": "prettier --check . --ignore-path ../../.gitignore",
12 | "lint": "eslint",
13 | "start": "NODE_ENV=production pnpm env:run node dist/index.js",
14 | "typecheck": "tsc --noEmit"
15 | },
16 | "prettier": "@repo/prettier-config",
17 | "dependencies": {
18 | "@hono/node-server": "catalog:",
19 | "@hono/trpc-server": "catalog:",
20 | "@repo/api": "workspace:*",
21 | "@repo/auth": "workspace:*",
22 | "@repo/db": "workspace:*",
23 | "hono": "catalog:",
24 | "valibot": "catalog:"
25 | },
26 | "devDependencies": {
27 | "@repo/eslint-config": "workspace:*",
28 | "@repo/prettier-config": "workspace:*",
29 | "@repo/typescript-config": "workspace:*",
30 | "@types/node": "catalog:",
31 | "dotenv-cli": "catalog:",
32 | "eslint": "catalog:",
33 | "shx": "catalog:",
34 | "tsup": "catalog:",
35 | "tsx": "catalog:",
36 | "typescript": "catalog:"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/apps/server/src/env.ts:
--------------------------------------------------------------------------------
1 | import * as v from 'valibot';
2 |
3 | const DEFAULT_SERVER_PORT = 3035;
4 | const DEFAULT_SERVER_HOST = 'localhost';
5 |
6 | const createPortSchema = ({ defaultPort }: { defaultPort: number }) =>
7 | v.pipe(
8 | v.optional(v.string(), `${defaultPort}`),
9 | v.transform((s) => parseInt(s, 10)),
10 | v.integer(),
11 | v.minValue(0),
12 | v.maxValue(65535),
13 | );
14 |
15 | export const envSchema = v.object({
16 | SERVER_PORT: createPortSchema({ defaultPort: DEFAULT_SERVER_PORT }),
17 | SERVER_HOST: v.pipe(
18 | v.optional(v.string(), DEFAULT_SERVER_HOST),
19 | v.minLength(1),
20 | ),
21 | SERVER_AUTH_SECRET: v.pipe(v.string(), v.minLength(1)),
22 | SERVER_POSTGRES_URL: v.string(),
23 |
24 | // Frontend URL, used to configure trusted origin (CORS)
25 | PUBLIC_WEB_URL: v.pipe(v.string(), v.url()),
26 | });
27 |
28 | export const env = v.parse(envSchema, process.env);
29 |
--------------------------------------------------------------------------------
/apps/server/src/index.ts:
--------------------------------------------------------------------------------
1 | import { serve } from '@hono/node-server';
2 | import { trpcServer } from '@hono/trpc-server';
3 | import { createApi } from '@repo/api/server';
4 | import { createAuth } from '@repo/auth/server';
5 | import { createDb } from '@repo/db/client';
6 | import { Hono } from 'hono';
7 | import { cors } from 'hono/cors';
8 | import { logger } from 'hono/logger';
9 | import { env } from './env';
10 |
11 | const trustedOrigins = [env.PUBLIC_WEB_URL].map((url) => new URL(url).origin);
12 |
13 | const wildcardPath = {
14 | ALL: '*',
15 | BETTER_AUTH: '/api/auth/*',
16 | TRPC: '/trpc/*',
17 | } as const;
18 |
19 | const db = createDb({ databaseUrl: env.SERVER_POSTGRES_URL });
20 | const auth = createAuth({
21 | authSecret: env.SERVER_AUTH_SECRET,
22 | db,
23 | webUrl: env.PUBLIC_WEB_URL,
24 | });
25 | const api = createApi({ auth, db });
26 |
27 | const app = new Hono<{
28 | Variables: {
29 | user: typeof auth.$Infer.Session.user | null;
30 | session: typeof auth.$Infer.Session.session | null;
31 | };
32 | }>();
33 |
34 | app.get('/healthcheck', (c) => {
35 | return c.text('OK');
36 | });
37 |
38 | app.use(logger());
39 |
40 | app.use(
41 | wildcardPath.BETTER_AUTH,
42 | cors({
43 | origin: trustedOrigins,
44 | credentials: true,
45 | allowHeaders: ['Content-Type', 'Authorization'],
46 | allowMethods: ['POST', 'GET', 'OPTIONS'],
47 | exposeHeaders: ['Content-Length'],
48 | maxAge: 600,
49 | }),
50 | );
51 |
52 | app.use(
53 | wildcardPath.TRPC,
54 | cors({
55 | origin: trustedOrigins,
56 | credentials: true,
57 | }),
58 | );
59 |
60 | app.on(['POST', 'GET'], wildcardPath.BETTER_AUTH, (c) =>
61 | auth.handler(c.req.raw),
62 | );
63 |
64 | app.use(
65 | wildcardPath.TRPC,
66 | trpcServer({
67 | router: api.trpcRouter,
68 | createContext: (c) => api.createTRPCContext({ headers: c.req.headers }),
69 | }),
70 | );
71 |
72 | app.get('/', (c) => {
73 | return c.text('Hello Hono!');
74 | });
75 |
76 | const server = serve(
77 | {
78 | fetch: app.fetch,
79 | port: env.SERVER_PORT,
80 | hostname: env.SERVER_HOST,
81 | },
82 | (info) => {
83 | const host = info.family === 'IPv6' ? `[${info.address}]` : info.address;
84 | console.log(`Hono internal server: http://${host}:${info.port}`);
85 | },
86 | );
87 |
88 | const shutdown = () => {
89 | server.close((error) => {
90 | if (error) {
91 | console.error(error);
92 | } else {
93 | console.log('\nServer has stopped gracefully.');
94 | }
95 | process.exit(0);
96 | });
97 | };
98 |
99 | process.on('SIGINT', shutdown);
100 | process.on('SIGTERM', shutdown);
101 |
--------------------------------------------------------------------------------
/apps/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/base.json",
3 | "include": ["src"],
4 | "compilerOptions": {
5 | "types": ["node"],
6 | "jsxImportSource": "hono/jsx"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/apps/server/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'tsup';
2 |
3 | export default defineConfig({
4 | entry: ['./src/index.ts'],
5 | format: 'esm',
6 | noExternal: [/.*/],
7 | platform: 'node',
8 | splitting: false,
9 | bundle: true,
10 | outDir: './dist',
11 | clean: true,
12 | env: { IS_SERVER_BUILD: 'true' },
13 | loader: { '.json': 'copy' },
14 | minify: false,
15 | sourcemap: true,
16 |
17 | // https://github.com/egoist/tsup/issues/927#issuecomment-2416440833
18 | banner: ({ format }) => {
19 | if (format === 'esm')
20 | return {
21 | js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url);`,
22 | };
23 | return {};
24 | },
25 | });
26 |
--------------------------------------------------------------------------------
/apps/server/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turborepo.org/schema.json",
3 | "extends": ["//"],
4 | "tasks": {
5 | "dev": {
6 | "env": ["SERVER_*", "PUBLIC_WEB_URL"],
7 | "inputs": ["$TURBO_DEFAULT$", ".env"],
8 | "persistent": true
9 | },
10 | "build": {
11 | "env": ["SERVER_*", "PUBLIC_WEB_URL"],
12 | "inputs": ["$TURBO_DEFAULT$", ".env"]
13 | },
14 | "start": {
15 | "env": ["SERVER_*", "PUBLIC_WEB_URL"],
16 | "persistent": true
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/apps/web/.env.example:
--------------------------------------------------------------------------------
1 | # Used for web application - this is the backend API server.
2 | # This should be passed as a build-time variable (ARG) in docker.
3 | PUBLIC_SERVER_URL=http://localhost:3035
4 |
5 | # Used only to set default port/host in development (vite.config.ts)
6 | PUBLIC_WEB_URL=http://localhost:8085
7 |
--------------------------------------------------------------------------------
/apps/web/.gitignore:
--------------------------------------------------------------------------------
1 | .cache/
2 | .turbo/
3 | .DS_Store
4 |
5 | .env*
6 | !.env.example
7 |
8 | dist/
9 | node_modules/
--------------------------------------------------------------------------------
/apps/web/.prettierignore:
--------------------------------------------------------------------------------
1 | **/src/routeTree.gen.ts
2 |
--------------------------------------------------------------------------------
/apps/web/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:22-alpine AS base
2 |
3 | WORKDIR /app
4 |
5 | ENV NODE_ENV=production
6 | ENV TURBO_TELEMETRY_DISABLED=1
7 | ENV PNPM_HOME="/pnpm"
8 | ENV PATH="$PNPM_HOME:$PATH"
9 | ENV CI=1
10 |
11 | RUN corepack enable pnpm
12 |
13 | # =========================================================================== #
14 |
15 | FROM base AS builder
16 |
17 | RUN pnpm install --global turbo@^2
18 |
19 | COPY . .
20 |
21 | # https://turbo.build/repo/docs/guides/tools/docker#the-solution
22 | RUN turbo prune web --docker
23 |
24 | # =========================================================================== #
25 |
26 | FROM base AS installer
27 |
28 | ARG PUBLIC_SERVER_URL
29 | ENV PUBLIC_SERVER_URL=${PUBLIC_SERVER_URL}
30 |
31 | COPY --from=builder /app/out/json/ .
32 | RUN pnpm install --frozen-lockfile
33 |
34 | COPY --from=builder /app/out/full/ .
35 | RUN pnpm build
36 |
37 | # =========================================================================== #
38 |
39 | FROM nginx:stable-alpine AS production
40 |
41 | WORKDIR /app
42 |
43 | COPY apps/web/nginx.conf /etc/nginx/nginx.conf
44 | COPY --from=installer /app/apps/web/dist /usr/share/nginx/html
45 |
46 | HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
47 | CMD curl --fail --silent http://0.0.0.0:80/healthcheck || exit 1
48 |
49 | CMD ["nginx", "-g", "daemon off;"]
50 |
--------------------------------------------------------------------------------
/apps/web/eslint.config.js:
--------------------------------------------------------------------------------
1 | import { restrictEnvAccess } from '@repo/eslint-config/base';
2 | import reactConfig from '@repo/eslint-config/react';
3 |
4 | /** @type {import("eslint").Linter.Config} */
5 | export default [
6 | ...reactConfig,
7 | ...restrictEnvAccess,
8 | {
9 | files: ['vite.config.ts'],
10 | rules: {
11 | 'no-restricted-properties': 'off',
12 | },
13 | },
14 | ];
15 |
--------------------------------------------------------------------------------
/apps/web/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
45 | RT Stack
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/apps/web/nginx.conf:
--------------------------------------------------------------------------------
1 | worker_processes 4;
2 |
3 | events {
4 | worker_connections 1024;
5 | }
6 |
7 | http {
8 | include mime.types;
9 |
10 | default_type application/octet-stream;
11 |
12 | set_real_ip_from 0.0.0.0/0;
13 | real_ip_recursive on;
14 | real_ip_header X-Forward-For;
15 | limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;
16 |
17 | access_log /dev/stdout;
18 | error_log /dev/stderr;
19 |
20 | server {
21 | listen 80;
22 |
23 | location / {
24 | root /usr/share/nginx/html;
25 | index index.html index.htm;
26 | try_files $uri $uri/ /index.html;
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/apps/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web",
3 | "version": "0.0.0",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "build": "pnpm env:run build",
8 | "clean": "git clean -xdf .cache dist .turbo node_modules",
9 | "dev": "pnpm env:run dev",
10 | "env:copy-example": "shx -- cp -n .env.example .env",
11 | "env:remove": "shx -- rm -f .env",
12 | "env:run": "dotenv -- vite --configLoader runner",
13 | "format": "prettier --check . --ignore-path .prettierignore --ignore-path ../../.gitignore",
14 | "lint": "eslint \"src/**/*.{ts,tsx}\"",
15 | "preview": "pnpm env:run preview",
16 | "start": "pnpm env:run",
17 | "typecheck": "tsc --build"
18 | },
19 | "dependencies": {
20 | "@radix-ui/react-icons": "catalog:",
21 | "@repo/auth": "workspace:*",
22 | "@repo/ui": "workspace:*",
23 | "@tailwindcss/vite": "catalog:",
24 | "@tanstack/react-form": "catalog:",
25 | "@tanstack/react-query": "catalog:",
26 | "@tanstack/react-router": "catalog:",
27 | "@tanstack/router-devtools": "catalog:",
28 | "@trpc/client": "catalog:",
29 | "@trpc/tanstack-react-query": "catalog:",
30 | "dotenv-cli": "catalog:",
31 | "next-themes": "catalog:",
32 | "react": "catalog:",
33 | "react-dom": "catalog:",
34 | "sonner": "catalog:",
35 | "tailwindcss": "catalog:",
36 | "valibot": "catalog:"
37 | },
38 | "prettier": "@repo/prettier-config",
39 | "devDependencies": {
40 | "@repo/api": "workspace:*",
41 | "@repo/eslint-config": "workspace:*",
42 | "@repo/prettier-config": "workspace:*",
43 | "@repo/tailwind-config": "workspace:*",
44 | "@repo/typescript-config": "workspace:*",
45 | "@tanstack/router-plugin": "catalog:",
46 | "@trpc/server": "catalog:",
47 | "@types/node": "catalog:",
48 | "@types/react": "catalog:",
49 | "@types/react-dom": "catalog:",
50 | "@vitejs/plugin-react-swc": "catalog:",
51 | "eslint": "catalog:",
52 | "shx": "catalog:",
53 | "typescript": "catalog:",
54 | "vite": "catalog:"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/apps/web/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nktnet1/rt-stack/d51722d969cf596a4b51dcff15ff93bd3c002a54/apps/web/public/favicon.png
--------------------------------------------------------------------------------
/apps/web/public/healthcheck:
--------------------------------------------------------------------------------
1 | OK
2 |
--------------------------------------------------------------------------------
/apps/web/src/clients/authClient.ts:
--------------------------------------------------------------------------------
1 | import { createAuthClient } from '@repo/auth/client';
2 | import { env } from '@/env';
3 |
4 | export const authClient = createAuthClient({
5 | apiBaseUrl: env.PUBLIC_SERVER_URL,
6 | });
7 |
8 | export type AuthSession =
9 | | ReturnType['$Infer']['Session']
10 | | null;
11 |
--------------------------------------------------------------------------------
/apps/web/src/clients/queryClient.ts:
--------------------------------------------------------------------------------
1 | import { QueryClient } from '@tanstack/react-query';
2 |
3 | export const queryClient = new QueryClient();
4 |
--------------------------------------------------------------------------------
/apps/web/src/clients/trpcClient.ts:
--------------------------------------------------------------------------------
1 | import { createTrpcClient } from '@repo/api/client';
2 | import { env } from '@/env';
3 |
4 | export const trpcClient = createTrpcClient({
5 | serverUrl: env.PUBLIC_SERVER_URL,
6 | });
7 |
--------------------------------------------------------------------------------
/apps/web/src/env.ts:
--------------------------------------------------------------------------------
1 | import * as v from 'valibot';
2 |
3 | export const CLIENT_ENV_PREFIX = 'PUBLIC_';
4 |
5 | export const envSchema = v.object({
6 | /**
7 | * This is the backend API server. Note that this should be passed as
8 | * a build-time variable (ARG) in docker.
9 | */
10 | PUBLIC_SERVER_URL: v.pipe(v.string(), v.url()),
11 |
12 | /**
13 | * Set this if you want to run or deploy your app at a base URL. This is
14 | * usually required for deploying a repository to Github/Gitlab pages.
15 | */
16 | PUBLIC_BASE_PATH: v.pipe(v.optional(v.string(), '/'), v.startsWith('/')),
17 | });
18 |
19 | export const env = v.parse(envSchema, import.meta.env);
20 |
--------------------------------------------------------------------------------
/apps/web/src/main.tsx:
--------------------------------------------------------------------------------
1 | import '@/style.css';
2 | import { RouterProvider } from '@tanstack/react-router';
3 | import { ThemeProvider } from 'next-themes';
4 | import React from 'react';
5 | import ReactDOM from 'react-dom/client';
6 | import { createRouter } from '@/router';
7 |
8 | const ROOT_ELEMENT_ID = 'app';
9 |
10 | const rootElement = document.getElementById(ROOT_ELEMENT_ID);
11 |
12 | if (!rootElement) {
13 | throw new Error(`Root element with ID '${ROOT_ELEMENT_ID}' not found.`);
14 | }
15 |
16 | const router = createRouter();
17 |
18 | if (!rootElement.innerHTML) {
19 | const root = ReactDOM.createRoot(rootElement);
20 | root.render(
21 |
22 |
29 |
30 |
31 | ,
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/apps/web/src/routeTree.gen.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 |
3 | // @ts-nocheck
4 |
5 | // noinspection JSUnusedGlobalSymbols
6 |
7 | // This file was automatically generated by TanStack Router.
8 | // You should NOT make any changes in this file as it will be overwritten.
9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
10 |
11 | // Import Routes
12 |
13 | import { Route as rootRoute } from './routes/__root'
14 | import { Route as PublicLayoutImport } from './routes/_public/layout'
15 | import { Route as ProtectedLayoutImport } from './routes/_protected/layout'
16 | import { Route as IndexImport } from './routes/index'
17 | import { Route as PublicRegisterImport } from './routes/_public/register'
18 | import { Route as PublicLoginImport } from './routes/_public/login'
19 | import { Route as ProtectedPostsIndexImport } from './routes/_protected/posts/index'
20 | import { Route as ProtectedPostsPostidIndexImport } from './routes/_protected/posts/$postid/index'
21 |
22 | // Create/Update Routes
23 |
24 | const PublicLayoutRoute = PublicLayoutImport.update({
25 | id: '/_public',
26 | getParentRoute: () => rootRoute,
27 | } as any)
28 |
29 | const ProtectedLayoutRoute = ProtectedLayoutImport.update({
30 | id: '/_protected',
31 | getParentRoute: () => rootRoute,
32 | } as any)
33 |
34 | const IndexRoute = IndexImport.update({
35 | id: '/',
36 | path: '/',
37 | getParentRoute: () => rootRoute,
38 | } as any)
39 |
40 | const PublicRegisterRoute = PublicRegisterImport.update({
41 | id: '/register',
42 | path: '/register',
43 | getParentRoute: () => PublicLayoutRoute,
44 | } as any)
45 |
46 | const PublicLoginRoute = PublicLoginImport.update({
47 | id: '/login',
48 | path: '/login',
49 | getParentRoute: () => PublicLayoutRoute,
50 | } as any)
51 |
52 | const ProtectedPostsIndexRoute = ProtectedPostsIndexImport.update({
53 | id: '/posts/',
54 | path: '/posts/',
55 | getParentRoute: () => ProtectedLayoutRoute,
56 | } as any)
57 |
58 | const ProtectedPostsPostidIndexRoute = ProtectedPostsPostidIndexImport.update({
59 | id: '/posts/$postid/',
60 | path: '/posts/$postid/',
61 | getParentRoute: () => ProtectedLayoutRoute,
62 | } as any)
63 |
64 | // Populate the FileRoutesByPath interface
65 |
66 | declare module '@tanstack/react-router' {
67 | interface FileRoutesByPath {
68 | '/': {
69 | id: '/'
70 | path: '/'
71 | fullPath: '/'
72 | preLoaderRoute: typeof IndexImport
73 | parentRoute: typeof rootRoute
74 | }
75 | '/_protected': {
76 | id: '/_protected'
77 | path: ''
78 | fullPath: ''
79 | preLoaderRoute: typeof ProtectedLayoutImport
80 | parentRoute: typeof rootRoute
81 | }
82 | '/_public': {
83 | id: '/_public'
84 | path: ''
85 | fullPath: ''
86 | preLoaderRoute: typeof PublicLayoutImport
87 | parentRoute: typeof rootRoute
88 | }
89 | '/_public/login': {
90 | id: '/_public/login'
91 | path: '/login'
92 | fullPath: '/login'
93 | preLoaderRoute: typeof PublicLoginImport
94 | parentRoute: typeof PublicLayoutImport
95 | }
96 | '/_public/register': {
97 | id: '/_public/register'
98 | path: '/register'
99 | fullPath: '/register'
100 | preLoaderRoute: typeof PublicRegisterImport
101 | parentRoute: typeof PublicLayoutImport
102 | }
103 | '/_protected/posts/': {
104 | id: '/_protected/posts/'
105 | path: '/posts'
106 | fullPath: '/posts'
107 | preLoaderRoute: typeof ProtectedPostsIndexImport
108 | parentRoute: typeof ProtectedLayoutImport
109 | }
110 | '/_protected/posts/$postid/': {
111 | id: '/_protected/posts/$postid/'
112 | path: '/posts/$postid'
113 | fullPath: '/posts/$postid'
114 | preLoaderRoute: typeof ProtectedPostsPostidIndexImport
115 | parentRoute: typeof ProtectedLayoutImport
116 | }
117 | }
118 | }
119 |
120 | // Create and export the route tree
121 |
122 | interface ProtectedLayoutRouteChildren {
123 | ProtectedPostsIndexRoute: typeof ProtectedPostsIndexRoute
124 | ProtectedPostsPostidIndexRoute: typeof ProtectedPostsPostidIndexRoute
125 | }
126 |
127 | const ProtectedLayoutRouteChildren: ProtectedLayoutRouteChildren = {
128 | ProtectedPostsIndexRoute: ProtectedPostsIndexRoute,
129 | ProtectedPostsPostidIndexRoute: ProtectedPostsPostidIndexRoute,
130 | }
131 |
132 | const ProtectedLayoutRouteWithChildren = ProtectedLayoutRoute._addFileChildren(
133 | ProtectedLayoutRouteChildren,
134 | )
135 |
136 | interface PublicLayoutRouteChildren {
137 | PublicLoginRoute: typeof PublicLoginRoute
138 | PublicRegisterRoute: typeof PublicRegisterRoute
139 | }
140 |
141 | const PublicLayoutRouteChildren: PublicLayoutRouteChildren = {
142 | PublicLoginRoute: PublicLoginRoute,
143 | PublicRegisterRoute: PublicRegisterRoute,
144 | }
145 |
146 | const PublicLayoutRouteWithChildren = PublicLayoutRoute._addFileChildren(
147 | PublicLayoutRouteChildren,
148 | )
149 |
150 | export interface FileRoutesByFullPath {
151 | '/': typeof IndexRoute
152 | '': typeof PublicLayoutRouteWithChildren
153 | '/login': typeof PublicLoginRoute
154 | '/register': typeof PublicRegisterRoute
155 | '/posts': typeof ProtectedPostsIndexRoute
156 | '/posts/$postid': typeof ProtectedPostsPostidIndexRoute
157 | }
158 |
159 | export interface FileRoutesByTo {
160 | '/': typeof IndexRoute
161 | '': typeof PublicLayoutRouteWithChildren
162 | '/login': typeof PublicLoginRoute
163 | '/register': typeof PublicRegisterRoute
164 | '/posts': typeof ProtectedPostsIndexRoute
165 | '/posts/$postid': typeof ProtectedPostsPostidIndexRoute
166 | }
167 |
168 | export interface FileRoutesById {
169 | __root__: typeof rootRoute
170 | '/': typeof IndexRoute
171 | '/_protected': typeof ProtectedLayoutRouteWithChildren
172 | '/_public': typeof PublicLayoutRouteWithChildren
173 | '/_public/login': typeof PublicLoginRoute
174 | '/_public/register': typeof PublicRegisterRoute
175 | '/_protected/posts/': typeof ProtectedPostsIndexRoute
176 | '/_protected/posts/$postid/': typeof ProtectedPostsPostidIndexRoute
177 | }
178 |
179 | export interface FileRouteTypes {
180 | fileRoutesByFullPath: FileRoutesByFullPath
181 | fullPaths: '/' | '' | '/login' | '/register' | '/posts' | '/posts/$postid'
182 | fileRoutesByTo: FileRoutesByTo
183 | to: '/' | '' | '/login' | '/register' | '/posts' | '/posts/$postid'
184 | id:
185 | | '__root__'
186 | | '/'
187 | | '/_protected'
188 | | '/_public'
189 | | '/_public/login'
190 | | '/_public/register'
191 | | '/_protected/posts/'
192 | | '/_protected/posts/$postid/'
193 | fileRoutesById: FileRoutesById
194 | }
195 |
196 | export interface RootRouteChildren {
197 | IndexRoute: typeof IndexRoute
198 | ProtectedLayoutRoute: typeof ProtectedLayoutRouteWithChildren
199 | PublicLayoutRoute: typeof PublicLayoutRouteWithChildren
200 | }
201 |
202 | const rootRouteChildren: RootRouteChildren = {
203 | IndexRoute: IndexRoute,
204 | ProtectedLayoutRoute: ProtectedLayoutRouteWithChildren,
205 | PublicLayoutRoute: PublicLayoutRouteWithChildren,
206 | }
207 |
208 | export const routeTree = rootRoute
209 | ._addFileChildren(rootRouteChildren)
210 | ._addFileTypes()
211 |
212 | /* ROUTE_MANIFEST_START
213 | {
214 | "routes": {
215 | "__root__": {
216 | "filePath": "__root.tsx",
217 | "children": [
218 | "/",
219 | "/_protected",
220 | "/_public"
221 | ]
222 | },
223 | "/": {
224 | "filePath": "index.tsx"
225 | },
226 | "/_protected": {
227 | "filePath": "_protected/layout.tsx",
228 | "children": [
229 | "/_protected/posts/",
230 | "/_protected/posts/$postid/"
231 | ]
232 | },
233 | "/_public": {
234 | "filePath": "_public/layout.tsx",
235 | "children": [
236 | "/_public/login",
237 | "/_public/register"
238 | ]
239 | },
240 | "/_public/login": {
241 | "filePath": "_public/login.tsx",
242 | "parent": "/_public"
243 | },
244 | "/_public/register": {
245 | "filePath": "_public/register.tsx",
246 | "parent": "/_public"
247 | },
248 | "/_protected/posts/": {
249 | "filePath": "_protected/posts/index.tsx",
250 | "parent": "/_protected"
251 | },
252 | "/_protected/posts/$postid/": {
253 | "filePath": "_protected/posts/$postid/index.tsx",
254 | "parent": "/_protected"
255 | }
256 | }
257 | }
258 | ROUTE_MANIFEST_END */
259 |
--------------------------------------------------------------------------------
/apps/web/src/router.tsx:
--------------------------------------------------------------------------------
1 | import { QueryClientProvider } from '@tanstack/react-query';
2 | import { createRouter as createTanstackRouter } from '@tanstack/react-router';
3 | import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
4 | import type { AppRouter } from '@repo/api/server';
5 | import { queryClient } from '@/clients/queryClient';
6 | import { trpcClient } from '@/clients/trpcClient';
7 | import { env } from '@/env';
8 | import { routeTree } from '@/routeTree.gen';
9 | import Spinner from '@/routes/-components/common/spinner';
10 |
11 | export const trpc = createTRPCOptionsProxy({
12 | client: trpcClient,
13 | queryClient,
14 | });
15 |
16 | export function createRouter() {
17 | const router = createTanstackRouter({
18 | routeTree,
19 | basepath: env.PUBLIC_BASE_PATH,
20 | scrollRestoration: true,
21 | defaultPreload: 'intent',
22 | defaultPendingComponent: () => ,
23 | Wrap: function WrapComponent({ children }) {
24 | return (
25 |
26 | {children}
27 |
28 | );
29 | },
30 | });
31 | return router;
32 | }
33 |
34 | declare module '@tanstack/react-router' {
35 | interface Register {
36 | router: ReturnType;
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/apps/web/src/routes/-components/common/form-field-info.tsx:
--------------------------------------------------------------------------------
1 | import type { AnyFieldApi } from '@tanstack/react-form';
2 |
3 | export default function FormFieldInfo({ field }: { field: AnyFieldApi }) {
4 | return (
5 |
6 | {field.state.meta.isTouched && field.state.meta.errors.length ? (
7 |
8 | {field.state.meta.errors.map((e) => e.message).join(', ')}
9 |
10 | ) : null}
11 | {field.state.meta.isValidating ? 'Validating...' : null}
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/apps/web/src/routes/-components/common/spinner.tsx:
--------------------------------------------------------------------------------
1 | import { GearIcon } from '@radix-ui/react-icons';
2 | import { cn } from '@repo/ui/lib/utils';
3 |
4 | function Spinner({ className }: Readonly<{ className?: string }>) {
5 | return (
6 |
7 |
8 |
9 | );
10 | }
11 |
12 | export default Spinner;
13 |
--------------------------------------------------------------------------------
/apps/web/src/routes/-components/layout/nav/nav-container.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from 'react';
2 |
3 | export default function NavContainer({
4 | children,
5 | }: Readonly<{
6 | children?: ReactNode;
7 | }>) {
8 | return (
9 |
10 | {children}
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/apps/web/src/routes/-components/layout/nav/navbar.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from '@tanstack/react-router';
2 | import type { AuthSession } from '@/clients/authClient';
3 | import NavContainer from '@/routes/-components/layout/nav/nav-container';
4 | import UserAvatar from '@/routes/-components/layout/nav/user-avatar';
5 | import { postsLinkOptions } from '@/routes/_protected/posts/-validations/posts-link-options';
6 |
7 | const activeClassName = 'underline decoration-2 opacity-70';
8 |
9 | export function Navbar({ session }: Readonly<{ session: AuthSession }>) {
10 | return (
11 |
12 |
13 |
18 | Home
19 |
20 | {session?.user ? (
21 |
25 | Posts
26 |
27 | ) : null}
28 |
29 | {session?.user ? (
30 |
31 | ) : (
32 |
33 |
38 | Login
39 |
40 | |
41 |
46 | Register
47 |
48 |
49 | )}
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/apps/web/src/routes/-components/layout/nav/user-avatar.tsx:
--------------------------------------------------------------------------------
1 | import { ExitIcon, MoonIcon, SunIcon } from '@radix-ui/react-icons';
2 | import {
3 | Avatar,
4 | AvatarFallback,
5 | AvatarImage,
6 | } from '@repo/ui/components/avatar';
7 | import {
8 | DropdownMenu,
9 | DropdownMenuContent,
10 | DropdownMenuItem,
11 | DropdownMenuTrigger,
12 | } from '@repo/ui/components/dropdown-menu';
13 | import { useTheme } from 'next-themes';
14 | import { authClient } from '@/clients/authClient';
15 |
16 | export default function UserAvatar({
17 | user,
18 | }: Readonly<{
19 | user: typeof authClient.$Infer.Session.user;
20 | }>) {
21 | const { resolvedTheme, setTheme } = useTheme();
22 |
23 | return (
24 |
25 |
26 |
27 |
28 |
29 | {(user.name?.split(' ')[0]?.[0] || '') +
30 | (user.name?.split(' ')[1]?.[0] || '')}
31 |
32 |
33 |
34 |
35 |
36 | {user.name}
37 | {user.email}
38 |
39 |
40 |
41 | {
44 | e.preventDefault();
45 | setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
46 | }}
47 | >
48 | {resolvedTheme === 'dark' ? : }
49 | Theme
50 |
51 | {
53 | await authClient.signOut();
54 | }}
55 | className="cursor-pointer"
56 | >
57 |
58 | Logout
59 |
60 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/apps/web/src/routes/__root.tsx:
--------------------------------------------------------------------------------
1 | import { Toaster } from '@repo/ui/components/sonner';
2 | import { Outlet, createRootRoute } from '@tanstack/react-router';
3 | import React from 'react';
4 | import { authClient } from '@/clients/authClient';
5 | import Spinner from '@/routes/-components/common/spinner';
6 | import NavContainer from '@/routes/-components/layout/nav/nav-container';
7 | import { Navbar } from '@/routes/-components/layout/nav/navbar';
8 |
9 | export const Route = createRootRoute({
10 | component: RootComponent,
11 | });
12 |
13 | // https://tanstack.com/router/v1/docs/framework/react/devtools
14 | const TanStackRouterDevtools = import.meta.env.PROD
15 | ? () => null
16 | : React.lazy(() =>
17 | import('@tanstack/router-devtools').then((res) => ({
18 | default: res.TanStackRouterDevtools,
19 | })),
20 | );
21 |
22 | function RootComponent() {
23 | const { data: session, isPending } = authClient.useSession();
24 |
25 | if (isPending) {
26 | return (
27 |
28 |
29 |
30 | );
31 | }
32 |
33 | return (
34 | <>
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | >
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/apps/web/src/routes/_protected/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Navigate, Outlet, createFileRoute } from '@tanstack/react-router';
2 | import { authClient } from '@/clients/authClient';
3 | import Spinner from '@/routes/-components/common/spinner';
4 |
5 | export const Route = createFileRoute('/_protected')({
6 | component: Layout,
7 | });
8 |
9 | function Layout() {
10 | const { data: session, isPending } = authClient.useSession();
11 |
12 | if (isPending) {
13 | return ;
14 | }
15 |
16 | if (!session?.user) {
17 | return ;
18 | }
19 |
20 | return ;
21 | }
22 |
--------------------------------------------------------------------------------
/apps/web/src/routes/_protected/posts/$postid/index.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowLeftIcon, ReloadIcon } from '@radix-ui/react-icons';
2 | import { Button } from '@repo/ui/components/button';
3 | import {
4 | TooltipProvider,
5 | Tooltip,
6 | TooltipTrigger,
7 | TooltipContent,
8 | TooltipArrow,
9 | } from '@repo/ui/components/tooltip';
10 | import { createFileRoute, Link } from '@tanstack/react-router';
11 | import { queryClient } from '@/clients/queryClient';
12 | import { trpc } from '@/router';
13 | import { postsLinkOptions } from '@/routes/_protected/posts/-validations/posts-link-options';
14 |
15 | export const Route = createFileRoute('/_protected/posts/$postid/')({
16 | loader: ({ params }) =>
17 | queryClient.ensureQueryData(
18 | trpc.posts.one.queryOptions({ id: params.postid }),
19 | ),
20 | component: RouteComponent,
21 | errorComponent: ({ error, reset }) => {
22 | return (
23 |
24 |
{error.message}
25 |
26 |
27 |
28 |
29 | Go Back
30 |
31 |
32 |
{
35 | // Reset the router error boundary
36 | reset();
37 | }}
38 | className="w-full"
39 | >
40 | Retry?
41 |
42 |
43 |
44 | );
45 | },
46 | });
47 |
48 | function RouteComponent() {
49 | const post = Route.useLoaderData();
50 |
51 | return (
52 |
53 |
54 |
{post.title}
55 |
56 | Created by {post.author.name} ,{' '}
57 | {post.createdAt.toLocaleString()}
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
70 |
71 |
72 |
73 |
74 |
75 |
81 | View all posts
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 | {post.content ?? 'No content available.'}
90 |
91 |
92 |
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/apps/web/src/routes/_protected/posts/-components/create-post.tsx:
--------------------------------------------------------------------------------
1 | import { PlusIcon } from '@radix-ui/react-icons';
2 | import { Button } from '@repo/ui/components/button';
3 | import {
4 | Dialog,
5 | DialogContent,
6 | DialogDescription,
7 | DialogFooter,
8 | DialogHeader,
9 | DialogTitle,
10 | DialogTrigger,
11 | } from '@repo/ui/components/dialog';
12 | import { Input } from '@repo/ui/components/input';
13 | import { Label } from '@repo/ui/components/label';
14 | import { Textarea } from '@repo/ui/components/textarea';
15 | import { useForm } from '@tanstack/react-form';
16 | import { useMutation, useQuery } from '@tanstack/react-query';
17 | import { TRPCClientError } from '@trpc/client';
18 | import { useState } from 'react';
19 | import { toast } from 'sonner';
20 | import * as v from 'valibot';
21 | import { trpc } from '@/router';
22 | import FormFieldInfo from '@/routes/-components/common/form-field-info';
23 | import Spinner from '@/routes/-components/common/spinner';
24 |
25 | const FormSchema = v.object({
26 | title: v.pipe(
27 | v.string(),
28 | v.minLength(3, 'Please enter at least 3 characters'),
29 | ),
30 | content: v.pipe(
31 | v.string(),
32 | v.minLength(5, 'Please enter at least 5 characters'),
33 | ),
34 | });
35 |
36 | const generateTimestamp = () => +new Date();
37 |
38 | export default function CreatePostButton() {
39 | const getAllPostsQuery = useQuery(trpc.posts.all.queryOptions());
40 | const createPostMutation = useMutation(trpc.posts.create.mutationOptions());
41 | const [openDialog, setOpenDialog] = useState(false);
42 |
43 | const form = useForm({
44 | defaultValues: {
45 | title: `Post ${generateTimestamp()}`,
46 | content: `\
47 | The year was 2081, and everybody was finally equal.
48 | They weren't only equal before God and the law.
49 | They were equal every which way.
50 | Nobody was smarter than anybody else.
51 | Nobody was better looking than anybody else.
52 | Nobody was stronger or quicker than anybody else.
53 | All this equality was due to the 211th, 212th, and 213th Amendments to the Constitution,
54 | and to the unceasing vigilance of agents of the United States Handicapper General.
55 |
56 | - Harrison Bergeron by Kurt Vonnegut
57 | `,
58 | },
59 | validators: {
60 | onChange: FormSchema,
61 | },
62 | onSubmit: async ({ value, formApi }) => {
63 | try {
64 | await createPostMutation.mutateAsync({
65 | title: value.title,
66 | content: value.content,
67 | });
68 | setOpenDialog(false);
69 | await getAllPostsQuery.refetch();
70 | formApi.reset();
71 | toast.success('Your post has been created!');
72 | } catch (error) {
73 | if (error instanceof TRPCClientError) {
74 | toast.error(error.message);
75 | } else {
76 | toast.error('An unknown error has occurred. Please try again!');
77 | }
78 | }
79 | },
80 | });
81 |
82 | return (
83 |
84 |
85 |
86 |
87 | Create
88 |
89 |
90 |
91 |
92 | Create Post
93 |
94 | Write about an interesting topic!
95 |
96 |
97 |
163 |
164 |
165 | );
166 | }
167 |
--------------------------------------------------------------------------------
/apps/web/src/routes/_protected/posts/-components/delete-post.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@repo/ui/components/button';
2 | import {
3 | Tooltip,
4 | TooltipArrow,
5 | TooltipContent,
6 | TooltipProvider,
7 | TooltipTrigger,
8 | } from '@repo/ui/components/tooltip';
9 | import { cn } from '@repo/ui/lib/utils';
10 | import { useMutation, useQuery } from '@tanstack/react-query';
11 | import { toast } from 'sonner';
12 | import type { ReactNode } from '@tanstack/react-router';
13 | import { trpc } from '@/router';
14 | import Spinner from '@/routes/-components/common/spinner';
15 |
16 | export default function DeletePostButton({
17 | children,
18 | className,
19 | postId,
20 | }: Readonly<{
21 | children: ReactNode;
22 | className?: string;
23 | postId: string;
24 | }>) {
25 | const { refetch } = useQuery(trpc.posts.all.queryOptions());
26 |
27 | const deletePostMutation = useMutation(
28 | trpc.posts.delete.mutationOptions({
29 | onError: (error) => {
30 | toast.error(error.message);
31 | },
32 | onSuccess: async () => {
33 | await refetch();
34 | toast.info('Post deleted successfully.');
35 | },
36 | }),
37 | );
38 | return (
39 |
40 |
41 |
42 | {
45 | e.preventDefault();
46 | deletePostMutation.mutate({ id: postId });
47 | }}
48 | variant="destructive"
49 | className={cn('h-9 w-10', className)}
50 | >
51 | {deletePostMutation.isPending ? : children}
52 |
53 |
54 |
60 | Delete Post
61 |
62 |
63 |
64 |
65 | );
66 | }
67 |
--------------------------------------------------------------------------------
/apps/web/src/routes/_protected/posts/-validations/posts-link-options.ts:
--------------------------------------------------------------------------------
1 | import { linkOptions } from '@tanstack/react-router';
2 | import * as v from 'valibot';
3 |
4 | export const postsSearchSchema = v.object({
5 | searchString: v.fallback(v.string(), ''),
6 | sortDirection: v.fallback(v.picklist(['asc', 'desc']), 'desc'),
7 | });
8 |
9 | export type PostSearchSchema = v.InferOutput;
10 |
11 | export const postsSearchDefaults = v.getFallbacks(postsSearchSchema);
12 |
13 | export const postsLinkOptions = linkOptions({
14 | to: '/posts',
15 |
16 | /**
17 | * If we want links to contain default values in the URL
18 | */
19 | // search: postsSearchDefaults,
20 | });
21 |
--------------------------------------------------------------------------------
/apps/web/src/routes/_protected/posts/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ArrowDownIcon,
3 | ArrowUpIcon,
4 | MagnifyingGlassIcon,
5 | TrashIcon,
6 | } from '@radix-ui/react-icons';
7 | import { Button } from '@repo/ui/components/button';
8 | import { Input } from '@repo/ui/components/input';
9 | import {
10 | Tooltip,
11 | TooltipTrigger,
12 | TooltipContent,
13 | TooltipArrow,
14 | TooltipProvider,
15 | } from '@repo/ui/components/tooltip';
16 | import { useQuery } from '@tanstack/react-query';
17 | import {
18 | createFileRoute,
19 | stripSearchParams,
20 | type SearchSchemaInput,
21 | } from '@tanstack/react-router';
22 | import { Link, useNavigate } from '@tanstack/react-router';
23 | import * as v from 'valibot';
24 | import type { AppRouter } from '@repo/api/server';
25 | import type { inferRouterOutputs } from '@trpc/server';
26 | import { queryClient } from '@/clients/queryClient';
27 | import { trpc } from '@/router';
28 | import CreatePostButton from '@/routes/_protected/posts/-components/create-post';
29 | import DeletePostButton from '@/routes/_protected/posts/-components/delete-post';
30 | import {
31 | postsSearchDefaults,
32 | postsSearchSchema,
33 | type PostSearchSchema,
34 | } from '@/routes/_protected/posts/-validations/posts-link-options';
35 |
36 | export const Route = createFileRoute('/_protected/posts/')({
37 | loader: () => queryClient.ensureQueryData(trpc.posts.all.queryOptions()),
38 | component: RouteComponent,
39 | validateSearch: (input: SearchSchemaInput) =>
40 | v.parse(postsSearchSchema, input),
41 | search: {
42 | middlewares: [stripSearchParams(postsSearchDefaults)],
43 | },
44 | errorComponent: ({ error }) => {
45 | return (
46 |
47 |
{error.message}
48 |
49 | );
50 | },
51 | });
52 |
53 | function PostItem({
54 | post,
55 | disabled,
56 | }: Readonly<{
57 | post: inferRouterOutputs['posts']['all'][number];
58 | disabled: boolean;
59 | }>) {
60 | return (
61 |
67 |
68 |
{post.title}
69 |
{post.createdAt.toLocaleString()}
70 |
71 |
72 |
73 |
74 |
75 |
76 | );
77 | }
78 |
79 | function RouteComponent() {
80 | const { data: posts, isPending } = useQuery(trpc.posts.all.queryOptions());
81 | const navigate = useNavigate({ from: Route.fullPath });
82 | const search = Route.useSearch();
83 |
84 | const updateFilters = (name: keyof PostSearchSchema, value: unknown) => {
85 | navigate({ search: (prev) => ({ ...prev, [name]: value }) });
86 | };
87 |
88 | /**
89 | * You could memoize posts, although if you use the react 19 compiler
90 | * (which RT-stack will in the future), it won't be necessary.
91 | */
92 | const lowercaseSearch = search.searchString.toLowerCase();
93 | const filteredPost = posts
94 | ?.filter((p) => p.title.toLowerCase().includes(lowercaseSearch))
95 | .sort((a, b) =>
96 | search.sortDirection === 'asc'
97 | ? a.createdAt.getTime() - b.createdAt.getTime()
98 | : b.createdAt.getTime() - a.createdAt.getTime(),
99 | );
100 | return (
101 |
102 |
103 |
Posts
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 | e.preventDefault()}>
112 |
116 | updateFilters(
117 | 'sortDirection',
118 | search.sortDirection === 'asc' ? 'desc' : 'asc',
119 | )
120 | }
121 | >
122 | {search.sortDirection === 'asc' ? (
123 |
124 | ) : (
125 |
126 | )}
127 |
128 |
129 | e.preventDefault()}
134 | className="bg-neutral-500 fill-neutral-500 duration-0"
135 | >
136 | Sort by created date ({search.sortDirection})
137 |
138 |
139 |
140 |
141 |
142 | updateFilters('searchString', e.target.value)}
145 | placeholder="Search by title..."
146 | className="w-full pr-10 placeholder:italic peer"
147 | />
148 |
149 |
150 |
151 |
152 |
153 | {filteredPost?.length
154 | ? filteredPost.map((p) => (
155 |
156 | ))
157 | : 'There are no posts available.'}
158 |
159 |
160 | );
161 | }
162 |
--------------------------------------------------------------------------------
/apps/web/src/routes/_public/-components/login-form.tsx:
--------------------------------------------------------------------------------
1 | import { EyeNoneIcon, EyeOpenIcon } from '@radix-ui/react-icons';
2 | import { Button } from '@repo/ui/components/button';
3 | import { Input } from '@repo/ui/components/input';
4 | import { Label } from '@repo/ui/components/label';
5 | import { useForm } from '@tanstack/react-form';
6 | import { useNavigate } from '@tanstack/react-router';
7 | import { useState } from 'react';
8 | import { toast } from 'sonner';
9 | import * as v from 'valibot';
10 | import { authClient } from '@/clients/authClient';
11 | import FormFieldInfo from '@/routes/-components/common/form-field-info';
12 | import Spinner from '@/routes/-components/common/spinner';
13 |
14 | const FormSchema = v.object({
15 | email: v.pipe(v.string(), v.email('Please enter a valid email address')),
16 | password: v.pipe(
17 | v.string(),
18 | v.minLength(8, 'Password must be at least 8 characters'),
19 | ),
20 | });
21 |
22 | export default function LoginCredentialsForm() {
23 | const navigate = useNavigate();
24 | const [isPasswordVisible, setIsPasswordVisible] = useState(false);
25 | const form = useForm({
26 | defaultValues: {
27 | email: '',
28 | password: '',
29 | },
30 | validators: {
31 | onChange: FormSchema,
32 | },
33 | onSubmit: async ({ value }) => {
34 | const { error } = await authClient.signIn.email(
35 | {
36 | email: value.email,
37 | password: value.password,
38 | },
39 | {
40 | onSuccess: () => {
41 | navigate({ to: '/' });
42 | },
43 | },
44 | );
45 | if (error) {
46 | toast.error(error.message ?? JSON.stringify(error));
47 | }
48 | },
49 | });
50 |
51 | return (
52 | {
55 | e.preventDefault();
56 | e.stopPropagation();
57 | form.handleSubmit();
58 | }}
59 | >
60 |
61 |
{
64 | return (
65 | <>
66 | Email
67 | field.handleChange(e.target.value)}
75 | />
76 |
77 | >
78 | );
79 | }}
80 | />
81 |
82 |
119 | [state.canSubmit, state.isSubmitting]}
121 | children={([canSubmit, isSubmitting]) => (
122 |
123 | {isSubmitting ? : 'Log in'}
124 |
125 | )}
126 | />
127 |
128 | );
129 | }
130 |
--------------------------------------------------------------------------------
/apps/web/src/routes/_public/-components/register-form.tsx:
--------------------------------------------------------------------------------
1 | import { EyeNoneIcon, EyeOpenIcon } from '@radix-ui/react-icons';
2 | import { Button } from '@repo/ui/components/button';
3 | import { Input } from '@repo/ui/components/input';
4 | import { Label } from '@repo/ui/components/label';
5 | import { useForm } from '@tanstack/react-form';
6 | import { useNavigate } from '@tanstack/react-router';
7 | import { useState } from 'react';
8 | import { toast } from 'sonner';
9 | import * as v from 'valibot';
10 | import { authClient } from '@/clients/authClient';
11 | import FormFieldInfo from '@/routes/-components/common/form-field-info';
12 | import Spinner from '@/routes/-components/common/spinner';
13 |
14 | const FormSchema = v.pipe(
15 | v.object({
16 | name: v.pipe(
17 | v.string(),
18 | v.minLength(2, 'Name must be at least 2 characters'),
19 | ),
20 | email: v.pipe(v.string(), v.email('Please enter a valid email address')),
21 | password: v.pipe(
22 | v.string(),
23 | v.minLength(8, 'Password must be at least 8 characters'),
24 | ),
25 | confirmPassword: v.string(),
26 | }),
27 | v.forward(
28 | v.partialCheck(
29 | [['password'], ['confirmPassword']],
30 | (input) => input.password === input.confirmPassword,
31 | 'The two passwords do not match.',
32 | ),
33 | ['confirmPassword'],
34 | ),
35 | );
36 |
37 | export default function RegisterCredentialsForm() {
38 | const [isPasswordVisible, setIsPasswordVisible] = useState(false);
39 | const [isConfirmPasswordVisible, setIsConfirmPasswordVisible] =
40 | useState(false);
41 | const navigate = useNavigate();
42 |
43 | const form = useForm({
44 | defaultValues: {
45 | name: '',
46 | email: '',
47 | password: '',
48 | confirmPassword: '',
49 | },
50 | validators: {
51 | onChange: FormSchema,
52 | },
53 | onSubmit: async ({ value }) => {
54 | const { error } = await authClient.signUp.email(
55 | {
56 | name: value.name,
57 | email: value.email,
58 | password: value.password,
59 | },
60 | {
61 | onSuccess: () => {
62 | navigate({ to: '/' });
63 | },
64 | },
65 | );
66 | if (error) {
67 | toast.error(error.message ?? JSON.stringify(error));
68 | }
69 | },
70 | });
71 |
72 | return (
73 | {
76 | e.preventDefault();
77 | e.stopPropagation();
78 | form.handleSubmit();
79 | }}
80 | >
81 |
82 |
(
85 | <>
86 | Full Name
87 | field.handleChange(e.target.value)}
95 | />
96 |
97 | >
98 | )}
99 | />
100 |
101 |
102 |
(
105 | <>
106 | Email
107 | field.handleChange(e.target.value)}
115 | />
116 |
117 | >
118 | )}
119 | />
120 |
121 |
156 |
191 | [state.canSubmit, state.isSubmitting]}
193 | children={([canSubmit, isSubmitting]) => (
194 |
195 | {isSubmitting ? : 'Register'}
196 |
197 | )}
198 | />
199 |
200 | );
201 | }
202 |
--------------------------------------------------------------------------------
/apps/web/src/routes/_public/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Navigate, Outlet, createFileRoute } from '@tanstack/react-router';
2 | import { authClient } from '@/clients/authClient';
3 | import Spinner from '@/routes/-components/common/spinner';
4 |
5 | export const Route = createFileRoute('/_public')({
6 | component: Layout,
7 | });
8 |
9 | function Layout() {
10 | const { data: session, isPending } = authClient.useSession();
11 |
12 | if (isPending) {
13 | return ;
14 | }
15 |
16 | if (!session?.user) {
17 | return ;
18 | }
19 |
20 | return ;
21 | }
22 |
--------------------------------------------------------------------------------
/apps/web/src/routes/_public/login.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute, Link } from '@tanstack/react-router';
2 | import LoginCredentialsForm from '@/routes/_public/-components/login-form';
3 |
4 | export const Route = createFileRoute('/_public/login')({
5 | component: RouteComponent,
6 | });
7 |
8 | function RouteComponent() {
9 | return (
10 |
11 |
12 |
13 |
14 | {"Don't have an account? "}
15 |
16 | Register
17 |
18 | !
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/apps/web/src/routes/_public/register.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute, Link } from '@tanstack/react-router';
2 | import RegisterCredentialsForm from '@/routes/_public/-components/register-form';
3 |
4 | export const Route = createFileRoute('/_public/register')({
5 | component: RouteComponent,
6 | });
7 |
8 | function RouteComponent() {
9 | return (
10 |
11 |
12 |
13 |
14 | Already have an account?{' '}
15 |
16 | Log in
17 |
18 | !
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/apps/web/src/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import { Link2Icon, MoonIcon, SunIcon } from '@radix-ui/react-icons';
2 | import { Button } from '@repo/ui/components/button';
3 | import { createFileRoute, Link } from '@tanstack/react-router';
4 | import { useTheme } from 'next-themes';
5 | import { authClient } from '@/clients/authClient';
6 | import { postsLinkOptions } from '@/routes/_protected/posts/-validations/posts-link-options';
7 |
8 | export const Route = createFileRoute('/')({
9 | component: RouteComponent,
10 | });
11 |
12 | function RouteComponent() {
13 | const { data: session } = authClient.useSession();
14 | const { resolvedTheme, setTheme } = useTheme();
15 |
16 | return (
17 |
18 | {session?.user && (
19 | <>
20 |
21 |
22 | Welcome, {session.user.name} !
23 |
24 |
25 | Click{' '}
26 |
30 | here
31 | {' '}
32 | to view your posts.
33 |
34 |
35 | >
36 | )}
37 |
49 | {!session?.user && (
50 |
51 | Please{' '}
52 |
53 | log in
54 |
55 | .
56 |
57 | )}
58 |
59 |
60 | Toggle theme:
61 | setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
65 | >
66 | {resolvedTheme === 'dark' ? (
67 |
68 | ) : (
69 |
70 | )}
71 |
72 |
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/apps/web/src/style.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 | @import '@repo/tailwind-config/style.css';
3 |
4 | /**
5 | * This is necessary to load the @repo/ui package when moving from
6 | * @tailwindcss/vite v4.0.7 to v4.0.8.
7 | *
8 | * For more details, see:
9 | * https://github.com/tailwindlabs/tailwindcss/issues/16733
10 | */
11 | @source '../../../packages/ui';
12 |
--------------------------------------------------------------------------------
/apps/web/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/apps/web/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/vite.json",
3 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/style.css", "env.ts"],
4 | "compilerOptions": {
5 | "target": "ES2020",
6 | "useDefineForClassFields": true,
7 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
8 | "module": "ESNext",
9 | "jsx": "react-jsx",
10 | "paths": {
11 | "@/*": ["./src/*"]
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/apps/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/apps/web/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/base.json",
3 | "include": ["vite.config.ts"],
4 | "compilerOptions": {
5 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
6 | "target": "ES2022",
7 | "lib": ["ES2023"],
8 | "module": "ESNext"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/apps/web/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turborepo.org/schema.json",
3 | "extends": ["//"],
4 | "tasks": {
5 | "dev": {
6 | "env": ["PUBLIC_*", "PROD"],
7 | "inputs": ["$TURBO_DEFAULT$", ".env"],
8 | "persistent": true
9 | },
10 | "build": {
11 | "env": ["PUBLIC_*", "PROD"],
12 | "inputs": ["$TURBO_DEFAULT$", ".env"]
13 | },
14 | "start": {
15 | "env": ["PUBLIC_*", "PROD"],
16 | "persistent": true
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/apps/web/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import { fileURLToPath } from 'node:url';
3 | import tailwindcss from '@tailwindcss/vite';
4 | import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
5 | import react from '@vitejs/plugin-react-swc';
6 | import * as v from 'valibot';
7 | import { defineConfig } from 'vite';
8 |
9 | /**
10 | * Fixes issue with "__dirname is not defined in ES module scope"
11 | * https://flaviocopes.com/fix-dirname-not-defined-es-module-scope
12 | *
13 | * This is only necessary when using vite with `--configLoader runner`.
14 | * We use this option to allow for importing TS files from monorepos.
15 | * https://vite.dev/config/#configuring-vite
16 | */
17 | const __filename = fileURLToPath(import.meta.url);
18 | const __dirname = path.dirname(__filename);
19 |
20 | const envSchema = v.object({
21 | /**
22 | * Since vite is only used during development, we can assume the structure
23 | * will resemble a URL such as: http://localhost:3035.
24 | * This will then be used to set the vite dev server's host and port.
25 | */
26 | PUBLIC_WEB_URL: v.pipe(
27 | v.optional(v.string(), 'http://localhost:3035'),
28 | v.url(),
29 | ),
30 |
31 | /**
32 | * Set this if you want to run or deploy your app at a base URL. This is
33 | * usually required for deploying a repository to Github/Gitlab pages.
34 | */
35 | PUBLIC_BASE_PATH: v.pipe(v.optional(v.string(), '/'), v.startsWith('/')),
36 | });
37 |
38 | const env = v.parse(envSchema, process.env);
39 | const webUrl = new URL(env.PUBLIC_WEB_URL);
40 | const host = webUrl.hostname;
41 | const port = parseInt(webUrl.port, 10);
42 |
43 | export default defineConfig({
44 | plugins: [
45 | TanStackRouterVite({
46 | routeToken: 'layout',
47 | autoCodeSplitting: true,
48 | }),
49 | tailwindcss(),
50 | react(),
51 | ],
52 | base: env.PUBLIC_BASE_PATH,
53 | envPrefix: 'PUBLIC_',
54 | server: {
55 | host,
56 | port,
57 | strictPort: true,
58 | },
59 | build: {
60 | rollupOptions: {
61 | output: {
62 | /**
63 | * Modified from:
64 | * https://github.com/vitejs/vite/discussions/9440#discussioncomment-11430454
65 | */
66 | manualChunks(id) {
67 | if (id.includes('node_modules')) {
68 | const modulePath = id.split('node_modules/')[1];
69 | const topLevelFolder = modulePath?.split('/')[0];
70 | if (topLevelFolder !== '.pnpm') {
71 | return topLevelFolder;
72 | }
73 | const scopedPackageName = modulePath?.split('/')[1];
74 | const chunkName =
75 | scopedPackageName?.split('@')[
76 | scopedPackageName.startsWith('@') ? 1 : 0
77 | ];
78 | return chunkName;
79 | }
80 | },
81 | },
82 | },
83 | },
84 | resolve: {
85 | alias: {
86 | '@': path.resolve(__dirname, './src'),
87 | },
88 | },
89 | });
90 |
--------------------------------------------------------------------------------
/compose.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | web:
3 | build:
4 | context: .
5 | dockerfile: ./apps/web/Dockerfile
6 | args:
7 | # Backend API server - used at build time to create the bundle
8 | PUBLIC_SERVER_URL: http://localhost:3035
9 | ports:
10 | - 8085:80
11 | healthcheck:
12 | interval: 30s
13 | timeout: 10s
14 | retries: 3
15 | test:
16 | ['CMD-SHELL', 'curl --fail --silent http://localhost:80/healthcheck']
17 | depends_on:
18 | - server
19 |
20 | server:
21 | build:
22 | context: .
23 | dockerfile: ./apps/server/Dockerfile
24 | ports:
25 | - 3035:3000
26 | environment:
27 | - SERVER_AUTH_SECRET=${SERVER_AUTH_SECRET:-please_change_this_in_production}
28 | - SERVER_POSTGRES_URL=postgres://postgres:postgres@db:5432/postgres
29 | - SERVER_HOST=0.0.0.0
30 | - SERVER_PORT=3000
31 | - PUBLIC_WEB_URL=http://localhost:8085
32 | healthcheck:
33 | interval: 30s
34 | timeout: 10s
35 | retries: 3
36 | test:
37 | [
38 | 'CMD-SHELL',
39 | 'wget --quiet --spider http://$$SERVER_HOST:$$SERVER_PORT/healthcheck',
40 | ]
41 | depends_on:
42 | - db
43 |
44 | db:
45 | image: docker.io/postgres:latest
46 | ports:
47 | - 5432:5432
48 | command: ['postgres', '-c', 'log_statement=all']
49 | environment:
50 | - POSTGRES_USER=postgres
51 | - POSTGRES_PASSWORD=postgres
52 | - POSTGRES_DB=postgres
53 | volumes:
54 | - postgres_data:/var/lib/postgresql/data
55 | healthcheck:
56 | interval: 30s
57 | timeout: 10s
58 | retries: 3
59 | test: ['CMD', 'pg_isready', '-U', 'postgres', '-d', 'postgres']
60 |
61 | drizzle:
62 | restart: 'no'
63 | command: /bin/sh
64 | build:
65 | context: .
66 | dockerfile: ./packages/db/Dockerfile
67 | environment:
68 | - DB_POSTGRES_URL=postgres://postgres:postgres@db:5432/postgres
69 | - TURBO_UI=true
70 | profiles:
71 | # Using profiles to avoid starting this container by default.
72 | # We only use this to run `pnpm db:push`
73 | - drizzle
74 |
75 | volumes:
76 | postgres_data:
77 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "type": "module",
4 | "scripts": {
5 | "auth:schema:generate": "turbo run auth:schema:generate",
6 | "postauth:schema:generate": "echo NOTE: you will also need to fix styles and db:push your new schema",
7 | "build": "turbo run build",
8 | "clean": "turbo run clean",
9 | "db:push": "turbo -F @repo/db push",
10 | "db:studio": "turbo -F @repo/db studio",
11 | "dev": "turbo watch dev --continue",
12 | "env:copy-example": "turbo run env:copy-example",
13 | "env:remove": "turbo run env:remove",
14 | "format": "turbo run format --continue -- --cache --cache-location .cache/.prettiercache",
15 | "format:fix": "pnpm format --write",
16 | "lint": "turbo run lint --continue -- --cache --cache-location .cache/.eslintcache",
17 | "lint:fix": "pnpm lint --fix",
18 | "postclean": "git clean -xdf .cache .turbo node_modules",
19 | "start": "turbo run start",
20 | "typecheck": "turbo run typecheck",
21 | "ui-add": "turbo run ui-add -F @repo/ui --"
22 | },
23 | "packageManager": "pnpm@10.12.1",
24 | "prettier": "@repo/prettier-config",
25 | "devDependencies": {
26 | "@repo/prettier-config": "workspace:*",
27 | "prettier": "catalog:",
28 | "turbo": "catalog:"
29 | },
30 | "engines": {
31 | "node": ">=22.10.0"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/packages/api/eslint.config.js:
--------------------------------------------------------------------------------
1 | import baseConfig from '@repo/eslint-config/base';
2 |
3 | /** @type {import('typescript-eslint').Config} */
4 | export default [
5 | {
6 | ignores: ['dist/**'],
7 | },
8 | ...baseConfig,
9 | ];
10 |
--------------------------------------------------------------------------------
/packages/api/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/api",
3 | "version": "0.1.0",
4 | "private": true,
5 | "type": "module",
6 | "exports": {
7 | "./client": {
8 | "types": "./dist/src/client/index.d.ts",
9 | "default": "./src/client/index.ts"
10 | },
11 | "./server": {
12 | "types": "./dist/src/server/index.d.ts",
13 | "default": "./src/server/index.ts"
14 | }
15 | },
16 | "scripts": {
17 | "build": "tsc",
18 | "clean": "git clean -xdf .cache .turbo dist node_modules",
19 | "dev": "tsc",
20 | "format": "prettier --check . --ignore-path ../../.gitignore",
21 | "lint": "eslint",
22 | "typecheck": "tsc --noEmit --emitDeclarationOnly false"
23 | },
24 | "prettier": "@repo/prettier-config",
25 | "dependencies": {
26 | "@repo/auth": "workspace:*",
27 | "@repo/db": "workspace:*",
28 | "@trpc/client": "catalog:",
29 | "@trpc/server": "catalog:",
30 | "superjson": "catalog:",
31 | "url-join": "catalog:",
32 | "valibot": "catalog:"
33 | },
34 | "devDependencies": {
35 | "@repo/eslint-config": "workspace:*",
36 | "@repo/prettier-config": "workspace:*",
37 | "@repo/typescript-config": "workspace:*",
38 | "eslint": "catalog:",
39 | "prettier": "catalog:",
40 | "typescript": "catalog:",
41 | "vite": "catalog:"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/packages/api/src/client/index.ts:
--------------------------------------------------------------------------------
1 | import { createTRPCClient, httpBatchLink } from '@trpc/client';
2 | import SuperJSON from 'superjson';
3 | import urlJoin from 'url-join';
4 | import type { AppRouter } from '../server';
5 |
6 | export interface APIClientOptions {
7 | serverUrl: string;
8 | }
9 |
10 | export const createTrpcClient = ({ serverUrl }: APIClientOptions) => {
11 | return createTRPCClient({
12 | links: [
13 | httpBatchLink({
14 | url: urlJoin(serverUrl, 'trpc'),
15 | transformer: SuperJSON,
16 | fetch(url, options) {
17 | return fetch(url, {
18 | ...options,
19 | /**
20 | * https://trpc.io/docs/client/cors
21 | *
22 | * This is required if you are deploying your frontend (web)
23 | * and backend (server) on two different domains.
24 | */
25 | credentials: 'include',
26 | });
27 | },
28 | }),
29 | ],
30 | });
31 | };
32 |
--------------------------------------------------------------------------------
/packages/api/src/server/index.ts:
--------------------------------------------------------------------------------
1 | import type { AuthInstance } from '@repo/auth/server';
2 | import type { DatabaseInstance } from '@repo/db/client';
3 | import postRouter from './router/post';
4 | import { createTRPCContext as createTRPCContextInternal, router } from './trpc';
5 |
6 | export const appRouter = router({
7 | posts: postRouter,
8 | });
9 |
10 | export const createApi = ({
11 | auth,
12 | db,
13 | }: {
14 | auth: AuthInstance;
15 | db: DatabaseInstance;
16 | }) => {
17 | return {
18 | trpcRouter: appRouter,
19 | createTRPCContext: ({ headers }: { headers: Headers }) =>
20 | createTRPCContextInternal({ auth, db, headers }),
21 | };
22 | };
23 |
24 | export type AppRouter = typeof appRouter;
25 |
--------------------------------------------------------------------------------
/packages/api/src/server/router/post.ts:
--------------------------------------------------------------------------------
1 | import { desc, eq } from '@repo/db';
2 | import { CreatePostSchema, post, user } from '@repo/db/schema';
3 |
4 | import { TRPCError } from '@trpc/server';
5 | import * as v from 'valibot';
6 | import { protectedProcedure, publicProcedure, router } from '../trpc';
7 |
8 | const postRouter = router({
9 | all: protectedProcedure.query(({ ctx }) => {
10 | return ctx.db.query.post.findMany({
11 | columns: {
12 | id: true,
13 | title: true,
14 | createdAt: true,
15 | },
16 | orderBy: desc(post.createdAt),
17 | });
18 | }),
19 |
20 | one: publicProcedure
21 | .input(v.object({ id: v.pipe(v.string(), v.uuid()) }))
22 | .query(async ({ ctx, input }) => {
23 | const [dbPost] = await ctx.db
24 | .select({
25 | id: post.id,
26 | title: post.title,
27 | content: post.content,
28 | createdAt: post.createdAt,
29 | author: {
30 | id: user.id,
31 | name: user.name,
32 | },
33 | })
34 | .from(post)
35 | .innerJoin(user, eq(post.createdBy, user.id))
36 | .where(eq(post.id, input.id));
37 |
38 | if (!dbPost) {
39 | throw new TRPCError({
40 | code: 'BAD_REQUEST',
41 | message: `No such post with ID ${input.id}`,
42 | });
43 | }
44 | return dbPost;
45 | }),
46 |
47 | create: protectedProcedure
48 | .input(CreatePostSchema)
49 | .mutation(async ({ ctx, input }) => {
50 | await ctx.db.insert(post).values({
51 | createdBy: ctx.session.user.id,
52 | ...input,
53 | });
54 | return {};
55 | }),
56 |
57 | delete: protectedProcedure
58 | .input(v.object({ id: v.pipe(v.string(), v.uuid()) }))
59 | .mutation(async ({ ctx, input }) => {
60 | const res = await ctx.db.delete(post).where(eq(post.id, input.id));
61 | if (res.rowCount === 0) {
62 | throw new TRPCError({
63 | code: 'BAD_REQUEST',
64 | message: `No such post with id ${input.id}`,
65 | });
66 | }
67 | return {};
68 | }),
69 | });
70 |
71 | export default postRouter;
72 |
--------------------------------------------------------------------------------
/packages/api/src/server/trpc.ts:
--------------------------------------------------------------------------------
1 | import { initTRPC, TRPCError } from '@trpc/server';
2 | import SuperJSON from 'superjson';
3 | import type { AuthInstance } from '@repo/auth/server';
4 | import type { DatabaseInstance } from '@repo/db/client';
5 |
6 | export const createTRPCContext = async ({
7 | auth,
8 | db,
9 | headers,
10 | }: {
11 | auth: AuthInstance;
12 | db: DatabaseInstance;
13 | headers: Headers;
14 | }): Promise<{
15 | db: DatabaseInstance;
16 | session: AuthInstance['$Infer']['Session'] | null;
17 | }> => {
18 | const session = await auth.api.getSession({
19 | headers,
20 | });
21 | return {
22 | db,
23 | session,
24 | };
25 | };
26 |
27 | export const t = initTRPC.context().create({
28 | transformer: SuperJSON,
29 | });
30 |
31 | export const router = t.router;
32 |
33 | const timingMiddleware = t.middleware(async ({ next, path }) => {
34 | const start = Date.now();
35 | let waitMsDisplay = '';
36 | if (t._config.isDev) {
37 | // artificial delay in dev 100-500ms
38 | const waitMs = Math.floor(Math.random() * 400) + 100;
39 | await new Promise((resolve) => setTimeout(resolve, waitMs));
40 | waitMsDisplay = ` (artificial delay: ${waitMs}ms)`;
41 | }
42 | const result = await next();
43 | const end = Date.now();
44 |
45 | console.log(
46 | `\t[TRPC] /${path} executed after ${end - start}ms${waitMsDisplay}`,
47 | );
48 | return result;
49 | });
50 |
51 | export const publicProcedure = t.procedure.use(timingMiddleware);
52 |
53 | export const protectedProcedure = publicProcedure.use(({ ctx, next }) => {
54 | if (!ctx.session?.user) {
55 | throw new TRPCError({ code: 'FORBIDDEN' });
56 | }
57 | return next({
58 | ctx: {
59 | session: { ...ctx.session },
60 | },
61 | });
62 | });
63 |
--------------------------------------------------------------------------------
/packages/api/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/internal-package.json",
3 | "include": ["src"],
4 | "exclude": ["node_modules"],
5 | "compilerOptions": {
6 | "types": ["vite/client"]
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/auth/eslint.config.js:
--------------------------------------------------------------------------------
1 | import baseConfig from '@repo/eslint-config/base';
2 |
3 | /** @type {import('typescript-eslint').Config} */
4 | export default [
5 | {
6 | ignores: [],
7 | },
8 | ...baseConfig,
9 | ];
10 |
--------------------------------------------------------------------------------
/packages/auth/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/auth",
3 | "version": "0.1.0",
4 | "private": true,
5 | "type": "module",
6 | "exports": {
7 | "./client": "./src/client.ts",
8 | "./server": "./src/server.ts"
9 | },
10 | "scripts": {
11 | "build": "tsc",
12 | "clean": "git clean -xdf .cache .turbo dist node_modules",
13 | "dev": "tsc",
14 | "format": "prettier --check . --ignore-path ../../.gitignore",
15 | "auth:schema:generate": "pnpx @better-auth/cli generate --config ./src/cli-config.ts --output ../db/src/schemas/auth.ts",
16 | "lint": "eslint",
17 | "typecheck": "tsc --noEmit"
18 | },
19 | "prettier": "@repo/prettier-config",
20 | "dependencies": {
21 | "@repo/db": "workspace:*",
22 | "better-auth": "catalog:"
23 | },
24 | "devDependencies": {
25 | "@repo/eslint-config": "workspace:*",
26 | "@repo/prettier-config": "workspace:*",
27 | "@repo/typescript-config": "workspace:*",
28 | "@types/node": "catalog:",
29 | "eslint": "catalog:",
30 | "typescript": "catalog:"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/packages/auth/src/cli-config.ts:
--------------------------------------------------------------------------------
1 | import { createDb } from '@repo/db/client';
2 | import { betterAuth } from 'better-auth';
3 | import { getBaseOptions } from './server';
4 |
5 | /**
6 | * @internal
7 | *
8 | * This export is needed strictly for the CLI to work with
9 | * pnpm auth:schema:generate
10 | *
11 | * It should not be imported or used for any other purpose.
12 | *
13 | * The documentation for better-auth CLI can be found here:
14 | * - https://www.better-auth.com/docs/concepts/cli
15 | */
16 | export const auth = betterAuth({
17 | ...getBaseOptions(createDb()),
18 | });
19 |
--------------------------------------------------------------------------------
/packages/auth/src/client.ts:
--------------------------------------------------------------------------------
1 | import { createAuthClient as createBetterAuthClient } from 'better-auth/react';
2 |
3 | export interface AuthClientOptions {
4 | apiBaseUrl: string;
5 | }
6 |
7 | export const createAuthClient = ({ apiBaseUrl }: AuthClientOptions) =>
8 | createBetterAuthClient({
9 | baseURL: apiBaseUrl,
10 |
11 | /**
12 | * Only uncomment the line below if you are using plugins, so that
13 | * your types can be correctly inferred.
14 | * Ensure that you are using the client-side version of the plugin,
15 | * e.g. `adminClient` instead of `admin`.
16 | */
17 | // plugins: []
18 | });
19 |
--------------------------------------------------------------------------------
/packages/auth/src/server.ts:
--------------------------------------------------------------------------------
1 | import { type BetterAuthOptions, betterAuth } from 'better-auth';
2 |
3 | import { drizzleAdapter } from 'better-auth/adapters/drizzle';
4 | import type { DatabaseInstance } from '@repo/db/client';
5 |
6 | export interface AuthOptions {
7 | webUrl: string;
8 | authSecret: string;
9 | db: DatabaseInstance;
10 | }
11 |
12 | export type AuthInstance = ReturnType;
13 |
14 | /**
15 | * This function is abstracted for schema generations in cli-config.ts
16 | */
17 | export const getBaseOptions = (db: DatabaseInstance) =>
18 | ({
19 | database: drizzleAdapter(db, {
20 | provider: 'pg',
21 | }),
22 |
23 | /**
24 | * Only uncomment the line below if you are using plugins, so that
25 | * your types can be correctly inferred:
26 | */
27 | // plugins: [],
28 | }) satisfies BetterAuthOptions;
29 |
30 | export const createAuth = ({ webUrl, db, authSecret }: AuthOptions) => {
31 | return betterAuth({
32 | ...getBaseOptions(db),
33 | secret: authSecret,
34 | trustedOrigins: [webUrl].map((url) => new URL(url).origin),
35 | session: {
36 | cookieCache: {
37 | enabled: true,
38 | maxAge: 5 * 60,
39 | },
40 | },
41 | emailAndPassword: {
42 | enabled: true,
43 | autoSignIn: true,
44 | requireEmailVerification: false,
45 | },
46 | });
47 | };
48 |
--------------------------------------------------------------------------------
/packages/auth/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/internal-package.json",
3 | "include": ["src"],
4 | "exclude": ["node_modules"],
5 | "compilerOptions": {
6 | "lib": ["dom", "ES2022"],
7 | "composite": false,
8 | "emitDeclarationOnly": false,
9 | "declarationMap": false,
10 | "declaration": false,
11 | "baseUrl": "."
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/packages/db/.env.example:
--------------------------------------------------------------------------------
1 | DB_POSTGRES_URL=postgres://postgres:postgres@localhost:5432/postgres
2 |
--------------------------------------------------------------------------------
/packages/db/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:22-alpine AS base
2 |
3 | WORKDIR /app
4 |
5 | # =========================================================================== #
6 |
7 | FROM base AS builder-base
8 |
9 | ENV TURBO_TELEMETRY_DISABLED=1
10 | ENV PNPM_HOME="/pnpm"
11 | ENV PATH="$PNPM_HOME:$PATH"
12 | ENV CI=1
13 |
14 | RUN corepack enable pnpm
15 |
16 | # =========================================================================== #
17 |
18 | FROM builder-base AS builder
19 |
20 | RUN pnpm install --global turbo@^2
21 |
22 | COPY . .
23 |
24 | # https://turbo.build/repo/docs/guides/tools/docker#the-solution
25 | RUN turbo prune @repo/db --docker \
26 | && pnpm install --frozen-lockfile
27 |
28 | CMD ["/bin/sh"]
29 |
--------------------------------------------------------------------------------
/packages/db/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import * as v from 'valibot';
2 | import type { Config } from 'drizzle-kit';
3 |
4 | const envSchema = v.object({
5 | DB_POSTGRES_URL: v.pipe(v.string(), v.minLength(1)),
6 | });
7 |
8 | const env = v.parse(envSchema, process.env);
9 |
10 | // Supabase pooling URL uses 6543, which we don't need for migrations
11 | const nonPoolingUrl = env.DB_POSTGRES_URL.replace(':6543', ':5432');
12 |
13 | export default {
14 | schema: './src/schema.ts',
15 | dialect: 'postgresql',
16 | dbCredentials: { url: nonPoolingUrl },
17 | casing: 'snake_case',
18 | } satisfies Config;
19 |
--------------------------------------------------------------------------------
/packages/db/eslint.config.js:
--------------------------------------------------------------------------------
1 | import baseConfig, { restrictEnvAccess } from '@repo/eslint-config/base';
2 |
3 | /** @type {import('typescript-eslint').Config} */
4 | export default [
5 | {
6 | ignores: ['dist/**'],
7 | },
8 | ...baseConfig,
9 | ...restrictEnvAccess,
10 | {
11 | files: ['drizzle.config.ts'],
12 | rules: {
13 | 'no-restricted-properties': 'off',
14 | },
15 | },
16 | ];
17 |
--------------------------------------------------------------------------------
/packages/db/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/db",
3 | "private": true,
4 | "type": "module",
5 | "exports": {
6 | ".": {
7 | "types": "./dist/src/index.d.ts",
8 | "default": "./src/index.ts"
9 | },
10 | "./client": {
11 | "types": "./dist/src/client.d.ts",
12 | "default": "./src/client.ts"
13 | },
14 | "./schema": {
15 | "types": "./dist/src/schema.d.ts",
16 | "default": "./src/schema.ts"
17 | }
18 | },
19 | "scripts": {
20 | "build": "tsc --build",
21 | "clean": "git clean -xdf .cache .turbo dist node_modules",
22 | "dev": "tsc --build tsconfig.package.json",
23 | "env:copy-example": "shx -- cp -n .env.example .env",
24 | "env:remove": "shx -- rm -f .env",
25 | "env:run": "dotenv --",
26 | "format": "prettier --check . --ignore-path ../../.gitignore",
27 | "lint": "eslint",
28 | "push": "pnpm env:run drizzle-kit push",
29 | "studio": "pnpm env:run drizzle-kit studio",
30 | "typecheck": "tsc --build --noEmit --emitDeclarationOnly false"
31 | },
32 | "prettier": "@repo/prettier-config",
33 | "dependencies": {
34 | "drizzle-orm": "catalog:",
35 | "drizzle-valibot": "catalog:",
36 | "pg": "catalog:",
37 | "valibot": "catalog:"
38 | },
39 | "devDependencies": {
40 | "@repo/eslint-config": "workspace:*",
41 | "@repo/prettier-config": "workspace:*",
42 | "@repo/typescript-config": "workspace:*",
43 | "@types/node": "catalog:",
44 | "@types/pg": "catalog:",
45 | "dotenv-cli": "catalog:",
46 | "drizzle-kit": "catalog:",
47 | "eslint": "catalog:",
48 | "shx": "catalog:",
49 | "typescript": "catalog:"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/packages/db/src/client.ts:
--------------------------------------------------------------------------------
1 | import { drizzle, NodePgDatabase } from 'drizzle-orm/node-postgres';
2 |
3 | import * as schema from './schema';
4 |
5 | export interface DatabaseClientOptions {
6 | databaseUrl?: string;
7 | max?: number;
8 | }
9 |
10 | export type DatabaseInstance = NodePgDatabase;
11 |
12 | export const createDb = (opts?: DatabaseClientOptions): DatabaseInstance => {
13 | return drizzle({
14 | schema,
15 | casing: 'snake_case',
16 | connection: {
17 | connectionString: opts?.databaseUrl,
18 | max: opts?.max,
19 | },
20 | });
21 | };
22 |
--------------------------------------------------------------------------------
/packages/db/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from 'drizzle-orm/sql';
2 | export { alias } from 'drizzle-orm/pg-core';
3 |
--------------------------------------------------------------------------------
/packages/db/src/schema.ts:
--------------------------------------------------------------------------------
1 | export * from './schemas/auth';
2 | export * from './schemas/posts';
3 |
--------------------------------------------------------------------------------
/packages/db/src/schemas/auth.ts:
--------------------------------------------------------------------------------
1 | import { pgTable, text, timestamp, boolean } from 'drizzle-orm/pg-core';
2 |
3 | export const user = pgTable('user', {
4 | id: text('id').primaryKey(),
5 | name: text('name').notNull(),
6 | email: text('email').notNull().unique(),
7 | emailVerified: boolean('email_verified').notNull(),
8 | image: text('image'),
9 | createdAt: timestamp('created_at').notNull(),
10 | updatedAt: timestamp('updated_at').notNull(),
11 | });
12 |
13 | export const session = pgTable('session', {
14 | id: text('id').primaryKey(),
15 | expiresAt: timestamp('expires_at').notNull(),
16 | token: text('token').notNull().unique(),
17 | createdAt: timestamp('created_at').notNull(),
18 | updatedAt: timestamp('updated_at').notNull(),
19 | ipAddress: text('ip_address'),
20 | userAgent: text('user_agent'),
21 | userId: text('user_id')
22 | .notNull()
23 | .references(() => user.id, { onDelete: 'cascade' }),
24 | });
25 |
26 | export const account = pgTable('account', {
27 | id: text('id').primaryKey(),
28 | accountId: text('account_id').notNull(),
29 | providerId: text('provider_id').notNull(),
30 | userId: text('user_id')
31 | .notNull()
32 | .references(() => user.id, { onDelete: 'cascade' }),
33 | accessToken: text('access_token'),
34 | refreshToken: text('refresh_token'),
35 | idToken: text('id_token'),
36 | accessTokenExpiresAt: timestamp('access_token_expires_at'),
37 | refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
38 | scope: text('scope'),
39 | password: text('password'),
40 | createdAt: timestamp('created_at').notNull(),
41 | updatedAt: timestamp('updated_at').notNull(),
42 | });
43 |
44 | export const verification = pgTable('verification', {
45 | id: text('id').primaryKey(),
46 | identifier: text('identifier').notNull(),
47 | value: text('value').notNull(),
48 | expiresAt: timestamp('expires_at').notNull(),
49 | createdAt: timestamp('created_at'),
50 | updatedAt: timestamp('updated_at'),
51 | });
52 |
--------------------------------------------------------------------------------
/packages/db/src/schemas/posts.ts:
--------------------------------------------------------------------------------
1 | import { pgTable } from 'drizzle-orm/pg-core';
2 | import { createInsertSchema } from 'drizzle-valibot';
3 | import * as v from 'valibot';
4 | import { user } from './auth';
5 |
6 | export const post = pgTable('post', (t) => ({
7 | id: t.uuid().primaryKey().defaultRandom(),
8 | title: t.varchar({ length: 256 }).notNull(),
9 | content: t.text().notNull(),
10 | createdAt: t.timestamp().notNull().defaultNow(),
11 | createdBy: t
12 | .text()
13 | .references(() => user.id)
14 | .notNull(),
15 | }));
16 |
17 | export const CreatePostSchema = v.omit(
18 | createInsertSchema(post, {
19 | title: v.pipe(v.string(), v.maxLength(256)),
20 | content: v.pipe(v.string(), v.maxLength(512)),
21 | }),
22 | ['id', 'createdAt', 'createdBy'],
23 | );
24 |
--------------------------------------------------------------------------------
/packages/db/tsconfig.drizzlekit.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/base.json",
3 | "include": ["drizzle.config.ts"],
4 | "exclude": ["node_modules"]
5 | }
6 |
--------------------------------------------------------------------------------
/packages/db/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.package.json" },
5 | { "path": "./tsconfig.drizzlekit.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/packages/db/tsconfig.package.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/internal-package.json",
3 | "include": ["src"],
4 | "exclude": ["node_modules"]
5 | }
6 |
--------------------------------------------------------------------------------
/packages/db/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turborepo.org/schema.json",
3 | "extends": ["//"],
4 | "tasks": {
5 | "dev": {
6 | "env": ["DB_*"],
7 | "inputs": ["$TURBO_DEFAULT$", ".env"]
8 | },
9 | "build": {
10 | "env": ["DB_*"],
11 | "inputs": ["$TURBO_DEFAULT$", ".env"]
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/packages/ui/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "../../tools/tailwind/style.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "ui": "src/components",
14 | "utils": "#/lib/utils",
15 | "components": "#/components"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/packages/ui/eslint.config.js:
--------------------------------------------------------------------------------
1 | import baseConfig from '@repo/eslint-config/base';
2 | import reactConfig from '@repo/eslint-config/react';
3 |
4 | /** @type {import('typescript-eslint').Config} */
5 | export default [
6 | {
7 | ignores: ['dist/**'],
8 | },
9 | ...baseConfig,
10 | ...reactConfig,
11 | ];
12 |
--------------------------------------------------------------------------------
/packages/ui/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/ui",
3 | "private": true,
4 | "version": "0.1.0",
5 | "type": "module",
6 | "files": [
7 | "dist"
8 | ],
9 | "imports": {
10 | "#*": "./src/*"
11 | },
12 | "exports": {
13 | "./lib/*": {
14 | "types": "./dist/src/lib/*.d.ts",
15 | "default": "./src/lib/*.ts"
16 | },
17 | "./components/*": {
18 | "types": "./dist/src/components/*.d.ts",
19 | "default": "./src/components/*.tsx"
20 | }
21 | },
22 | "scripts": {
23 | "build": "tsc",
24 | "clean": "git clean -xdf .cache .turbo dist node_modules",
25 | "dev": "tsc",
26 | "format": "prettier --check . --ignore-path ../../.gitignore",
27 | "lint": "eslint",
28 | "postui-add": "prettier src --write --list-different",
29 | "typecheck": "tsc --noEmit --emitDeclarationOnly false",
30 | "ui-add": "pnpm dlx shadcn@canary add"
31 | },
32 | "dependencies": {
33 | "@radix-ui/react-avatar": "catalog:",
34 | "@radix-ui/react-dialog": "catalog:",
35 | "@radix-ui/react-dropdown-menu": "catalog:",
36 | "@radix-ui/react-icons": "catalog:",
37 | "@radix-ui/react-label": "catalog:",
38 | "@radix-ui/react-slot": "catalog:",
39 | "@radix-ui/react-toast": "catalog:",
40 | "@radix-ui/react-tooltip": "catalog:",
41 | "class-variance-authority": "catalog:",
42 | "next-themes": "catalog:",
43 | "radix-ui": "catalog:",
44 | "sonner": "catalog:",
45 | "tailwind-merge": "catalog:"
46 | },
47 | "devDependencies": {
48 | "@repo/eslint-config": "workspace:*",
49 | "@repo/prettier-config": "workspace:*",
50 | "@repo/tailwind-config": "workspace:*",
51 | "@repo/typescript-config": "workspace:*",
52 | "@types/react": "catalog:",
53 | "eslint": "catalog:",
54 | "prettier": "catalog:",
55 | "react": "catalog:",
56 | "typescript": "catalog:"
57 | },
58 | "peerDependencies": {
59 | "react": "catalog:"
60 | },
61 | "prettier": "@repo/prettier-config"
62 | }
63 |
--------------------------------------------------------------------------------
/packages/ui/src/components/avatar.tsx:
--------------------------------------------------------------------------------
1 | import * as AvatarPrimitive from '@radix-ui/react-avatar';
2 | import * as React from 'react';
3 |
4 | import { cn } from '#/lib/utils';
5 |
6 | const Avatar = React.forwardRef<
7 | React.ComponentRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 | ));
19 | Avatar.displayName = AvatarPrimitive.Root.displayName;
20 |
21 | const AvatarImage = React.forwardRef<
22 | React.ComponentRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, ...props }, ref) => (
25 |
30 | ));
31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName;
32 |
33 | const AvatarFallback = React.forwardRef<
34 | React.ComponentRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, ...props }, ref) => (
37 |
45 | ));
46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
47 |
48 | export { Avatar, AvatarImage, AvatarFallback };
49 |
--------------------------------------------------------------------------------
/packages/ui/src/components/button.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from '@radix-ui/react-slot';
2 | import { cva, type VariantProps } from 'class-variance-authority';
3 | import * as React from 'react';
4 |
5 | import { cn } from '#/lib/utils';
6 |
7 | const buttonVariants = cva(
8 | 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
9 | {
10 | variants: {
11 | variant: {
12 | default: 'bg-primary text-primary-foreground hover:bg-primary/70',
13 | destructive:
14 | 'bg-destructive text-destructive-foreground hover:bg-destructive/70',
15 | outline:
16 | 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
17 | secondary:
18 | 'bg-secondary text-secondary-foreground hover:bg-secondary/70',
19 | ghost: 'hover:bg-accent hover:text-accent-foreground',
20 | link: 'text-primary underline-offset-4 hover:underline',
21 | },
22 | size: {
23 | default: 'h-10 px-4 py-2',
24 | sm: 'h-9 rounded-md px-3',
25 | lg: 'h-11 rounded-md px-8',
26 | icon: 'h-10 w-10',
27 | },
28 | },
29 | defaultVariants: {
30 | variant: 'default',
31 | size: 'default',
32 | },
33 | },
34 | );
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean;
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : 'button';
45 | return (
46 |
51 | );
52 | },
53 | );
54 | Button.displayName = 'Button';
55 |
56 | export { Button, buttonVariants };
57 |
--------------------------------------------------------------------------------
/packages/ui/src/components/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as DialogPrimitive from '@radix-ui/react-dialog';
2 | import { Cross2Icon } from '@radix-ui/react-icons';
3 | import * as React from 'react';
4 | import { cn } from '#/lib/utils';
5 |
6 | const Dialog = DialogPrimitive.Root;
7 |
8 | const DialogTrigger = DialogPrimitive.Trigger;
9 |
10 | const DialogPortal = DialogPrimitive.Portal;
11 |
12 | const DialogClose = DialogPrimitive.Close;
13 |
14 | const DialogOverlay = React.forwardRef<
15 | React.ComponentRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, ...props }, ref) => (
18 |
26 | ));
27 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
28 |
29 | const DialogContent = React.forwardRef<
30 | React.ComponentRef,
31 | React.ComponentPropsWithoutRef
32 | >(({ className, children, ...props }, ref) => (
33 |
34 |
35 |
43 | {children}
44 |
45 |
46 | Close
47 |
48 |
49 |
50 | ));
51 | DialogContent.displayName = DialogPrimitive.Content.displayName;
52 |
53 | const DialogHeader = ({
54 | className,
55 | ...props
56 | }: React.HTMLAttributes) => (
57 |
64 | );
65 | DialogHeader.displayName = 'DialogHeader';
66 |
67 | const DialogFooter = ({
68 | className,
69 | ...props
70 | }: React.HTMLAttributes) => (
71 |
75 | );
76 | DialogFooter.displayName = 'DialogFooter';
77 |
78 | const DialogTitle = React.forwardRef<
79 | React.ComponentRef,
80 | React.ComponentPropsWithoutRef
81 | >(({ className, ...props }, ref) => (
82 |
90 | ));
91 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
92 |
93 | const DialogDescription = React.forwardRef<
94 | React.ComponentRef,
95 | React.ComponentPropsWithoutRef
96 | >(({ className, ...props }, ref) => (
97 |
102 | ));
103 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
104 |
105 | export {
106 | Dialog,
107 | DialogPortal,
108 | DialogOverlay,
109 | DialogClose,
110 | DialogTrigger,
111 | DialogContent,
112 | DialogHeader,
113 | DialogFooter,
114 | DialogTitle,
115 | DialogDescription,
116 | };
117 |
--------------------------------------------------------------------------------
/packages/ui/src/components/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
2 | import {
3 | CheckIcon,
4 | ChevronRightIcon,
5 | DotFilledIcon,
6 | } from '@radix-ui/react-icons';
7 | import * as React from 'react';
8 | import { cn } from '#/lib/utils';
9 |
10 | const DropdownMenu = DropdownMenuPrimitive.Root;
11 |
12 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
13 |
14 | const DropdownMenuGroup = DropdownMenuPrimitive.Group;
15 |
16 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
17 |
18 | const DropdownMenuSub = DropdownMenuPrimitive.Sub;
19 |
20 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
21 |
22 | const DropdownMenuSubTrigger = React.forwardRef<
23 | React.ElementRef,
24 | React.ComponentPropsWithoutRef & {
25 | inset?: boolean;
26 | }
27 | >(({ className, inset, children, ...props }, ref) => (
28 |
37 | {children}
38 |
39 |
40 | ));
41 | DropdownMenuSubTrigger.displayName =
42 | DropdownMenuPrimitive.SubTrigger.displayName;
43 |
44 | const DropdownMenuSubContent = React.forwardRef<
45 | React.ElementRef,
46 | React.ComponentPropsWithoutRef
47 | >(({ className, ...props }, ref) => (
48 |
56 | ));
57 | DropdownMenuSubContent.displayName =
58 | DropdownMenuPrimitive.SubContent.displayName;
59 |
60 | const DropdownMenuContent = React.forwardRef<
61 | React.ElementRef,
62 | React.ComponentPropsWithoutRef
63 | >(({ className, sideOffset = 4, ...props }, ref) => (
64 |
65 |
74 |
75 | ));
76 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
77 |
78 | const DropdownMenuItem = React.forwardRef<
79 | React.ElementRef,
80 | React.ComponentPropsWithoutRef & {
81 | inset?: boolean;
82 | }
83 | >(({ className, inset, ...props }, ref) => (
84 |
93 | ));
94 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
95 |
96 | const DropdownMenuCheckboxItem = React.forwardRef<
97 | React.ElementRef,
98 | React.ComponentPropsWithoutRef
99 | >(({ className, children, checked, ...props }, ref) => (
100 |
109 |
110 |
111 |
112 |
113 |
114 | {children}
115 |
116 | ));
117 | DropdownMenuCheckboxItem.displayName =
118 | DropdownMenuPrimitive.CheckboxItem.displayName;
119 |
120 | const DropdownMenuRadioItem = React.forwardRef<
121 | React.ElementRef,
122 | React.ComponentPropsWithoutRef
123 | >(({ className, children, ...props }, ref) => (
124 |
132 |
133 |
134 |
135 |
136 |
137 | {children}
138 |
139 | ));
140 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
141 |
142 | const DropdownMenuLabel = React.forwardRef<
143 | React.ElementRef,
144 | React.ComponentPropsWithoutRef & {
145 | inset?: boolean;
146 | }
147 | >(({ className, inset, ...props }, ref) => (
148 |
157 | ));
158 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
159 |
160 | const DropdownMenuSeparator = React.forwardRef<
161 | React.ElementRef,
162 | React.ComponentPropsWithoutRef
163 | >(({ className, ...props }, ref) => (
164 |
169 | ));
170 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
171 |
172 | const DropdownMenuShortcut = ({
173 | className,
174 | ...props
175 | }: React.HTMLAttributes) => {
176 | return (
177 |
181 | );
182 | };
183 | DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
184 |
185 | export {
186 | DropdownMenu,
187 | DropdownMenuTrigger,
188 | DropdownMenuContent,
189 | DropdownMenuItem,
190 | DropdownMenuCheckboxItem,
191 | DropdownMenuRadioItem,
192 | DropdownMenuLabel,
193 | DropdownMenuSeparator,
194 | DropdownMenuShortcut,
195 | DropdownMenuGroup,
196 | DropdownMenuPortal,
197 | DropdownMenuSub,
198 | DropdownMenuSubContent,
199 | DropdownMenuSubTrigger,
200 | DropdownMenuRadioGroup,
201 | };
202 |
--------------------------------------------------------------------------------
/packages/ui/src/components/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '#/lib/utils';
4 |
5 | const Input = React.forwardRef>(
6 | ({ className, type, ...props }, ref) => {
7 | return (
8 |
17 | );
18 | },
19 | );
20 | Input.displayName = 'Input';
21 |
22 | export { Input };
23 |
--------------------------------------------------------------------------------
/packages/ui/src/components/label.tsx:
--------------------------------------------------------------------------------
1 | import * as LabelPrimitive from '@radix-ui/react-label';
2 | import { cva, type VariantProps } from 'class-variance-authority';
3 | import * as React from 'react';
4 |
5 | import { cn } from '#/lib/utils';
6 |
7 | const labelVariants = cva(
8 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
9 | );
10 |
11 | const Label = React.forwardRef<
12 | React.ComponentRef,
13 | React.ComponentPropsWithoutRef &
14 | VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ));
22 | Label.displayName = LabelPrimitive.Root.displayName;
23 |
24 | export { Label };
25 |
--------------------------------------------------------------------------------
/packages/ui/src/components/sonner.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from 'next-themes';
2 | import { Toaster as Sonner } from 'sonner';
3 |
4 | type ToasterProps = React.ComponentProps;
5 |
6 | const Toaster = ({ ...props }: ToasterProps) => {
7 | const { theme = 'light' } = useTheme();
8 |
9 | return (
10 |
27 | );
28 | };
29 |
30 | export { Toaster };
31 |
--------------------------------------------------------------------------------
/packages/ui/src/components/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '#/lib/utils';
4 |
5 | const Textarea = React.forwardRef<
6 | HTMLTextAreaElement,
7 | React.ComponentProps<'textarea'>
8 | >(({ className, ...props }, ref) => {
9 | return (
10 |
18 | );
19 | });
20 | Textarea.displayName = 'Textarea';
21 |
22 | export { Textarea };
23 |
--------------------------------------------------------------------------------
/packages/ui/src/components/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as TooltipPrimitive from '@radix-ui/react-tooltip';
2 | import * as React from 'react';
3 |
4 | import { cn } from '#/lib/utils';
5 |
6 | const TooltipProvider = TooltipPrimitive.Provider;
7 |
8 | const Tooltip = TooltipPrimitive.Root;
9 |
10 | const TooltipTrigger = TooltipPrimitive.Trigger;
11 |
12 | const TooltipArrow = TooltipPrimitive.Arrow;
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ComponentRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
19 |
28 |
29 | ));
30 | TooltipContent.displayName = TooltipPrimitive.Content.displayName;
31 |
32 | export {
33 | Tooltip,
34 | TooltipArrow,
35 | TooltipTrigger,
36 | TooltipContent,
37 | TooltipProvider,
38 | };
39 |
--------------------------------------------------------------------------------
/packages/ui/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { cx } from 'class-variance-authority';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | const cn = (...inputs: Parameters) => twMerge(cx(inputs));
5 |
6 | export { cn };
7 |
--------------------------------------------------------------------------------
/packages/ui/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/internal-package.json",
3 | "compilerOptions": {
4 | "lib": ["ES2022", "dom", "dom.iterable"],
5 | "jsx": "preserve",
6 | "paths": {
7 | "#/*": ["./src/*"]
8 | }
9 | },
10 | "include": ["src"],
11 | "exclude": ["node_modules"]
12 | }
13 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - apps/*
3 | - packages/*
4 | - tools/*
5 |
6 | catalog:
7 | '@eslint/js': ^9.28.0
8 | '@hono/node-server': ^1.14.4
9 | '@hono/trpc-server': ^0.3.4
10 | '@radix-ui/react-avatar': ^1.1.10
11 | '@radix-ui/react-dialog': ^1.1.14
12 | '@radix-ui/react-dropdown-menu': ^2.1.15
13 | '@radix-ui/react-icons': ^1.3.2
14 | '@radix-ui/react-label': ^2.1.7
15 | '@radix-ui/react-slot': ^1.2.3
16 | '@radix-ui/react-toast': ^1.2.14
17 | '@radix-ui/react-tooltip': ^1.2.7
18 | '@tailwindcss/vite': ^4.1.8
19 | '@tanstack/react-form': ^1.12.2
20 | '@tanstack/react-query': ^5.80.6
21 | '@tanstack/react-router': ^1.120.20
22 | '@tanstack/router-devtools': ^1.120.20
23 | '@tanstack/router-plugin': ^1.120.20
24 | '@trpc/client': ^11.3.1
25 | '@trpc/server': ^11.3.1
26 | '@trpc/tanstack-react-query': ^11.3.1
27 | '@types/eslint-config-prettier': ^6.11.3
28 | '@types/node': ^22.15.30
29 | '@types/pg': ^8.15.4
30 | '@types/react': ^19.1.6
31 | '@types/react-dom': ^19.1.6
32 | '@vitejs/plugin-react-swc': ^3.10.1
33 | better-auth: ^1.2.8
34 | class-variance-authority: ^0.7.1
35 | dotenv-cli: ^8.0.0
36 | drizzle-kit: ^0.31.1
37 | drizzle-orm: ^0.44.2
38 | drizzle-valibot: ^0.4.2
39 | eslint: ^9.28.0
40 | eslint-config-prettier: ^10.1.5
41 | eslint-config-turbo: ^2.5.4
42 | eslint-plugin-import: ^2.31.0
43 | eslint-plugin-only-warn: ^1.1.0
44 | eslint-plugin-react: ^7.37.5
45 | eslint-plugin-react-hooks: ^5.2.0
46 | eslint-plugin-turbo: ^2.5.4
47 | globals: ^16.2.0
48 | hono: ^4.7.11
49 | next-themes: ^0.4.6
50 | pg: ^8.16.0
51 | prettier: ^3.5.3
52 | radix-ui: ^1.4.2
53 | react: ^19.1.0
54 | react-dom: ^19.1.0
55 | shx: ^0.4.0
56 | sonner: ^2.0.5
57 | superjson: ^2.2.2
58 | tailwind-merge: ^3.3.0
59 | tailwindcss: ^4.1.8
60 | tailwindcss-animate: ^1.0.7
61 | tsup: ^8.5.0
62 | tsx: ^4.19.4
63 | turbo: ^2.5.4
64 | typescript: ^5.8.3
65 | typescript-eslint: ^8.33.1
66 | url-join: ^5.0.0
67 | valibot: ^1.1.0
68 | vite: ^6.3.5
69 |
70 | ignoredBuiltDependencies:
71 | - '@swc/core'
72 | - '@tailwindcss/oxide'
73 | - esbuild
74 |
75 | overrides:
76 | esbuild@<=0.24.2: '>=0.25.0'
77 |
--------------------------------------------------------------------------------
/tools/eslint/base.js:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import js from '@eslint/js';
4 | import eslintConfigPrettier from 'eslint-config-prettier';
5 | import turboConfig from 'eslint-config-turbo/flat';
6 | import eslintPluginImport from 'eslint-plugin-import';
7 | import turboPlugin from 'eslint-plugin-turbo';
8 | import tseslint from 'typescript-eslint';
9 | import onlyWarn from 'eslint-plugin-only-warn';
10 |
11 | export const restrictEnvAccess = tseslint.config(
12 | { ignores: ['**/env.ts', 'dist/**'] },
13 | {
14 | files: ['**/*.js', '**/*.ts', '**/*.tsx'],
15 | rules: {
16 | 'no-restricted-properties': [
17 | 'error',
18 | {
19 | object: 'process',
20 | property: 'env',
21 | message:
22 | 'Avoid using process.env directly - validate your types with valibot (example in ./apps/server/env.ts)',
23 | },
24 | ],
25 | 'no-restricted-imports': [
26 | 'error',
27 | {
28 | name: 'process',
29 | importNames: ['env'],
30 | message:
31 | 'Avoid using process.env directly - validate your types with valibot (example in ./apps/server/env.ts)',
32 | },
33 | ],
34 | },
35 | },
36 | );
37 |
38 | export default tseslint.config([
39 | { ignores: ['dist/**'] },
40 | ...turboConfig,
41 | js.configs.recommended,
42 | eslintConfigPrettier,
43 | ...tseslint.configs.recommended,
44 | {
45 | plugins: {
46 | turbo: turboPlugin,
47 | },
48 | rules: {
49 | 'turbo/no-undeclared-env-vars': 'warn',
50 | },
51 | },
52 | {
53 | plugins: {
54 | onlyWarn,
55 | },
56 | },
57 | {
58 | plugins: {
59 | import: eslintPluginImport,
60 | },
61 | rules: {
62 | 'import/no-cycle': 'warn',
63 | 'import/order': [
64 | 'warn',
65 | {
66 | groups: [
67 | 'builtin',
68 | 'external',
69 | 'type',
70 | 'internal',
71 | 'parent',
72 | 'sibling',
73 | 'index',
74 | 'object',
75 | ],
76 | alphabetize: {
77 | order: 'asc',
78 | },
79 | },
80 | ],
81 | },
82 | },
83 | {
84 | rules: {
85 | semi: ['error', 'always'],
86 | },
87 | },
88 | ]);
89 |
--------------------------------------------------------------------------------
/tools/eslint/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/eslint-config",
3 | "version": "0.0.0",
4 | "type": "module",
5 | "private": true,
6 | "scripts": {
7 | "clean": "git clean -xdf .cache .turbo node_modules",
8 | "format": "prettier --check . --ignore-path ../../.gitignore",
9 | "typecheck": "tsc --noEmit"
10 | },
11 | "exports": {
12 | "./base": "./base.js",
13 | "./react": "./react.js"
14 | },
15 | "prettier": "@repo/prettier-config",
16 | "devDependencies": {
17 | "@eslint/js": "catalog:",
18 | "@repo/prettier-config": "workspace:*",
19 | "@repo/typescript-config": "workspace:*",
20 | "@types/eslint-config-prettier": "catalog:",
21 | "eslint": "catalog:",
22 | "eslint-config-prettier": "catalog:",
23 | "eslint-plugin-import": "catalog:",
24 | "eslint-plugin-only-warn": "catalog:",
25 | "eslint-plugin-react": "catalog:",
26 | "eslint-plugin-react-hooks": "catalog:",
27 | "eslint-plugin-turbo": "catalog:",
28 | "globals": "catalog:",
29 | "typescript": "catalog:",
30 | "typescript-eslint": "catalog:"
31 | },
32 | "dependencies": {
33 | "eslint-config-turbo": "catalog:"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/tools/eslint/react.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js';
2 | import eslintConfigPrettier from 'eslint-config-prettier';
3 | import tseslint from 'typescript-eslint';
4 | import pluginReactHooks from 'eslint-plugin-react-hooks';
5 | import pluginReact from 'eslint-plugin-react';
6 | import globals from 'globals';
7 | import baseConfig from './base.js';
8 |
9 | export default tseslint.config([
10 | ...baseConfig,
11 | js.configs.recommended,
12 | eslintConfigPrettier,
13 | ...tseslint.configs.recommended,
14 | pluginReact.configs.flat.recommended,
15 | {
16 | languageOptions: {
17 | ...pluginReact.configs.flat.recommended.languageOptions,
18 | globals: {
19 | ...globals.serviceworker,
20 | ...globals.browser,
21 | },
22 | },
23 | },
24 | {
25 | plugins: {
26 | 'react-hooks': pluginReactHooks,
27 | },
28 | settings: { react: { version: 'detect' } },
29 | rules: {
30 | ...pluginReactHooks.configs.recommended.rules,
31 | // React scope no longer necessary with new JSX transform.
32 | 'react/react-in-jsx-scope': 'off',
33 | 'react/prop-types': 'off',
34 | 'react/no-children-prop': 'off',
35 | '@typescript-eslint/no-explicit-any': 'off',
36 | },
37 | },
38 | ]);
39 |
--------------------------------------------------------------------------------
/tools/eslint/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/base.json",
3 | "include": ["."],
4 | "exclude": ["node_modules"]
5 | }
6 |
--------------------------------------------------------------------------------
/tools/eslint/types.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'eslint-plugin-import' {
2 | import type { Linter, Rule } from 'eslint';
3 |
4 | export const configs: {
5 | recommended: { rules: Linter.RulesRecord };
6 | };
7 | export const rules: Record;
8 | }
9 |
10 | declare module 'eslint-plugin-only-warn' {
11 | import type { Linter, Rule } from 'eslint';
12 |
13 | export const configs: {
14 | recommended: { rules: Linter.RulesRecord };
15 | };
16 | export const rules: Record;
17 | }
18 |
19 | declare module 'eslint-plugin-react' {
20 | import type { Linter, Rule } from 'eslint';
21 |
22 | export const configs: {
23 | flat: {
24 | rules: Linter.RulesRecord;
25 | recommended: {
26 | rules: Linter.RulesRecord;
27 | languageOptions: Linter.LanguageOptions;
28 | };
29 | };
30 | };
31 | export const rules: Record;
32 | }
33 |
34 | declare module 'eslint-config-turbo/flat' {
35 | import type { Linter } from 'eslint';
36 |
37 | export const configs: {
38 | recommended: {
39 | rules: Linter.RulesRecord;
40 | };
41 | flat: {
42 | rules: Linter.RulesRecord;
43 | };
44 | };
45 | const turboConfig: Array<{
46 | rules: Linter.RulesRecord;
47 | }>;
48 | export default turboConfig;
49 | }
50 |
51 | declare module 'eslint-plugin-react-hooks' {
52 | import type { Linter, Rule } from 'eslint';
53 |
54 | export const configs: {
55 | recommended: {
56 | rules: {
57 | 'rules-of-hooks': Linter.RuleEntry;
58 | 'exhaustive-deps': Linter.RuleEntry;
59 | };
60 | };
61 | };
62 | export const rules: Record;
63 | }
64 |
--------------------------------------------------------------------------------
/tools/prettier/index.js:
--------------------------------------------------------------------------------
1 | /** @typedef {import("prettier").Config} PrettierConfig */
2 |
3 | /** @type { PrettierConfig } */
4 | const config = {
5 | plugins: [],
6 | singleQuote: true,
7 | };
8 |
9 | export default config;
10 |
--------------------------------------------------------------------------------
/tools/prettier/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/prettier-config",
3 | "private": true,
4 | "version": "0.1.0",
5 | "type": "module",
6 | "exports": {
7 | ".": "./index.js"
8 | },
9 | "scripts": {
10 | "clean": "git clean -xdf .cache .turbo node_modules",
11 | "format": "prettier --check . --ignore-path ../../.gitignore",
12 | "typecheck": "tsc --noEmit"
13 | },
14 | "dependencies": {
15 | "prettier": "catalog:"
16 | },
17 | "devDependencies": {
18 | "@repo/typescript-config": "workspace:*",
19 | "typescript": "catalog:"
20 | },
21 | "prettier": "@repo/prettier-config"
22 | }
23 |
--------------------------------------------------------------------------------
/tools/prettier/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/base.json",
3 | "include": ["."],
4 | "exclude": ["node_modules"]
5 | }
6 |
--------------------------------------------------------------------------------
/tools/tailwind/eslint.config.js:
--------------------------------------------------------------------------------
1 | import baseConfig from '@repo/eslint-config/base';
2 |
3 | /** @type {import('typescript-eslint').Config} */
4 | export default [...baseConfig];
5 |
--------------------------------------------------------------------------------
/tools/tailwind/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/tailwind-config",
3 | "type": "module",
4 | "scripts": {
5 | "clean": "git clean -xdf .cache .turbo node_modules",
6 | "format": "prettier --check . --ignore-path ../../.gitignore",
7 | "lint": "eslint"
8 | },
9 | "exports": {
10 | "./style.css": "./style.css"
11 | },
12 | "prettier": "@repo/prettier-config",
13 | "dependencies": {
14 | "eslint": "catalog:",
15 | "tailwindcss": "catalog:",
16 | "tailwindcss-animate": "catalog:"
17 | },
18 | "devDependencies": {
19 | "@repo/eslint-config": "workspace:*",
20 | "@repo/prettier-config": "workspace:*"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/tools/tailwind/style.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 |
3 | @plugin 'tailwindcss-animate';
4 |
5 | @custom-variant dark (&:is(.dark *));
6 |
7 | @theme {
8 | --color-selected: hsl(var(--selected));
9 | --color-nav: hsl(var(--nav));
10 | --color-elevated: hsl(var(--elevated));
11 |
12 | --color-background: hsl(var(--background));
13 | --color-foreground: hsl(var(--foreground));
14 |
15 | --color-card: hsl(var(--card));
16 | --color-card-foreground: hsl(var(--card-foreground));
17 |
18 | --color-popover: hsl(var(--popover));
19 | --color-popover-foreground: hsl(var(--popover-foreground));
20 |
21 | --color-primary: hsl(var(--primary));
22 | --color-primary-foreground: hsl(var(--primary-foreground));
23 |
24 | --color-secondary: hsl(var(--secondary));
25 | --color-secondary-foreground: hsl(var(--secondary-foreground));
26 |
27 | --color-muted: hsl(var(--muted));
28 | --color-muted-foreground: hsl(var(--muted-foreground));
29 |
30 | --color-accent: hsl(var(--accent));
31 | --color-accent-foreground: hsl(var(--accent-foreground));
32 |
33 | --color-destructive: hsl(var(--destructive));
34 | --color-destructive-foreground: hsl(var(--destructive-foreground));
35 | --color-success: hsl(var(--success));
36 | --color-success-foreground: hsl(var(--success-foreground));
37 | --color-info: hsl(var(--info));
38 | --color-info-foreground: hsl(var(--info-foreground));
39 | --color-warning: hsl(var(--warning));
40 | --color-warning-foreground: hsl(var(--warning-foreground));
41 |
42 | --color-border: hsl(var(--border));
43 | --color-input: hsl(var(--input));
44 | --color-ring: hsl(var(--ring));
45 |
46 | --color-chart-1: hsl(var(--chart-1));
47 | --color-chart-2: hsl(var(--chart-2));
48 | --color-chart-3: hsl(var(--chart-3));
49 | --color-chart-4: hsl(var(--chart-4));
50 | --color-chart-5: hsl(var(--chart-5));
51 |
52 | --color-sidebar: hsl(var(--sidebar-background));
53 | --color-sidebar-foreground: hsl(var(--sidebar-foreground));
54 | --color-sidebar-primary: hsl(var(--sidebar-primary));
55 | --color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
56 | --color-sidebar-accent: hsl(var(--sidebar-accent));
57 | --color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
58 | --color-sidebar-border: hsl(var(--sidebar-border));
59 | --color-sidebar-ring: hsl(var(--sidebar-ring));
60 |
61 | --radius-lg: var(--radius);
62 | --radius-md: calc(var(--radius) - 2px);
63 | --radius-sm: calc(var(--radius) - 4px);
64 |
65 | --animate-accordion-down: accordion-down 0.2s ease-out;
66 | --animate-accordion-up: accordion-up 0.2s ease-out;
67 |
68 | --color-toast-error: hsl(var(--toast-error));
69 | --color-toast-info: hsl(var(--toast-info));
70 | --color-toast-loading: hsl(var(--toast-loading));
71 | --color-toast-success: hsl(var(--toast-success));
72 | --color-toast-warning: hsl(var(--toast-warning));
73 |
74 | @keyframes accordion-down {
75 | from {
76 | height: 0;
77 | }
78 | to {
79 | height: var(--radix-accordion-content-height);
80 | }
81 | }
82 | @keyframes accordion-up {
83 | from {
84 | height: var(--radix-accordion-content-height);
85 | }
86 | to {
87 | height: 0;
88 | }
89 | }
90 | }
91 |
92 | /*
93 | The default border color has changed to `currentColor` in Tailwind CSS v4,
94 | so we've added these compatibility styles to make sure everything still
95 | looks the same as it did with Tailwind CSS v3.
96 |
97 | If we ever want to remove these styles, we need to add an explicit border
98 | color utility to any element that depends on these defaults.
99 | */
100 | @layer base {
101 | *,
102 | ::after,
103 | ::before,
104 | ::backdrop,
105 | ::file-selector-button {
106 | border-color: var(--color-gray-200, currentColor);
107 | }
108 | }
109 |
110 | @layer base {
111 | :root {
112 | --selected: 350 50% 70%;
113 | --nav: 350 50% 80%;
114 | --elevated: 0 0% 90%;
115 |
116 | --background: 0 0% 100%;
117 | --foreground: 0 0% 3.9%;
118 | --card: 0 0% 100%;
119 | --card-foreground: 0 0% 3.9%;
120 | --popover: 0 0% 100%;
121 | --popover-foreground: 0 0% 3.9%;
122 | --primary: 350 50% 40%;
123 | --primary-foreground: 0 0% 98%;
124 | --secondary: 0 0% 96.1%;
125 | --secondary-foreground: 0 0% 9%;
126 | --muted: 0 0% 96.1%;
127 | --muted-foreground: 0 0% 45.1%;
128 | --accent: 0 0% 96.1%;
129 | --accent-foreground: 0 0% 9%;
130 |
131 | --destructive: 0 84.2% 60.2%;
132 | --destructive-foreground: 0 0% 98%;
133 | --success: 120 74.2% 48.9%;
134 | --success-foreground: 0 0 0%;
135 | --info: 200 88.8% 52%;
136 | --info-foreground: 200 20% 98%;
137 | --warning: 40 94.2% 61%;
138 | --warning-foreground: 40 60% 12%;
139 |
140 | --border: 0 0% 89.8%;
141 | --input: 0 0% 89.8%;
142 | --ring: 0 0% 3.9%;
143 | --chart-1: 12 76% 61%;
144 | --chart-2: 173 58% 39%;
145 | --chart-3: 197 37% 24%;
146 | --chart-4: 43 74% 66%;
147 | --chart-5: 27 87% 67%;
148 | --radius: 0.5rem;
149 |
150 | --toast-error: 0 100% 80%;
151 | --toast-info: 220 50% 85%;
152 | --toast-loading: 0 0% 90%;
153 | --toast-success: 140 50% 80%;
154 | --toast-warning: 40 100% 60%;
155 |
156 | --sidebar-background: 0 0% 98%;
157 | --sidebar-foreground: 240 5.3% 26.1%;
158 | --sidebar-primary: 240 5.9% 10%;
159 | --sidebar-primary-foreground: 0 0% 98%;
160 | --sidebar-accent: 240 4.8% 95.9%;
161 | --sidebar-accent-foreground: 240 5.9% 10%;
162 | --sidebar-border: 220 13% 91%;
163 | --sidebar-ring: 217.2 91.2% 59.8%;
164 | }
165 |
166 | .dark {
167 | --selected: 350 50% 30%;
168 | --nav: 350 50% 40%;
169 | --elevated: 0 0% 25%;
170 |
171 | --background: 50 20% 13%;
172 | --foreground: 0 0% 98%;
173 | --card: 0 0% 3.9%;
174 | --card-foreground: 0 0% 98%;
175 | --popover: 0 0% 3.9%;
176 | --popover-foreground: 0 0% 98%;
177 | --primary: 350 50% 80%;
178 | --primary-foreground: 0 0% 9%;
179 | --secondary: 0 0% 14.9%;
180 | --secondary-foreground: 0 0% 98%;
181 | --muted: 0 0% 14.9%;
182 | --muted-foreground: 0 0% 63.9%;
183 | --accent: 0 0% 14.9%;
184 | --accent-foreground: 0 0% 98%;
185 |
186 | --destructive: 0 62.8% 30.6%;
187 | --destructive-foreground: 0 0% 98%;
188 | --success: 120 45.8% 26.6%;
189 | --success-foreground: 120 20% 98%;
190 | --info: 200 60% 35.5%;
191 | --info-foreground: 200 20% 98%;
192 | --warning: 40 68.9% 42%;
193 | --warning-foreground: 40 60% 12%;
194 |
195 | --border: 0 0% 14.9%;
196 | --input: 0 0% 30%;
197 | --ring: 0 0% 83.1%;
198 | --chart-1: 220 70% 50%;
199 | --chart-2: 160 60% 45%;
200 | --chart-3: 30 80% 55%;
201 | --chart-4: 280 65% 60%;
202 | --chart-5: 340 75% 55%;
203 |
204 | --toast-error: 0 100% 30%;
205 | --toast-info: 220 50% 30%;
206 | --toast-loading: 0 0% 10%;
207 | --toast-success: 140 50% 20%;
208 | --toast-warning: 40 100% 30%;
209 |
210 | --sidebar-background: 240 5.9% 10%;
211 | --sidebar-foreground: 240 4.8% 95.9%;
212 | --sidebar-primary-foreground: 0 0% 100%;
213 | --sidebar-accent: 240 3.7% 15.9%;
214 | --sidebar-accent-foreground: 240 4.8% 95.9%;
215 | --sidebar-border: 240 3.7% 15.9%;
216 | --sidebar-ring: 217.2 91.2% 59.8%;
217 | }
218 | }
219 |
220 | @layer base {
221 | * {
222 | @apply border-border;
223 | }
224 |
225 | body {
226 | @apply bg-background text-foreground;
227 | background-color: var(--background);
228 | color: var(--foreground);
229 | }
230 | }
231 |
232 | /*
233 | ---break---
234 | */
235 |
236 | @layer base {
237 | * {
238 | @apply border-border outline-ring/50;
239 | }
240 | body {
241 | @apply bg-background text-foreground;
242 | }
243 | }
244 |
--------------------------------------------------------------------------------
/tools/typescript/base.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "esModuleInterop": true,
4 | "skipLibCheck": true,
5 | "target": "ES2022",
6 | "lib": ["ES2022"],
7 | "allowJs": true,
8 | "resolveJsonModule": true,
9 | "moduleDetection": "force",
10 | "verbatimModuleSyntax": true,
11 | "isolatedModules": true,
12 |
13 | /** Keep TSC performant in monorepos */
14 | "incremental": true,
15 | "disableSourceOfProjectReferenceRedirect": true,
16 | "tsBuildInfoFile": "${configDir}/.cache/tsbuildinfo.json",
17 |
18 | /** Strictness */
19 | "strict": true,
20 | "noUncheckedIndexedAccess": true,
21 | "checkJs": true,
22 |
23 | /** Transpile using Bundler (not tsc) */
24 | "module": "es2022",
25 | "moduleResolution": "bundler",
26 | "noEmit": true
27 | },
28 | "exclude": ["node_modules", "build", "dist"]
29 | }
30 |
--------------------------------------------------------------------------------
/tools/typescript/internal-package.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "extends": "./base.json",
4 | "compilerOptions": {
5 | "composite": true,
6 | "declaration": true,
7 | "declarationMap": true,
8 | "sourceMap": true,
9 | "emitDeclarationOnly": true,
10 | "noEmit": false,
11 | "outDir": "${configDir}/dist"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/tools/typescript/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/typescript-config",
3 | "type": "module",
4 | "private": true,
5 | "files": [
6 | "*.json"
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/tools/typescript/vite.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./base.json",
3 | "compilerOptions": {
4 | "allowImportingTsExtensions": true,
5 | "declaration": true,
6 | "declarationMap": true,
7 | "inlineSources": false,
8 | "jsx": "react-jsx",
9 | "module": "ESNext",
10 | "moduleResolution": "bundler",
11 | "noFallthroughCasesInSwitch": true,
12 | "noUnusedLocals": true,
13 | "noUnusedParameters": true,
14 | "preserveWatchOutput": true
15 | },
16 | "exclude": ["node_modules"]
17 | }
18 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "ui": "tui",
4 | "tasks": {
5 | "dev": {
6 | "dependsOn": ["^dev"],
7 | "persistent": false,
8 | "cache": false
9 | },
10 | "clean": {
11 | "cache": false
12 | },
13 | "build": {
14 | "dependsOn": ["^build"],
15 | "inputs": ["$TURBO_DEFAULT$", ".env*"],
16 | "outputs": ["dist/**"]
17 | },
18 | "auth:schema:generate": {
19 | "cache": false,
20 | "interactive": true
21 | },
22 | "format": {
23 | "outputs": [".cache/.prettiercache"],
24 | "outputLogs": "new-only"
25 | },
26 | "lint": {
27 | "dependsOn": ["^build"],
28 | "outputs": [".cache/.eslintcache"]
29 | },
30 | "typecheck": {
31 | "dependsOn": ["^build"],
32 | "outputs": [".cache/tsbuildinfo.json"]
33 | },
34 | "push": {
35 | "env": ["DB_POSTGRES_URL"],
36 | "cache": false,
37 | "interactive": true
38 | },
39 | "start": {
40 | "dependsOn": ["^build"]
41 | },
42 | "studio": {
43 | "cache": false,
44 | "persistent": true
45 | },
46 | "ui-add": {
47 | "cache": false,
48 | "interactive": true
49 | },
50 | "env:copy-example": {
51 | "cache": false
52 | },
53 | "env:remove": {
54 | "cache": false
55 | }
56 | },
57 | "globalEnv": [],
58 | "globalPassThroughEnv": ["NODE_ENV", "CI", "npm_lifecycle_event"]
59 | }
60 |
--------------------------------------------------------------------------------