├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── biome.jsonc ├── drizzle.config.ts ├── package.json ├── packages ├── api │ ├── .dev.vars.example │ ├── package.json │ ├── src │ │ ├── db │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ ├── index.ts │ │ ├── lib │ │ │ ├── auth.ts │ │ │ ├── cookies.ts │ │ │ └── env.ts │ │ ├── migrations │ │ │ ├── 0000_mean_invaders.sql │ │ │ └── meta │ │ │ │ ├── 0000_snapshot.json │ │ │ │ └── _journal.json │ │ └── trpc │ │ │ ├── router.ts │ │ │ └── routes │ │ │ ├── posts.ts │ │ │ └── users.ts │ └── wrangler.jsonc.example ├── shared │ ├── package.json │ └── src │ │ ├── constants │ │ └── index.ts │ │ ├── index.ts │ │ ├── lib │ │ ├── auth.ts │ │ └── db.ts │ │ ├── types │ │ └── index.ts │ │ └── utils │ │ └── index.ts └── web │ ├── .env.development.local.example │ ├── .env.example │ ├── components.json │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── open-next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-48x48.png │ ├── mstile-144x144.png │ ├── mstile-150x150.png │ ├── mstile-310x150.png │ ├── mstile-310x310.png │ ├── mstile-70x70.png │ ├── og-image.png │ ├── shortcut-icon.png │ ├── site.webmanifest │ └── twitter-card.png │ ├── src │ ├── app │ │ ├── (protected) │ │ │ ├── dashboard │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── favicon.ico │ │ ├── layout.tsx │ │ └── page.tsx │ ├── auth │ │ └── client.tsx │ ├── components │ │ ├── FeatureCard.tsx │ │ ├── GradientButton.tsx │ │ ├── Stars.tsx │ │ ├── dashboard │ │ │ ├── Header.tsx │ │ │ └── Sidebar.tsx │ │ └── ui │ │ │ ├── avatar.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── separator.tsx │ │ │ └── sheet.tsx │ ├── lib │ │ ├── auth.ts │ │ ├── envConfig.ts │ │ ├── hooks │ │ │ └── useAuthGuard.ts │ │ └── utils.ts │ ├── styles │ │ └── globals.css │ └── trpc │ │ ├── client.tsx │ │ ├── query-client.tsx │ │ └── server.tsx │ ├── tsconfig.json │ └── wrangler.jsonc.example ├── scripts ├── setup-db.ts └── setup-env.ts ├── tsconfig.json ├── worker-configuration.d.ts └── wrangler.jsonc.example /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | .pnp 4 | .pnp.js 5 | 6 | # Build outputs 7 | .next/ 8 | out/ 9 | build/ 10 | dist/ 11 | 12 | # Environment variables 13 | .env 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | # Logs 20 | logs 21 | *.log 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | pnpm-debug.log* 26 | lerna-debug.log* 27 | 28 | # Editor directories and files 29 | .vscode/* 30 | !.vscode/extensions.json 31 | !.vscode/settings.json 32 | .idea 33 | .DS_Store 34 | *.suo 35 | *.ntvs* 36 | *.njsproj 37 | *.sln 38 | *.sw? 39 | 40 | # Cloudflare 41 | .wrangler/ 42 | .dev.vars 43 | wrangler.jsonc 44 | **/.wrangler.jsonc 45 | # Bun 46 | bun.lockb 47 | 48 | # Testing 49 | coverage/ 50 | 51 | # Misc 52 | .turbo 53 | .cache 54 | .open-next/ 55 | .wrangler 56 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "tailwindCSS.experimental.configFile": "packages/web/src/styles/globals.css" 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 @celestial-rose/stack 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🌌🌹 @celestial-rose/stack 2 | ## Hono w/ tRPC - D1 w/ Drizzle - NextJS 15 w/ Better Auth 3 | 4 |
5 |

The First Full-Stack Meta-Framework that lets you ship for FREE

6 |

Type-safe • Serverless • Developer-friendly • Cloudflare Deployed

7 |

The ultimate combination in a template that has never been made before, in a monorepo

8 |
9 | 10 |

11 | Features • 12 | Tech Stack • 13 | Getting Started • 14 | Opinionated Decisions • 15 | Acknowledgements • 16 | Why Celestial? • 17 | Roadmap • 18 | License 19 |

20 | 21 | ## ✨ Features 22 | 23 | - **🔋 Batteries Included**: Everything configured and ready to go - no setup hassle 24 | - **🚀 Complete Platform**: Everything you need in one place - powered by Cloudflare's ecosystem 25 | - **🔒 Full Type Safety**: End-to-end TypeScript with tRPC for reliable API communication 26 | - **⚡ Peak Performance**: Optimized architecture from frontend to database 27 | - **🔐 Secure by Default**: Ready-to-use authentication with Better Auth 28 | - **📊 Data Ready**: Built-in database with Drizzle ORM and D1 29 | - **🎨 Modern Interface**: Latest tech with NextJS 15.2.3, React 19, and Tailwind CSS 4 30 | - **🧰 Streamlined Development**: Biome and Bun for a smooth coding experience 31 | 32 | ### ⭐ What you're getting out-of-the-box 📦 33 | 34 | - [x] Frontend (Next.js) and Backend (Hono) setup ✅ 35 | - [x] CDN, HTTPS, custom domains, auto-scaling ✅ 36 | - [x] Database with D1 and ORM with Drizzle ✅ 37 | - [x] Authentication with Better Auth ✅ 38 | - [ ] Blob storage (coming soon with R2) ⏳ 39 | - [x] Email system with Resend (⏳ soon AWS SES) ✅ 40 | - [ ] Payment processing integration ⏳ 41 | - [x] Built-in analytics through Cloudflare ✅ 42 | - [x] Real-time monitoring and logs through Cloudflare ✅ 43 | - [x] Development tools (CI/CD, staging environments) ✅ 44 | - [x] Secure secrets management ⏳ (very soon) 45 | 46 | ## 🛠️ Tech Stack 47 | 48 | ### Frontend 49 | - **[Next.js 15.2.3](https://github.com/vercel/next.js)**: The latest version with improved performance and features 50 | - **[React 19](https://github.com/facebook/react)**: With enhanced server components and improved developer experience 51 | - **[Tailwind CSS 4](https://github.com/tailwindlabs/tailwindcss)**: Utility-first CSS framework with the latest optimizations 52 | - **[shadcn/ui](https://github.com/shadcn-ui/ui)**: Accessible component library built on Radix UI primitives 53 | - **[tRPC Client](https://github.com/trpc/trpc)**: Type-safe API calls with Tanstack Query for data fetching 54 | 55 | ### Backend 56 | - **[Hono](https://github.com/honojs/hono)**: Lightweight, fast web framework for Cloudflare Workers 57 | - **[tRPC Server](https://github.com/trpc/trpc)**: Type-safe API layer integrated with Hono 58 | - **[Better Auth](https://github.com/better-auth/better-auth)**: Comprehensive authentication and authorization 59 | - **[Drizzle ORM](https://github.com/drizzle-team/drizzle-orm)**: Type-safe database access for D1 60 | - **[Cloudflare D1](https://developers.cloudflare.com/d1)**: Distributed SQL database built on SQLite 61 | 62 | ### Development 63 | - **[TypeScript](https://github.com/microsoft/TypeScript)**: For type safety across the entire application 64 | - **[Bun](https://github.com/oven-sh/bun)**: Fast JavaScript runtime and package manager 65 | - **[Biome 2.0](https://github.com/biomejs/biome)**: Modern, fast linter and formatter replacing ESLint and Prettier 66 | - **[Wrangler](https://github.com/cloudflare/workers-sdk)**: Cloudflare's CLI for development and deployment 67 | 68 | 69 | ## 🚀 Getting Started 70 | 71 | ### 1. Project Setup 72 | 73 | ```bash 74 | # Clone the repository 75 | git clone https://github.com/celestial-rose/stack.git 76 | 77 | # Navigate to the project 78 | cd stack 79 | 80 | # Install dependencies 81 | bun install 82 | 83 | # Create a Cloudflare Account: Sign up at cloudflare.com if you don't already have an account. 84 | # No need to install Wrangler globally: Wrangler is included as a project dependency. 85 | 86 | # Authenticate Wrangler: Connect Wrangler to your Cloudflare account. 87 | bun wrangler:login 88 | 89 | # Set up the database (very important step) 90 | # This prepares the whole env for you by injecting the database name in important files 91 | bun setup-db 92 | ``` 93 | 94 | ### 2. Environment Configuration 95 | 96 | Run the environment setup script: 97 | ```bash 98 | bun setup-env 99 | ``` 100 | 101 | This script will: 102 | 1. Copy all example environment files to their actual environment files if they don't exist yet 103 | 2. Generate a random secret for `BETTER_AUTH_SECRET` and update it in all environment files 104 | 105 | After running the script, you may want to update other environment variables: 106 | - Set up a [Resend](https://resend.com) account for email services and add your API key *(optional: only for sending OTP for auth)* 107 | - Update domain-related variables if you're using a custom domain 108 | 109 | ### 3. Running in Development Mode 110 | 111 | ```bash 112 | # Start both API and web servers concurrently 113 | bun dev 114 | 115 | # Or start them individually 116 | bun dev:api # Starts the API server on http://localhost:8787 117 | bun dev:web # Starts the Next.js server on http://localhost:3000 118 | ``` 119 | 120 | The development server will: 121 | - Start the API server using Wrangler on http://localhost:8787 122 | - Start the Next.js web server on http://localhost:3000 123 | - Connect to a local D1 database for development 124 | 125 | ### 4. Database Management 126 | 127 | After running the development server, you can use `bun fix-db` to synchronize your database. 128 | > This is a current workaround as we couldn't manage to share the same locally simulated D1 MiniFlare database in wrangler. 129 | 130 | ```bash 131 | # Fix database synchronization issues after running the dev server 132 | bun fix-db 133 | 134 | # Generate migrations after schema changes 135 | bun generate 136 | 137 | # Apply migrations to local development database 138 | bun migrate 139 | 140 | # Apply migrations to production database 141 | bun migrate:prod 142 | ``` 143 | 144 | ### 5. Deployment Process 145 | 146 | > **⚠️ IMPORTANT: Security Disclaimer** 147 | > Never commit secrets or API keys to git. While the current setup uses wrangler.jsonc for configuration (which may contain secrets), you should **never push wrangler.jsonc with secrets to production**. A better approach using Wrangler CLI for secret management is being explored. In the meantime, consider leaving wrangler.jsonc in your .gitignore file if it contains sensitive information. 148 | 149 | 1. Ensure your Cloudflare account is set up and you're authenticated with Wrangler. 150 | 151 | 2. Update your production environment variables: 152 | - Update the domain information in `packages/api/wrangler.jsonc` and `packages/web/wrangler.jsonc` 153 | - Set the correct values for `BETTER_AUTH_URL`, `BETTER_AUTH_COOKIES_DOMAIN`, etc. 154 | 155 | 3. Deploy your application: 156 | ```bash 157 | # Deploy both API and web concurrently 158 | bun deploy:all 159 | 160 | # Or deploy them individually 161 | bun deploy:api # Deploys only the API 162 | bun deploy:web # Deploys only the web frontend 163 | ``` 164 | 165 | 4. After deployment, your application will be available at your configured domains: 166 | - API: https://api.your-domain.dev (or your custom domain) 167 | - Web: https://your-domain.dev (or your custom domain) 168 | 169 | #### OpenNext for Cloudflare 170 | 171 | This project uses [OpenNext](https://open-next.js.org/) to deploy Next.js applications to Cloudflare. OpenNext is an open-source adapter that enables Next.js applications to run on various serverless platforms, including Cloudflare Workers. 172 | 173 | Special thanks to [Dax Raad](https://twitter.com/thdxr) and the [SST team](https://sst.dev) for creating and maintaining OpenNext, which makes it possible to deploy Next.js applications to Cloudflare's edge network with minimal configuration. 174 | 175 | The integration is handled through the [`@opennextjs/cloudflare`](https://github.com/sst/open-next) package, which: 176 | - Builds your Next.js application for Cloudflare Workers 177 | - Handles asset management and routing 178 | - Optimizes for Cloudflare's edge runtime environment 179 | 180 | ## 📁 Repository Structure 181 | 182 | The @celestial-rose/stack is organized as a monorepo with a clear separation of concerns: 183 | 184 | ``` 185 | celestial-rose-stack/ 186 | ├── packages/ # Main code packages 187 | │ ├── api/ # Backend API (Hono + tRPC) 188 | │ │ ├── src/ 189 | │ │ │ ├── db/ # Database schema and connection 190 | │ │ │ ├── lib/ # Utility functions and helpers 191 | │ │ │ ├── migrations/# Database migrations 192 | │ │ │ └── trpc/ # tRPC router and route handlers 193 | │ │ └── wrangler.jsonc # Cloudflare Workers configuration 194 | │ │ 195 | │ ├── shared/ # Shared code between frontend and backend 196 | │ │ └── src/ 197 | │ │ ├── constants/ # Shared constants 198 | │ │ ├── lib/ # Shared utility functions 199 | │ │ ├── types/ # Shared TypeScript types 200 | │ │ └── utils/ # Shared utility functions 201 | │ │ 202 | │ └── web/ # Frontend Next.js application 203 | │ ├── public/ # Static assets 204 | │ └── src/ 205 | │ ├── app/ # Next.js app router pages 206 | │ ├── auth/ # Authentication components 207 | │ ├── components/# React components 208 | │ ├── lib/ # Frontend utility functions 209 | │ ├── styles/ # Global styles 210 | │ └── trpc/ # tRPC client setup 211 | │ 212 | ├── scripts/ # Utility scripts for setup and maintenance 213 | │ ├── setup-db.ts # Database setup script 214 | │ └── setup-env.ts # Environment setup script 215 | │ 216 | ├── biome.jsonc # Biome configuration 217 | ├── drizzle.config.ts # Drizzle ORM configuration 218 | ├── package.json # Root package.json with workspace config 219 | ├── tsconfig.json # TypeScript configuration 220 | └── wrangler.jsonc # Root Cloudflare Workers configuration 221 | ``` 222 | 223 | ### Key Components 224 | 225 | - **API Package**: Contains the Hono server with tRPC integration, database schema, and API routes. This is deployed to Cloudflare Workers. 226 | 227 | - **Web Package**: Contains the Next.js frontend application with React components, pages, and styles. This is deployed to Cloudflare Pages via OpenNext. 228 | 229 | - **Shared Package**: Contains code shared between the frontend and backend, such as types, constants, and utility functions. This ensures type safety across the entire application. 230 | 231 | - **Scripts**: Utility scripts for setting up the database and environment variables. 232 | 233 | ### Type Safety Across Packages 234 | 235 | One of the key benefits of this monorepo structure is the end-to-end type safety: 236 | 237 | 1. Database schema is defined in `packages/api/src/db/schema.ts` 238 | 2. This schema is used to generate TypeScript types 239 | 3. These types are shared via the shared package 240 | 4. The frontend can use these types for form validation and data display 241 | 5. tRPC ensures that API calls are type-safe from frontend to backend 242 | 243 | ## Troubleshooting 244 | 245 | ### Common Issues 246 | 247 | 1. **Wrangler Authentication Issues** 248 | ``` 249 | Error: You need to login to Cloudflare first. 250 | ``` 251 | Solution: Run `bun wrangler:login` and follow the authentication flow. 252 | 253 | 2. **Database Connection Errors** 254 | ``` 255 | Error: D1_ERROR: database not found 256 | ``` 257 | Solution: Ensure you've run `bun setup-db` and that the database ID in your wrangler.jsonc files matches. 258 | 259 | 3. **Environment Variable Issues** 260 | ``` 261 | Error: Missing environment variable: BETTER_AUTH_SECRET 262 | ``` 263 | Solution: Check that you've copied and updated all the necessary .env files as described in the Environment Configuration section. 264 | 265 | 4. **Port Conflicts** 266 | ``` 267 | Error: Port 3000 is already in use 268 | ``` 269 | Solution: Stop any other services using the port or change the port in your Next.js configuration. 270 | 271 | 5. **Deployment Failures** 272 | ``` 273 | Error: Failed to deploy to Cloudflare 274 | ``` 275 | Solution: Check your Cloudflare account permissions and ensure your wrangler.jsonc configuration is correct. 276 | 277 | ## 🧠 Opinionated Decisions 278 | 279 | The @celestial-rose/stack makes several opinionated technical decisions to optimize for developer experience and deployment efficiency: 280 | 281 | - **No Better Auth migrations → Full Drizzle schema**: We use Drizzle for all database schema management, including auth tables, for a unified approach to database management. 282 | - **No Drizzle-kit push → Wrangler execute**: Direct database operations through Wrangler for better integration with Cloudflare's ecosystem. 283 | - **Bun over npm/yarn/pnpm**: Significantly faster package management and runtime execution. 284 | - **Biome over ESLint/Prettier**: Single tool for linting and formatting with better performance. 285 | - **Monorepo structure**: Shared types and utilities across frontend and backend for maximum type safety. 286 | - **Edge-first architecture**: Built for Cloudflare Workers from the ground up, not adapted afterward. 287 | - **tRPC over REST/GraphQL**: End-to-end type safety without the need for code generation or schemas. 288 | - **shadcn/ui over component libraries**: Unstyled, accessible components that you own and can customize. 289 | - **D1 over other databases**: Native integration with Cloudflare Workers and zero configuration needed. 290 | 291 | 292 | ## 🙏 Acknowledgements 293 | 294 | Special thanks to Theo [@theo](https://twitter.com/theo) for laying the foundation with the revolutionary [T3 Stack](https://create.t3.gg/). Also grateful to [tRPC](https://trpc.io/) and [Tanstack Query](https://tanstack.com/query) for building such amazing libraries that make type-safe development a joy. 295 | 296 | 297 | ## 💫 Why Celestial in 2025? 298 | 299 | ### Batteries Included 300 | No more reinventing the wheel or setting up boilerplate for every project. Everything you need is included and preconfigured, making it your one-stop solution for modern web development. 301 | 302 | ### Free Deployment Until You Scale 303 | Deploy on Cloudflare's free tier and scale only when you need to. The entire stack is optimized to work within Cloudflare's free tier limits, allowing you to ship production-ready applications without upfront costs. 304 | 305 | ### Developer Experience Matters 306 | We've carefully selected tools that make development a joy - from Biome's lightning-fast linting to tRPC's end-to-end type safety and Bun's ultra-fast package management. 307 | 308 | ### Performance is Non-Negotiable 309 | Every component of the stack is optimized for performance, from React 19 to Hono's lightweight API framework. 310 | 311 | ### Scalability Without Complexity 312 | The modular architecture allows your applications to grow without becoming unwieldy, with clear patterns for organizing code. 313 | 314 | ## 🗺️ Roadmap 315 | 316 | The @celestial-rose/stack is continuously evolving. Here are the key features and improvements planned for future releases: 317 | 318 | ### Enhanced SST Integration 319 | - While we already use OpenNext for Cloudflare deployment, future plans include deeper SST integration: 320 | - Eliminate dependency on Wrangler for deployments 321 | - Use AWS SES for more control over email delivery 322 | - Manage all Cloudflare bindings through SST configuration 323 | - Simplify multi-environment deployments (dev, staging, prod) 324 | 325 | ### Performance Enhancements 326 | - Hono rate limiting for API protection 327 | - Further optimizations for Cloudflare's free tier limits 328 | - Enhanced caching strategies for static assets 329 | 330 | ### Developer Experience 331 | - Improved local development environment 332 | - More comprehensive documentation and examples 333 | - CLI tool for scaffolding new features and components 334 | 335 | ### Additional Features 336 | - Enhanced authentication options (OAuth providers, SAML) 337 | - Real-time capabilities with WebSockets 338 | - File storage integration with R2 339 | - Analytics and monitoring tools 340 | - Emails with AWS SES 341 | - Payment maybe ? 342 | 343 | We welcome contributions and suggestions for the roadmap. Feel free to open issues or pull requests with your ideas! 344 | 345 | ## 📄 License 346 | 347 | @celestial-rose/stack is [MIT licensed](./LICENSE). -------------------------------------------------------------------------------- /biome.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.0.0-beta.1/schema.json", 3 | "assist": { 4 | "actions": { 5 | "source": { 6 | "organizeImports": "on" 7 | } 8 | } 9 | }, 10 | "linter": { 11 | "includes": [ 12 | "**", 13 | "!**/.next/**", 14 | "!**/.open-next/**", 15 | "!**/.wrangler/**" 16 | ], 17 | "enabled": true, 18 | "rules": { 19 | "style": { 20 | "useConst": "error", 21 | "useLiteralEnumMembers": "error", 22 | "noCommaOperator": "error", 23 | "useNodejsImportProtocol": "error", 24 | "useAsConstAssertion": "error", 25 | "useNumericLiterals": "error", 26 | "useEnumInitializers": "error", 27 | "useSelfClosingElements": "error", 28 | "useSingleVarDeclarator": "error", 29 | "noUnusedTemplateLiteral": "error", 30 | "useNumberNamespace": "error", 31 | "noInferrableTypes": "error", 32 | "useExponentiationOperator": "error", 33 | "useTemplate": "error", 34 | "noParameterAssign": "error", 35 | "noNonNullAssertion": "error", 36 | "useDefaultParameterLast": "error", 37 | "noArguments": "error", 38 | "useImportType": "error", 39 | "useExportType": "error", 40 | "noUselessElse": "error", 41 | "useShorthandFunctionType": "error" 42 | }, 43 | "correctness": { 44 | "noUnusedVariables": "error" 45 | }, 46 | "suspicious": { 47 | "noExplicitAny": "warn" 48 | } 49 | } 50 | }, 51 | "formatter": { 52 | "enabled": true, 53 | "indentStyle": "space", 54 | "indentWidth": 2, 55 | "lineWidth": 100 56 | }, 57 | "javascript": { 58 | "formatter": { 59 | "quoteStyle": "single", 60 | "trailingCommas": "es5", 61 | "semicolons": "always" 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | 3 | export default defineConfig({ 4 | dialect: "sqlite", // 'mysql' | 'sqlite' | 'turso' 5 | schema: "packages/api/src/db/schema.ts", 6 | out: "./packages/api/src/migrations", 7 | }); 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "celestial-rose", 3 | "private": true, 4 | "workspaces": [ 5 | "packages/*" 6 | ], 7 | "scripts": { 8 | "dev": "concurrently -n api,web \"bun run dev:api\" \"bun run dev:web\"", 9 | "dev:api": "cd packages/api && ENV=dev bun run dev", 10 | "dev:web": "cd packages/web && bun run dev", 11 | "deploy:all": "concurrently -n api,web \"bun run deploy:api\" \"bun run deploy:web\"", 12 | "deploy:api": "cd packages/api && bun run deploy", 13 | "deploy:web": "cd packages/web && bun run deploy", 14 | "lint": "biome lint .", 15 | "format": "biome format --write .", 16 | "check": "biome check --apply .", 17 | "setup-db": "bun run scripts/setup-db.ts", 18 | "setup-env": "bun run scripts/setup-env.ts", 19 | "fix-db": "cd packages/web && (rm -rf .wrangler || true) && ln -s ../api/.wrangler .", 20 | "generate": "drizzle-kit generate", 21 | "migrate": "cd packages/api && wrangler d1 migrations apply [DB-NAME] && cd ../../ && bun fix-db", 22 | "migrate:prod": "cd packages/api && wrangler d1 migrations apply [DB-NAME] --remote" 23 | }, 24 | "dependencies": { 25 | "@better-auth/cli": "^1.2.4", 26 | "@opennextjs/cloudflare": "^0.5.12", 27 | "dotenv-cli": "^8.0.0", 28 | "kysely": "^0.27.6", 29 | "kysely-d1": "^0.3.0" 30 | }, 31 | "devDependencies": { 32 | "@biomejs/biome": "^2.0.0-beta.1", 33 | "@cloudflare/workers-types": "^4.20250321.0", 34 | "concurrently": "^9.1.2", 35 | "install": "^0.13.0", 36 | "typescript": "^5.0.0", 37 | "wrangler": "^4.4.0" 38 | } 39 | } -------------------------------------------------------------------------------- /packages/api/.dev.vars.example: -------------------------------------------------------------------------------- 1 | ## Env 2 | ENV="local" 3 | ## Better Auth 4 | BETTER_AUTH_COOKIES_PREFIX="my-app" 5 | BETTER_AUTH_URL="http://localhost:8787" 6 | ### Generate the secret with `openssl rand -base64 32`` 7 | BETTER_AUTH_SECRET="" 8 | ## Resend 9 | RESEND_API_KEY="" 10 | ### Email from which to send Resend OTP 11 | RESEND_FROM_EMAIL="noreply@your-domain.dev" -------------------------------------------------------------------------------- /packages/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@celestial/api", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "wrangler dev", 7 | "deploy": "wrangler deploy", 8 | "lint": "biome lint src --ext .ts" 9 | }, 10 | "dependencies": { 11 | "@celestial/shared": "workspace:*", 12 | "@hono/trpc-server": "^0.3.4", 13 | "@trpc/server": "^11.0.0", 14 | "better-auth": "^1.0.0", 15 | "dotenv": "^16.4.7", 16 | "drizzle-orm": "^0.39.3", 17 | "hono": "^4.7.5", 18 | "resend": "^4.2.0", 19 | "zod": "^3.0.0" 20 | }, 21 | "devDependencies": { 22 | "drizzle-kit": "^0.30.4", 23 | "install": "^0.13.0", 24 | "typescript": "^5.0.0" 25 | } 26 | } -------------------------------------------------------------------------------- /packages/api/src/db/index.ts: -------------------------------------------------------------------------------- 1 | import { env } from "cloudflare:workers"; 2 | import { initDbConnection } from "@celestial/shared/src/lib/db"; 3 | 4 | export const db = initDbConnection((env as { DB: D1Database }).DB); 5 | -------------------------------------------------------------------------------- /packages/api/src/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; 2 | 3 | export const user = sqliteTable("user", { 4 | id: text("id").notNull().primaryKey(), 5 | name: text("name").notNull(), 6 | email: text("email").notNull().unique(), 7 | emailVerified: integer("emailVerified").notNull(), 8 | image: text("image"), 9 | createdAt: text("createdAt").notNull(), 10 | updatedAt: text("updatedAt").notNull(), 11 | phone: text("phone"), 12 | }); 13 | 14 | export const session = sqliteTable("session", { 15 | id: text("id").notNull().primaryKey(), 16 | expiresAt: text("expiresAt").notNull(), 17 | token: text("token").notNull().unique(), 18 | createdAt: text("createdAt").notNull(), 19 | updatedAt: text("updatedAt").notNull(), 20 | ipAddress: text("ipAddress"), 21 | userAgent: text("userAgent"), 22 | userId: text("userId") 23 | .notNull() 24 | .references(() => user.id), 25 | }); 26 | 27 | export const account = sqliteTable("account", { 28 | id: text("id").notNull().primaryKey(), 29 | accountId: text("accountId").notNull(), 30 | providerId: text("providerId").notNull(), 31 | userId: text("userId") 32 | .notNull() 33 | .references(() => user.id), 34 | accessToken: text("accessToken"), 35 | refreshToken: text("refreshToken"), 36 | idToken: text("idToken"), 37 | accessTokenExpiresAt: text("accessTokenExpiresAt"), 38 | refreshTokenExpiresAt: text("refreshTokenExpiresAt"), 39 | scope: text("scope"), 40 | password: text("password"), 41 | createdAt: text("createdAt").notNull(), 42 | updatedAt: text("updatedAt").notNull(), 43 | }); 44 | 45 | export const verification = sqliteTable("verification", { 46 | id: text("id").notNull().primaryKey(), 47 | identifier: text("identifier").notNull(), 48 | value: text("value").notNull(), 49 | expiresAt: text("expiresAt").notNull(), 50 | createdAt: text("createdAt"), 51 | updatedAt: text("updatedAt"), 52 | }); 53 | 54 | // Posts table (example) 55 | export const post = sqliteTable("post", { 56 | id: text("id").primaryKey(), 57 | title: text("title").notNull(), 58 | content: text("content").notNull(), 59 | authorId: text("author_id") 60 | .notNull() 61 | .references(() => user.id), 62 | createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 63 | updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), 64 | }); 65 | 66 | // Comments table (example) 67 | export const comment = sqliteTable("comment", { 68 | id: text("id").primaryKey(), 69 | content: text("content").notNull(), 70 | postId: text("post_id") 71 | .notNull() 72 | .references(() => post.id), 73 | authorId: text("author_id") 74 | .notNull() 75 | .references(() => user.id), 76 | createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 77 | }); 78 | -------------------------------------------------------------------------------- /packages/api/src/index.ts: -------------------------------------------------------------------------------- 1 | import { type Context, Hono } from "hono"; 2 | import { cors } from "hono/cors"; 3 | import { trpcServer } from "@hono/trpc-server"; 4 | import { appRouter } from "./trpc/router"; 5 | import * as schema from "./db/schema"; 6 | import { drizzle, type DrizzleD1Database } from "drizzle-orm/d1"; 7 | import type { Request } from "@cloudflare/workers-types"; 8 | import { ALLOWED_ORIGINS } from "@celestial/shared"; 9 | import { type Auth, auth } from "./lib/auth"; 10 | 11 | // This ensures c.env.DB is correctly typed 12 | export type Bindings = { 13 | DB: D1Database; 14 | }; 15 | export type HonoContext = { 16 | orm: DrizzleD1Database; 17 | req: Request; 18 | honoCtx: Context; 19 | auth: Auth; 20 | }; 21 | // Create the main Hono app 22 | const app = new Hono<{ 23 | Bindings: Bindings; 24 | Variables: { 25 | user: typeof auth.$Infer.Session.user | null; 26 | session: typeof auth.$Infer.Session.session | null; 27 | } & HonoContext; 28 | }>(); 29 | 30 | // Apply CORS middleware 31 | // CORS should be called before the route 32 | app.use( 33 | "*", 34 | cors({ 35 | origin: ALLOWED_ORIGINS, 36 | allowHeaders: ["Content-Type", "Authorization"], 37 | allowMethods: ["POST", "GET", "OPTIONS"], 38 | exposeHeaders: ["Content-Length"], 39 | maxAge: 600, 40 | credentials: true, 41 | }) 42 | ); 43 | 44 | app.use("*", async (c, next) => { 45 | const session = await auth.api.getSession({ headers: c.req.raw.headers }); 46 | 47 | if (!session) { 48 | c.set("user", null); 49 | c.set("session", null); 50 | return next(); 51 | } 52 | 53 | c.set("user", session.user); 54 | c.set("session", session.session); 55 | return next(); 56 | }); 57 | 58 | // Mount the auth handler 59 | app.on(["POST", "GET"], "/api/auth/*", (c) => { 60 | return auth.handler(c.req.raw); 61 | }); 62 | 63 | // Mount the tRPC router 64 | app.use("/api/trpc/*", async (c, next) => { 65 | const middleware = trpcServer({ 66 | router: appRouter, 67 | endpoint: "/api/trpc", 68 | onError({ error }) { 69 | console.error(error); 70 | }, 71 | createContext: (opts) => ({ 72 | ...opts, 73 | honoCtx: c, 74 | orm: drizzle(c.env?.DB, { schema }), 75 | auth: auth, 76 | }), 77 | }); 78 | return await middleware(c, next); 79 | }); 80 | 81 | // Health check endpoint 82 | app.get("/health", (c) => { 83 | return c.json({ 84 | status: "ok", 85 | timestamp: new Date().toISOString(), 86 | }); 87 | }); 88 | 89 | // // Session info endpoint 90 | // app.get("/session", async (c) => { 91 | // const session = c.get("session"); 92 | // const user = c.get("user"); 93 | 94 | // if (!user) { 95 | // return c.json({ authenticated: false }, 401); 96 | // } 97 | 98 | // return c.json({ 99 | // authenticated: true, 100 | // user, 101 | // session, 102 | // }); 103 | // }); 104 | 105 | // Export the app as the default export 106 | export default app; 107 | -------------------------------------------------------------------------------- /packages/api/src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { db } from "../../src/db"; 2 | import { sharedAuth } from "@celestial/shared/src/lib/auth"; 3 | import { typedEnv } from "./env"; 4 | 5 | export const auth = sharedAuth({ 6 | DB: db, 7 | BETTER_AUTH_COOKIES_PREFIX: typedEnv.BETTER_AUTH_COOKIES_PREFIX, 8 | BETTER_AUTH_COOKIES_DOMAIN: typedEnv.BETTER_AUTH_COOKIES_DOMAIN, 9 | BETTER_AUTH_SECRET: typedEnv.BETTER_AUTH_SECRET, 10 | BETTER_AUTH_URL: typedEnv.BETTER_AUTH_URL, 11 | RESEND_API_KEY: typedEnv.RESEND_API_KEY, 12 | RESEND_FROM_EMAIL: typedEnv.RESEND_FROM_EMAIL, 13 | ENV: typedEnv.ENV, 14 | }); 15 | 16 | export type Auth = typeof auth; 17 | // Export auth-related types 18 | export type { Session, User } from "better-auth"; 19 | -------------------------------------------------------------------------------- /packages/api/src/lib/cookies.ts: -------------------------------------------------------------------------------- 1 | import cookie, { type SerializeOptions } from "cookie"; 2 | 3 | export function getCookies(req: Request) { 4 | const cookieHeader = req.headers.get("Cookie"); 5 | if (!cookieHeader) return {}; 6 | return cookie.parse(cookieHeader); 7 | } 8 | 9 | export function getCookie(req: Request, name: string) { 10 | const cookieHeader = req.headers.get("Cookie"); 11 | if (!cookieHeader) return; 12 | const cookies = cookie.parse(cookieHeader); 13 | return cookies[name]; 14 | } 15 | 16 | export function setCookie(resHeaders: Headers, name: string, value: string, options?: SerializeOptions) { 17 | resHeaders.append("Set-Cookie", cookie.serialize(name, value, options)); 18 | } 19 | -------------------------------------------------------------------------------- /packages/api/src/lib/env.ts: -------------------------------------------------------------------------------- 1 | import type { TypedEnv } from "@celestial/shared"; 2 | import { env } from "cloudflare:workers"; 3 | 4 | export const typedEnv = env as TypedEnv; 5 | -------------------------------------------------------------------------------- /packages/api/src/migrations/0000_mean_invaders.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `account` ( 2 | `id` text PRIMARY KEY NOT NULL, 3 | `accountId` text NOT NULL, 4 | `providerId` text NOT NULL, 5 | `userId` text NOT NULL, 6 | `accessToken` text, 7 | `refreshToken` text, 8 | `idToken` text, 9 | `accessTokenExpiresAt` text, 10 | `refreshTokenExpiresAt` text, 11 | `scope` text, 12 | `password` text, 13 | `createdAt` text NOT NULL, 14 | `updatedAt` text NOT NULL, 15 | FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action 16 | ); 17 | --> statement-breakpoint 18 | CREATE TABLE `comment` ( 19 | `id` text PRIMARY KEY NOT NULL, 20 | `content` text NOT NULL, 21 | `post_id` text NOT NULL, 22 | `author_id` text NOT NULL, 23 | `created_at` integer NOT NULL, 24 | FOREIGN KEY (`post_id`) REFERENCES `post`(`id`) ON UPDATE no action ON DELETE no action, 25 | FOREIGN KEY (`author_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action 26 | ); 27 | --> statement-breakpoint 28 | CREATE TABLE `post` ( 29 | `id` text PRIMARY KEY NOT NULL, 30 | `title` text NOT NULL, 31 | `content` text NOT NULL, 32 | `author_id` text NOT NULL, 33 | `created_at` integer NOT NULL, 34 | `updated_at` integer NOT NULL, 35 | FOREIGN KEY (`author_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action 36 | ); 37 | --> statement-breakpoint 38 | CREATE TABLE `session` ( 39 | `id` text PRIMARY KEY NOT NULL, 40 | `expiresAt` text NOT NULL, 41 | `token` text NOT NULL, 42 | `createdAt` text NOT NULL, 43 | `updatedAt` text NOT NULL, 44 | `ipAddress` text, 45 | `userAgent` text, 46 | `userId` text NOT NULL, 47 | FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action 48 | ); 49 | --> statement-breakpoint 50 | CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint 51 | CREATE TABLE `user` ( 52 | `id` text PRIMARY KEY NOT NULL, 53 | `name` text NOT NULL, 54 | `email` text NOT NULL, 55 | `emailVerified` integer NOT NULL, 56 | `image` text, 57 | `createdAt` text NOT NULL, 58 | `updatedAt` text NOT NULL, 59 | `phone` text 60 | ); 61 | --> statement-breakpoint 62 | CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint 63 | CREATE TABLE `verification` ( 64 | `id` text PRIMARY KEY NOT NULL, 65 | `identifier` text NOT NULL, 66 | `value` text NOT NULL, 67 | `expiresAt` text NOT NULL, 68 | `createdAt` text, 69 | `updatedAt` text 70 | ); 71 | -------------------------------------------------------------------------------- /packages/api/src/migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "14ff5b6d-3118-4be0-98a6-d5d7e6135250", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "account": { 8 | "name": "account", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "text", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": false 16 | }, 17 | "accountId": { 18 | "name": "accountId", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "providerId": { 25 | "name": "providerId", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | }, 31 | "userId": { 32 | "name": "userId", 33 | "type": "text", 34 | "primaryKey": false, 35 | "notNull": true, 36 | "autoincrement": false 37 | }, 38 | "accessToken": { 39 | "name": "accessToken", 40 | "type": "text", 41 | "primaryKey": false, 42 | "notNull": false, 43 | "autoincrement": false 44 | }, 45 | "refreshToken": { 46 | "name": "refreshToken", 47 | "type": "text", 48 | "primaryKey": false, 49 | "notNull": false, 50 | "autoincrement": false 51 | }, 52 | "idToken": { 53 | "name": "idToken", 54 | "type": "text", 55 | "primaryKey": false, 56 | "notNull": false, 57 | "autoincrement": false 58 | }, 59 | "accessTokenExpiresAt": { 60 | "name": "accessTokenExpiresAt", 61 | "type": "text", 62 | "primaryKey": false, 63 | "notNull": false, 64 | "autoincrement": false 65 | }, 66 | "refreshTokenExpiresAt": { 67 | "name": "refreshTokenExpiresAt", 68 | "type": "text", 69 | "primaryKey": false, 70 | "notNull": false, 71 | "autoincrement": false 72 | }, 73 | "scope": { 74 | "name": "scope", 75 | "type": "text", 76 | "primaryKey": false, 77 | "notNull": false, 78 | "autoincrement": false 79 | }, 80 | "password": { 81 | "name": "password", 82 | "type": "text", 83 | "primaryKey": false, 84 | "notNull": false, 85 | "autoincrement": false 86 | }, 87 | "createdAt": { 88 | "name": "createdAt", 89 | "type": "text", 90 | "primaryKey": false, 91 | "notNull": true, 92 | "autoincrement": false 93 | }, 94 | "updatedAt": { 95 | "name": "updatedAt", 96 | "type": "text", 97 | "primaryKey": false, 98 | "notNull": true, 99 | "autoincrement": false 100 | } 101 | }, 102 | "indexes": {}, 103 | "foreignKeys": { 104 | "account_userId_user_id_fk": { 105 | "name": "account_userId_user_id_fk", 106 | "tableFrom": "account", 107 | "tableTo": "user", 108 | "columnsFrom": [ 109 | "userId" 110 | ], 111 | "columnsTo": [ 112 | "id" 113 | ], 114 | "onDelete": "no action", 115 | "onUpdate": "no action" 116 | } 117 | }, 118 | "compositePrimaryKeys": {}, 119 | "uniqueConstraints": {}, 120 | "checkConstraints": {} 121 | }, 122 | "comment": { 123 | "name": "comment", 124 | "columns": { 125 | "id": { 126 | "name": "id", 127 | "type": "text", 128 | "primaryKey": true, 129 | "notNull": true, 130 | "autoincrement": false 131 | }, 132 | "content": { 133 | "name": "content", 134 | "type": "text", 135 | "primaryKey": false, 136 | "notNull": true, 137 | "autoincrement": false 138 | }, 139 | "post_id": { 140 | "name": "post_id", 141 | "type": "text", 142 | "primaryKey": false, 143 | "notNull": true, 144 | "autoincrement": false 145 | }, 146 | "author_id": { 147 | "name": "author_id", 148 | "type": "text", 149 | "primaryKey": false, 150 | "notNull": true, 151 | "autoincrement": false 152 | }, 153 | "created_at": { 154 | "name": "created_at", 155 | "type": "integer", 156 | "primaryKey": false, 157 | "notNull": true, 158 | "autoincrement": false 159 | } 160 | }, 161 | "indexes": {}, 162 | "foreignKeys": { 163 | "comment_post_id_post_id_fk": { 164 | "name": "comment_post_id_post_id_fk", 165 | "tableFrom": "comment", 166 | "tableTo": "post", 167 | "columnsFrom": [ 168 | "post_id" 169 | ], 170 | "columnsTo": [ 171 | "id" 172 | ], 173 | "onDelete": "no action", 174 | "onUpdate": "no action" 175 | }, 176 | "comment_author_id_user_id_fk": { 177 | "name": "comment_author_id_user_id_fk", 178 | "tableFrom": "comment", 179 | "tableTo": "user", 180 | "columnsFrom": [ 181 | "author_id" 182 | ], 183 | "columnsTo": [ 184 | "id" 185 | ], 186 | "onDelete": "no action", 187 | "onUpdate": "no action" 188 | } 189 | }, 190 | "compositePrimaryKeys": {}, 191 | "uniqueConstraints": {}, 192 | "checkConstraints": {} 193 | }, 194 | "post": { 195 | "name": "post", 196 | "columns": { 197 | "id": { 198 | "name": "id", 199 | "type": "text", 200 | "primaryKey": true, 201 | "notNull": true, 202 | "autoincrement": false 203 | }, 204 | "title": { 205 | "name": "title", 206 | "type": "text", 207 | "primaryKey": false, 208 | "notNull": true, 209 | "autoincrement": false 210 | }, 211 | "content": { 212 | "name": "content", 213 | "type": "text", 214 | "primaryKey": false, 215 | "notNull": true, 216 | "autoincrement": false 217 | }, 218 | "author_id": { 219 | "name": "author_id", 220 | "type": "text", 221 | "primaryKey": false, 222 | "notNull": true, 223 | "autoincrement": false 224 | }, 225 | "created_at": { 226 | "name": "created_at", 227 | "type": "integer", 228 | "primaryKey": false, 229 | "notNull": true, 230 | "autoincrement": false 231 | }, 232 | "updated_at": { 233 | "name": "updated_at", 234 | "type": "integer", 235 | "primaryKey": false, 236 | "notNull": true, 237 | "autoincrement": false 238 | } 239 | }, 240 | "indexes": {}, 241 | "foreignKeys": { 242 | "post_author_id_user_id_fk": { 243 | "name": "post_author_id_user_id_fk", 244 | "tableFrom": "post", 245 | "tableTo": "user", 246 | "columnsFrom": [ 247 | "author_id" 248 | ], 249 | "columnsTo": [ 250 | "id" 251 | ], 252 | "onDelete": "no action", 253 | "onUpdate": "no action" 254 | } 255 | }, 256 | "compositePrimaryKeys": {}, 257 | "uniqueConstraints": {}, 258 | "checkConstraints": {} 259 | }, 260 | "session": { 261 | "name": "session", 262 | "columns": { 263 | "id": { 264 | "name": "id", 265 | "type": "text", 266 | "primaryKey": true, 267 | "notNull": true, 268 | "autoincrement": false 269 | }, 270 | "expiresAt": { 271 | "name": "expiresAt", 272 | "type": "text", 273 | "primaryKey": false, 274 | "notNull": true, 275 | "autoincrement": false 276 | }, 277 | "token": { 278 | "name": "token", 279 | "type": "text", 280 | "primaryKey": false, 281 | "notNull": true, 282 | "autoincrement": false 283 | }, 284 | "createdAt": { 285 | "name": "createdAt", 286 | "type": "text", 287 | "primaryKey": false, 288 | "notNull": true, 289 | "autoincrement": false 290 | }, 291 | "updatedAt": { 292 | "name": "updatedAt", 293 | "type": "text", 294 | "primaryKey": false, 295 | "notNull": true, 296 | "autoincrement": false 297 | }, 298 | "ipAddress": { 299 | "name": "ipAddress", 300 | "type": "text", 301 | "primaryKey": false, 302 | "notNull": false, 303 | "autoincrement": false 304 | }, 305 | "userAgent": { 306 | "name": "userAgent", 307 | "type": "text", 308 | "primaryKey": false, 309 | "notNull": false, 310 | "autoincrement": false 311 | }, 312 | "userId": { 313 | "name": "userId", 314 | "type": "text", 315 | "primaryKey": false, 316 | "notNull": true, 317 | "autoincrement": false 318 | } 319 | }, 320 | "indexes": { 321 | "session_token_unique": { 322 | "name": "session_token_unique", 323 | "columns": [ 324 | "token" 325 | ], 326 | "isUnique": true 327 | } 328 | }, 329 | "foreignKeys": { 330 | "session_userId_user_id_fk": { 331 | "name": "session_userId_user_id_fk", 332 | "tableFrom": "session", 333 | "tableTo": "user", 334 | "columnsFrom": [ 335 | "userId" 336 | ], 337 | "columnsTo": [ 338 | "id" 339 | ], 340 | "onDelete": "no action", 341 | "onUpdate": "no action" 342 | } 343 | }, 344 | "compositePrimaryKeys": {}, 345 | "uniqueConstraints": {}, 346 | "checkConstraints": {} 347 | }, 348 | "user": { 349 | "name": "user", 350 | "columns": { 351 | "id": { 352 | "name": "id", 353 | "type": "text", 354 | "primaryKey": true, 355 | "notNull": true, 356 | "autoincrement": false 357 | }, 358 | "name": { 359 | "name": "name", 360 | "type": "text", 361 | "primaryKey": false, 362 | "notNull": true, 363 | "autoincrement": false 364 | }, 365 | "email": { 366 | "name": "email", 367 | "type": "text", 368 | "primaryKey": false, 369 | "notNull": true, 370 | "autoincrement": false 371 | }, 372 | "emailVerified": { 373 | "name": "emailVerified", 374 | "type": "integer", 375 | "primaryKey": false, 376 | "notNull": true, 377 | "autoincrement": false 378 | }, 379 | "image": { 380 | "name": "image", 381 | "type": "text", 382 | "primaryKey": false, 383 | "notNull": false, 384 | "autoincrement": false 385 | }, 386 | "createdAt": { 387 | "name": "createdAt", 388 | "type": "text", 389 | "primaryKey": false, 390 | "notNull": true, 391 | "autoincrement": false 392 | }, 393 | "updatedAt": { 394 | "name": "updatedAt", 395 | "type": "text", 396 | "primaryKey": false, 397 | "notNull": true, 398 | "autoincrement": false 399 | }, 400 | "phone": { 401 | "name": "phone", 402 | "type": "text", 403 | "primaryKey": false, 404 | "notNull": false, 405 | "autoincrement": false 406 | } 407 | }, 408 | "indexes": { 409 | "user_email_unique": { 410 | "name": "user_email_unique", 411 | "columns": [ 412 | "email" 413 | ], 414 | "isUnique": true 415 | } 416 | }, 417 | "foreignKeys": {}, 418 | "compositePrimaryKeys": {}, 419 | "uniqueConstraints": {}, 420 | "checkConstraints": {} 421 | }, 422 | "verification": { 423 | "name": "verification", 424 | "columns": { 425 | "id": { 426 | "name": "id", 427 | "type": "text", 428 | "primaryKey": true, 429 | "notNull": true, 430 | "autoincrement": false 431 | }, 432 | "identifier": { 433 | "name": "identifier", 434 | "type": "text", 435 | "primaryKey": false, 436 | "notNull": true, 437 | "autoincrement": false 438 | }, 439 | "value": { 440 | "name": "value", 441 | "type": "text", 442 | "primaryKey": false, 443 | "notNull": true, 444 | "autoincrement": false 445 | }, 446 | "expiresAt": { 447 | "name": "expiresAt", 448 | "type": "text", 449 | "primaryKey": false, 450 | "notNull": true, 451 | "autoincrement": false 452 | }, 453 | "createdAt": { 454 | "name": "createdAt", 455 | "type": "text", 456 | "primaryKey": false, 457 | "notNull": false, 458 | "autoincrement": false 459 | }, 460 | "updatedAt": { 461 | "name": "updatedAt", 462 | "type": "text", 463 | "primaryKey": false, 464 | "notNull": false, 465 | "autoincrement": false 466 | } 467 | }, 468 | "indexes": {}, 469 | "foreignKeys": {}, 470 | "compositePrimaryKeys": {}, 471 | "uniqueConstraints": {}, 472 | "checkConstraints": {} 473 | } 474 | }, 475 | "views": {}, 476 | "enums": {}, 477 | "_meta": { 478 | "schemas": {}, 479 | "tables": {}, 480 | "columns": {} 481 | }, 482 | "internal": { 483 | "indexes": {} 484 | } 485 | } -------------------------------------------------------------------------------- /packages/api/src/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1742997857934, 9 | "tag": "0000_mean_invaders", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /packages/api/src/trpc/router.ts: -------------------------------------------------------------------------------- 1 | import type { HonoContext } from "./../index"; 2 | import { initTRPC } from "@trpc/server"; 3 | import * as schema from "../db/schema"; 4 | // Import route definitions 5 | import { z } from "zod"; 6 | import { userRouter } from "./routes/users"; 7 | 8 | // Create a new tRPC instance 9 | export const t = initTRPC.context().create(); 10 | // Export reusable router and procedure helpers 11 | export const router = t.router; 12 | 13 | export const publicProcedure = t.procedure; 14 | // Create the app router 15 | export const appRouter = router({ 16 | users: userRouter, 17 | hello: t.procedure.query(() => { 18 | return "Hello 👋 from tRPC"; 19 | }), 20 | }); 21 | export const createCallerFactory = t.createCallerFactory(appRouter); 22 | 23 | // Export type definition of API 24 | export type AppRouter = typeof appRouter; 25 | -------------------------------------------------------------------------------- /packages/api/src/trpc/routes/posts.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { post } from "../../db/schema"; 3 | import { eq } from "drizzle-orm"; 4 | 5 | import { initTRPC } from "@trpc/server"; 6 | import { HonoContext } from "../.."; 7 | 8 | const t = initTRPC.context().create(); 9 | 10 | export const postRouter = t.router({ 11 | list: t.procedure.query(async ({ ctx }) => { 12 | return await ctx.orm.select().from(post).all(); 13 | }), 14 | 15 | byId: t.procedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => { 16 | return await ctx.orm.select().from(post).where(eq(post.id, input.id)).get(); 17 | }), 18 | 19 | byAuthor: t.procedure.input(z.object({ authorId: z.string() })).query(async ({ ctx, input }) => { 20 | return await ctx.orm.select().from(post).where(eq(post.authorId, input.authorId)).all(); 21 | }), 22 | 23 | create: t.procedure 24 | .input( 25 | z.object({ 26 | title: z.string().min(1), 27 | content: z.string().min(1), 28 | authorId: z.string(), 29 | }) 30 | ) 31 | .mutation(async ({ input, ctx }) => { 32 | const id = crypto.randomUUID(); 33 | const now = new Date(); 34 | 35 | await ctx.orm.insert(post).values({ 36 | id, 37 | title: input.title, 38 | content: input.content, 39 | authorId: input.authorId, 40 | createdAt: now, 41 | updatedAt: now, 42 | }); 43 | 44 | return { id, ...input, createdAt: now, updatedAt: now }; 45 | }), 46 | }); 47 | -------------------------------------------------------------------------------- /packages/api/src/trpc/routes/users.ts: -------------------------------------------------------------------------------- 1 | import { user } from "../../db/schema"; 2 | import { initTRPC, TRPCError } from "@trpc/server"; 3 | import type { HonoContext } from "../.."; 4 | import { z } from "zod"; 5 | import { eq } from "drizzle-orm"; 6 | 7 | const t = initTRPC.context().create(); 8 | 9 | export const userRouter = t.router({ 10 | verifyOtp: t.procedure 11 | .input( 12 | z.object({ 13 | otp: z.string(), 14 | profileId: z.string(), // This is the email in our case 15 | }) 16 | ) 17 | .mutation(async ({ input, ctx }) => { 18 | try { 19 | const { otp, profileId } = input; 20 | 21 | if (!otp || !profileId) { 22 | throw new TRPCError({ 23 | code: "BAD_REQUEST", 24 | message: "OTP and profile ID are required", 25 | }); 26 | } 27 | 28 | // Find the user with the given email (profileId) 29 | const userResult = await ctx.orm.select({ id: user.id, email: user.email }).from(user).where(eq(user.email, profileId)).limit(1); 30 | 31 | // If user doesn't exist, create a new one 32 | let userId: string; 33 | let userEmail = profileId; 34 | 35 | if (userResult.length === 0) { 36 | // Create a new user 37 | const id = crypto.randomUUID(); 38 | const now = new Date(); 39 | 40 | await ctx.orm.insert(user).values({ 41 | id, 42 | name: profileId.split("@")[0], // Simple name from email 43 | email: profileId, 44 | emailVerified: 0, // Not verified yet 45 | createdAt: now.toISOString(), 46 | updatedAt: now.toISOString(), 47 | }); 48 | 49 | userId = id; 50 | } else { 51 | userId = userResult[0].id; 52 | userEmail = userResult[0].email; 53 | } 54 | 55 | // Verify the OTP 56 | const result = await ctx.auth.api.signInEmailOTP({ 57 | asResponse: true, 58 | body: { 59 | email: userEmail, 60 | otp, 61 | }, 62 | }); 63 | 64 | if (!result.ok) { 65 | const errorJson = await result.json(); 66 | const errorMessage = 67 | typeof errorJson === "object" && errorJson !== null && "message" in errorJson ? String(errorJson.message) : "Invalid OTP"; 68 | throw new TRPCError({ 69 | code: "BAD_REQUEST", 70 | message: errorMessage, 71 | }); 72 | } 73 | // Set Auth Cookie 74 | ctx.honoCtx.header("Set-Cookie", result.headers.getSetCookie()[0]); 75 | 76 | // Update user's emailVerified status 77 | await ctx.orm.update(user).set({ emailVerified: 1, updatedAt: new Date().toISOString() }).where(eq(user.id, userId)); 78 | return { success: true, userId }; 79 | } catch (error) { 80 | if (error instanceof TRPCError) throw error; 81 | 82 | console.error("Error verifying OTP:", error); 83 | throw new TRPCError({ 84 | code: "INTERNAL_SERVER_ERROR", 85 | message: "An error occurred while verifying the OTP", 86 | }); 87 | } 88 | }), 89 | }); 90 | -------------------------------------------------------------------------------- /packages/api/wrangler.jsonc.example: -------------------------------------------------------------------------------- 1 | { 2 | "name": "celestial-api", 3 | "main": "src/index.ts", 4 | "compatibility_date": "2025-03-20", 5 | "compatibility_flags": [ 6 | "nodejs_compat" 7 | ], 8 | "observability": { 9 | "enabled": true, 10 | "head_sampling_rate": 1 11 | }, 12 | // "routes": [ 13 | // { 14 | // "pattern": "api.your-domain.dev", 15 | // "custom_domain": true 16 | // } 17 | // ], 18 | "vars": { 19 | "BETTER_AUTH_URL": "https://api.your-domain.dev", 20 | "BETTER_AUTH_COOKIES_DOMAIN": ".your-domain.dev", 21 | "BETTER_AUTH_COOKIES_PREFIX": "my-app", 22 | "BETTER_AUTH_SECRET": "", 23 | "RESEND_FROM_EMAIL": "noreply@your-domain.dev", 24 | "RESEND_API_KEY": "", 25 | "ENV": "production" 26 | }, 27 | "d1_databases": [ 28 | { 29 | "binding": "DB", 30 | "database_name": "", 31 | "database_id": "", 32 | "migrations_table": "migrations", 33 | "migrations_dir": "src/migrations" 34 | } 35 | ] 36 | } -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@celestial/shared", 3 | "version": "0.1.0", 4 | "private": true, 5 | "main": "./src/index.ts", 6 | "types": "./src/index.ts", 7 | "dependencies": { 8 | "zod": "^3.0.0" 9 | }, 10 | "devDependencies": { 11 | "@types/node": "^20.0.0", 12 | "typescript": "^5.0.0" 13 | } 14 | } -------------------------------------------------------------------------------- /packages/shared/src/constants/index.ts: -------------------------------------------------------------------------------- 1 | // Constants used across the application 2 | 3 | // API endpoints 4 | export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8787"; 5 | export const TRPC_ENDPOINT = `${API_BASE_URL}/api/trpc`; 6 | export const THEME_COLOR = "#2d1e5e"; 7 | // Application 8 | export const APP_NAME = "@celestial-rose/stack"; 9 | export const APP_TITLE = "@celestial-rose/stack"; 10 | export const APP_DESCRIPTION = "The Ultimate Full-Stack Meta-Framework for Cloudflare"; 11 | export const APP_VERSION = "0.1.0"; 12 | export const APP_URL = "https://your-domain.dev"; 13 | export const APP_METADATA_BASE_URL = new URL(APP_URL); 14 | 15 | // Creator and publisher info 16 | export const APP_CREATOR = "Your Name"; 17 | export const APP_PUBLISHER = "your-domain.dev"; 18 | export const APP_TWITTER_HANDLE = "@your-handle"; 19 | 20 | // Keywords for SEO 21 | export const APP_KEYWORDS = [ 22 | "Cloudflare Workers", 23 | "tRPC", 24 | "type-safe API", 25 | "fullstack", 26 | "metaframework", 27 | "Hono", 28 | "D1", 29 | "Drizzle", 30 | "edge-native database", 31 | "NextJS 15", 32 | "Better Auth", 33 | "authentication", 34 | "Bun", 35 | "package management", 36 | "Cloudflare", 37 | "free tier", 38 | "scalable", 39 | "serverless", 40 | "edge computing", 41 | "full-stack development", 42 | "cloud deployment", 43 | ]; 44 | 45 | // OpenGraph metadata 46 | export const APP_OG = { 47 | title: `${APP_TITLE} - ${APP_DESCRIPTION}`, 48 | description: "Type-safe • Serverless • Developer-friendly • Cloudflare Deployed. The first full-stack meta-framework that lets you ship for FREE.", 49 | url: APP_URL, 50 | siteName: APP_TITLE, 51 | locale: "en_US", 52 | type: "website", 53 | images: [ 54 | { 55 | url: "/og-image.png", 56 | width: 1200, 57 | height: 630, 58 | alt: `${APP_TITLE} - The ultimate full-stack meta-framework`, 59 | }, 60 | ], 61 | }; 62 | 63 | // Twitter metadata 64 | export const APP_TWITTER = { 65 | card: "summary_large_image" as const, 66 | title: `${APP_TITLE} - ${APP_DESCRIPTION}`, 67 | description: "Type-safe • Serverless • Developer-friendly • Cloudflare Deployed. The first full-stack meta-framework that lets you ship for FREE.", 68 | creator: APP_TWITTER_HANDLE, 69 | site: APP_TWITTER_HANDLE, 70 | images: [ 71 | { 72 | url: "/twitter-card.png", 73 | width: 1200, 74 | height: 600, 75 | alt: `${APP_TITLE} - The ultimate full-stack meta-framework`, 76 | }, 77 | ], 78 | }; 79 | 80 | // Icons configuration 81 | export const APP_ICONS = { 82 | icon: [ 83 | { url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" }, 84 | { url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" }, 85 | { url: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png" }, 86 | { url: "/android-chrome-512x512.png", sizes: "512x512", type: "image/png" }, 87 | ], 88 | shortcut: ["/favicon.ico"], 89 | apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }], 90 | other: [ 91 | { rel: "mask-icon", url: "/safari-pinned-tab.svg", color: THEME_COLOR }, 92 | { rel: "msapplication-TileImage", url: "/mstile-144x144.png" }, 93 | ], 94 | }; 95 | 96 | // Feature flags 97 | export const FEATURES = { 98 | darkMode: true, 99 | notifications: true, 100 | analytics: false, 101 | }; 102 | 103 | export const ALLOWED_ORIGINS = ["http://localhost:3000", APP_URL]; 104 | -------------------------------------------------------------------------------- /packages/shared/src/index.ts: -------------------------------------------------------------------------------- 1 | // Export types 2 | export * from "./types"; 3 | 4 | // Export utilities 5 | export * from "./utils"; 6 | 7 | // Export constants 8 | export * from "./constants"; 9 | -------------------------------------------------------------------------------- /packages/shared/src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { betterAuth } from "better-auth"; 2 | import { emailOTP } from "better-auth/plugins"; 3 | import { ALLOWED_ORIGINS, APP_NAME } from "../constants"; 4 | import type { TypedEnv } from "../types"; 5 | import { Resend } from "resend"; 6 | 7 | export const sharedAuth = ({ 8 | DB, 9 | BETTER_AUTH_COOKIES_DOMAIN, 10 | BETTER_AUTH_COOKIES_PREFIX, 11 | BETTER_AUTH_SECRET, 12 | BETTER_AUTH_URL, 13 | RESEND_API_KEY, 14 | RESEND_FROM_EMAIL, 15 | ENV, 16 | }: TypedEnv) => { 17 | const cookiesAdvancedSettings: Partial = 18 | ENV === "local" 19 | ? {} 20 | : { 21 | crossSubDomainCookies: { 22 | enabled: true, 23 | domain: `.${BETTER_AUTH_COOKIES_DOMAIN}`, // Domain with a leading period 24 | }, 25 | defaultCookieAttributes: { 26 | domain: BETTER_AUTH_COOKIES_DOMAIN, 27 | secure: true, 28 | httpOnly: true, 29 | sameSite: "none", // Allows CORS-based cookie sharing across subdomains 30 | partitioned: true, // New browser standards will mandate this for foreign cookies 31 | }, 32 | }; 33 | return betterAuth({ 34 | appName: APP_NAME, 35 | database: { 36 | db: DB, 37 | type: "sqlite", 38 | }, 39 | baseURL: BETTER_AUTH_URL, 40 | advanced: { 41 | cookiePrefix: BETTER_AUTH_COOKIES_PREFIX, 42 | ...cookiesAdvancedSettings, 43 | }, 44 | trustedOrigins: ALLOWED_ORIGINS, 45 | secret: BETTER_AUTH_SECRET, 46 | user: { 47 | additionalFields: { 48 | phone: { 49 | type: "string", 50 | required: false, 51 | }, 52 | }, 53 | }, 54 | plugins: [ 55 | emailOTP({ 56 | async sendVerificationOTP({ email, otp }) { 57 | const from = RESEND_FROM_EMAIL || "noreply@your-domain.dev"; 58 | const resend = new Resend(RESEND_API_KEY); 59 | 60 | await resend.emails.send({ 61 | from, 62 | to: email, 63 | subject: `Your ${APP_NAME} Verification Code`, 64 | html: ` 65 | 66 | 67 | 68 | 69 | 70 | ${APP_NAME} Verification 71 | 72 | 73 | 74 | 75 | 103 | 104 |
76 | 77 | 78 | 81 | 82 | 83 | 90 | 91 | 92 | 100 | 101 |
79 |

${APP_NAME}

80 |
84 | ${otp} 85 | 86 |

87 | If you didn't request this code, you can safely ignore this email. 88 |

89 |
93 |

94 | Experience the power of ${APP_NAME} Stack for your next project. 95 |

96 |

97 | © ${new Date().getFullYear()} ${APP_NAME}. All rights reserved. 98 |

99 |
102 |
105 | 106 | 107 | `, 108 | }); 109 | }, 110 | }), 111 | ], 112 | }); 113 | }; 114 | 115 | export type SharedAuth = typeof sharedAuth; 116 | -------------------------------------------------------------------------------- /packages/shared/src/lib/db.ts: -------------------------------------------------------------------------------- 1 | import { Kysely } from "kysely"; 2 | import { D1Dialect } from "kysely-d1"; 3 | 4 | export function initDbConnection(db: D1Database) { 5 | return new Kysely({ 6 | dialect: new D1Dialect({ 7 | database: db, 8 | }), 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /packages/shared/src/types/index.ts: -------------------------------------------------------------------------------- 1 | // Common types used across the application 2 | 3 | import type { Kysely } from "kysely"; 4 | 5 | // API response types 6 | export interface ApiResponse { 7 | data?: T; 8 | error?: string; 9 | status: "success" | "error"; 10 | } 11 | 12 | export type TypedEnv = { 13 | DB: Kysely; 14 | BETTER_AUTH_COOKIES_DOMAIN: string; 15 | BETTER_AUTH_COOKIES_PREFIX: string; 16 | BETTER_AUTH_SECRET: string; 17 | BETTER_AUTH_URL: string; 18 | RESEND_FROM_EMAIL: string; 19 | RESEND_API_KEY: string; 20 | ENV: string; 21 | }; 22 | -------------------------------------------------------------------------------- /packages/shared/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | // Utility functions used across the application 2 | 3 | /** 4 | * Format a date to a human-readable string 5 | */ 6 | export function formatDate(date: Date): string { 7 | return date.toLocaleDateString("en-US", { 8 | year: "numeric", 9 | month: "long", 10 | day: "numeric", 11 | }); 12 | } 13 | 14 | /** 15 | * Generate a random ID 16 | */ 17 | export function generateId(): string { 18 | return Math.random().toString(36).substring(2, 15); 19 | } 20 | 21 | /** 22 | * Safely parse JSON with error handling 23 | */ 24 | export function safeJsonParse(json: string, fallback: T): T { 25 | try { 26 | return JSON.parse(json) as T; 27 | } catch (_error) { 28 | return fallback; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/web/.env.development.local.example: -------------------------------------------------------------------------------- 1 | ENV="local" 2 | NEXT_PUBLIC_API_URL="http://localhost:8787" 3 | BETTER_AUTH_COOKIES_DOMAIN="localhost" 4 | BETTER_AUTH_COOKIES_PREFIX="my-app" 5 | BETTER_AUTH_URL="http://localhost:8787" 6 | ## Generate the secret with `openssl rand -base64 32`` 7 | BETTER_AUTH_SECRET="" 8 | ## Resend 9 | RESEND_API_KEY="" 10 | ## Email from which to send Resend OTP 11 | RESEND_FROM_EMAIL="" 12 | -------------------------------------------------------------------------------- /packages/web/.env.example: -------------------------------------------------------------------------------- 1 | ENV="production" 2 | NEXT_PUBLIC_API_URL="https://api.your-domain.dev" 3 | BETTER_AUTH_COOKIES_DOMAIN=".your-domain.dev" 4 | BETTER_AUTH_COOKIES_PREFIX="my-app" 5 | BETTER_AUTH_URL="https://api.your-domain.dev" 6 | ## Generate the secret with `openssl rand -base64 32`` 7 | BETTER_AUTH_SECRET="" 8 | ## Resend 9 | RESEND_API_KEY="" 10 | ## Email from which to send Resend OTP 11 | RESEND_FROM_EMAIL="" 12 | -------------------------------------------------------------------------------- /packages/web/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/styles/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /packages/web/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /packages/web/next.config.mjs: -------------------------------------------------------------------------------- 1 | // next.config.ts 2 | 3 | import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare"; 4 | initOpenNextCloudflareForDev(); 5 | 6 | /** @type {import('next').NextConfig} */ 7 | 8 | const nextConfig = { 9 | /* config options here */ 10 | reactStrictMode: true, 11 | transpilePackages: ["@celestial/shared"], 12 | images: { 13 | domains: ["images.unsplash.com"], 14 | }, 15 | env: {}, 16 | }; 17 | 18 | export default nextConfig; 19 | -------------------------------------------------------------------------------- /packages/web/open-next.config.ts: -------------------------------------------------------------------------------- 1 | import { defineCloudflareConfig } from "@opennextjs/cloudflare"; 2 | 3 | export default defineCloudflareConfig(); 4 | -------------------------------------------------------------------------------- /packages/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@celestial/web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "deploy": "opennextjs-cloudflare && wrangler deploy", 9 | "start": "next start", 10 | "lint": "next lint" 11 | }, 12 | "dependencies": { 13 | "@celestial/shared": "workspace:*", 14 | "@next/env": "^15.2.4", 15 | "@opennextjs/cloudflare": "^0.5.12", 16 | "@radix-ui/react-avatar": "^1.1.3", 17 | "@radix-ui/react-dialog": "^1.1.6", 18 | "@radix-ui/react-dropdown-menu": "^2.1.6", 19 | "@radix-ui/react-label": "^2.1.2", 20 | "@radix-ui/react-separator": "^1.1.2", 21 | "@radix-ui/react-slot": "^1.1.2", 22 | "@tanstack/react-query": "^5.69.0", 23 | "@trpc/client": "^11.0.0", 24 | "@trpc/next": "^11.0.0", 25 | "@trpc/react-query": "^11.0.0", 26 | "better-auth": "^1.0.0", 27 | "class-variance-authority": "^0.7.1", 28 | "lucide-react": "^0.484.0", 29 | "next": "15.2.3", 30 | "react": "^19.0.0", 31 | "react-dom": "^19.0.0", 32 | "server-only": "^0.0.1", 33 | "superjson": "^2.2.2", 34 | "tailwind-merge": "^3.0.2", 35 | "tailwindcss": "^4.0.0" 36 | }, 37 | "devDependencies": { 38 | "@tailwindcss/postcss": "^4", 39 | "@types/react": "^19.0.0", 40 | "@types/react-dom": "^19.0.0", 41 | "autoprefixer": "^10.0.0", 42 | "typescript": "^5.0.0" 43 | } 44 | } -------------------------------------------------------------------------------- /packages/web/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /packages/web/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /packages/web/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /packages/web/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/apple-touch-icon.png -------------------------------------------------------------------------------- /packages/web/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/favicon-16x16.png -------------------------------------------------------------------------------- /packages/web/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/favicon-32x32.png -------------------------------------------------------------------------------- /packages/web/public/favicon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/favicon-48x48.png -------------------------------------------------------------------------------- /packages/web/public/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/mstile-144x144.png -------------------------------------------------------------------------------- /packages/web/public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/mstile-150x150.png -------------------------------------------------------------------------------- /packages/web/public/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/mstile-310x150.png -------------------------------------------------------------------------------- /packages/web/public/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/mstile-310x310.png -------------------------------------------------------------------------------- /packages/web/public/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/mstile-70x70.png -------------------------------------------------------------------------------- /packages/web/public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/og-image.png -------------------------------------------------------------------------------- /packages/web/public/shortcut-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/shortcut-icon.png -------------------------------------------------------------------------------- /packages/web/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@celestial-rose/stack", 3 | "short_name": "@celestial-rose/stack", 4 | "description": "The Ultimate Full-Stack Meta-Framework for Cloudflare", 5 | "start_url": "/", 6 | "display": "standalone", 7 | "background_color": "#2d1e5e", 8 | "theme_color": "#1e1a4d", 9 | "lang": "en-US", 10 | "dir": "ltr", 11 | "orientation": "portrait-primary", 12 | "icons": [ 13 | { 14 | "src": "/favicon-16x16.png", 15 | "sizes": "16x16", 16 | "type": "image/png" 17 | }, 18 | { 19 | "src": "/favicon-32x32.png", 20 | "sizes": "32x32", 21 | "type": "image/png" 22 | }, 23 | { 24 | "src": "/android-chrome-192x192.png", 25 | "sizes": "192x192", 26 | "type": "image/png" 27 | }, 28 | { 29 | "src": "/android-chrome-512x512.png", 30 | "sizes": "512x512", 31 | "type": "image/png" 32 | }, 33 | { 34 | "src": "/apple-touch-icon.png", 35 | "sizes": "180x180", 36 | "type": "image/png" 37 | } 38 | ], 39 | "categories": [ 40 | "technology" 41 | ], 42 | "screenshots": [], 43 | "shortcuts": [], 44 | "related_applications": [] 45 | } -------------------------------------------------------------------------------- /packages/web/public/twitter-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/public/twitter-card.png -------------------------------------------------------------------------------- /packages/web/src/app/(protected)/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Card } from "@/components/ui/card"; 4 | import { Button } from "@/components/ui/button"; 5 | import { BarChart3, Users, FileText, Activity, ArrowUpRight, ArrowDownRight, DollarSign, Clock, CheckCircle } from "lucide-react"; 6 | 7 | export default function DashboardPage() { 8 | return ( 9 |
10 | {/* Success Banner */} 11 | 12 |
13 |
14 | 15 |
16 |
17 |

Congratulations! 🎉

18 |

19 | You've successfully accessed this protected page powered by Better Auth. This dashboard is an example of what you can build with the 20 | @celestial-rose/stack. 21 |

22 |
23 |
24 |
25 | 26 |
27 |

Dashboard

28 | 29 |
30 | 31 | {/* Stats Cards */} 32 |
33 | 34 |
35 |
36 |

Total Users

37 |

2,543

38 |
39 | 40 | 41 | 12% 42 | 43 | from last month 44 |
45 |
46 |
47 | 48 |
49 |
50 |
51 | 52 | 53 |
54 |
55 |

Active Projects

56 |

18

57 |
58 | 59 | 60 | 4% 61 | 62 | from last month 63 |
64 |
65 |
66 | 67 |
68 |
69 |
70 | 71 | 72 |
73 |
74 |

Revenue

75 |

$48,295

76 |
77 | 78 | 79 | 3% 80 | 81 | from last month 82 |
83 |
84 |
85 | 86 |
87 |
88 |
89 | 90 | 91 |
92 |
93 |

Avg. Response Time

94 |

1.2h

95 |
96 | 97 | 98 | 18% 99 | 100 | from last month 101 |
102 |
103 |
104 | 105 |
106 |
107 |
108 |
109 | 110 | {/* Activity Chart */} 111 | 112 |
113 |

Activity Overview

114 |
115 | 118 | 121 | 124 |
125 |
126 |
127 |
128 | 129 |

Activity chart visualization would appear here

130 |
131 |
132 |
133 | 134 | {/* Recent Activity */} 135 | 136 |

Recent Activity

137 |
138 | {[1, 2, 3, 4, 5].map((item) => ( 139 |
140 |
141 | 142 |
143 |
144 |

New user registered

145 |

2 hours ago

146 |
147 | 150 |
151 | ))} 152 |
153 |
154 | 157 |
158 |
159 |
160 | ); 161 | } 162 | -------------------------------------------------------------------------------- /packages/web/src/app/(protected)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { useAuthGuard } from "@/lib/hooks/useAuthGuard"; 2 | import { Header } from "@/components/dashboard/Header"; 3 | import { Sidebar } from "@/components/dashboard/Sidebar"; 4 | 5 | async function Layout({ children }: { children: React.ReactNode }) { 6 | await useAuthGuard(); 7 | 8 | return ( 9 |
10 |
11 | {/* Sidebar - full height */} 12 | 15 | 16 | {/* Main content area with floating header */} 17 |
18 | {/* Floating header elements */} 19 |
20 | 21 | {/* Main content */} 22 |
23 |
{children}
24 |

25 | @celestial-rose/stack, an idea by Celestial © 2025 26 |

27 |
28 |
29 |
30 |
31 | ); 32 | } 33 | 34 | export default Layout; 35 | -------------------------------------------------------------------------------- /packages/web/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/celestial-rose/stack/6d270bb2d75c3823405029e06ada9556d77435a0/packages/web/src/app/favicon.ico -------------------------------------------------------------------------------- /packages/web/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inconsolata, Raleway, Lora } from "next/font/google"; 3 | import "@/styles/globals.css"; 4 | import { TRPCProvider } from "@/trpc/client"; 5 | import { 6 | APP_TITLE, 7 | APP_DESCRIPTION, 8 | APP_KEYWORDS, 9 | APP_CREATOR, 10 | APP_PUBLISHER, 11 | APP_METADATA_BASE_URL, 12 | APP_ICONS, 13 | APP_OG, 14 | APP_TWITTER, 15 | APP_URL, 16 | } from "@celestial/shared/src/constants"; 17 | 18 | const inconsolata = Inconsolata({ 19 | subsets: ["latin"], 20 | variable: "--font-mono", 21 | }); 22 | 23 | const raleway = Raleway({ 24 | subsets: ["latin"], 25 | variable: "--font-display", 26 | }); 27 | 28 | const lora = Lora({ 29 | subsets: ["latin"], 30 | variable: "--font-serif", 31 | }); 32 | 33 | export const metadata: Metadata = { 34 | title: APP_TITLE, 35 | description: APP_DESCRIPTION, 36 | keywords: APP_KEYWORDS, 37 | authors: [{ name: APP_CREATOR, url: APP_URL }], 38 | creator: APP_CREATOR, 39 | publisher: APP_PUBLISHER, 40 | metadataBase: APP_METADATA_BASE_URL, 41 | alternates: { 42 | canonical: "/", 43 | languages: { 44 | "en-US": "/", 45 | }, 46 | }, 47 | applicationName: APP_TITLE, 48 | referrer: "origin-when-cross-origin", 49 | formatDetection: { 50 | email: false, 51 | address: false, 52 | telephone: false, 53 | }, 54 | icons: APP_ICONS, 55 | manifest: "/site.webmanifest", 56 | openGraph: APP_OG, 57 | twitter: APP_TWITTER, 58 | robots: { 59 | index: true, 60 | follow: true, 61 | nocache: false, 62 | googleBot: { 63 | index: true, 64 | follow: true, 65 | "max-image-preview": "large", 66 | "max-snippet": -1, 67 | "max-video-preview": -1, 68 | }, 69 | }, 70 | // verification: { 71 | // google: "your-google-site-verification-code", 72 | // yandex: "your-yandex-verification-code", 73 | // yahoo: "your-yahoo-verification-code", 74 | // other: { 75 | // "msvalidate.01": "your-bing-verification-code", 76 | // }, 77 | // }, 78 | category: "technology", 79 | }; 80 | 81 | async function RootLayout({ children }: { children: React.ReactNode }) { 82 | return ( 83 | 84 | 85 | 86 |
{children}
87 | 88 | 89 |
90 | ); 91 | } 92 | 93 | export default RootLayout; 94 | -------------------------------------------------------------------------------- /packages/web/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FeatureCard } from "@/components/FeatureCard"; 4 | import { Stars } from "@/components/Stars"; 5 | import { Button } from "@/components/ui/button"; 6 | import { Label } from "@/components/ui/label"; 7 | import { Input } from "@/components/ui/input"; 8 | import { trpc } from "@/trpc/client"; 9 | import { authClient } from "@/auth/client"; 10 | import { useState } from "react"; 11 | import { useRouter } from "next/navigation"; 12 | import { GradientButton } from "@/components/GradientButton"; 13 | 14 | export default function Home() { 15 | const [input, setInput] = useState(""); 16 | const [showOtpInput, setShowOtpInput] = useState(false); 17 | const [otp, setOtp] = useState(""); 18 | const [profileId, setProfileId] = useState(""); 19 | const [otpError, setOtpError] = useState(""); 20 | const [isLoading, setIsLoading] = useState(false); 21 | const [resendingOtp, setResendingOtp] = useState(false); 22 | const { data } = trpc.hello.useQuery(); 23 | 24 | const verifyOtpMutation = trpc.users.verifyOtp.useMutation(); 25 | const router = useRouter(); 26 | const handleSendOTP = async () => { 27 | if (input.length === 0) { 28 | setOtpError("E-mail data missing"); 29 | return; 30 | } 31 | try { 32 | setResendingOtp(true); 33 | setOtpError(""); // Clear any previous errors 34 | const otpResponse = await authClient.emailOtp.sendVerificationOtp({ 35 | email: input, 36 | type: "sign-in", 37 | }); 38 | if (otpResponse.error) throw otpResponse.error; 39 | 40 | setProfileId(input); // Using email as profile ID for now 41 | setShowOtpInput(true); 42 | } catch (error) { 43 | console.error("Error sending OTP:", error); 44 | setOtpError("Failed to send verification code. Please try again."); 45 | setShowOtpInput(false); // Don't show OTP input if there was an error 46 | } finally { 47 | setResendingOtp(false); 48 | } 49 | }; 50 | 51 | const handleVerifyOTP = async () => { 52 | if (!otp || !profileId) { 53 | setOtpError("Please enter the verification code"); 54 | return; 55 | } 56 | 57 | try { 58 | setIsLoading(true); 59 | const result = await verifyOtpMutation.mutateAsync({ 60 | otp, 61 | profileId, 62 | }); 63 | 64 | if (result.success) { 65 | // Redirect or update UI on successful verification 66 | console.log("OTP verified successfully"); 67 | router.push("/dashboard"); 68 | // You might want to redirect to a dashboard or home page 69 | } else { 70 | setOtpError("Invalid verification code. Please try again."); 71 | } 72 | } catch (error) { 73 | console.error("Error verifying OTP:", error); 74 | setOtpError("Failed to verify code. Please try again."); 75 | } finally { 76 | setIsLoading(false); 77 | } 78 | }; 79 | 80 | const handleResendOTP = async () => { 81 | await handleSendOTP(); 82 | }; 83 | 84 | return ( 85 |
86 | 87 | 88 |
89 |
90 | {/* Left Side: Hero, Title, and Auth */} 91 |
92 |

93 | 94 | @celestial-rose/stack 95 | 96 |

97 | 125 | 126 |
127 |

128 | The Ultimate Full-Stack Meta-Framework for Cloudflare that lets you ship for{" "} 129 | free 130 |

131 |

The ultimate combination in a template that has never been made before:

132 |
    133 |
  • 134 | Hono with tRPC for type-safe API calls 135 |
  • 136 |
  • 137 | D1 with Drizzle for edge-native database 138 |
  • 139 |
  • 140 | NextJS 15 with Better Auth for secure authentication 141 |
  • 142 |
  • 143 | Bun for ultra-fast package management and runtime 144 |
  • 145 |
146 |

Deploy on Cloudflare's free tier and scale only when you need to

147 |
148 | 149 |
150 | {data ? `${data} !` : "⏱️ Loading ..."} 151 |

152 | Sign up to access the showcase protected route — this is what you get when you deploy the template 153 |

154 | {!showOtpInput ? ( 155 |
156 | setInput(e.target.value)} 159 | placeholder="Enter your email" 160 | className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" 161 | onKeyDown={(e) => { 162 | if (e.key === "Enter") { 163 | handleSendOTP(); 164 | } 165 | }} 166 | /> 167 | {otpError &&

{otpError}

} 168 | 169 | 170 | {resendingOtp ? "Sending..." : "Send Verification Code"} 171 | 172 |
173 | ) : ( 174 |
175 |
176 |
177 | 180 | setOtp(e.target.value)} 185 | placeholder="Enter the code from your email" 186 | className={` "bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 187 | ${otpError ? "border-red-400" : "border-indigo-300"} focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:ring-opacity-50 rounded-lg`} 188 | autoFocus 189 | /> 190 | {otpError &&

{otpError}

} 191 |
192 | 193 | 194 | {isLoading ? ( 195 | 196 | 210 | Verifying... 211 | 212 | ) : ( 213 | "Verify & Continue" 214 | )} 215 | 216 | 217 |
218 | 226 | 227 |
228 | 240 |
241 |
242 |
243 |
244 | )} 245 |
246 |
247 | 248 | {/* Right Side: Feature Cards */} 249 |
250 |
251 | 256 | 261 | 266 | 271 | 276 | 281 |
282 |
283 |
284 | {/* Coming Soon Section */} 285 |
286 |

Coming Soon ✨

287 |

288 | We're working on exciting new features to make your development experience even better. Stay tuned for updates! 289 |

290 |
291 | {/* Tech Stack Section */} 292 |
293 |

Tech Stack

294 |
295 | {[ 296 | { name: "Next.js 15.2.3", url: "https://github.com/vercel/next.js" }, 297 | { name: "React 19", url: "https://github.com/facebook/react" }, 298 | { name: "Tailwind CSS 4", url: "https://github.com/tailwindlabs/tailwindcss" }, 299 | { name: "shadcn/ui", url: "https://github.com/shadcn-ui/ui" }, 300 | { name: "tRPC 11", url: "https://github.com/trpc/trpc" }, 301 | { name: "Hono", url: "https://github.com/honojs/hono" }, 302 | { name: "Better Auth", url: "https://github.com/better-auth/better-auth" }, 303 | { name: "Drizzle ORM", url: "https://github.com/drizzle-team/drizzle-orm" }, 304 | { name: "Cloudflare D1", url: "https://developers.cloudflare.com/d1" }, 305 | { name: "TypeScript", url: "https://github.com/microsoft/TypeScript" }, 306 | { name: "Bun", url: "https://github.com/oven-sh/bun" }, 307 | { name: "Biome 2", url: "https://github.com/biomejs/biome" }, 308 | { name: "Wrangler", url: "https://github.com/cloudflare/workers-sdk" }, 309 | ].map((tech) => ( 310 | 317 | {tech.name} 318 | 319 | ))} 320 |
321 |
322 | 323 | {/* Acknowledgements Section */} 324 |
325 |

Acknowledgements

326 |

327 | Special thanks to Theo{" "} 328 | 334 | (@theo) 335 | {" "} 336 | for laying the foundation with the revolutionary{" "} 337 | 338 | T3 Stack 339 | 340 | . Also grateful to{" "} 341 | 342 | tRPC 343 | {" "} 344 | and{" "} 345 | 351 | Tanstack Query 352 | {" "} 353 | for building such amazing libraries that make type-safe development a joy. 354 |

355 |
356 | 357 | {/* Footer Section */} 358 |
359 |
360 |

@celestial-rose/stack, an idea by Celestial © 2025

361 |
362 |
363 |
364 | 365 | {/* Add custom animations */} 366 | 409 |
410 | ); 411 | } 412 | -------------------------------------------------------------------------------- /packages/web/src/auth/client.tsx: -------------------------------------------------------------------------------- 1 | import { API_BASE_URL } from "@celestial/shared"; 2 | import type { SharedAuth } from "@celestial/shared/src/lib/auth"; 3 | import { createAuthClient } from "better-auth/client"; 4 | import { emailOTPClient, inferAdditionalFields } from "better-auth/client/plugins"; 5 | 6 | export const authClient = createAuthClient({ 7 | plugins: [emailOTPClient(), inferAdditionalFields()], 8 | baseURL: API_BASE_URL, 9 | }); 10 | -------------------------------------------------------------------------------- /packages/web/src/components/FeatureCard.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; 2 | export function FeatureCard({ title, description, icon }: { title: string; description: string; icon: string }) { 3 | return ( 4 | 5 | 6 |
{icon}
7 | {title} 8 |
9 | 10 | {description} 11 | 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /packages/web/src/components/GradientButton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import type { ReactNode } from "react"; 3 | import { Button } from "./ui/button"; 4 | 5 | interface GradientButtonProps extends React.ButtonHTMLAttributes { 6 | children: ReactNode; 7 | gradientFrom?: string; 8 | gradientTo?: string; 9 | hoverFrom?: string; 10 | hoverTo?: string; 11 | className?: string; 12 | } 13 | 14 | export function GradientButton({ 15 | children, 16 | gradientFrom = "from-indigo-500", 17 | gradientTo = "to-purple-600", 18 | hoverFrom = "hover:from-indigo-600", 19 | hoverTo = "hover:to-purple-700", 20 | className, 21 | ...props 22 | }: GradientButtonProps) { 23 | return ( 24 |
25 | 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /packages/web/src/components/Stars.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useMemo } from "react"; 2 | 3 | type Star = { 4 | id: number; 5 | x: number; 6 | y: number; 7 | size: number; 8 | opacity: number; 9 | animationDelay: number; 10 | depth: number; 11 | autoMove: boolean; 12 | moveSpeed: number; 13 | moveDirection: { x: number; y: number }; 14 | blurAmount: number; 15 | element: HTMLDivElement | null; 16 | }; 17 | 18 | const STARS_AMOUNT = 100; 19 | 20 | // Stars Component with parallax effect and automatic movement 21 | export function Stars() { 22 | // Use refs instead of state to avoid re-renders 23 | const starsRef = useRef([]); 24 | const containerRef = useRef(null); 25 | const mousePositionRef = useRef({ x: 0, y: 0 }); 26 | const animationRef = useRef(null); 27 | const lastTimeRef = useRef(0); 28 | 29 | // Generate stars only once with useMemo 30 | const initialStars = useMemo(() => { 31 | return Array.from({ length: STARS_AMOUNT }, (_, i) => { 32 | const autoMove = Math.random() > 0.7; // 30% of stars will move automatically 33 | return { 34 | id: i, 35 | x: Math.random() * 100, 36 | y: Math.random() * 100, 37 | size: Math.random() * 2 + 0.5, 38 | opacity: Math.random() * 0.7 + 0.3, 39 | animationDelay: Math.random() * 3, 40 | depth: Math.random() * 5, // Depth for parallax effect 41 | autoMove: autoMove, 42 | moveSpeed: Math.random() * 30 + 20, // Much slower movement (higher value = slower) 43 | moveDirection: { 44 | x: (Math.random() - 0.5) * 0.03, // Reduced movement amount 45 | y: (Math.random() - 0.5) * 0.03, // Reduced movement amount 46 | }, 47 | blurAmount: Math.random() > 0.7 ? Math.random() * 3 : 0, // Some stars are blurred 48 | element: null, // Reference to DOM element 49 | }; 50 | }); 51 | }, []); 52 | 53 | // Setup stars and animation 54 | useEffect(() => { 55 | starsRef.current = initialStars; 56 | 57 | // Create all star elements once 58 | if (containerRef.current) { 59 | starsRef.current.forEach((star) => { 60 | const element = document.createElement("div"); 61 | element.className = "absolute rounded-full bg-white animate-twinkle"; 62 | element.style.width = `${star.size}px`; 63 | element.style.height = `${star.size}px`; 64 | element.style.opacity = `${star.opacity}`; 65 | element.style.animationDelay = `${star.animationDelay}s`; 66 | element.style.filter = star.blurAmount > 0 ? `blur(${star.blurAmount}px)` : "none"; 67 | 68 | // Initial position 69 | updateStarPosition(star, element, mousePositionRef.current); 70 | 71 | containerRef.current?.appendChild(element); 72 | star.element = element; 73 | }); 74 | } 75 | 76 | // Animation function that doesn't cause re-renders 77 | const animate = (time: number) => { 78 | if (lastTimeRef.current === 0) { 79 | lastTimeRef.current = time; 80 | } 81 | 82 | const deltaTime = time - lastTimeRef.current; 83 | lastTimeRef.current = time; 84 | 85 | // Update positions directly in the DOM 86 | starsRef.current.forEach((star) => { 87 | if (!star.autoMove || !star.element) return; 88 | 89 | let newX = star.x + star.moveDirection.x * (deltaTime / star.moveSpeed); 90 | let newY = star.y + star.moveDirection.y * (deltaTime / star.moveSpeed); 91 | 92 | // Wrap around edges 93 | if (newX > 100) newX = 0; 94 | if (newX < 0) newX = 100; 95 | if (newY > 100) newY = 0; 96 | if (newY < 0) newY = 100; 97 | 98 | // Update star position in our data 99 | star.x = newX; 100 | star.y = newY; 101 | 102 | // Update DOM directly 103 | updateStarPosition(star, star.element, mousePositionRef.current); 104 | }); 105 | 106 | animationRef.current = requestAnimationFrame(animate); 107 | }; 108 | 109 | animationRef.current = requestAnimationFrame(animate); 110 | 111 | return () => { 112 | if (animationRef.current) { 113 | cancelAnimationFrame(animationRef.current); 114 | } 115 | 116 | // Clean up DOM elements 117 | if (containerRef.current) { 118 | while (containerRef.current.firstChild) { 119 | containerRef.current.removeChild(containerRef.current.firstChild); 120 | } 121 | } 122 | }; 123 | }, [initialStars]); 124 | 125 | // Handle mouse movement without state updates 126 | useEffect(() => { 127 | // Throttle mouse move events 128 | let ticking = false; 129 | 130 | const handleMouseMove = (e: MouseEvent) => { 131 | if (!ticking) { 132 | window.requestAnimationFrame(() => { 133 | mousePositionRef.current = { 134 | x: (e.clientX / window.innerWidth - 0.5) * 2, 135 | y: (e.clientY / window.innerHeight - 0.5) * 2, 136 | }; 137 | 138 | // Update all star positions with new mouse position 139 | starsRef.current.forEach((star) => { 140 | if (star.element) { 141 | updateStarPosition(star, star.element, mousePositionRef.current); 142 | } 143 | }); 144 | 145 | ticking = false; 146 | }); 147 | 148 | ticking = true; 149 | } 150 | }; 151 | 152 | window.addEventListener("mousemove", handleMouseMove); 153 | return () => window.removeEventListener("mousemove", handleMouseMove); 154 | }, []); 155 | 156 | // Helper function to update star position 157 | function updateStarPosition(star: Star, element: HTMLDivElement, mousePosition: { x: number; y: number }) { 158 | const xPos = `calc(${star.x}% + ${mousePosition.x * star.depth * 5}px)`; 159 | const yPos = `calc(${star.y}% + ${mousePosition.y * star.depth * 5}px)`; 160 | 161 | element.style.left = xPos; 162 | element.style.top = yPos; 163 | element.style.transform = `perspective(1000px) translateZ(${star.depth * 20}px)`; 164 | element.style.transition = "transform 0.1s ease-out, left 0.5s ease-out, top 0.5s ease-out"; 165 | } 166 | 167 | // Return an empty container that will be filled with stars via DOM manipulation 168 | return
; 169 | } 170 | -------------------------------------------------------------------------------- /packages/web/src/components/dashboard/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 2 | import { Button } from "@/components/ui/button"; 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuItem, 7 | DropdownMenuLabel, 8 | DropdownMenuSeparator, 9 | DropdownMenuTrigger, 10 | } from "@/components/ui/dropdown-menu"; 11 | import { Bell, Menu, User } from "lucide-react"; 12 | import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; 13 | import { Sidebar } from "./Sidebar"; 14 | 15 | interface HeaderProps { 16 | className?: string; 17 | } 18 | 19 | export function Header({ className }: HeaderProps) { 20 | return ( 21 | <> 22 | {/* User controls - top right */} 23 |
24 | 28 | 29 | 30 | 31 | 39 | 40 | 41 | My Account 42 | 43 | Profile 44 | Settings 45 | 46 | Logout 47 | 48 | 49 |
50 | 51 | {/* Mobile menu button - only visible on small screens */} 52 |
53 | 54 | 55 | 59 | 60 | 61 | Sidebar 62 | 63 | 64 | 65 |
66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /packages/web/src/components/dashboard/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Button } from "@/components/ui/button"; 3 | import { Separator } from "@/components/ui/separator"; 4 | import { LayoutDashboard, Settings, Users, FileText, BarChart3, LogOut } from "lucide-react"; 5 | import { APP_NAME } from "@celestial/shared"; 6 | 7 | interface SidebarProps { 8 | className?: string; 9 | } 10 | 11 | export function Sidebar({ className }: SidebarProps) { 12 | return ( 13 |
14 |
15 |

{APP_NAME}

16 |
17 | 18 |
19 | 51 |
52 | 53 |
54 | 58 |
59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /packages/web/src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import type * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Avatar({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | function AvatarImage({ 25 | className, 26 | ...props 27 | }: React.ComponentProps) { 28 | return ( 29 | 34 | ) 35 | } 36 | 37 | function AvatarFallback({ 38 | className, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 50 | ) 51 | } 52 | 53 | export { Avatar, AvatarImage, AvatarFallback } 54 | -------------------------------------------------------------------------------- /packages/web/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center 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", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", 14 | outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 15 | secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", 16 | ghost: "hover:bg-accent hover:text-accent-foreground", 17 | link: "text-primary underline-offset-4 hover:underline", 18 | }, 19 | size: { 20 | default: "h-10 px-4 py-2", 21 | sm: "h-9 rounded-md px-3", 22 | lg: "h-11 rounded-md px-8", 23 | icon: "h-10 w-10", 24 | }, 25 | }, 26 | defaultVariants: { 27 | variant: "default", 28 | size: "default", 29 | }, 30 | } 31 | ); 32 | 33 | export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { 34 | asChild?: boolean; 35 | } 36 | 37 | const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => { 38 | const Comp = asChild ? Slot : "button"; 39 | return ; 40 | }); 41 | Button.displayName = "Button"; 42 | 43 | export { Button, buttonVariants }; 44 | -------------------------------------------------------------------------------- /packages/web/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Card = React.forwardRef>(({ className, ...props }, ref) => ( 6 |
7 | )); 8 | Card.displayName = "Card"; 9 | 10 | const CardHeader = React.forwardRef>(({ className, ...props }, ref) => ( 11 |
12 | )); 13 | CardHeader.displayName = "CardHeader"; 14 | 15 | const CardTitle = React.forwardRef>(({ className, ...props }, ref) => ( 16 |

17 | )); 18 | CardTitle.displayName = "CardTitle"; 19 | 20 | const CardDescription = React.forwardRef>(({ className, ...props }, ref) => ( 21 |

22 | )); 23 | CardDescription.displayName = "CardDescription"; 24 | 25 | const CardContent = React.forwardRef>(({ className, ...props }, ref) => ( 26 |

27 | )); 28 | CardContent.displayName = "CardContent"; 29 | 30 | const CardFooter = React.forwardRef>(({ className, ...props }, ref) => ( 31 |
32 | )); 33 | CardFooter.displayName = "CardFooter"; 34 | 35 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; 36 | -------------------------------------------------------------------------------- /packages/web/src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import type * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function DropdownMenu({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function DropdownMenuPortal({ 16 | ...props 17 | }: React.ComponentProps) { 18 | return ( 19 | 20 | ) 21 | } 22 | 23 | function DropdownMenuTrigger({ 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 31 | ) 32 | } 33 | 34 | function DropdownMenuContent({ 35 | className, 36 | sideOffset = 4, 37 | ...props 38 | }: React.ComponentProps) { 39 | return ( 40 | 41 | 50 | 51 | ) 52 | } 53 | 54 | function DropdownMenuGroup({ 55 | ...props 56 | }: React.ComponentProps) { 57 | return ( 58 | 59 | ) 60 | } 61 | 62 | function DropdownMenuItem({ 63 | className, 64 | inset, 65 | variant = "default", 66 | ...props 67 | }: React.ComponentProps & { 68 | inset?: boolean 69 | variant?: "default" | "destructive" 70 | }) { 71 | return ( 72 | 82 | ) 83 | } 84 | 85 | function DropdownMenuCheckboxItem({ 86 | className, 87 | children, 88 | checked, 89 | ...props 90 | }: React.ComponentProps) { 91 | return ( 92 | 101 | 102 | 103 | 104 | 105 | 106 | {children} 107 | 108 | ) 109 | } 110 | 111 | function DropdownMenuRadioGroup({ 112 | ...props 113 | }: React.ComponentProps) { 114 | return ( 115 | 119 | ) 120 | } 121 | 122 | function DropdownMenuRadioItem({ 123 | className, 124 | children, 125 | ...props 126 | }: React.ComponentProps) { 127 | return ( 128 | 136 | 137 | 138 | 139 | 140 | 141 | {children} 142 | 143 | ) 144 | } 145 | 146 | function DropdownMenuLabel({ 147 | className, 148 | inset, 149 | ...props 150 | }: React.ComponentProps & { 151 | inset?: boolean 152 | }) { 153 | return ( 154 | 163 | ) 164 | } 165 | 166 | function DropdownMenuSeparator({ 167 | className, 168 | ...props 169 | }: React.ComponentProps) { 170 | return ( 171 | 176 | ) 177 | } 178 | 179 | function DropdownMenuShortcut({ 180 | className, 181 | ...props 182 | }: React.ComponentProps<"span">) { 183 | return ( 184 | 192 | ) 193 | } 194 | 195 | function DropdownMenuSub({ 196 | ...props 197 | }: React.ComponentProps) { 198 | return 199 | } 200 | 201 | function DropdownMenuSubTrigger({ 202 | className, 203 | inset, 204 | children, 205 | ...props 206 | }: React.ComponentProps & { 207 | inset?: boolean 208 | }) { 209 | return ( 210 | 219 | {children} 220 | 221 | 222 | ) 223 | } 224 | 225 | function DropdownMenuSubContent({ 226 | className, 227 | ...props 228 | }: React.ComponentProps) { 229 | return ( 230 | 238 | ) 239 | } 240 | 241 | export { 242 | DropdownMenu, 243 | DropdownMenuPortal, 244 | DropdownMenuTrigger, 245 | DropdownMenuContent, 246 | DropdownMenuGroup, 247 | DropdownMenuLabel, 248 | DropdownMenuItem, 249 | DropdownMenuCheckboxItem, 250 | DropdownMenuRadioGroup, 251 | DropdownMenuRadioItem, 252 | DropdownMenuSeparator, 253 | DropdownMenuShortcut, 254 | DropdownMenuSub, 255 | DropdownMenuSubTrigger, 256 | DropdownMenuSubContent, 257 | } 258 | -------------------------------------------------------------------------------- /packages/web/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 4 | return ( 5 | 16 | ); 17 | } 18 | 19 | export { Input }; 20 | -------------------------------------------------------------------------------- /packages/web/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import type * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /packages/web/src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import type * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Separator({ 9 | className, 10 | orientation = "horizontal", 11 | decorative = true, 12 | ...props 13 | }: React.ComponentProps) { 14 | return ( 15 | 25 | ) 26 | } 27 | 28 | export { Separator } 29 | -------------------------------------------------------------------------------- /packages/web/src/components/ui/sheet.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import type * as React from "react" 4 | import * as SheetPrimitive from "@radix-ui/react-dialog" 5 | import { XIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Sheet({ ...props }: React.ComponentProps) { 10 | return 11 | } 12 | 13 | function SheetTrigger({ 14 | ...props 15 | }: React.ComponentProps) { 16 | return 17 | } 18 | 19 | function SheetClose({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return 23 | } 24 | 25 | function SheetPortal({ 26 | ...props 27 | }: React.ComponentProps) { 28 | return 29 | } 30 | 31 | function SheetOverlay({ 32 | className, 33 | ...props 34 | }: React.ComponentProps) { 35 | return ( 36 | 44 | ) 45 | } 46 | 47 | function SheetContent({ 48 | className, 49 | children, 50 | side = "right", 51 | ...props 52 | }: React.ComponentProps & { 53 | side?: "top" | "right" | "bottom" | "left" 54 | }) { 55 | return ( 56 | 57 | 58 | 74 | {children} 75 | 76 | 77 | Close 78 | 79 | 80 | 81 | ) 82 | } 83 | 84 | function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { 85 | return ( 86 |
91 | ) 92 | } 93 | 94 | function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { 95 | return ( 96 |
101 | ) 102 | } 103 | 104 | function SheetTitle({ 105 | className, 106 | ...props 107 | }: React.ComponentProps) { 108 | return ( 109 | 114 | ) 115 | } 116 | 117 | function SheetDescription({ 118 | className, 119 | ...props 120 | }: React.ComponentProps) { 121 | return ( 122 | 127 | ) 128 | } 129 | 130 | export { 131 | Sheet, 132 | SheetTrigger, 133 | SheetClose, 134 | SheetContent, 135 | SheetHeader, 136 | SheetFooter, 137 | SheetTitle, 138 | SheetDescription, 139 | } 140 | -------------------------------------------------------------------------------- /packages/web/src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | // biome-ignore-all lint/style/noNonNullAssertion: 2 | // TODO validate env using t3-oss/t3-env 3 | import { API_BASE_URL } from "@celestial/shared"; 4 | import { sharedAuth } from "@celestial/shared/src/lib/auth"; 5 | import { initDbConnection } from "@celestial/shared/src/lib/db"; 6 | 7 | export const auth = (db: D1Database) => 8 | sharedAuth({ 9 | DB: initDbConnection(db), 10 | BETTER_AUTH_COOKIES_DOMAIN: process.env.BETTER_AUTH_COOKIES_DOMAIN!, 11 | BETTER_AUTH_COOKIES_PREFIX: process.env.BETTER_AUTH_COOKIES_PREFIX!, 12 | BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET!, 13 | BETTER_AUTH_URL: process.env.BETTER_AUTH_URL || API_BASE_URL, 14 | RESEND_API_KEY: process.env.RESEND_API_KEY!, 15 | RESEND_FROM_EMAIL: process.env.RESEND_FROM_EMAIL!, 16 | ENV: process.env.ENV!, 17 | }); 18 | // Export auth-related types 19 | export type { Session, User } from "better-auth"; 20 | -------------------------------------------------------------------------------- /packages/web/src/lib/envConfig.ts: -------------------------------------------------------------------------------- 1 | import { loadEnvConfig } from "@next/env"; 2 | 3 | const projectDir = process.cwd(); 4 | loadEnvConfig(projectDir); 5 | -------------------------------------------------------------------------------- /packages/web/src/lib/hooks/useAuthGuard.ts: -------------------------------------------------------------------------------- 1 | import { headers } from "next/headers"; 2 | import { redirect } from "next/navigation"; 3 | import { auth as _Auth } from "../auth"; 4 | import { getCloudflareContext } from "@opennextjs/cloudflare"; 5 | 6 | export async function useAuthGuard(redirectPath = "/") { 7 | const db = ((await getCloudflareContext({ async: true })).env as { DB: D1Database }).DB; 8 | const auth = _Auth(db); 9 | const session = await auth.api 10 | .getSession({ 11 | headers: await headers(), 12 | }) 13 | .catch(() => { 14 | redirect(redirectPath); 15 | }); 16 | 17 | if (!session) { 18 | redirect(redirectPath); 19 | } 20 | 21 | return session; 22 | } 23 | 24 | export async function checkAuth() { 25 | const db = ((await getCloudflareContext({ async: true })).env as { DB: D1Database }).DB; 26 | const auth = _Auth(db); 27 | try { 28 | const session = await auth.api.getSession({ 29 | headers: await headers(), 30 | }); 31 | return session; 32 | } catch (_error) { 33 | return null; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/web/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /packages/web/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | @theme { 4 | --font-mono: "Inconsolata", monospace; 5 | --font-display: "Raleway", sans-serif; 6 | 7 | /* Font feature settings for Inconsolata (monospace) */ 8 | --font-mono--font-feature-settings: "tnum", "zero"; 9 | 10 | /* Font feature settings for Raleway (display) */ 11 | --font-display--font-feature-settings: "ss01", "ss03"; 12 | 13 | /* Purple theme colors */ 14 | --color-primary: #8b5cf6; /* purple-500 */ 15 | --color-primary-light: #a78bfa; /* purple-400 */ 16 | --color-primary-dark: #7c3aed; /* purple-600 */ 17 | --color-primary-bg: #f5f3ff; /* purple-50 */ 18 | --color-primary-bg-hover: #ede9fe; /* purple-100 */ 19 | } 20 | -------------------------------------------------------------------------------- /packages/web/src/trpc/client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | // ^-- to make sure we can mount the Provider from a server component 3 | 4 | import type { QueryClient } from "@tanstack/react-query"; 5 | import { QueryClientProvider } from "@tanstack/react-query"; 6 | import { httpLink } from "@trpc/client"; 7 | import { createTRPCReact } from "@trpc/react-query"; 8 | import { useState } from "react"; 9 | import { makeQueryClient } from "./query-client"; 10 | import type { appRouter } from "@celestial/api/src/trpc/router"; 11 | import { TRPC_ENDPOINT } from "@celestial/shared"; 12 | 13 | // Use the inferred type from the router instance instead of the type 14 | export const trpc = createTRPCReact(); 15 | let clientQueryClientSingleton: QueryClient; 16 | function getQueryClient() { 17 | if (typeof window === "undefined") { 18 | // Server: always make a new query client 19 | return makeQueryClient(); 20 | } 21 | // Browser: use singleton pattern to keep the same query client 22 | // biome-ignore lint: singleton pattern using nullish coalescing assignment 23 | return (clientQueryClientSingleton ??= makeQueryClient()); 24 | } 25 | 26 | export function TRPCProvider( 27 | props: Readonly<{ 28 | children: React.ReactNode; 29 | }> 30 | ) { 31 | // NOTE: Avoid useState when initializing the query client if you don't 32 | // have a suspense boundary between this and the code that may 33 | // suspend because React will throw away the client on the initial 34 | // render if it suspends and there is no boundary 35 | const queryClient = getQueryClient(); 36 | const [trpcClient] = useState(() => 37 | trpc.createClient({ 38 | links: [ 39 | httpLink({ 40 | // transformer: superjson, <-- if you use a data transformer 41 | url: TRPC_ENDPOINT, 42 | fetch(url, options) { 43 | return fetch(url, { 44 | ...options, 45 | credentials: "include", 46 | }); 47 | }, 48 | }), 49 | ], 50 | }) 51 | ); 52 | return ( 53 | 54 | {props.children} 55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /packages/web/src/trpc/query-client.tsx: -------------------------------------------------------------------------------- 1 | import { defaultShouldDehydrateQuery, QueryClient } from "@tanstack/react-query"; 2 | import superjson from "superjson"; 3 | export function makeQueryClient() { 4 | return new QueryClient({ 5 | defaultOptions: { 6 | queries: { 7 | staleTime: 30 * 1000, 8 | }, 9 | dehydrate: { 10 | serializeData: superjson.serialize, 11 | shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === "pending", 12 | }, 13 | hydrate: { 14 | deserializeData: superjson.deserialize, 15 | }, 16 | }, 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /packages/web/src/trpc/server.tsx: -------------------------------------------------------------------------------- 1 | import "server-only"; // <-- ensure this file cannot be imported from the client 2 | import { createHydrationHelpers } from "@trpc/react-query/rsc"; 3 | import { cache } from "react"; 4 | import { makeQueryClient } from "./query-client"; 5 | import { type appRouter, createCallerFactory } from "@celestial/api/src/trpc/router"; 6 | import { getCloudflareContext } from "@opennextjs/cloudflare"; 7 | import { drizzle } from "drizzle-orm/d1"; 8 | import * as schema from "../../../api/src/db/schema"; 9 | import { auth } from "@/lib/auth"; 10 | 11 | export const getQueryClient = cache(makeQueryClient); 12 | // @ts-ignore 13 | export const createCaller = createCallerFactory(async () => { 14 | const db = ((await getCloudflareContext({ async: true })).env as { DB: D1Database }).DB; 15 | 16 | return { 17 | orm: drizzle(db, { schema }), 18 | auth: auth(db), 19 | }; 20 | }); 21 | export const { trpc: api, HydrateClient } = createHydrationHelpers(createCaller, getQueryClient); 22 | -------------------------------------------------------------------------------- /packages/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@/*": [ 7 | "./src/*" 8 | ] 9 | }, 10 | "plugins": [ 11 | { 12 | "name": "next" 13 | } 14 | ], 15 | "jsx": "preserve", 16 | "allowJs": true 17 | }, 18 | "include": [ 19 | "next-env.d.ts", 20 | "**/*.ts", 21 | "**/*.tsx", 22 | ".next/types/**/*.ts" 23 | ], 24 | "exclude": [ 25 | "node_modules" 26 | ] 27 | } -------------------------------------------------------------------------------- /packages/web/wrangler.jsonc.example: -------------------------------------------------------------------------------- 1 | { 2 | "name": "celestial-web", 3 | "main": ".open-next/worker.js", 4 | "compatibility_date": "2025-03-20", 5 | "compatibility_flags": [ 6 | "nodejs_compat" 7 | ], 8 | "assets": { 9 | "directory": ".open-next/assets", 10 | "binding": "ASSETS" 11 | }, 12 | "observability": { 13 | "enabled": true, 14 | "head_sampling_rate": 1 15 | }, 16 | // "routes": [ 17 | // { 18 | // "pattern": "your-domain.dev", 19 | // "custom_domain": true 20 | // } 21 | // ], 22 | "d1_databases": [ 23 | { 24 | "binding": "DB", 25 | "database_name": "", 26 | "database_id": "" 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /scripts/setup-db.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | 3 | /** 4 | * This script sets up the D1 database for the @celestial-rose/stack. 5 | * It creates a new D1 database and updates the wrangler.jsonc files with the database ID and name. 6 | * It also updates the package.json file to use the same database name in the migrate scripts. 7 | */ 8 | 9 | import { execSync } from "node:child_process"; 10 | import fs from "node:fs"; 11 | import path from "node:path"; 12 | 13 | // Create the scripts directory if it doesn't exist 14 | if (!fs.existsSync("scripts")) { 15 | fs.mkdirSync("scripts"); 16 | } 17 | 18 | console.log("🌹 Setting up @celestial-rose/stack D1 database..."); 19 | 20 | // Function to get user input 21 | async function prompt(question: string, defaultValue?: string): Promise { 22 | const defaultText = defaultValue ? ` (default: ${defaultValue})` : ""; 23 | process.stdout.write(`${question}${defaultText}: `); 24 | 25 | const result = await new Promise((resolve) => { 26 | process.stdin.once("data", (data) => { 27 | resolve(data.toString().trim()); 28 | }); 29 | }); 30 | 31 | return result || defaultValue || ""; 32 | } 33 | 34 | try { 35 | // Check if the user is logged in to Cloudflare 36 | console.log("Checking Cloudflare login status..."); 37 | try { 38 | execSync("bunx wrangler whoami", { stdio: "pipe" }); 39 | console.log("✅ You are logged in to Cloudflare"); 40 | } catch (_error) { 41 | console.log("❌ You are not logged in to Cloudflare"); 42 | console.log("Please login to Cloudflare using:"); 43 | console.log("bunx wrangler login"); 44 | process.exit(1); 45 | } 46 | 47 | // Prompt for database name 48 | const dbName = await prompt("Enter the database name", "celestial-db"); 49 | 50 | // Create a new D1 database 51 | console.log(`Creating D1 database with name: ${dbName}...`); 52 | const result = execSync(`bunx wrangler d1 create ${dbName}`).toString(); 53 | console.log(result); 54 | 55 | // Extract the database ID from the result 56 | const databaseIdMatch = result.match(/"database_id":\s*"([^"]+)"/); 57 | if (!databaseIdMatch) { 58 | throw new Error("Could not extract database ID from wrangler output"); 59 | } 60 | 61 | const databaseId = databaseIdMatch[1]; 62 | console.log(`Database ID: ${databaseId}`); 63 | 64 | // Copy wrangler example files to their real versions if they don't exist 65 | const wranglerFiles: string[] = ["wrangler.jsonc", "packages/api/wrangler.jsonc", "packages/web/wrangler.jsonc"]; 66 | 67 | // Update the wrangler.jsonc files with the database ID and name 68 | for (const realFile of wranglerFiles) { 69 | const exampleFile = `${realFile}.example`; 70 | const examplePath = path.join(process.cwd(), exampleFile); 71 | const realPath = path.join(process.cwd(), realFile); 72 | 73 | if (fs.existsSync(examplePath) && !fs.existsSync(realPath)) { 74 | console.log(`Copying ${exampleFile} to ${realFile}...`); 75 | fs.copyFileSync(examplePath, realPath); 76 | } 77 | } 78 | 79 | for (const wranglerFile of wranglerFiles) { 80 | console.log(`Updating ${wranglerFile}...`); 81 | const wranglerPath = path.join(process.cwd(), wranglerFile); 82 | 83 | if (fs.existsSync(wranglerPath)) { 84 | let wranglerContent = fs.readFileSync(wranglerPath, "utf-8"); 85 | wranglerContent = wranglerContent.replace(/"database_id"\s*:\s*"[^"]*"/, `"database_id": "${databaseId}"`); 86 | wranglerContent = wranglerContent.replace(/"database_name"\s*:\s*"[^"]*"/, `"database_name": "${dbName}"`); 87 | fs.writeFileSync(wranglerPath, wranglerContent); 88 | } 89 | } 90 | 91 | // Update package.json to replace [DB-NAME] with the actual database name in migrate commands 92 | console.log("Updating package.json migrate commands..."); 93 | const packageJsonPath = path.join(process.cwd(), "package.json"); 94 | 95 | if (fs.existsSync(packageJsonPath)) { 96 | let packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8"); 97 | packageJsonContent = packageJsonContent.replace(/\[DB-NAME\]/g, dbName); 98 | fs.writeFileSync(packageJsonPath, packageJsonContent); 99 | console.log("✅ Updated migrate commands in package.json"); 100 | } 101 | 102 | console.log("✅ D1 database setup complete!"); 103 | console.log(`Database name: ${dbName}`); 104 | console.log(`Database ID: ${databaseId}`); 105 | console.log("Next steps:"); 106 | console.log("1. Run `bun dev` to start the development server"); 107 | console.log("2. Visit http://localhost:3000 to see your app"); 108 | process.exit(0); 109 | } catch (error) { 110 | console.error("❌ Error setting up D1 database:", error instanceof Error ? error.message : String(error)); 111 | process.exit(1); 112 | } 113 | -------------------------------------------------------------------------------- /scripts/setup-env.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | 3 | /** 4 | * This script sets up the environment files and BETTER_AUTH_SECRET for the @celestial-rose/stack. 5 | * It: 6 | * 1. Copies example environment files to their actual environment files if they don't exist 7 | * 2. Generates a random secret using openssl and updates the BETTER_AUTH_SECRET in the actual environment files 8 | */ 9 | 10 | import { execSync } from "node:child_process"; 11 | import fs from "node:fs"; 12 | import path from "node:path"; 13 | 14 | console.log("🌹 Setting up environment files for @celestial-rose/stack..."); 15 | 16 | // Define file paths 17 | const webEnvPath = path.join(process.cwd(), "packages/web/.env"); 18 | const webEnvExamplePath = path.join(process.cwd(), "packages/web/.env.example"); 19 | const webDevEnvPath = path.join(process.cwd(), "packages/web/.env.development.local"); 20 | const webDevEnvExamplePath = path.join(process.cwd(), "packages/web/.env.development.local.example"); 21 | const apiEnvPath = path.join(process.cwd(), "packages/api/.dev.vars"); 22 | const apiEnvExamplePath = path.join(process.cwd(), "packages/api/.dev.vars.example"); 23 | 24 | try { 25 | // Function to copy example file to actual file if it doesn't exist 26 | const copyExampleFile = (targetPath: string, examplePath: string) => { 27 | if (!fs.existsSync(targetPath) && fs.existsSync(examplePath)) { 28 | console.log(`Creating ${targetPath} from example file...`); 29 | fs.copyFileSync(examplePath, targetPath); 30 | console.log(`✅ Created ${targetPath}`); 31 | return true; 32 | } 33 | 34 | if (fs.existsSync(targetPath)) { 35 | console.log(`${targetPath} already exists, skipping copy.`); 36 | return true; 37 | } 38 | 39 | console.error(`Example file ${examplePath} not found. Cannot create ${targetPath}.`); 40 | return false; 41 | }; 42 | 43 | // Copy example files to actual files 44 | const webEnvCopied = copyExampleFile(webEnvPath, webEnvExamplePath); 45 | const webDevEnvCopied = copyExampleFile(webDevEnvPath, webDevEnvExamplePath); 46 | const apiEnvCopied = copyExampleFile(apiEnvPath, apiEnvExamplePath); 47 | 48 | // Generate a random secret using openssl 49 | console.log("Generating random secret for BETTER_AUTH_SECRET..."); 50 | const secret = execSync("openssl rand -base64 32").toString().trim(); 51 | console.log("Secret generated successfully!"); 52 | 53 | // Function to update the BETTER_AUTH_SECRET in an env file 54 | const updateEnvFile = (filePath: string) => { 55 | console.log(`Updating BETTER_AUTH_SECRET in ${filePath}...`); 56 | 57 | // Check if the file exists 58 | if (!fs.existsSync(filePath)) { 59 | console.error(`${filePath} does not exist. Cannot update.`); 60 | return false; 61 | } 62 | 63 | // Read the file content 64 | let content = fs.readFileSync(filePath, "utf-8"); 65 | 66 | // Update the BETTER_AUTH_SECRET value 67 | content = content.replace(/BETTER_AUTH_SECRET=".*?"/g, `BETTER_AUTH_SECRET="${secret}"`); 68 | 69 | // Write the updated content back to the file 70 | fs.writeFileSync(filePath, content); 71 | console.log(`✅ Updated ${filePath} with new BETTER_AUTH_SECRET`); 72 | return true; 73 | }; 74 | 75 | // Update BETTER_AUTH_SECRET in all env files 76 | const webEnvUpdated = webEnvCopied ? updateEnvFile(webEnvPath) : false; 77 | const webDevEnvUpdated = webDevEnvCopied ? updateEnvFile(webDevEnvPath) : false; 78 | const apiEnvUpdated = apiEnvCopied ? updateEnvFile(apiEnvPath) : false; 79 | 80 | if (webEnvCopied && webDevEnvCopied && apiEnvCopied && webEnvUpdated && webDevEnvUpdated && apiEnvUpdated) { 81 | console.log("✅ Environment setup complete!"); 82 | } else { 83 | console.log("⚠️ Environment setup completed with warnings."); 84 | } 85 | 86 | process.exit(0); 87 | } catch (error) { 88 | console.error("❌ Error setting up environment:", error instanceof Error ? error.message : String(error)); 89 | process.exit(1); 90 | } 91 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "esnext", // Changed from NodeNext 5 | "moduleResolution": "bundler", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "noEmit": true, 13 | "jsx": "preserve", 14 | "types": [ 15 | "@cloudflare/workers-types" 16 | ], 17 | "lib": [ 18 | "DOM", 19 | "DOM.Iterable", 20 | "ESNext" 21 | ], 22 | "incremental": true 23 | }, 24 | "exclude": [ 25 | "node_modules" 26 | ] 27 | } -------------------------------------------------------------------------------- /worker-configuration.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by Wrangler by running `wrangler types` 2 | 3 | interface Env { 4 | DB: D1Database; 5 | } 6 | -------------------------------------------------------------------------------- /wrangler.jsonc.example: -------------------------------------------------------------------------------- 1 | { 2 | "name": "celestial-rose", 3 | "main": "src/index.ts", 4 | "compatibility_date": "2025-03-20", 5 | "compatibility_flags": [ 6 | "nodejs_compat" 7 | ], 8 | "observability": { 9 | "enabled": true, 10 | "head_sampling_rate": 1 11 | }, 12 | "d1_databases": [ 13 | { 14 | "binding": "DB", 15 | "database_name": "", 16 | "database_id": "", 17 | "migrations_table": "migrations", 18 | "migrations_dir": "packages/api/src/migrations" 19 | } 20 | ] 21 | } --------------------------------------------------------------------------------