├── .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 |
17 |
18 | Home
19 |
20 |
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 |
80 |
81 |
90 |
91 | {isUploading ? (
92 |
93 |
94 |
95 |
96 |
97 | ) : (
98 |
99 |
100 | {previewUrl ? (
101 | <>
102 |
108 | Change
109 |
110 |
116 | Delete
117 |
118 | >
119 | ) : (
120 |
126 | Add avatar
127 |
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 |
40 | {getIcon()}
41 |
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 |
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 && }
63 |
69 | {errorId ? : null}
70 |
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 &&
}
89 |
90 |
98 | setIsVisible(!isVisible)}
103 | className="absolute inset-y-0 right-0 flex h-full items-center justify-center pr-3 text-muted-foreground/80 hover:bg-transparent"
104 | aria-label={isVisible ? "Hide password" : "Show password"}
105 | tabIndex={-1}
106 | >
107 | {isVisible ? (
108 |
109 | ) : (
110 |
111 | )}
112 |
113 |
114 | {errorId ?
: null}
115 |
116 | );
117 | }
118 |
119 | export function LoadingButton({
120 | buttonText,
121 | loadingText,
122 | isPending,
123 | className = "",
124 | ...props
125 | }: LoadingButtonProps) {
126 | return (
127 |
128 | {isPending ? (
129 | <>
130 | {loadingText}
131 | >
132 | ) : (
133 | buttonText
134 | )}
135 |
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 |
28 | Delete
29 |
30 |
31 |
32 |
33 |
37 |
38 |
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 |
64 | Cancel
65 |
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 |
44 | Change Password
45 |
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 |
91 | Cancel
92 |
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 | Sign out of other sessions
36 |
37 |
38 |
39 |
43 |
44 |
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 |
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 |
22 | {
28 | setIsChecked(!isChecked);
29 | fetcher.submit(
30 | {
31 | intent: "toggle",
32 | id,
33 | },
34 | { method: "POST", preventScrollReset: true },
35 | );
36 | }}
37 | />
38 |
43 | {todo.title}
44 |
45 |
46 | {
53 | fetcher.submit(
54 | {
55 | intent: "delete",
56 | id,
57 | },
58 | { method: "POST", preventScrollReset: true },
59 | );
60 | }}
61 | >
62 |
63 |
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 |
35 |
36 |
37 |
38 | {initials}
39 |
40 |
41 |
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 | *
22 | * Click me
23 | *
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 |