├── .dockerignore ├── .env.example ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── README.md ├── app ├── db.server.ts ├── entry.client.tsx ├── entry.server.tsx ├── models │ ├── note.server.ts │ └── user.server.ts ├── root.tsx ├── routes │ ├── healthcheck.tsx │ ├── index.tsx │ ├── join.tsx │ ├── login.tsx │ ├── logout.tsx │ ├── notes.tsx │ └── notes │ │ ├── $noteId.tsx │ │ ├── index.tsx │ │ └── new.tsx ├── session.server.ts ├── utils.test.ts └── utils.ts ├── cypress.json ├── cypress ├── .eslintrc.js ├── e2e │ └── smoke.ts ├── fixtures │ └── example.json ├── plugins │ └── index.ts ├── support │ ├── commands.ts │ ├── create-user.ts │ ├── delete-user.ts │ └── index.ts └── tsconfig.json ├── mocks ├── README.md ├── index.js └── start.ts ├── package-lock.json ├── package.json ├── prisma ├── migrations │ ├── 20220418211410_init │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── seed.ts ├── public └── favicon.ico ├── remix.config.js ├── remix.env.d.ts ├── remix.init ├── index.js ├── package-lock.json └── package.json ├── render.yaml ├── tailwind.config.js ├── test └── setup-test-env.ts ├── tsconfig.json └── vitest.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | *.log 3 | .DS_Store 4 | .env 5 | /.cache 6 | /public/build 7 | /build 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="postgres://postgres:password@localhost:5432/postgres" 2 | SESSION_SECRET="super-duper-s3cret" 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@types/eslint').Linter.BaseConfig} 3 | */ 4 | module.exports = { 5 | extends: [ 6 | "@remix-run/eslint-config", 7 | "@remix-run/eslint-config/node", 8 | "@remix-run/eslint-config/jest-testing-library", 9 | "prettier", 10 | ], 11 | // we're using vitest which has a very similar API to jest 12 | // (so the linting plugins work nicely), but it we have to explicitly 13 | // set the jest version. 14 | settings: { 15 | jest: { 16 | version: 27, 17 | }, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | .env 6 | 7 | /cypress/screenshots 8 | /cypress/videos 9 | /prisma/data.db 10 | /prisma/data.db-journal 11 | 12 | /app/styles/tailwind.css 13 | 14 | .vscode 15 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | .env 6 | 7 | /app/styles/tailwind.css 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remix Indie Stack (on Render) 2 | 3 | *This repository (and README) is based on Remix's [Indie Stack template](https://github.com/remix-run/indie-stack). It has been modified to be easily deployable to [Render](https://render.com).* 4 | 5 | ![The Remix Indie Stack](https://repository-images.githubusercontent.com/465928257/a241fa49-bd4d-485a-a2a5-5cb8e4ee0abf) 6 | 7 | ## What's in the stack 8 | 9 | - [Render app deployment](https://render.com) with a Node.js [Native Environment](https://render.com/docs/native-environments) 10 | - Production-ready, Render-managed [PostgreSQL database](https://render.com/docs/databases) 11 | - Healthcheck endpoint for [zero downtime deploys](https://render.com/docs/deploys#zero-downtime-deploys) 12 | - Email/Password Authentication with [cookie-based sessions](https://remix.run/docs/en/v1/api/remix#createcookiesessionstorage) 13 | - Database ORM with [Prisma](https://prisma.io) 14 | - Styling with [Tailwind](https://tailwindcss.com/) 15 | - End-to-end testing with [Cypress](https://cypress.io) 16 | - Local third party request mocking with [MSW](https://mswjs.io) 17 | - Unit testing with [Vitest](https://vitest.dev) and [Testing Library](https://testing-library.com) 18 | - Code formatting with [Prettier](https://prettier.io) 19 | - Linting with [ESLint](https://eslint.org) 20 | - Static Types with [TypeScript](https://typescriptlang.org) 21 | 22 | ## Development 23 | 24 | - Initial setup: _If you just generated this project, this step has been done for you._ 25 | 26 | ```sh 27 | npm run setup 28 | ``` 29 | 30 | - Start dev server: 31 | 32 | ```sh 33 | npm run dev 34 | ``` 35 | 36 | This starts your app in development mode, rebuilding assets on file changes. 37 | 38 | The database seed script creates a new user with some data you can use to get started: 39 | 40 | - Email: `rachel@remix.run` 41 | - Password: `racheliscool` 42 | 43 | ### Relevant code: 44 | 45 | This is a pretty simple note-taking app, but it's a good example of how you can build a full stack app with Prisma and Remix. The main functionality is creating users, logging in and out, and creating and deleting notes. 46 | 47 | - creating users, and logging in and out [./app/models/user.server.ts](./app/models/user.server.ts) 48 | - user sessions, and verifying them [./app/session.server.ts](./app/session.server.ts) 49 | - creating, and deleting notes [./app/models/note.server.ts](./app/models/note.server.ts) 50 | 51 | ## Deployment 52 | 53 | *It's free to deploy this example to Render, including a managed PostgreSQL database.* 54 | 55 | 1. Click the **Use this template** to create a copy of this repository in your GitHub account. 56 | 2. In the [Render Dashboard](https://dashboard.render.com), click **New** --> **Blueprint** and select your copy of this repository. You may need to connect your GitHub account to Render if you haven't already done so. 57 | 3. Give your **Service Group** a name and click **Apply**. 58 | 4. When the database and service have been created, open your service's `.onrender.com` URL in a browser to see your Remix app. 59 | 60 | See the Render [Remix Quickstart page](https://render.com/docs/deploy-remix) for more details. 61 | 62 | ### Connecting to your database 63 | 64 | A PostgreSQL database (free for 90 days) is created automatically when you deploy the `render.yaml` at the root of this repository as a [Blueprint](https://render.com/docs/infrastructure-as-code). Using `psql`, you can connect to it using the web shell of your Remix service or [SSH directly from your development machine](https://render.com/docs/ssh). 65 | 66 | ## Testing 67 | 68 | ### Cypress 69 | 70 | This project uses Cypress for our End-to-End tests in this project. You'll find those in the `cypress` directory. As you make changes, add to an existing file or create a new file in the `cypress/e2e` directory to test your changes. 71 | 72 | The project uses [`@testing-library/cypress`](https://testing-library.com/cypress) for selecting elements on the page semantically. 73 | 74 | To run these tests in development, run `npm run test:e2e:dev` which will start the dev server for the app as well as the Cypress client. Make sure the database is running in docker as described above. 75 | 76 | There is a utility for testing authenticated features without having to go through the login flow: 77 | 78 | ```ts 79 | cy.login(); 80 | // you are now logged in as a new user 81 | ``` 82 | 83 | The project also has a utility to auto-delete the user at the end of your test. Just make sure to add this in each test file: 84 | 85 | ```ts 86 | afterEach(() => { 87 | cy.cleanupUser(); 88 | }); 89 | ``` 90 | 91 | That way, you can keep your local db clean and keep your tests isolated from one another. 92 | 93 | ### Vitest 94 | 95 | For lower level tests of utilities and individual components, the project uses `vitest`. There are DOM-specific assertion helpers via [`@testing-library/jest-dom`](https://testing-library.com/jest-dom). 96 | 97 | ### Type Checking 98 | 99 | This project uses TypeScript. It's recommended to get TypeScript set up for your editor to get a really great in-editor experience with type checking and auto-complete. To run type checking across the whole project, run `npm run typecheck`. 100 | 101 | ### Linting 102 | 103 | This project uses ESLint for linting. That is configured in `.eslintrc.js`. 104 | 105 | ### Formatting 106 | 107 | The project uses [Prettier](https://prettier.io/) for auto-formatting in this project. It's recommended to install an editor plugin (like the [VSCode Prettier plugin](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)) to get auto-formatting on save. There's also a `npm run format` script you can run to format all files in the project. 108 | 109 | ## Credit 110 | 111 | - Remix team for creating an innovative new project and for creating this repository's foundation with their [Indie Stacks](https://remix.run/stacks). 112 | - [TerribleDev](https://github.com/TerribleDev) for the [inspiration and idea](https://github.com/TerribleDev/remix-render) for this Render Quickstart repository. 113 | -------------------------------------------------------------------------------- /app/db.server.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | let prisma: PrismaClient; 4 | 5 | declare global { 6 | var __db__: PrismaClient; 7 | } 8 | 9 | // this is needed because in development we don't want to restart 10 | // the server with every change, but we want to make sure we don't 11 | // create a new connection to the DB with every change either. 12 | // in production we'll have a single connection to the DB. 13 | if (process.env.NODE_ENV === "production") { 14 | prisma = new PrismaClient(); 15 | } else { 16 | if (!global.__db__) { 17 | global.__db__ = new PrismaClient(); 18 | } 19 | prisma = global.__db__; 20 | prisma.$connect(); 21 | } 22 | 23 | export { prisma }; 24 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from "@remix-run/react"; 2 | import { hydrate } from "react-dom"; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import type { EntryContext } from "@remix-run/node"; 2 | import { RemixServer } from "@remix-run/react"; 3 | import { renderToString } from "react-dom/server"; 4 | 5 | export default function handleRequest( 6 | request: Request, 7 | responseStatusCode: number, 8 | responseHeaders: Headers, 9 | remixContext: EntryContext 10 | ) { 11 | const markup = renderToString( 12 | 13 | ); 14 | 15 | responseHeaders.set("Content-Type", "text/html"); 16 | 17 | return new Response("" + markup, { 18 | status: responseStatusCode, 19 | headers: responseHeaders, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /app/models/note.server.ts: -------------------------------------------------------------------------------- 1 | import type { User, Note } from "@prisma/client"; 2 | 3 | import { prisma } from "~/db.server"; 4 | 5 | export type { Note } from "@prisma/client"; 6 | 7 | export function getNote({ 8 | id, 9 | userId, 10 | }: Pick & { 11 | userId: User["id"]; 12 | }) { 13 | return prisma.note.findFirst({ 14 | where: { id, userId }, 15 | }); 16 | } 17 | 18 | export function getNoteListItems({ userId }: { userId: User["id"] }) { 19 | return prisma.note.findMany({ 20 | where: { userId }, 21 | select: { id: true, title: true }, 22 | orderBy: { updatedAt: "desc" }, 23 | }); 24 | } 25 | 26 | export function createNote({ 27 | body, 28 | title, 29 | userId, 30 | }: Pick & { 31 | userId: User["id"]; 32 | }) { 33 | return prisma.note.create({ 34 | data: { 35 | title, 36 | body, 37 | user: { 38 | connect: { 39 | id: userId, 40 | }, 41 | }, 42 | }, 43 | }); 44 | } 45 | 46 | export function deleteNote({ 47 | id, 48 | userId, 49 | }: Pick & { userId: User["id"] }) { 50 | return prisma.note.deleteMany({ 51 | where: { id, userId }, 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /app/models/user.server.ts: -------------------------------------------------------------------------------- 1 | import type { Password, User } from "@prisma/client"; 2 | import bcrypt from "bcryptjs"; 3 | 4 | import { prisma } from "~/db.server"; 5 | 6 | export type { User } from "@prisma/client"; 7 | 8 | export async function getUserById(id: User["id"]) { 9 | return prisma.user.findUnique({ where: { id } }); 10 | } 11 | 12 | export async function getUserByEmail(email: User["email"]) { 13 | return prisma.user.findUnique({ where: { email } }); 14 | } 15 | 16 | export async function createUser(email: User["email"], password: string) { 17 | const hashedPassword = await bcrypt.hash(password, 10); 18 | 19 | return prisma.user.create({ 20 | data: { 21 | email, 22 | password: { 23 | create: { 24 | hash: hashedPassword, 25 | }, 26 | }, 27 | }, 28 | }); 29 | } 30 | 31 | export async function deleteUserByEmail(email: User["email"]) { 32 | return prisma.user.delete({ where: { email } }); 33 | } 34 | 35 | export async function verifyLogin( 36 | email: User["email"], 37 | password: Password["hash"] 38 | ) { 39 | const userWithPassword = await prisma.user.findUnique({ 40 | where: { email }, 41 | include: { 42 | password: true, 43 | }, 44 | }); 45 | 46 | if (!userWithPassword || !userWithPassword.password) { 47 | return null; 48 | } 49 | 50 | const isValid = await bcrypt.compare( 51 | password, 52 | userWithPassword.password.hash 53 | ); 54 | 55 | if (!isValid) { 56 | return null; 57 | } 58 | 59 | const { password: _password, ...userWithoutPassword } = userWithPassword; 60 | 61 | return userWithoutPassword; 62 | } 63 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | LinksFunction, 3 | LoaderFunction, 4 | MetaFunction, 5 | } from "@remix-run/node"; 6 | import { json } from "@remix-run/node"; 7 | import { 8 | Links, 9 | LiveReload, 10 | Meta, 11 | Outlet, 12 | Scripts, 13 | ScrollRestoration, 14 | } from "@remix-run/react"; 15 | 16 | import tailwindStylesheetUrl from "./styles/tailwind.css"; 17 | import { getUser } from "./session.server"; 18 | 19 | export const links: LinksFunction = () => { 20 | return [{ rel: "stylesheet", href: tailwindStylesheetUrl }]; 21 | }; 22 | 23 | export const meta: MetaFunction = () => ({ 24 | charset: "utf-8", 25 | title: "Remix Notes", 26 | viewport: "width=device-width,initial-scale=1", 27 | }); 28 | 29 | type LoaderData = { 30 | user: Awaited>; 31 | }; 32 | 33 | export const loader: LoaderFunction = async ({ request }) => { 34 | return json({ 35 | user: await getUser(request), 36 | }); 37 | }; 38 | 39 | export default function App() { 40 | return ( 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /app/routes/healthcheck.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction } from "@remix-run/node"; 2 | 3 | import { prisma } from "~/db.server"; 4 | 5 | export const loader: LoaderFunction = async ({ request }) => { 6 | try { 7 | const url = new URL(`http://localhost:${process.env.PORT ?? 3000}/`); 8 | // if we can connect to the database and make a simple query 9 | // and make a HEAD request to ourselves, then we're good. 10 | await Promise.all([ 11 | prisma.user.count(), 12 | fetch(url.toString(), { method: "HEAD" }).then((r) => { 13 | if (!r.ok) return Promise.reject(r); 14 | }), 15 | ]); 16 | return new Response("OK"); 17 | } catch (error: unknown) { 18 | console.log("healthcheck ❌", { error }); 19 | return new Response("ERROR", { status: 500 }); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@remix-run/react"; 2 | 3 | import { useOptionalUser } from "~/utils"; 4 | 5 | export default function Index() { 6 | const user = useOptionalUser(); 7 | return ( 8 |
9 |
10 |
11 |
12 |
13 | Sonic Youth On Stage 18 |
19 |
20 |
21 |

22 | 23 | Indie Stack 24 | 25 |

26 |

27 | Check the README.md file for instructions on how to get this 28 | project deployed. 29 |

30 |
31 | {user ? ( 32 | 36 | View Notes for {user.email} 37 | 38 | ) : ( 39 |
40 | 44 | Sign up 45 | 46 | 50 | Log In 51 | 52 |
53 | )} 54 |
55 | 56 | Remix 61 | 62 |
63 |
64 |
65 | 66 |
67 |
68 | {[ 69 | { 70 | src: "https://user-images.githubusercontent.com/1500684/157764484-ad64a21a-d7fb-47e3-8669-ec046da20c1f.svg", 71 | alt: "Prisma", 72 | href: "https://prisma.io", 73 | }, 74 | { 75 | src: "https://user-images.githubusercontent.com/1500684/157764276-a516a239-e377-4a20-b44a-0ac7b65c8c14.svg", 76 | alt: "Tailwind", 77 | href: "https://tailwindcss.com", 78 | }, 79 | { 80 | src: "https://user-images.githubusercontent.com/1500684/157764454-48ac8c71-a2a9-4b5e-b19c-edef8b8953d6.svg", 81 | alt: "Cypress", 82 | href: "https://www.cypress.io", 83 | }, 84 | { 85 | src: "https://user-images.githubusercontent.com/1500684/157772386-75444196-0604-4340-af28-53b236faa182.svg", 86 | alt: "MSW", 87 | href: "https://mswjs.io", 88 | }, 89 | { 90 | src: "https://user-images.githubusercontent.com/1500684/157772447-00fccdce-9d12-46a3-8bb4-fac612cdc949.svg", 91 | alt: "Vitest", 92 | href: "https://vitest.dev", 93 | }, 94 | { 95 | src: "https://user-images.githubusercontent.com/1500684/157772662-92b0dd3a-453f-4d18-b8be-9fa6efde52cf.png", 96 | alt: "Testing Library", 97 | href: "https://testing-library.com", 98 | }, 99 | { 100 | src: "https://user-images.githubusercontent.com/1500684/157772934-ce0a943d-e9d0-40f8-97f3-f464c0811643.svg", 101 | alt: "Prettier", 102 | href: "https://prettier.io", 103 | }, 104 | { 105 | src: "https://user-images.githubusercontent.com/1500684/157772990-3968ff7c-b551-4c55-a25c-046a32709a8e.svg", 106 | alt: "ESLint", 107 | href: "https://eslint.org", 108 | }, 109 | { 110 | src: "https://user-images.githubusercontent.com/1500684/157773063-20a0ed64-b9f8-4e0b-9d1e-0b65a3d4a6db.svg", 111 | alt: "TypeScript", 112 | href: "https://typescriptlang.org", 113 | }, 114 | ].map((img) => ( 115 | 120 | {img.alt} 121 | 122 | ))} 123 |
124 |
125 |
126 |
127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /app/routes/join.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | ActionFunction, 3 | LoaderFunction, 4 | MetaFunction, 5 | } from "@remix-run/node"; 6 | import { json, redirect } from "@remix-run/node"; 7 | import { Form, Link, useActionData, useSearchParams } from "@remix-run/react"; 8 | import * as React from "react"; 9 | 10 | import { getUserId, createUserSession } from "~/session.server"; 11 | 12 | import { createUser, getUserByEmail } from "~/models/user.server"; 13 | import { validateEmail } from "~/utils"; 14 | 15 | export const loader: LoaderFunction = async ({ request }) => { 16 | const userId = await getUserId(request); 17 | if (userId) return redirect("/"); 18 | return json({}); 19 | }; 20 | 21 | interface ActionData { 22 | errors: { 23 | email?: string; 24 | password?: string; 25 | }; 26 | } 27 | 28 | export const action: ActionFunction = async ({ request }) => { 29 | const formData = await request.formData(); 30 | const email = formData.get("email"); 31 | const password = formData.get("password"); 32 | const redirectTo = formData.get("redirectTo"); 33 | 34 | if (!validateEmail(email)) { 35 | return json( 36 | { errors: { email: "Email is invalid" } }, 37 | { status: 400 } 38 | ); 39 | } 40 | 41 | if (typeof password !== "string") { 42 | return json( 43 | { errors: { password: "Password is required" } }, 44 | { status: 400 } 45 | ); 46 | } 47 | 48 | if (password.length < 8) { 49 | return json( 50 | { errors: { password: "Password is too short" } }, 51 | { status: 400 } 52 | ); 53 | } 54 | 55 | const existingUser = await getUserByEmail(email); 56 | if (existingUser) { 57 | return json( 58 | { errors: { email: "A user already exists with this email" } }, 59 | { status: 400 } 60 | ); 61 | } 62 | 63 | const user = await createUser(email, password); 64 | 65 | return createUserSession({ 66 | request, 67 | userId: user.id, 68 | remember: false, 69 | redirectTo: typeof redirectTo === "string" ? redirectTo : "/", 70 | }); 71 | }; 72 | 73 | export const meta: MetaFunction = () => { 74 | return { 75 | title: "Sign Up", 76 | }; 77 | }; 78 | 79 | export default function Join() { 80 | const [searchParams] = useSearchParams(); 81 | const redirectTo = searchParams.get("redirectTo") ?? undefined; 82 | const actionData = useActionData() as ActionData; 83 | const emailRef = React.useRef(null); 84 | const passwordRef = React.useRef(null); 85 | 86 | React.useEffect(() => { 87 | if (actionData?.errors?.email) { 88 | emailRef.current?.focus(); 89 | } else if (actionData?.errors?.password) { 90 | passwordRef.current?.focus(); 91 | } 92 | }, [actionData]); 93 | 94 | return ( 95 |
96 |
97 |
98 |
99 | 105 |
106 | 118 | {actionData?.errors?.email && ( 119 |
120 | {actionData.errors.email} 121 |
122 | )} 123 |
124 |
125 | 126 |
127 | 133 |
134 | 144 | {actionData?.errors?.password && ( 145 |
146 | {actionData.errors.password} 147 |
148 | )} 149 |
150 |
151 | 152 | 153 | 159 |
160 |
161 | Already have an account?{" "} 162 | 169 | Log in 170 | 171 |
172 |
173 |
174 |
175 |
176 | ); 177 | } 178 | -------------------------------------------------------------------------------- /app/routes/login.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | ActionFunction, 3 | LoaderFunction, 4 | MetaFunction, 5 | } from "@remix-run/node"; 6 | import { json, redirect } from "@remix-run/node"; 7 | import { Form, Link, useActionData, useSearchParams } from "@remix-run/react"; 8 | import * as React from "react"; 9 | 10 | import { createUserSession, getUserId } from "~/session.server"; 11 | import { verifyLogin } from "~/models/user.server"; 12 | import { validateEmail } from "~/utils"; 13 | 14 | export const loader: LoaderFunction = async ({ request }) => { 15 | const userId = await getUserId(request); 16 | if (userId) return redirect("/"); 17 | return json({}); 18 | }; 19 | 20 | interface ActionData { 21 | errors?: { 22 | email?: string; 23 | password?: string; 24 | }; 25 | } 26 | 27 | export const action: ActionFunction = async ({ request }) => { 28 | const formData = await request.formData(); 29 | const email = formData.get("email"); 30 | const password = formData.get("password"); 31 | const redirectTo = formData.get("redirectTo"); 32 | const remember = formData.get("remember"); 33 | 34 | if (!validateEmail(email)) { 35 | return json( 36 | { errors: { email: "Email is invalid" } }, 37 | { status: 400 } 38 | ); 39 | } 40 | 41 | if (typeof password !== "string") { 42 | return json( 43 | { errors: { password: "Password is required" } }, 44 | { status: 400 } 45 | ); 46 | } 47 | 48 | if (password.length < 8) { 49 | return json( 50 | { errors: { password: "Password is too short" } }, 51 | { status: 400 } 52 | ); 53 | } 54 | 55 | const user = await verifyLogin(email, password); 56 | 57 | if (!user) { 58 | return json( 59 | { errors: { email: "Invalid email or password" } }, 60 | { status: 400 } 61 | ); 62 | } 63 | 64 | return createUserSession({ 65 | request, 66 | userId: user.id, 67 | remember: remember === "on" ? true : false, 68 | redirectTo: typeof redirectTo === "string" ? redirectTo : "/notes", 69 | }); 70 | }; 71 | 72 | export const meta: MetaFunction = () => { 73 | return { 74 | title: "Login", 75 | }; 76 | }; 77 | 78 | export default function LoginPage() { 79 | const [searchParams] = useSearchParams(); 80 | const redirectTo = searchParams.get("redirectTo") || "/notes"; 81 | const actionData = useActionData() as ActionData; 82 | const emailRef = React.useRef(null); 83 | const passwordRef = React.useRef(null); 84 | 85 | React.useEffect(() => { 86 | if (actionData?.errors?.email) { 87 | emailRef.current?.focus(); 88 | } else if (actionData?.errors?.password) { 89 | passwordRef.current?.focus(); 90 | } 91 | }, [actionData]); 92 | 93 | return ( 94 |
95 |
96 |
97 |
98 | 104 |
105 | 117 | {actionData?.errors?.email && ( 118 |
119 | {actionData.errors.email} 120 |
121 | )} 122 |
123 |
124 | 125 |
126 | 132 |
133 | 143 | {actionData?.errors?.password && ( 144 |
145 | {actionData.errors.password} 146 |
147 | )} 148 |
149 |
150 | 151 | 152 | 158 |
159 |
160 | 166 | 172 |
173 |
174 | Don't have an account?{" "} 175 | 182 | Sign up 183 | 184 |
185 |
186 |
187 |
188 |
189 | ); 190 | } 191 | -------------------------------------------------------------------------------- /app/routes/logout.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionFunction, LoaderFunction } from "@remix-run/node"; 2 | import { redirect } from "@remix-run/node"; 3 | 4 | import { logout } from "~/session.server"; 5 | 6 | export const action: ActionFunction = async ({ request }) => { 7 | return logout(request); 8 | }; 9 | 10 | export const loader: LoaderFunction = async () => { 11 | return redirect("/"); 12 | }; 13 | -------------------------------------------------------------------------------- /app/routes/notes.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction } from "@remix-run/node"; 2 | import { json } from "@remix-run/node"; 3 | import { Form, Link, NavLink, Outlet, useLoaderData } from "@remix-run/react"; 4 | 5 | import { requireUserId } from "~/session.server"; 6 | import { useUser } from "~/utils"; 7 | import { getNoteListItems } from "~/models/note.server"; 8 | 9 | type LoaderData = { 10 | noteListItems: Awaited>; 11 | }; 12 | 13 | export const loader: LoaderFunction = async ({ request }) => { 14 | const userId = await requireUserId(request); 15 | const noteListItems = await getNoteListItems({ userId }); 16 | return json({ noteListItems }); 17 | }; 18 | 19 | export default function NotesPage() { 20 | const data = useLoaderData() as LoaderData; 21 | const user = useUser(); 22 | 23 | return ( 24 |
25 |
26 |

27 | Notes 28 |

29 |

{user.email}

30 |
31 | 37 |
38 |
39 | 40 |
41 |
42 | 43 | + New Note 44 | 45 | 46 |
47 | 48 | {data.noteListItems.length === 0 ? ( 49 |

No notes yet

50 | ) : ( 51 |
    52 | {data.noteListItems.map((note) => ( 53 |
  1. 54 | 56 | `block border-b p-4 text-xl ${isActive ? "bg-white" : ""}` 57 | } 58 | to={note.id} 59 | > 60 | 📝 {note.title} 61 | 62 |
  2. 63 | ))} 64 |
65 | )} 66 |
67 | 68 |
69 | 70 |
71 |
72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /app/routes/notes/$noteId.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionFunction, LoaderFunction } from "@remix-run/node"; 2 | import { json, redirect } from "@remix-run/node"; 3 | import { Form, useCatch, useLoaderData } from "@remix-run/react"; 4 | import invariant from "tiny-invariant"; 5 | 6 | import type { Note } from "~/models/note.server"; 7 | import { deleteNote } from "~/models/note.server"; 8 | import { getNote } from "~/models/note.server"; 9 | import { requireUserId } from "~/session.server"; 10 | 11 | type LoaderData = { 12 | note: Note; 13 | }; 14 | 15 | export const loader: LoaderFunction = async ({ request, params }) => { 16 | const userId = await requireUserId(request); 17 | invariant(params.noteId, "noteId not found"); 18 | 19 | const note = await getNote({ userId, id: params.noteId }); 20 | if (!note) { 21 | throw new Response("Not Found", { status: 404 }); 22 | } 23 | return json({ note }); 24 | }; 25 | 26 | export const action: ActionFunction = async ({ request, params }) => { 27 | const userId = await requireUserId(request); 28 | invariant(params.noteId, "noteId not found"); 29 | 30 | await deleteNote({ userId, id: params.noteId }); 31 | 32 | return redirect("/notes"); 33 | }; 34 | 35 | export default function NoteDetailsPage() { 36 | const data = useLoaderData() as LoaderData; 37 | 38 | return ( 39 |
40 |

{data.note.title}

41 |

{data.note.body}

42 |
43 |
44 | 50 |
51 |
52 | ); 53 | } 54 | 55 | export function ErrorBoundary({ error }: { error: Error }) { 56 | console.error(error); 57 | 58 | return
An unexpected error occurred: {error.message}
; 59 | } 60 | 61 | export function CatchBoundary() { 62 | const caught = useCatch(); 63 | 64 | if (caught.status === 404) { 65 | return
Note not found
; 66 | } 67 | 68 | throw new Error(`Unexpected caught response with status: ${caught.status}`); 69 | } 70 | -------------------------------------------------------------------------------- /app/routes/notes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@remix-run/react"; 2 | 3 | export default function NoteIndexPage() { 4 | return ( 5 |

6 | No note selected. Select a note on the left, or{" "} 7 | 8 | create a new note. 9 | 10 |

11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /app/routes/notes/new.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionFunction } from "@remix-run/node"; 2 | import { json, redirect } from "@remix-run/node"; 3 | import { Form, useActionData } from "@remix-run/react"; 4 | import * as React from "react"; 5 | 6 | import { createNote } from "~/models/note.server"; 7 | import { requireUserId } from "~/session.server"; 8 | 9 | type ActionData = { 10 | errors?: { 11 | title?: string; 12 | body?: string; 13 | }; 14 | }; 15 | 16 | export const action: ActionFunction = async ({ request }) => { 17 | const userId = await requireUserId(request); 18 | 19 | const formData = await request.formData(); 20 | const title = formData.get("title"); 21 | const body = formData.get("body"); 22 | 23 | if (typeof title !== "string" || title.length === 0) { 24 | return json( 25 | { errors: { title: "Title is required" } }, 26 | { status: 400 } 27 | ); 28 | } 29 | 30 | if (typeof body !== "string" || body.length === 0) { 31 | return json( 32 | { errors: { body: "Body is required" } }, 33 | { status: 400 } 34 | ); 35 | } 36 | 37 | const note = await createNote({ title, body, userId }); 38 | 39 | return redirect(`/notes/${note.id}`); 40 | }; 41 | 42 | export default function NewNotePage() { 43 | const actionData = useActionData() as ActionData; 44 | const titleRef = React.useRef(null); 45 | const bodyRef = React.useRef(null); 46 | 47 | React.useEffect(() => { 48 | if (actionData?.errors?.title) { 49 | titleRef.current?.focus(); 50 | } else if (actionData?.errors?.body) { 51 | bodyRef.current?.focus(); 52 | } 53 | }, [actionData]); 54 | 55 | return ( 56 |
65 |
66 | 78 | {actionData?.errors?.title && ( 79 |
80 | {actionData.errors.title} 81 |
82 | )} 83 |
84 | 85 |
86 |