├── .assets ├── avatar-cropper.jpeg ├── login.jpeg ├── sessions.jpeg └── settings.jpeg ├── .dev.vars.example ├── .github ├── dependabot.yml └── workflows │ └── pull-request-validation.yaml ├── .gitignore ├── .vscode ├── extensions.json ├── react-router.code-snippets └── settings.json ├── README.md ├── app ├── components │ ├── app-logo.tsx │ ├── auth-layout.tsx │ ├── avatar-cropper.tsx │ ├── avatar-selector.tsx │ ├── color-scheme-toggle.tsx │ ├── error-boundary.tsx │ ├── forms.tsx │ ├── icons.tsx │ ├── progress-bar.tsx │ ├── settings │ │ ├── account-action.tsx │ │ ├── connection-action.tsx │ │ ├── connection-item.tsx │ │ ├── password-action.tsx │ │ ├── session-action.tsx │ │ ├── session-item.tsx │ │ ├── setting-row.tsx │ │ ├── settings-layout.tsx │ │ └── settings-menu.tsx │ ├── spinner.tsx │ ├── todos │ │ └── todo-item.tsx │ ├── ui │ │ ├── alert-dialog.tsx │ │ ├── avatar.tsx │ │ ├── button.tsx │ │ ├── checkbox.tsx │ │ ├── cropper.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── radio-group.tsx │ │ ├── select.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ └── tooltip.tsx │ └── user-nav.tsx ├── entry.server.tsx ├── hooks │ ├── use-auth-user.ts │ ├── use-double-check.ts │ ├── use-file-upload.ts │ ├── use-hydrated.ts │ ├── use-is-pending.ts │ └── use-nonce.ts ├── lib │ ├── auth │ │ ├── auth.client.ts │ │ └── auth.server.ts │ ├── color-scheme │ │ ├── components.tsx │ │ └── server.ts │ ├── config.ts │ ├── contexts.ts │ ├── database │ │ ├── db.server.ts │ │ └── schema.ts │ ├── env.server.ts │ ├── http.server.ts │ ├── middlewares │ │ └── auth-guard.server.ts │ ├── utils.ts │ └── validations │ │ ├── auth.ts │ │ ├── settings.ts │ │ └── todo.ts ├── root.tsx ├── routes.ts ├── routes │ ├── api │ │ ├── better-error.tsx │ │ ├── better.tsx │ │ └── color-scheme.ts │ ├── auth │ │ ├── forget-password.tsx │ │ ├── layout.tsx │ │ ├── reset-password.tsx │ │ ├── sign-in.tsx │ │ ├── sign-out.tsx │ │ └── sign-up.tsx │ ├── home.tsx │ ├── images.ts │ ├── index.tsx │ ├── layout.tsx │ ├── not-found.tsx │ ├── settings │ │ ├── account.tsx │ │ ├── appearance.tsx │ │ ├── connections.tsx │ │ ├── layout.tsx │ │ ├── password.tsx │ │ └── sessions.tsx │ └── todos.tsx └── styles │ └── app.css ├── biome.json ├── commitlint.config.cjs ├── components.json ├── drizzle.config.ts ├── drizzle ├── 0000_nice_omega_red.sql └── meta │ ├── 0000_snapshot.json │ └── _journal.json ├── lefthook.yml ├── package.json ├── pnpm-lock.yaml ├── public ├── favicon.ico └── images │ ├── ui-dark.png │ ├── ui-light.png │ └── ui-system.png ├── react-router.config.ts ├── tsconfig.json ├── vite.config.ts ├── worker-configuration.d.ts ├── workers └── app.ts └── wrangler.jsonc /.assets/avatar-cropper.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foxlau/react-router-v7-better-auth/3bcf8a36ebe71989c1552cbd26843b3376570c82/.assets/avatar-cropper.jpeg -------------------------------------------------------------------------------- /.assets/login.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foxlau/react-router-v7-better-auth/3bcf8a36ebe71989c1552cbd26843b3376570c82/.assets/login.jpeg -------------------------------------------------------------------------------- /.assets/sessions.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foxlau/react-router-v7-better-auth/3bcf8a36ebe71989c1552cbd26843b3376570c82/.assets/sessions.jpeg -------------------------------------------------------------------------------- /.assets/settings.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foxlau/react-router-v7-better-auth/3bcf8a36ebe71989c1552cbd26843b3376570c82/.assets/settings.jpeg -------------------------------------------------------------------------------- /.dev.vars.example: -------------------------------------------------------------------------------- 1 | ENVIRONMENT = "development" # development | production 2 | 3 | BETTER_AUTH_SECRET = "3ebc25b381e87193f29ffea6b6d380dd" 4 | BETTER_AUTH_URL = "http://localhost:8787" 5 | GITHUB_CLIENT_ID = "..." 6 | GITHUB_CLIENT_SECRET = "..." 7 | GOOGLE_CLIENT_ID = "..." 8 | GOOGLE_CLIENT_SECRET = "..." -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | 13 | - package-ecosystem: github-actions # Enable version updates for Github-Actions. 14 | directory: / 15 | schedule: 16 | interval: weekly 17 | -------------------------------------------------------------------------------- /.github/workflows/pull-request-validation.yaml: -------------------------------------------------------------------------------- 1 | name: 🔍 PR Code Validation 2 | 3 | concurrency: 4 | group: ${{ github.repository }}-${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | pull_request: 9 | branches: 10 | - main 11 | paths: 12 | - "app/**" 13 | - "workers/**" 14 | - "*.js" 15 | - "*.ts" 16 | - "*.tsx" 17 | - "package.json" 18 | - "pnpm-lock.yaml" 19 | 20 | jobs: 21 | lint: 22 | name: ⬣ Biome lint 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: biomejs/setup-biome@v2 27 | - run: biome ci . --reporter=github 28 | 29 | typecheck: 30 | needs: lint 31 | name: 🔎 Type check 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: 📥 Checkout 35 | uses: actions/checkout@v4 36 | 37 | - name: 📦 Setup pnpm 38 | uses: pnpm/action-setup@v4 39 | with: 40 | version: 9.15.5 41 | 42 | - name: 🟢 Set up Node.js 43 | uses: actions/setup-node@v4 44 | with: 45 | node-version: 20 46 | cache: "pnpm" 47 | 48 | - name: 📎 Install dependencies 49 | run: pnpm install 50 | 51 | - name: 🔧 Generate Types 52 | run: pnpm typegen 53 | 54 | - name: 📝 Run type check 55 | run: pnpm typecheck 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /node_modules/ 3 | 4 | # React Router 5 | /.react-router/ 6 | /build/ 7 | 8 | # Cloudflare 9 | .mf 10 | .wrangler 11 | .dev.vars 12 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome", "redhat.vscode-yaml"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/react-router.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "loader": { 3 | "prefix": "/loader", 4 | "body": [ 5 | "", 6 | "export async function loader({ request }: Route.LoaderArgs) {", 7 | " return null", 8 | "}" 9 | ] 10 | }, 11 | "clientLoader": { 12 | "prefix": "/clientLoader", 13 | "body": [ 14 | "", 15 | "export async function clientLoader({ serverLoader }: Route.ClientLoaderArgs) {", 16 | " const data = await serverLoader();", 17 | " return data", 18 | "}" 19 | ] 20 | }, 21 | "action": { 22 | "prefix": "/action", 23 | "body": [ 24 | "", 25 | "export async function action({ request }: Route.ActionArgs) {", 26 | " return null", 27 | "}" 28 | ] 29 | }, 30 | "clientAction": { 31 | "prefix": "/clientAction", 32 | "body": [ 33 | "", 34 | "export async function clientAction({ request }: Route.ClientActionArgs) {", 35 | " return null", 36 | "}" 37 | ] 38 | }, 39 | "default": { 40 | "prefix": "/default", 41 | "body": [ 42 | "export default function ${TM_FILENAME_BASE/[^a-zA-Z0-9]*([a-zA-Z0-9])([a-zA-Z0-9]*)/${1:/capitalize}${2}/g}Route() {", 43 | " return (", 44 | "
", 45 | "

Unknown Route

", 46 | "
", 47 | " )", 48 | "}" 49 | ] 50 | }, 51 | "headers": { 52 | "prefix": "/headers", 53 | "body": [ 54 | "", 55 | "export const headers: Route.HeadersFunction = ({ loaderHeaders }) => ({", 56 | " 'Cache-Control': loaderHeaders.get('Cache-Control') ?? '',", 57 | "})" 58 | ] 59 | }, 60 | "links": { 61 | "prefix": "/links", 62 | "body": [ 63 | "", 64 | "export const links: Route.LinksFunction = () => [", 65 | " ", 66 | "];" 67 | ] 68 | }, 69 | "meta": { 70 | "prefix": "/meta", 71 | "body": [ 72 | "", 73 | "export const meta: Route.MetaFunction = () => [", 74 | " { title: \"Page title\" }", 75 | "]" 76 | ] 77 | }, 78 | "component": { 79 | "prefix": "/component", 80 | "body": [ 81 | "export function ${TM_FILENAME_BASE/[^a-zA-Z0-9]*([a-zA-Z0-9])([a-zA-Z0-9]*)/${1:/capitalize}${2}/g}() {", 82 | " return (", 83 | "
", 84 | " )", 85 | "}" 86 | ] 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.enable": false, 3 | "prettier.enable": false, 4 | "editor.defaultFormatter": "biomejs.biome", 5 | "editor.formatOnSave": true, 6 | "editor.formatOnType": false, 7 | "editor.trimAutoWhitespace": true, 8 | "editor.insertSpaces": false, 9 | "editor.renderWhitespace": "all", 10 | "editor.codeActionsOnSave": { 11 | "source.fixAll.biome": "explicit", 12 | "source.organizeImports": "never", 13 | "source.organizeImports.biome": "always", 14 | "quickfix.biome": "always" 15 | }, 16 | "files.associations": { 17 | "*.css": "css", 18 | "*.js": "javascript", 19 | "*.jsx": "javascriptreact", 20 | "*.json": "json", 21 | "*.ts": "typescript", 22 | "*.tsx": "typescriptreact", 23 | "*.yml": "yaml", 24 | "*.yaml": "yaml" 25 | }, 26 | "files.trimFinalNewlines": true, 27 | "files.trimTrailingWhitespace": true, 28 | "files.trimTrailingWhitespaceInRegexAndStrings": true, 29 | "[css]": { 30 | "editor.defaultFormatter": "biomejs.biome" 31 | }, 32 | "[javascript]": { 33 | "editor.defaultFormatter": "biomejs.biome" 34 | }, 35 | "[javascriptreact]": { 36 | "editor.defaultFormatter": "biomejs.biome" 37 | }, 38 | "[json]": { 39 | "editor.defaultFormatter": "biomejs.biome" 40 | }, 41 | "[typescript]": { 42 | "editor.defaultFormatter": "biomejs.biome" 43 | }, 44 | "[typescriptreact]": { 45 | "editor.defaultFormatter": "biomejs.biome" 46 | }, 47 | "[yaml]": { 48 | "editor.defaultFormatter": "redhat.vscode-yaml" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Router v7 with Better auth. 2 | 3 | This template features React Router v7, Better auth, Drizzle ORM, and D1, designed for deployment on Cloudflare Workers. 4 | 5 | ## Features 6 | 7 | - 🚀 Server-side rendering 8 | - ⚡️ Hot Module Replacement (HMR) 9 | - 📦 Asset bundling and optimization 10 | - 🔄 Data loading and mutations 11 | - 🔒 TypeScript by default 12 | - 🎉 [TailwindCSS](https://tailwindcss.com/) and [Shadcn](https://ui.shadcn.com/) for UI styling 13 | - 🔑 [Better Auth](https://better-auth.com/) for authentication 14 | - 🌧️ [Drizzle ORM](https://orm.drizzle.team/) for database 15 | - 🛢️ Cloudflare D1 for database 16 | - 📁 Cloudflare KV for caching 17 | - 📖 [React Router docs](https://reactrouter.com/) 18 | 19 | ## Demo 20 | 21 | Here's a preview of the app: 22 | 23 |
24 | 25 | 26 |
27 | 28 | For more demo images, check the **.assets** directory. 29 | 30 | ## Links 31 | 32 | React Router v7 Authentication Demo Series: 33 | - [React Router v7 Cloudflare workers template](https://github.com/foxlau/react-router-v7-cloudflare-workers) - React Router v7 Cloudflare workers template. 34 | - [React Router v7 with Remix Auth](https://github.com/foxlau/react-router-v7-remix-auth) - Multi-strategy authentication demo using Remix Auth 35 | 36 | ## Authentication Features 37 | 38 | This template implements a complete authentication system using Better Auth with the following features: 39 | 40 | - 📧 **Email and Password Authentication** - Secure login with email and password 41 | - 🔑 **Password Recovery** - Forgot password and reset password functionality 42 | - 🔄 **Social Login** - Sign in with Google and GitHub accounts 43 | - 👤 **Session Management** - Secure session handling with Cloudflare KV storage 44 | - 🗑️ **Account Management** - Including account deletion functionality 45 | 46 | ## Getting Started 47 | 48 | ### Installation 49 | 50 | Install the dependencies: 51 | 52 | ```bash 53 | git clone https://github.com/foxlau/react-router-v7-better-auth.git 54 | pnpm install 55 | ``` 56 | 57 | ### Development 58 | 59 | Run an initial database migration: 60 | 61 | ```bash 62 | cp .dev.vars.example .dev.vars 63 | npm run db:apply 64 | ``` 65 | 66 | If you modify the Drizzle ORM schema, please run `npm run db:generate` first. If you need to delete the generated SQL migrations, execute `npm run db:drop` and select the SQL migration you wish to remove. 67 | 68 | Start the development server with HMR: 69 | 70 | ```bash 71 | npm run dev 72 | ``` 73 | 74 | Your application will be available at `http://localhost:5173`. 75 | 76 | ## Building for Production 77 | 78 | Create a production build: 79 | 80 | ```bash 81 | npm run build 82 | ``` 83 | 84 | ## Deployment 85 | 86 | Deployment is done using the Wrangler CLI. 87 | 88 | Use the following commands to create the D1 database and KV cache for Better Auth sessions. Remember to replace the `db` and `kv` configurations in the `wrangler.toml` file with the data generated by these commands: 89 | 90 | ```bash 91 | npx wrangler d1 create rr7-better-auth 92 | npx wrangler kv namespace create APP_KV 93 | ``` 94 | 95 | To deploy directly to production: 96 | 97 | ```sh 98 | npm run db:apply-prod 99 | npm run deploy 100 | ``` 101 | 102 | To deploy a preview URL: 103 | 104 | ```sh 105 | npm run deploy:version 106 | ``` 107 | 108 | You can then promote a version to production after verification or roll it out progressively. 109 | 110 | ```sh 111 | npm run deploy:promote 112 | ``` 113 | 114 | ## Questions 115 | 116 | If you have any questions, please open an issue. 117 | -------------------------------------------------------------------------------- /app/components/app-logo.tsx: -------------------------------------------------------------------------------- 1 | import { XIcon } from "lucide-react"; 2 | import { BetterAuthIcon, ReactRouterIcon } from "~/components/icons"; 3 | 4 | export function AppLogo() { 5 | return ( 6 |
7 | 8 | 9 | 10 | 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /app/components/auth-layout.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowLeftIcon } from "lucide-react"; 2 | import { Link } from "react-router"; 3 | import { Button } from "~/components/ui/button"; 4 | 5 | export function AuthLayout({ 6 | title, 7 | description, 8 | children, 9 | }: { 10 | title: string; 11 | description: string; 12 | children: React.ReactNode; 13 | }) { 14 | return ( 15 |
16 | 21 |
22 |
23 |
24 |

{title}

25 |

26 | {description} 27 |

28 |
29 | {children} 30 |
31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /app/components/avatar-selector.tsx: -------------------------------------------------------------------------------- 1 | import { type ChangeEvent, useEffect, useRef, useState } from "react"; 2 | import { useFetcher } from "react-router"; 3 | import { toast } from "sonner"; 4 | import { formatBytes } from "~/hooks/use-file-upload"; 5 | import { 6 | ACCEPTED_IMAGE_TYPES, 7 | MAX_FILE_SIZE, 8 | } from "~/lib/validations/settings"; 9 | import { Spinner } from "./spinner"; 10 | 11 | export function AvatarSelector({ 12 | avatarUrl, 13 | placeholderUrl, 14 | }: { avatarUrl: string | null; placeholderUrl: string }) { 15 | const [previewUrl, setPreviewUrl] = useState(avatarUrl); 16 | const fileInputRef = useRef(null); 17 | const fetcher = useFetcher(); 18 | const isUploading = fetcher.state !== "idle"; 19 | 20 | const handleFileChange = (e: ChangeEvent) => { 21 | const file = e.target.files?.[0]; 22 | if (!file) return; 23 | if (previewUrl) URL.revokeObjectURL(previewUrl); 24 | setPreviewUrl(URL.createObjectURL(file)); 25 | 26 | if (file.size > MAX_FILE_SIZE) { 27 | toast.error(`File size must be less than ${formatBytes(MAX_FILE_SIZE)}.`); 28 | return; 29 | } 30 | 31 | if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { 32 | toast.error("Only .jpg, .jpeg, .png and .webp formats are supported."); 33 | if (fileInputRef.current) { 34 | fileInputRef.current.value = ""; 35 | } 36 | if (previewUrl) { 37 | URL.revokeObjectURL(previewUrl); 38 | setPreviewUrl(null); 39 | } 40 | return; 41 | } 42 | 43 | const formData = new FormData(); 44 | formData.append("image", file); 45 | formData.append("intent", "set-avatar"); 46 | fetcher.submit(formData, { 47 | method: "post", 48 | encType: "multipart/form-data", 49 | }); 50 | }; 51 | 52 | const handleDeleteAvatar = () => { 53 | if (fileInputRef.current) { 54 | fileInputRef.current.value = ""; 55 | } 56 | 57 | if (previewUrl) { 58 | URL.revokeObjectURL(previewUrl); 59 | setPreviewUrl(null); 60 | } 61 | 62 | fetcher.submit({ intent: "delete-avatar" }, { method: "post" }); 63 | }; 64 | 65 | const triggerFileInput = () => fileInputRef.current?.click(); 66 | 67 | useEffect(() => { 68 | return () => { 69 | if (previewUrl) URL.revokeObjectURL(previewUrl); 70 | }; 71 | }, [previewUrl]); 72 | 73 | return ( 74 |
75 | Current avatar 80 | 81 | 90 | 91 | {isUploading ? ( 92 |
93 | 94 | 95 | 96 |
97 | ) : ( 98 |
99 |
100 | {previewUrl ? ( 101 | <> 102 | 110 | 118 | 119 | ) : ( 120 | 128 | )} 129 |
130 |
131 | )} 132 |
133 | ); 134 | } 135 | -------------------------------------------------------------------------------- /app/components/color-scheme-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { LaptopIcon, MoonIcon, SunIcon } from "lucide-react"; 2 | import { 3 | DropdownMenu, 4 | DropdownMenuContent, 5 | DropdownMenuItem, 6 | DropdownMenuTrigger, 7 | } from "~/components/ui/dropdown-menu"; 8 | import { 9 | ColorSchemeSchema, 10 | useColorScheme, 11 | useSetColorScheme, 12 | } from "~/lib/color-scheme/components"; 13 | import { Button } from "./ui/button"; 14 | 15 | const THEME_ICONS = { 16 | light: , 17 | dark: , 18 | system: , 19 | } as const; 20 | 21 | export function ColorSchemeToggle() { 22 | const setColorScheme = useSetColorScheme(); 23 | const colorScheme = useColorScheme(); 24 | 25 | const getIcon = () => { 26 | switch (colorScheme) { 27 | case "dark": 28 | return ; 29 | case "light": 30 | return ; 31 | default: 32 | return ; 33 | } 34 | }; 35 | 36 | return ( 37 | 38 | 39 | 42 | 43 | 44 | {ColorSchemeSchema.shape.colorScheme.options.map((value) => ( 45 | setColorScheme(value)} 48 | aria-selected={colorScheme === value} 49 | className="capitalize" 50 | > 51 | {THEME_ICONS[value]} 52 | {value} 53 | 54 | ))} 55 | 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /app/components/error-boundary.tsx: -------------------------------------------------------------------------------- 1 | import { MehIcon } from "lucide-react"; 2 | import { isRouteErrorResponse, useRouteError } from "react-router"; 3 | import { buttonVariants } from "./ui/button"; 4 | 5 | type ErrorDisplayProps = { 6 | message: string; 7 | details: string; 8 | stack?: string; 9 | }; 10 | 11 | const ERROR_STATUS_MAP: Record< 12 | number, 13 | { message: string; defaultDetails: string } 14 | > = { 15 | 400: { 16 | message: "400 Bad Request", 17 | defaultDetails: "The request was invalid.", 18 | }, 19 | 401: { 20 | message: "401 Unauthorized Access", 21 | defaultDetails: 22 | "Please log in with the appropriate credentials to access this resource.", 23 | }, 24 | 403: { 25 | message: "403 Access Forbidden", 26 | defaultDetails: 27 | "You don't have necessary permission to view this resource.", 28 | }, 29 | 500: { 30 | message: "Oops! Something went wrong :')", 31 | defaultDetails: 32 | "We apologize for the inconvenience. Please try again later.", 33 | }, 34 | 503: { 35 | message: "503 Website is under maintenance!", 36 | defaultDetails: 37 | "The site is not available at the moment. We'll be back online shortly.", 38 | }, 39 | }; 40 | 41 | function DevErrorDisplay({ message, details, stack }: ErrorDisplayProps) { 42 | return ( 43 |
44 |
45 |

{message}

46 |

{details}

47 |
48 |
 49 |         {stack}
 50 |       
51 |
52 | ); 53 | } 54 | 55 | export function ProductionErrorDisplay({ 56 | message, 57 | details, 58 | }: ErrorDisplayProps) { 59 | return ( 60 |
61 |
62 |
63 | 64 |
65 | 66 |
67 |

{message}

68 |

{details}

69 |
70 | 71 | 72 | Back to home 73 | 74 |
75 |
76 | ); 77 | } 78 | 79 | export function GeneralErrorBoundary() { 80 | const error = useRouteError(); 81 | 82 | const defaultMessage = "Oops! App Crashed 💥"; 83 | const defaultDetails = "Please reload the page. or try again later."; 84 | 85 | // Handle route errors, Example: 404, 500, 503 86 | if (isRouteErrorResponse(error)) { 87 | const errorConfig = ERROR_STATUS_MAP[error.status]; 88 | const message = errorConfig?.message ?? defaultMessage; 89 | const details = 90 | error.statusText || errorConfig?.defaultDetails || defaultDetails; 91 | return ; 92 | } 93 | 94 | // Handle development errors 95 | if (import.meta.env.DEV && error && error instanceof Error) { 96 | console.log("🔴 error on dev", error); 97 | return ( 98 | 103 | ); 104 | } 105 | 106 | // Handle other errors 107 | return ( 108 | 109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /app/components/forms.tsx: -------------------------------------------------------------------------------- 1 | import type { VariantProps } from "class-variance-authority"; 2 | import { EyeIcon, EyeOffIcon } from "lucide-react"; 3 | import { useId, useState } from "react"; 4 | 5 | import { Spinner } from "~/components/spinner"; 6 | import { Button } from "~/components/ui/button"; 7 | import type { buttonVariants } from "~/components/ui/button"; 8 | import { Input } from "~/components/ui/input"; 9 | import { Label } from "~/components/ui/label"; 10 | import { cn } from "~/lib/utils"; 11 | 12 | export type ListOfErrors = Array | null | undefined; 13 | 14 | export interface FormFieldProps { 15 | labelProps?: React.LabelHTMLAttributes; 16 | inputProps: React.InputHTMLAttributes; 17 | errors?: ListOfErrors; 18 | className?: string; 19 | } 20 | 21 | export interface LoadingButtonProps 22 | extends React.ComponentProps<"button">, 23 | VariantProps { 24 | buttonText: string; 25 | loadingText: string; 26 | isPending: boolean; 27 | className?: string; 28 | } 29 | 30 | export function ErrorList({ 31 | id, 32 | errors, 33 | }: { 34 | errors?: ListOfErrors; 35 | id?: string; 36 | }) { 37 | const errorsToRender = errors?.filter(Boolean); 38 | if (!errorsToRender?.length) return null; 39 | return ( 40 |
    41 | {errorsToRender.map((e) => ( 42 |
  • 43 | {e} 44 |
  • 45 | ))} 46 |
47 | ); 48 | } 49 | 50 | export function InputField({ 51 | labelProps, 52 | inputProps, 53 | errors, 54 | className, 55 | }: FormFieldProps) { 56 | const fallbackId = useId(); 57 | const id = inputProps.id || fallbackId; 58 | const errorId = errors?.length ? `${id}-error` : undefined; 59 | 60 | return ( 61 |
62 | {labelProps &&
71 | ); 72 | } 73 | 74 | export function PasswordField({ 75 | labelProps, 76 | inputProps, 77 | errors, 78 | className, 79 | }: FormFieldProps) { 80 | const [isVisible, setIsVisible] = useState(false); 81 | const fallbackId = useId(); 82 | const id = inputProps.id || fallbackId; 83 | const errorId = errors?.length ? `${id}-error` : undefined; 84 | const { type, ...restInputProps } = inputProps; 85 | 86 | return ( 87 |
88 | {labelProps &&
116 | ); 117 | } 118 | 119 | export function LoadingButton({ 120 | buttonText, 121 | loadingText, 122 | isPending, 123 | className = "", 124 | ...props 125 | }: LoadingButtonProps) { 126 | return ( 127 | 136 | ); 137 | } 138 | -------------------------------------------------------------------------------- /app/components/icons.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "~/lib/utils"; 2 | 3 | interface IconProps { 4 | className?: string; 5 | theme?: "light" | "dark"; 6 | } 7 | 8 | export function ReactRouterIcon({ className, theme }: IconProps) { 9 | const color = theme === "light" ? "#121212" : "#FFFFFF"; 10 | 11 | return ( 12 | 20 | React Router 21 | 25 | 29 | 33 | 37 | 38 | ); 39 | } 40 | 41 | export function GoogleIcon({ className }: IconProps) { 42 | return ( 43 | 51 | Google 52 | 56 | 60 | 64 | 68 | 69 | ); 70 | } 71 | 72 | export function GithubIcon({ className }: IconProps) { 73 | return ( 74 | 81 | GitHub 82 | 86 | 87 | ); 88 | } 89 | 90 | export function BetterAuthIcon({ className }: IconProps) { 91 | return ( 92 | 100 | Better auth 101 | 107 | 108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /app/components/progress-bar.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { useNavigation } from "react-router"; 3 | import { useSpinDelay } from "spin-delay"; 4 | import { cn } from "~/lib/utils"; 5 | import { Spinner } from "./spinner"; 6 | 7 | interface ProgressBarProps { 8 | showSpinner?: boolean; 9 | } 10 | 11 | function ProgressBar({ showSpinner = false }: ProgressBarProps) { 12 | const transition = useNavigation(); 13 | const busy = transition.state !== "idle"; 14 | const delayedPending = useSpinDelay(busy, { 15 | delay: 600, 16 | minDuration: 400, 17 | }); 18 | const ref = useRef(null); 19 | const [animationComplete, setAnimationComplete] = useState(true); 20 | 21 | useEffect(() => { 22 | if (!ref.current) return; 23 | if (delayedPending) setAnimationComplete(false); 24 | 25 | const animationPromises = ref.current 26 | .getAnimations() 27 | .map(({ finished }) => finished); 28 | 29 | Promise.allSettled(animationPromises).then(() => { 30 | if (!delayedPending) setAnimationComplete(true); 31 | }); 32 | }, [delayedPending]); 33 | 34 | return ( 35 |
40 |
52 | {delayedPending && showSpinner && ( 53 |
54 | 55 |
56 | )} 57 |
58 | ); 59 | } 60 | 61 | export { ProgressBar }; 62 | -------------------------------------------------------------------------------- /app/components/settings/account-action.tsx: -------------------------------------------------------------------------------- 1 | import { CircleAlertIcon } from "lucide-react"; 2 | import { useState } from "react"; 3 | import { useFetcher } from "react-router"; 4 | 5 | import { Button } from "~/components/ui/button"; 6 | import { 7 | Dialog, 8 | DialogClose, 9 | DialogContent, 10 | DialogDescription, 11 | DialogFooter, 12 | DialogHeader, 13 | DialogTitle, 14 | DialogTrigger, 15 | } from "~/components/ui/dialog"; 16 | import { Input } from "~/components/ui/input"; 17 | import { LoadingButton } from "../forms"; 18 | 19 | export function DeleteAccount({ email }: { email: string }) { 20 | const [inputValue, setInputValue] = useState(""); 21 | const fetcher = useFetcher({ key: "delete-account" }); 22 | const isPending = fetcher.state !== "idle"; 23 | 24 | return ( 25 | 26 | 27 | 30 | 31 | 32 |
33 | 39 | 40 | 41 | Final confirmation 42 | 43 | 44 | This action cannot be undone. To confirm, please enter the email 45 | address {email}. 46 | 47 | 48 |
49 | 50 | 51 |
52 | setInputValue(e.target.value)} 58 | /> 59 | 60 |
61 | 62 | 63 | 66 | 67 | 75 | 76 |
77 |
78 |
79 | ); 80 | } 81 | 82 | export function SignOut() { 83 | const signOutFetcher = useFetcher(); 84 | const signOutIsPending = signOutFetcher.state !== "idle"; 85 | 86 | return ( 87 | 94 | signOutFetcher.submit(null, { 95 | method: "POST", 96 | action: "/auth/sign-out", 97 | }) 98 | } 99 | /> 100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /app/components/settings/connection-action.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useNavigate } from "react-router"; 3 | import { toast } from "sonner"; 4 | 5 | import { authClient } from "~/lib/auth/auth.client"; 6 | import type { AllowedProvider } from "~/lib/config"; 7 | import { LoadingButton } from "../forms"; 8 | 9 | export function ConnectionAction({ 10 | provider, 11 | isConnected, 12 | }: { 13 | provider: AllowedProvider; 14 | isConnected: boolean; 15 | }) { 16 | const navigate = useNavigate(); 17 | const [isConnecting, setIsConnecting] = useState(false); 18 | const variant = isConnected ? "secondary" : "outline"; 19 | const label = isConnected ? "Disconnect" : "Connect"; 20 | 21 | const handleLinkSocial = async () => { 22 | setIsConnecting(true); 23 | const { error } = await authClient.linkSocial({ 24 | provider, 25 | callbackURL: "/settings/connections", 26 | }); 27 | if (error) { 28 | toast.error(error.message || "Failed to connect."); 29 | } 30 | setIsConnecting(false); 31 | }; 32 | 33 | const handleUnlinkSocial = async () => { 34 | setIsConnecting(true); 35 | const { error } = await authClient.unlinkAccount({ 36 | providerId: provider, 37 | }); 38 | if (error) { 39 | toast.error(error.message || "Failed to disconnect."); 40 | } 41 | setIsConnecting(false); 42 | navigate("."); 43 | }; 44 | 45 | return ( 46 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /app/components/settings/connection-item.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, SVGProps } from "react"; 2 | 3 | import type { AllowedProvider } from "~/lib/config"; 4 | import { ConnectionAction } from "./connection-action"; 5 | 6 | export function ConnectionItem({ 7 | connection, 8 | }: { 9 | connection: { 10 | provider: AllowedProvider; 11 | displayName: string; 12 | icon: FC>; 13 | isConnected: boolean; 14 | createdAt: string | null; 15 | }; 16 | }) { 17 | return ( 18 |
19 |
20 |
21 | 22 |
23 |
24 |

{connection.displayName}

25 |

26 | {connection.isConnected && connection.createdAt 27 | ? `Connected on ${connection.createdAt}` 28 | : "Not connected"} 29 |

30 |
31 |
32 | 36 |
37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/components/settings/password-action.tsx: -------------------------------------------------------------------------------- 1 | import { getFormProps, getInputProps, useForm } from "@conform-to/react"; 2 | import { getZodConstraint, parseWithZod } from "@conform-to/zod"; 3 | import { useEffect, useState } from "react"; 4 | import { useFetcher } from "react-router"; 5 | 6 | import { Button } from "~/components/ui/button"; 7 | import { 8 | Dialog, 9 | DialogClose, 10 | DialogContent, 11 | DialogDescription, 12 | DialogFooter, 13 | DialogHeader, 14 | DialogTitle, 15 | DialogTrigger, 16 | } from "~/components/ui/dialog"; 17 | import { changePasswordSchema } from "~/lib/validations/auth"; 18 | import type { clientAction } from "~/routes/settings/password"; 19 | import { LoadingButton, PasswordField } from "../forms"; 20 | 21 | export function ChangePassword() { 22 | const fetcher = useFetcher({ key: "change-password" }); 23 | const isPending = fetcher.state !== "idle"; 24 | const [open, setOpen] = useState(false); 25 | 26 | const [form, fields] = useForm({ 27 | onValidate({ formData }) { 28 | return parseWithZod(formData, { schema: changePasswordSchema }); 29 | }, 30 | constraint: getZodConstraint(changePasswordSchema), 31 | shouldRevalidate: "onInput", 32 | }); 33 | 34 | useEffect(() => { 35 | if (fetcher.data?.status === "success") { 36 | setOpen(false); 37 | } 38 | }, [fetcher.data]); 39 | 40 | return ( 41 | 42 | 43 | 46 | 47 | 48 | 49 | Change Password 50 | 51 | Make changes to your password here. You can change your password and 52 | set a new password. 53 | 54 | 55 | 61 | 70 | 79 | 88 | 89 | 90 | 93 | 94 | 99 | 100 | 101 | 102 | 103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /app/components/settings/session-action.tsx: -------------------------------------------------------------------------------- 1 | import { CircleAlertIcon } from "lucide-react"; 2 | import { useEffect, useState } from "react"; 3 | import { useFetcher } from "react-router"; 4 | 5 | import { 6 | AlertDialog, 7 | AlertDialogCancel, 8 | AlertDialogContent, 9 | AlertDialogDescription, 10 | AlertDialogFooter, 11 | AlertDialogHeader, 12 | AlertDialogTitle, 13 | AlertDialogTrigger, 14 | } from "~/components/ui/alert-dialog"; 15 | import { Button } from "~/components/ui/button"; 16 | import type { clientAction } from "~/routes/settings/sessions"; 17 | import { LoadingButton } from "../forms"; 18 | 19 | export function SignOutOfOtherSessions() { 20 | const fetcher = useFetcher({ 21 | key: "sign-out-of-other-sessions", 22 | }); 23 | const isPending = fetcher.state !== "idle"; 24 | const [open, setOpen] = useState(false); 25 | 26 | useEffect(() => { 27 | if (fetcher.data?.status === "success") { 28 | setOpen(false); 29 | } 30 | }, [fetcher.data]); 31 | 32 | return ( 33 | 34 | 35 | 36 | 37 | 38 |
39 | 45 | 46 | Are you sure? 47 | 48 | Are you sure you want to sign out of other sessions? This will 49 | sign you out of all sessions except the current one. 50 | 51 | 52 |
53 | 54 | 55 | Cancel 56 | 61 | 62 | 63 |
64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /app/components/settings/session-item.tsx: -------------------------------------------------------------------------------- 1 | import { MonitorIcon, SmartphoneIcon } from "lucide-react"; 2 | 3 | import type { authClient } from "~/lib/auth/auth.client"; 4 | import { formatDate, parseUserAgent } from "~/lib/utils"; 5 | 6 | export function SessionItem({ 7 | session, 8 | currentSessionToken, 9 | }: { 10 | session: typeof authClient.$Infer.Session.session; 11 | currentSessionToken: string; 12 | }) { 13 | const { system, browser, isMobile } = parseUserAgent(session.userAgent || ""); 14 | const isCurrentSession = session.token === currentSessionToken; 15 | 16 | return ( 17 |
18 |
19 | {isMobile ? ( 20 | 21 | ) : ( 22 | 23 | )} 24 |
25 |
26 |
27 | 28 | {system} 29 | 30 | {browser} 31 | 32 | {isCurrentSession && ( 33 | 34 | 35 | Current device 36 | 37 | )} 38 |
39 | 40 |
41 | IP Address: {session.ipAddress || "unknown"} 42 | 43 | Last active: {formatDate(session.createdAt, "MMM d, yyyy hh:mm a")} 44 | 45 |
46 |
47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /app/components/settings/setting-row.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | 3 | interface SettingRowProps { 4 | title: string; 5 | description: string; 6 | action?: React.ReactNode; 7 | } 8 | 9 | export function SettingRow({ title, description, action }: SettingRowProps) { 10 | return ( 11 |
12 |
13 |
{title}
14 |
15 | {description} 16 |
17 |
18 | {action && ( 19 |
20 |
{action}
21 |
22 | )} 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /app/components/settings/settings-layout.tsx: -------------------------------------------------------------------------------- 1 | interface SettingsLayoutProps { 2 | title: string; 3 | description?: string; 4 | children: React.ReactNode; 5 | } 6 | 7 | export function SettingsLayout({ 8 | title, 9 | description, 10 | children, 11 | }: SettingsLayoutProps) { 12 | return ( 13 |
14 |
15 |

{title}

16 | {description &&

{description}

} 17 |
18 |
{children}
19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/components/settings/settings-menu.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | HardDriveIcon, 3 | KeyIcon, 4 | Link2Icon, 5 | type LucideIcon, 6 | SunMoonIcon, 7 | UserIcon, 8 | } from "lucide-react"; 9 | import { NavLink, href } from "react-router"; 10 | 11 | import { cn } from "~/lib/utils"; 12 | 13 | interface MenuItem { 14 | title: string; 15 | url: string; 16 | icon: LucideIcon; 17 | } 18 | 19 | const menuItems: MenuItem[] = [ 20 | { 21 | title: "Account", 22 | url: href("/settings/account"), 23 | icon: UserIcon, 24 | }, 25 | { 26 | title: "Appearance", 27 | url: href("/settings/appearance"), 28 | icon: SunMoonIcon, 29 | }, 30 | { 31 | title: "Connections", 32 | url: href("/settings/connections"), 33 | icon: Link2Icon, 34 | }, 35 | { 36 | title: "Sessions", 37 | url: href("/settings/sessions"), 38 | icon: HardDriveIcon, 39 | }, 40 | { 41 | title: "Password", 42 | url: href("/settings/password"), 43 | icon: KeyIcon, 44 | }, 45 | ]; 46 | 47 | export function Menu() { 48 | return ( 49 |
50 |
51 | {menuItems.map((item) => ( 52 | 56 | cn( 57 | "relative flex items-center justify-start gap-1.5 py-4 text-muted-foreground", 58 | { 59 | "font-medium text-foreground after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-foreground after:content-['']": 60 | isActive, 61 | }, 62 | ) 63 | } 64 | > 65 | 66 | {item.title} 67 | 68 | ))} 69 |
70 |
71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /app/components/spinner.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "~/lib/utils"; 2 | 3 | export function Spinner({ className }: { className?: string }) { 4 | return ( 5 | 11 | Loading... 12 | 21 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/components/todos/todo-item.tsx: -------------------------------------------------------------------------------- 1 | import { TrashIcon } from "lucide-react"; 2 | import { useState } from "react"; 3 | import { useFetcher } from "react-router"; 4 | 5 | import { Button } from "~/components/ui/button"; 6 | import { Checkbox } from "~/components/ui/checkbox"; 7 | import type { SelectTodo } from "~/lib/database/schema"; 8 | import { cn } from "~/lib/utils"; 9 | 10 | export function TodoItem({ todo }: { todo: SelectTodo }) { 11 | const fetcher = useFetcher(); 12 | const [isChecked, setIsChecked] = useState(Boolean(todo.completed)); 13 | const isSubmitting = fetcher.state !== "idle"; 14 | const id = todo.id.toString(); 15 | 16 | return ( 17 |
  • 21 | 46 | 64 |
  • 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /app/components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; 2 | import type * as React from "react"; 3 | 4 | import { buttonVariants } from "~/components/ui/button"; 5 | import { cn } from "~/lib/utils"; 6 | 7 | function AlertDialog({ 8 | ...props 9 | }: React.ComponentProps) { 10 | return ; 11 | } 12 | 13 | function AlertDialogTrigger({ 14 | ...props 15 | }: React.ComponentProps) { 16 | return ( 17 | 18 | ); 19 | } 20 | 21 | function AlertDialogPortal({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 26 | ); 27 | } 28 | 29 | function AlertDialogOverlay({ 30 | className, 31 | ...props 32 | }: React.ComponentProps) { 33 | return ( 34 | 42 | ); 43 | } 44 | 45 | function AlertDialogContent({ 46 | className, 47 | ...props 48 | }: React.ComponentProps) { 49 | return ( 50 | 51 | 52 | 60 | 61 | ); 62 | } 63 | 64 | function AlertDialogHeader({ 65 | className, 66 | ...props 67 | }: React.ComponentProps<"div">) { 68 | return ( 69 |
    74 | ); 75 | } 76 | 77 | function AlertDialogFooter({ 78 | className, 79 | ...props 80 | }: React.ComponentProps<"div">) { 81 | return ( 82 |
    90 | ); 91 | } 92 | 93 | function AlertDialogTitle({ 94 | className, 95 | ...props 96 | }: React.ComponentProps) { 97 | return ( 98 | 103 | ); 104 | } 105 | 106 | function AlertDialogDescription({ 107 | className, 108 | ...props 109 | }: React.ComponentProps) { 110 | return ( 111 | 116 | ); 117 | } 118 | 119 | function AlertDialogAction({ 120 | className, 121 | ...props 122 | }: React.ComponentProps) { 123 | return ( 124 | 128 | ); 129 | } 130 | 131 | function AlertDialogCancel({ 132 | className, 133 | ...props 134 | }: React.ComponentProps) { 135 | return ( 136 | 140 | ); 141 | } 142 | 143 | export { 144 | AlertDialog, 145 | AlertDialogPortal, 146 | AlertDialogOverlay, 147 | AlertDialogTrigger, 148 | AlertDialogContent, 149 | AlertDialogHeader, 150 | AlertDialogFooter, 151 | AlertDialogTitle, 152 | AlertDialogDescription, 153 | AlertDialogAction, 154 | AlertDialogCancel, 155 | }; 156 | -------------------------------------------------------------------------------- /app/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 2 | import type * as React from "react"; 3 | 4 | import { cn } from "~/lib/utils"; 5 | 6 | function Avatar({ 7 | className, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 19 | ); 20 | } 21 | 22 | function AvatarImage({ 23 | className, 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 32 | ); 33 | } 34 | 35 | function AvatarFallback({ 36 | className, 37 | ...props 38 | }: React.ComponentProps) { 39 | return ( 40 | 48 | ); 49 | } 50 | 51 | export { Avatar, AvatarImage, AvatarFallback }; 52 | -------------------------------------------------------------------------------- /app/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "@radix-ui/react-slot"; 2 | import { type VariantProps, cva } from "class-variance-authority"; 3 | import type * as React from "react"; 4 | 5 | import { cn } from "~/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", 16 | outline: 17 | "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 25 | sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5", 26 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 27 | icon: "size-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | }, 35 | ); 36 | 37 | function Button({ 38 | className, 39 | variant, 40 | size, 41 | asChild = false, 42 | ...props 43 | }: React.ComponentProps<"button"> & 44 | VariantProps & { 45 | asChild?: boolean; 46 | }) { 47 | const Comp = asChild ? Slot : "button"; 48 | 49 | return ( 50 | 55 | ); 56 | } 57 | 58 | export { Button, buttonVariants }; 59 | -------------------------------------------------------------------------------- /app/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; 2 | import { CheckIcon } from "lucide-react"; 3 | import type * as React from "react"; 4 | 5 | import { cn } from "~/lib/utils"; 6 | 7 | function Checkbox({ 8 | className, 9 | ...props 10 | }: React.ComponentProps) { 11 | return ( 12 | 20 | 24 | 25 | 26 | 27 | ); 28 | } 29 | 30 | export { Checkbox }; 31 | -------------------------------------------------------------------------------- /app/components/ui/cropper.tsx: -------------------------------------------------------------------------------- 1 | import { Cropper as CropperPrimitive } from "@origin-space/image-cropper"; 2 | 3 | import { cn } from "~/lib/utils"; 4 | 5 | function Cropper({ 6 | className, 7 | ...props 8 | }: React.ComponentProps) { 9 | return ( 10 | 18 | ); 19 | } 20 | 21 | function CropperDescription({ 22 | className, 23 | ...props 24 | }: React.ComponentProps) { 25 | return ( 26 | 31 | ); 32 | } 33 | 34 | function CropperImage({ 35 | className, 36 | ...props 37 | }: React.ComponentProps) { 38 | return ( 39 | 47 | ); 48 | } 49 | 50 | function CropperCropArea({ 51 | className, 52 | ...props 53 | }: React.ComponentProps) { 54 | return ( 55 | 63 | ); 64 | } 65 | 66 | export { Cropper, CropperDescription, CropperImage, CropperCropArea }; 67 | -------------------------------------------------------------------------------- /app/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as DialogPrimitive from "@radix-ui/react-dialog"; 2 | import { XIcon } from "lucide-react"; 3 | import type * as React from "react"; 4 | 5 | import { cn } from "~/lib/utils"; 6 | 7 | function Dialog({ 8 | ...props 9 | }: React.ComponentProps) { 10 | return ; 11 | } 12 | 13 | function DialogTrigger({ 14 | ...props 15 | }: React.ComponentProps) { 16 | return ; 17 | } 18 | 19 | function DialogPortal({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return ; 23 | } 24 | 25 | function DialogClose({ 26 | ...props 27 | }: React.ComponentProps) { 28 | return ; 29 | } 30 | 31 | function DialogOverlay({ 32 | className, 33 | ...props 34 | }: React.ComponentProps) { 35 | return ( 36 | 44 | ); 45 | } 46 | 47 | function DialogContent({ 48 | className, 49 | children, 50 | ...props 51 | }: React.ComponentProps) { 52 | return ( 53 | 54 | 55 | 63 | {children} 64 | 65 | 66 | Close 67 | 68 | 69 | 70 | ); 71 | } 72 | 73 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { 74 | return ( 75 |
    80 | ); 81 | } 82 | 83 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { 84 | return ( 85 |
    93 | ); 94 | } 95 | 96 | function DialogTitle({ 97 | className, 98 | ...props 99 | }: React.ComponentProps) { 100 | return ( 101 | 106 | ); 107 | } 108 | 109 | function DialogDescription({ 110 | className, 111 | ...props 112 | }: React.ComponentProps) { 113 | return ( 114 | 119 | ); 120 | } 121 | 122 | export { 123 | Dialog, 124 | DialogClose, 125 | DialogContent, 126 | DialogDescription, 127 | DialogFooter, 128 | DialogHeader, 129 | DialogOverlay, 130 | DialogPortal, 131 | DialogTitle, 132 | DialogTrigger, 133 | }; 134 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; 2 | import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; 3 | import type * as React from "react"; 4 | 5 | import { cn } from "~/lib/utils"; 6 | 7 | function DropdownMenu({ 8 | ...props 9 | }: React.ComponentProps) { 10 | return ; 11 | } 12 | 13 | function DropdownMenuPortal({ 14 | ...props 15 | }: React.ComponentProps) { 16 | return ( 17 | 18 | ); 19 | } 20 | 21 | function DropdownMenuTrigger({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 29 | ); 30 | } 31 | 32 | function DropdownMenuContent({ 33 | className, 34 | sideOffset = 4, 35 | ...props 36 | }: React.ComponentProps) { 37 | return ( 38 | 39 | 48 | 49 | ); 50 | } 51 | 52 | function DropdownMenuGroup({ 53 | ...props 54 | }: React.ComponentProps) { 55 | return ( 56 | 57 | ); 58 | } 59 | 60 | function DropdownMenuItem({ 61 | className, 62 | inset, 63 | variant = "default", 64 | ...props 65 | }: React.ComponentProps & { 66 | inset?: boolean; 67 | variant?: "default" | "destructive"; 68 | }) { 69 | return ( 70 | 80 | ); 81 | } 82 | 83 | function DropdownMenuCheckboxItem({ 84 | className, 85 | children, 86 | checked, 87 | ...props 88 | }: React.ComponentProps) { 89 | return ( 90 | 99 | 100 | 101 | 102 | 103 | 104 | {children} 105 | 106 | ); 107 | } 108 | 109 | function DropdownMenuRadioGroup({ 110 | ...props 111 | }: React.ComponentProps) { 112 | return ( 113 | 117 | ); 118 | } 119 | 120 | function DropdownMenuRadioItem({ 121 | className, 122 | children, 123 | ...props 124 | }: React.ComponentProps) { 125 | return ( 126 | 134 | 135 | 136 | 137 | 138 | 139 | {children} 140 | 141 | ); 142 | } 143 | 144 | function DropdownMenuLabel({ 145 | className, 146 | inset, 147 | ...props 148 | }: React.ComponentProps & { 149 | inset?: boolean; 150 | }) { 151 | return ( 152 | 161 | ); 162 | } 163 | 164 | function DropdownMenuSeparator({ 165 | className, 166 | ...props 167 | }: React.ComponentProps) { 168 | return ( 169 | 174 | ); 175 | } 176 | 177 | function DropdownMenuShortcut({ 178 | className, 179 | ...props 180 | }: React.ComponentProps<"span">) { 181 | return ( 182 | 190 | ); 191 | } 192 | 193 | function DropdownMenuSub({ 194 | ...props 195 | }: React.ComponentProps) { 196 | return ; 197 | } 198 | 199 | function DropdownMenuSubTrigger({ 200 | className, 201 | inset, 202 | children, 203 | ...props 204 | }: React.ComponentProps & { 205 | inset?: boolean; 206 | }) { 207 | return ( 208 | 217 | {children} 218 | 219 | 220 | ); 221 | } 222 | 223 | function DropdownMenuSubContent({ 224 | className, 225 | ...props 226 | }: React.ComponentProps) { 227 | return ( 228 | 236 | ); 237 | } 238 | 239 | export { 240 | DropdownMenu, 241 | DropdownMenuPortal, 242 | DropdownMenuTrigger, 243 | DropdownMenuContent, 244 | DropdownMenuGroup, 245 | DropdownMenuLabel, 246 | DropdownMenuItem, 247 | DropdownMenuCheckboxItem, 248 | DropdownMenuRadioGroup, 249 | DropdownMenuRadioItem, 250 | DropdownMenuSeparator, 251 | DropdownMenuShortcut, 252 | DropdownMenuSub, 253 | DropdownMenuSubTrigger, 254 | DropdownMenuSubContent, 255 | }; 256 | -------------------------------------------------------------------------------- /app/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import type * as React from "react"; 2 | 3 | import { cn } from "~/lib/utils"; 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ); 19 | } 20 | 21 | export { Input }; 22 | -------------------------------------------------------------------------------- /app/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as LabelPrimitive from "@radix-ui/react-label"; 2 | import type * as React from "react"; 3 | 4 | import { cn } from "~/lib/utils"; 5 | 6 | function Label({ 7 | className, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 19 | ); 20 | } 21 | 22 | export { Label }; 23 | -------------------------------------------------------------------------------- /app/components/ui/radio-group.tsx: -------------------------------------------------------------------------------- 1 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; 2 | import { CircleIcon } from "lucide-react"; 3 | import type * as React from "react"; 4 | 5 | import { cn } from "~/lib/utils"; 6 | 7 | function RadioGroup({ 8 | className, 9 | ...props 10 | }: React.ComponentProps) { 11 | return ( 12 | 17 | ); 18 | } 19 | 20 | function RadioGroupItem({ 21 | className, 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 33 | 37 | 38 | 39 | 40 | ); 41 | } 42 | 43 | export { RadioGroup, RadioGroupItem }; 44 | -------------------------------------------------------------------------------- /app/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | import * as SelectPrimitive from "@radix-ui/react-select"; 2 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; 3 | import type * as React from "react"; 4 | 5 | import { cn } from "~/lib/utils"; 6 | 7 | function Select({ 8 | ...props 9 | }: React.ComponentProps) { 10 | return ; 11 | } 12 | 13 | function SelectGroup({ 14 | ...props 15 | }: React.ComponentProps) { 16 | return ; 17 | } 18 | 19 | function SelectValue({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return ; 23 | } 24 | 25 | function SelectTrigger({ 26 | className, 27 | size = "default", 28 | children, 29 | ...props 30 | }: React.ComponentProps & { 31 | size?: "sm" | "default"; 32 | }) { 33 | return ( 34 | 43 | {children} 44 | 45 | 46 | 47 | 48 | ); 49 | } 50 | 51 | function SelectContent({ 52 | className, 53 | children, 54 | position = "popper", 55 | ...props 56 | }: React.ComponentProps) { 57 | return ( 58 | 59 | 70 | 71 | 78 | {children} 79 | 80 | 81 | 82 | 83 | ); 84 | } 85 | 86 | function SelectLabel({ 87 | className, 88 | ...props 89 | }: React.ComponentProps) { 90 | return ( 91 | 96 | ); 97 | } 98 | 99 | function SelectItem({ 100 | className, 101 | children, 102 | ...props 103 | }: React.ComponentProps) { 104 | return ( 105 | 113 | 114 | 115 | 116 | 117 | 118 | {children} 119 | 120 | ); 121 | } 122 | 123 | function SelectSeparator({ 124 | className, 125 | ...props 126 | }: React.ComponentProps) { 127 | return ( 128 | 133 | ); 134 | } 135 | 136 | function SelectScrollUpButton({ 137 | className, 138 | ...props 139 | }: React.ComponentProps) { 140 | return ( 141 | 149 | 150 | 151 | ); 152 | } 153 | 154 | function SelectScrollDownButton({ 155 | className, 156 | ...props 157 | }: React.ComponentProps) { 158 | return ( 159 | 167 | 168 | 169 | ); 170 | } 171 | 172 | export { 173 | Select, 174 | SelectContent, 175 | SelectGroup, 176 | SelectItem, 177 | SelectLabel, 178 | SelectScrollDownButton, 179 | SelectScrollUpButton, 180 | SelectSeparator, 181 | SelectTrigger, 182 | SelectValue, 183 | }; 184 | -------------------------------------------------------------------------------- /app/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "~/lib/utils"; 2 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { 4 | return ( 5 |
    10 | ); 11 | } 12 | 13 | export { Skeleton }; 14 | -------------------------------------------------------------------------------- /app/components/ui/slider.tsx: -------------------------------------------------------------------------------- 1 | import * as SliderPrimitive from "@radix-ui/react-slider"; 2 | import * as React from "react"; 3 | 4 | import { 5 | Tooltip, 6 | TooltipContent, 7 | TooltipProvider, 8 | TooltipTrigger, 9 | } from "~/components/ui/tooltip"; 10 | import { cn } from "~/lib/utils"; 11 | 12 | function Slider({ 13 | className, 14 | defaultValue, 15 | value, 16 | min = 0, 17 | max = 100, 18 | showTooltip = false, 19 | tooltipContent, 20 | ...props 21 | }: React.ComponentProps & { 22 | showTooltip?: boolean; 23 | tooltipContent?: (value: number) => React.ReactNode; 24 | }) { 25 | const [internalValues, setInternalValues] = React.useState( 26 | Array.isArray(value) 27 | ? value 28 | : Array.isArray(defaultValue) 29 | ? defaultValue 30 | : [min, max], 31 | ); 32 | 33 | React.useEffect(() => { 34 | if (value !== undefined) { 35 | setInternalValues(Array.isArray(value) ? value : [value]); 36 | } 37 | }, [value]); 38 | 39 | const handleValueChange = (newValue: number[]) => { 40 | setInternalValues(newValue); 41 | props.onValueChange?.(newValue); 42 | }; 43 | 44 | const [showTooltipState, setShowTooltipState] = React.useState(false); 45 | 46 | const handlePointerDown = () => { 47 | if (showTooltip) { 48 | setShowTooltipState(true); 49 | } 50 | }; 51 | 52 | const handlePointerUp = React.useCallback(() => { 53 | if (showTooltip) { 54 | setShowTooltipState(false); 55 | } 56 | }, [showTooltip]); 57 | 58 | React.useEffect(() => { 59 | if (showTooltip) { 60 | document.addEventListener("pointerup", handlePointerUp); 61 | return () => { 62 | document.removeEventListener("pointerup", handlePointerUp); 63 | }; 64 | } 65 | }, [showTooltip, handlePointerUp]); 66 | 67 | const renderThumb = (value: number) => { 68 | const thumb = ( 69 | 74 | ); 75 | 76 | if (!showTooltip) return thumb; 77 | 78 | return ( 79 | 80 | 81 | {thumb} 82 | 87 |

    {tooltipContent ? tooltipContent(value) : value}

    88 |
    89 |
    90 |
    91 | ); 92 | }; 93 | 94 | return ( 95 | 108 | 114 | 120 | 121 | {Array.from({ length: internalValues.length }, (_, index) => ( 122 | // biome-ignore lint/suspicious/noArrayIndexKey: 123 | 124 | {renderThumb(internalValues[index] ?? 0)} 125 | 126 | ))} 127 | 128 | ); 129 | } 130 | 131 | export { Slider }; 132 | -------------------------------------------------------------------------------- /app/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 2 | import type * as React from "react"; 3 | 4 | import { cn } from "~/lib/utils"; 5 | 6 | function TooltipProvider({ 7 | delayDuration = 0, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 16 | ); 17 | } 18 | 19 | function Tooltip({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return ( 23 | 24 | 25 | 26 | ); 27 | } 28 | 29 | function TooltipTrigger({ 30 | ...props 31 | }: React.ComponentProps) { 32 | return ; 33 | } 34 | 35 | function TooltipContent({ 36 | className, 37 | sideOffset = 4, 38 | showArrow = false, 39 | children, 40 | ...props 41 | }: React.ComponentProps & { 42 | showArrow?: boolean; 43 | }) { 44 | return ( 45 | 46 | 55 | {children} 56 | {showArrow && ( 57 | 58 | )} 59 | 60 | 61 | ); 62 | } 63 | 64 | export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }; 65 | -------------------------------------------------------------------------------- /app/components/user-nav.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate, useSubmit } from "react-router"; 2 | 3 | import { 4 | CircleGaugeIcon, 5 | HomeIcon, 6 | LogOutIcon, 7 | UserCogIcon, 8 | } from "lucide-react"; 9 | import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; 10 | import { 11 | DropdownMenu, 12 | DropdownMenuContent, 13 | DropdownMenuItem, 14 | DropdownMenuLabel, 15 | DropdownMenuSeparator, 16 | DropdownMenuTrigger, 17 | } from "~/components/ui/dropdown-menu"; 18 | import { useAuthUser } from "~/hooks/use-auth-user"; 19 | import { getAvatarUrl } from "~/lib/utils"; 20 | import { Button } from "./ui/button"; 21 | 22 | export function UserNav() { 23 | const { user } = useAuthUser(); 24 | const navigate = useNavigate(); 25 | const submit = useSubmit(); 26 | const { avatarUrl, placeholderUrl } = getAvatarUrl(user.image, user.name); 27 | const initials = user?.name?.slice(0, 2); 28 | const alt = user?.name ?? "User avatar"; 29 | const avatar = avatarUrl || placeholderUrl; 30 | 31 | return ( 32 | 33 | 34 | 42 | 43 | 44 | 45 |
    46 | 47 | 48 | {initials} 49 | 50 |
    51 | {user.name} 52 | 53 | {user.email} 54 | 55 |
    56 |
    57 |
    58 | 59 | { 61 | navigate("/"); 62 | }} 63 | > 64 | 65 | Home Page 66 | 67 | { 69 | navigate("/settings/account"); 70 | }} 71 | > 72 | 73 | Account Settings 74 | 75 | {/* Todo: coming soon */} 76 | 77 | 78 | Admin Dashboard 79 | 80 | 81 | { 83 | setTimeout(() => { 84 | submit(null, { method: "POST", action: "/auth/sign-out" }); 85 | }, 100); 86 | }} 87 | > 88 | 89 | Log out 90 | 91 |
    92 |
    93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { isbot } from "isbot"; 2 | import { renderToReadableStream } from "react-dom/server"; 3 | import type { 4 | AppLoadContext, 5 | EntryContext, 6 | HandleErrorFunction, 7 | } from "react-router"; 8 | import { ServerRouter } from "react-router"; 9 | import { NonceProvider } from "./hooks/use-nonce"; 10 | 11 | export default async function handleRequest( 12 | request: Request, 13 | responseStatusCode: number, 14 | responseHeaders: Headers, 15 | routerContext: EntryContext, 16 | _loadContext: AppLoadContext, 17 | ) { 18 | let shellRendered = false; 19 | const userAgent = request.headers.get("user-agent"); 20 | 21 | // Set a random nonce for CSP. 22 | const nonce = crypto.randomUUID() ?? undefined; 23 | 24 | // Set CSP headers to prevent 'Prop nonce did not match' error 25 | // Without this, browser security policy will clear the nonce attribute on the client side 26 | responseHeaders.set( 27 | "Content-Security-Policy", 28 | `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none';`, 29 | ); 30 | 31 | const body = await renderToReadableStream( 32 | 33 | 34 | , 35 | { 36 | onError(error: unknown) { 37 | responseStatusCode = 500; 38 | // Log streaming rendering errors from inside the shell. Don't log 39 | // errors encountered during initial shell rendering since they'll 40 | // reject and get logged in handleDocumentRequest. 41 | if (shellRendered) { 42 | console.error(error); 43 | } 44 | }, 45 | signal: request.signal, 46 | nonce, 47 | }, 48 | ); 49 | shellRendered = true; 50 | 51 | // Ensure requests from bots and SPA Mode renders wait for all content to load before responding 52 | // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation 53 | if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) { 54 | await body.allReady; 55 | } 56 | 57 | responseHeaders.set("Content-Type", "text/html"); 58 | return new Response(body, { 59 | headers: responseHeaders, 60 | status: responseStatusCode, 61 | }); 62 | } 63 | 64 | // Error Reporting 65 | // https://reactrouter.com/how-to/error-reporting 66 | export const handleError: HandleErrorFunction = (error, { request }) => { 67 | if (request.signal.aborted) { 68 | return; 69 | } 70 | 71 | if (error instanceof Error) { 72 | console.error(error.stack); 73 | } else { 74 | console.error(error); 75 | } 76 | }; 77 | -------------------------------------------------------------------------------- /app/hooks/use-auth-user.ts: -------------------------------------------------------------------------------- 1 | import { useRouteLoaderData } from "react-router"; 2 | import type { loader as authLayoutLoader } from "~/routes/layout"; 3 | 4 | export function useAuthUser() { 5 | const data = useRouteLoaderData("routes/layout"); 6 | if (!data) throw new Error("No user data found."); 7 | return { ...data }; 8 | } 9 | -------------------------------------------------------------------------------- /app/hooks/use-double-check.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { callAll } from "~/lib/utils"; 3 | 4 | export function useDoubleCheck() { 5 | const [doubleCheck, setDoubleCheck] = useState(false); 6 | const buttonRef = useRef(null); 7 | 8 | useEffect(() => { 9 | if (doubleCheck && buttonRef.current) { 10 | const handleClickOutside = (event: MouseEvent) => { 11 | if ( 12 | buttonRef.current && 13 | !buttonRef.current.contains(event.target as Node) 14 | ) { 15 | setDoubleCheck(false); 16 | } 17 | }; 18 | 19 | document.addEventListener("mousedown", handleClickOutside); 20 | return () => { 21 | document.removeEventListener("mousedown", handleClickOutside); 22 | }; 23 | } 24 | }, [doubleCheck]); 25 | 26 | function getButtonProps( 27 | props?: React.ButtonHTMLAttributes, 28 | ) { 29 | const onClick: React.ButtonHTMLAttributes["onClick"] = 30 | doubleCheck 31 | ? undefined 32 | : (e) => { 33 | e.preventDefault(); 34 | setDoubleCheck(true); 35 | }; 36 | 37 | const onKeyUp: React.ButtonHTMLAttributes["onKeyUp"] = ( 38 | e, 39 | ) => { 40 | if (e.key === "Escape") { 41 | setDoubleCheck(false); 42 | } 43 | }; 44 | 45 | return { 46 | ...props, 47 | onClick: callAll(onClick, props?.onClick), 48 | onKeyUp: callAll(onKeyUp, props?.onKeyUp), 49 | ref: buttonRef, 50 | }; 51 | } 52 | 53 | return { doubleCheck, setDoubleCheck, getButtonProps }; 54 | } 55 | -------------------------------------------------------------------------------- /app/hooks/use-hydrated.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This hook comes from https://github.com/sergiodxa/remix-utils 3 | */ 4 | import { useSyncExternalStore } from "react"; 5 | 6 | function subscribe() { 7 | return () => {}; 8 | } 9 | 10 | /** 11 | * Return a boolean indicating if the JS has been hydrated already. 12 | * When doing Server-Side Rendering, the result will always be false. 13 | * When doing Client-Side Rendering, the result will always be false on the 14 | * first render and true from then on. Even if a new component renders it will 15 | * always start with true. 16 | * 17 | * Example: Disable a button that needs JS to work. 18 | * ```tsx 19 | * let hydrated = useHydrated(); 20 | * return ( 21 | * 24 | * ); 25 | * ``` 26 | */ 27 | export function useHydrated() { 28 | return useSyncExternalStore( 29 | subscribe, 30 | () => true, 31 | () => false, 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /app/hooks/use-is-pending.ts: -------------------------------------------------------------------------------- 1 | import { useFormAction, useNavigation } from "react-router"; 2 | 3 | export function useIsPending({ 4 | formAction, 5 | formMethod = "POST", 6 | state = "non-idle", 7 | }: { 8 | formAction?: string; 9 | formMethod?: "POST" | "GET" | "PUT" | "PATCH" | "DELETE"; 10 | state?: "submitting" | "loading" | "non-idle"; 11 | } = {}) { 12 | const contextualFormAction = useFormAction(); 13 | const navigation = useNavigation(); 14 | const isPendingState = 15 | state === "non-idle" 16 | ? navigation.state !== "idle" 17 | : navigation.state === state; 18 | return ( 19 | isPendingState && 20 | navigation.formAction === (formAction ?? contextualFormAction) && 21 | navigation.formMethod === formMethod 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/hooks/use-nonce.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | 3 | export const NonceContext = createContext(""); 4 | 5 | export const NonceProvider = NonceContext.Provider; 6 | 7 | export const useNonce = () => useContext(NonceContext); 8 | -------------------------------------------------------------------------------- /app/lib/auth/auth.client.ts: -------------------------------------------------------------------------------- 1 | import { createAuthClient } from "better-auth/react"; 2 | 3 | export type AuthClient = ReturnType; 4 | export type AuthSession = AuthClient["$Infer"]["Session"]; 5 | 6 | export const authClient = createAuthClient(); 7 | -------------------------------------------------------------------------------- /app/lib/auth/auth.server.ts: -------------------------------------------------------------------------------- 1 | import { env } from "cloudflare:workers"; 2 | import { betterAuth } from "better-auth"; 3 | import { drizzleAdapter } from "better-auth/adapters/drizzle"; 4 | 5 | import { db } from "~/lib/database/db.server"; 6 | 7 | let _auth: ReturnType; 8 | 9 | export async function deleteUserImageFromR2(imageUrl: string | null) { 10 | if (!imageUrl) return; 11 | 12 | const isExternalUrl = 13 | imageUrl.startsWith("http://") || imageUrl.startsWith("https://"); 14 | 15 | if (!isExternalUrl) { 16 | let r2ObjectKey = imageUrl; 17 | const queryParamIndex = r2ObjectKey.indexOf("?"); // remove query params 18 | if (queryParamIndex !== -1) { 19 | r2ObjectKey = r2ObjectKey.substring(0, queryParamIndex); 20 | } 21 | if (r2ObjectKey) { 22 | await env.R2.delete(r2ObjectKey); 23 | } 24 | } 25 | } 26 | 27 | export function serverAuth() { 28 | if (!_auth) { 29 | _auth = betterAuth({ 30 | baseUrl: env.BETTER_AUTH_URL, 31 | trustedOrigins: [env.BETTER_AUTH_URL], 32 | database: drizzleAdapter(db, { 33 | provider: "sqlite", 34 | }), 35 | secondaryStorage: { 36 | get: async (key) => await env.APP_KV.get(`_auth:${key}`, "json"), 37 | set: async (key, value, ttl) => 38 | await env.APP_KV.put(`_auth:${key}`, JSON.stringify(value), { 39 | expirationTtl: ttl, 40 | }), 41 | delete: async (key) => await env.APP_KV.delete(`_auth:${key}`), 42 | }, 43 | emailAndPassword: { 44 | enabled: true, 45 | requireEmailVerification: true, 46 | sendResetPassword: async ({ user, url, token }) => { 47 | if (env.ENVIRONMENT === "development") { 48 | console.log("Send email to reset password"); 49 | console.log("User", user); 50 | console.log("URL", url); 51 | console.log("Token", token); 52 | } else { 53 | // Send email to user ... 54 | } 55 | }, 56 | }, 57 | emailVerification: { 58 | sendOnSignUp: true, 59 | autoSignInAfterVerification: true, 60 | sendVerificationEmail: async ({ user, url, token }) => { 61 | if (env.ENVIRONMENT === "development") { 62 | console.log("Send email to verify email address"); 63 | console.log(user, url, token); 64 | } else { 65 | // Send email to user ... 66 | } 67 | }, 68 | }, 69 | socialProviders: { 70 | github: { 71 | clientId: env.GITHUB_CLIENT_ID || "", 72 | clientSecret: env.GITHUB_CLIENT_SECRET || "", 73 | }, 74 | google: { 75 | clientId: env.GOOGLE_CLIENT_ID || "", 76 | clientSecret: env.GOOGLE_CLIENT_SECRET || "", 77 | }, 78 | }, 79 | account: { 80 | accountLinking: { 81 | enabled: true, 82 | allowDifferentEmails: true, 83 | trustedProviders: ["google", "github"], 84 | }, 85 | }, 86 | user: { 87 | deleteUser: { 88 | enabled: true, 89 | afterDelete: async (user) => { 90 | if (user.image) { 91 | await deleteUserImageFromR2(user.image); 92 | } 93 | }, 94 | }, 95 | }, 96 | rateLimit: { 97 | enabled: true, 98 | storage: "secondary-storage", 99 | window: 60, // time window in seconds 100 | max: 10, // max requests in the window 101 | }, 102 | advanced: { 103 | ipAddress: { 104 | ipAddressHeaders: [ 105 | "cf-connecting-ip", 106 | "x-forwarded-for", 107 | "x-real-ip", 108 | ], 109 | }, 110 | }, 111 | }); 112 | } 113 | 114 | return _auth; 115 | } 116 | -------------------------------------------------------------------------------- /app/lib/color-scheme/components.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Color scheme implementation based on React Router's official website solution 3 | * @see https://github.com/remix-run/react-router-website 4 | * 5 | * This component provides a complete color theme switching solution: 6 | * - Supports system/light/dark modes 7 | * - Includes client and server-side isomorphic rendering 8 | * - Uses Zod for type validation 9 | * - Responds to system theme changes 10 | */ 11 | 12 | import { useLayoutEffect, useMemo } from "react"; 13 | import { 14 | useLocation, 15 | useNavigation, 16 | useRouteLoaderData, 17 | useSubmit, 18 | } from "react-router"; 19 | import { z } from "zod"; 20 | import type { loader as rootLoader } from "~/root"; 21 | 22 | export const ColorSchemeSchema = z.object({ 23 | colorScheme: z.enum(["light", "dark", "system"]), 24 | returnTo: z.string().optional(), 25 | }); 26 | 27 | export type ColorScheme = z.infer["colorScheme"]; 28 | 29 | /** 30 | * This hook is used to get the color scheme from the fetcher or the root loader 31 | * @returns The color scheme 32 | */ 33 | export function useColorScheme(): ColorScheme { 34 | const rootLoaderData = useRouteLoaderData("root"); 35 | const rootColorScheme = rootLoaderData?.colorScheme ?? "system"; 36 | 37 | const { formData } = useNavigation(); 38 | const optimisticColorScheme = formData?.has("colorScheme") 39 | ? (formData.get("colorScheme") as ColorScheme) 40 | : null; 41 | return optimisticColorScheme || rootColorScheme; 42 | } 43 | 44 | /** 45 | * This hook is used to set the color scheme on the document element 46 | * @returns The submit function 47 | */ 48 | export function useSetColorScheme() { 49 | const location = useLocation(); 50 | const submit = useSubmit(); 51 | 52 | return (colorScheme: ColorScheme) => { 53 | submit( 54 | { 55 | colorScheme, 56 | returnTo: location.pathname + location.search, 57 | }, 58 | { 59 | method: "post", 60 | action: "/api/color-scheme", 61 | preventScrollReset: true, 62 | replace: true, 63 | }, 64 | ); 65 | }; 66 | } 67 | 68 | /** 69 | * This component is used to set the color scheme on the document element 70 | * @param nonce The nonce to use for the script 71 | * @returns The script element 72 | */ 73 | export function ColorSchemeScript({ nonce }: { nonce: string }) { 74 | const colorScheme = useColorScheme(); 75 | 76 | // biome-ignore lint/correctness/useExhaustiveDependencies: 77 | const script = useMemo( 78 | () => 79 | `let colorScheme = ${JSON.stringify(colorScheme)}; if (colorScheme === "system") { let media = window.matchMedia("(prefers-color-scheme: dark)"); if (media.matches) document.documentElement.classList.add("dark"); }`, 80 | [], 81 | // we don't want this script to ever change 82 | ); 83 | 84 | if (typeof document !== "undefined") { 85 | useLayoutEffect(() => { 86 | if (colorScheme === "light") { 87 | document.documentElement.classList.remove("dark"); 88 | } else if (colorScheme === "dark") { 89 | document.documentElement.classList.add("dark"); 90 | } else if (colorScheme === "system") { 91 | function check(media: MediaQueryList | MediaQueryListEvent) { 92 | if (media.matches) { 93 | document.documentElement.classList.add("dark"); 94 | } else { 95 | document.documentElement.classList.remove("dark"); 96 | } 97 | } 98 | 99 | const media = window.matchMedia("(prefers-color-scheme: dark)"); 100 | check(media); 101 | 102 | media.addEventListener("change", check); 103 | return () => media.removeEventListener("change", check); 104 | } else { 105 | console.error("Impossible color scheme state:", colorScheme); 106 | } 107 | }, [colorScheme]); 108 | } 109 | 110 | return ( 111 | <> 112 | 117 | 122 |