├── .dockerignore ├── .env.example ├── .eslintrc.js ├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── .prettierignore ├── Dockerfile ├── 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 ├── docker-compose.yml ├── k3s ├── 0_namespace.yml ├── 1_deployment.yml ├── 2_service.yml └── 3_ingress.yml ├── mocks ├── README.md ├── index.js └── start.ts ├── other └── pm2.config.js ├── package-lock.json ├── package.json ├── prisma ├── migrations │ ├── 20220224172159_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 ├── server.ts ├── 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="postgresql://postgres:postgres@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", 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 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Deploy 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - dev 7 | pull_request: {} 8 | 9 | jobs: 10 | lint: 11 | name: ⬣ ESLint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: 🛑 Cancel Previous Runs 15 | uses: styfle/cancel-workflow-action@0.9.1 16 | 17 | - name: ⬇️ Checkout repo 18 | uses: actions/checkout@v3 19 | 20 | - name: ⎔ Setup node 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: 16 24 | 25 | - name: 📥 Download deps 26 | uses: bahmutov/npm-install@v1 27 | 28 | - name: 🔬 Lint 29 | run: npm run lint 30 | 31 | typecheck: 32 | name: ʦ TypeScript 33 | runs-on: ubuntu-latest 34 | steps: 35 | - name: 🛑 Cancel Previous Runs 36 | uses: styfle/cancel-workflow-action@0.9.1 37 | 38 | - name: ⬇️ Checkout repo 39 | uses: actions/checkout@v3 40 | 41 | - name: ⎔ Setup node 42 | uses: actions/setup-node@v3 43 | with: 44 | node-version: 16 45 | 46 | - name: 📥 Download deps 47 | uses: bahmutov/npm-install@v1 48 | 49 | - name: 🔎 Type check 50 | run: npm run typecheck --if-present 51 | 52 | vitest: 53 | name: ⚡ Vitest 54 | runs-on: ubuntu-latest 55 | steps: 56 | - name: 🛑 Cancel Previous Runs 57 | uses: styfle/cancel-workflow-action@0.9.1 58 | 59 | - name: ⬇️ Checkout repo 60 | uses: actions/checkout@v3 61 | 62 | - name: ⎔ Setup node 63 | uses: actions/setup-node@v3 64 | with: 65 | node-version: 16 66 | 67 | - name: 📥 Download deps 68 | uses: bahmutov/npm-install@v1 69 | 70 | - name: ⚡ Run vitest 71 | run: npm run test -- --coverage 72 | 73 | cypress: 74 | name: ⚫️ Cypress 75 | runs-on: ubuntu-latest 76 | steps: 77 | - name: 🛑 Cancel Previous Runs 78 | uses: styfle/cancel-workflow-action@0.9.1 79 | 80 | - name: ⬇️ Checkout repo 81 | uses: actions/checkout@v3 82 | 83 | - name: 🏄 Copy test env vars 84 | run: cp .env.example .env 85 | 86 | - name: ⎔ Setup node 87 | uses: actions/setup-node@v3 88 | with: 89 | node-version: 16 90 | 91 | - name: 📥 Download deps 92 | uses: bahmutov/npm-install@v1 93 | 94 | - name: 🐳 Docker compose 95 | # the sleep is just there to give time for postgres to get started 96 | run: docker-compose up -d && sleep 3 97 | env: 98 | DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/postgres" 99 | 100 | - name: 🛠 Setup Database 101 | run: npx prisma migrate reset --force 102 | 103 | - name: 🌱 Seed the Database 104 | run: npx prisma db seed 105 | 106 | - name: ⚙️ Build 107 | run: npm run build 108 | 109 | - name: 🌳 Cypress run 110 | uses: cypress-io/github-action@v3 111 | with: 112 | start: npm run start:mocks 113 | wait-on: "http://localhost:8811" 114 | env: 115 | PORT: "8811" 116 | 117 | build: 118 | name: 🐳 Build 119 | # only build/deploy main branch on pushes 120 | if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }} 121 | runs-on: ubuntu-latest 122 | steps: 123 | - name: 🛑 Cancel Previous Runs 124 | uses: styfle/cancel-workflow-action@0.9.1 125 | 126 | - name: ⬇️ Checkout repo 127 | uses: actions/checkout@v3 128 | 129 | - name: 🐳 Set up Docker Buildx 130 | uses: docker/setup-buildx-action@v1 131 | 132 | # Setup cache 133 | - name: ⚡️ Cache Docker layers 134 | uses: actions/cache@v2 135 | with: 136 | path: /tmp/.buildx-cache 137 | key: ${{ runner.os }}-buildx-${{ github.sha }} 138 | restore-keys: | 139 | ${{ runner.os }}-buildx- 140 | 141 | - name: 🔑 Container Registry Auth 142 | uses: docker/login-action@v1 143 | with: 144 | registry: ${{ secrets.CONTAINER_REGISTRY_HOST }} 145 | username: ${{ secrets.CONTAINER_REGISTRY_USER }} 146 | password: ${{ secrets.CONTAINER_REGISTRY_PASS }} 147 | 148 | - name: 🐳 Docker build 149 | uses: docker/build-push-action@v2 150 | with: 151 | context: . 152 | push: true 153 | tags: ${{ secrets.CONTAINER_REGISTRY_HOST }}/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }} 154 | build-args: | 155 | COMMIT_SHA=${{ github.sha }} 156 | cache-from: type=local,src=/tmp/.buildx-cache 157 | cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new 158 | 159 | # This ugly bit is necessary if you don't want your cache to grow forever 160 | # till it hits GitHub's limit of 5GB. 161 | # Temp fix 162 | # https://github.com/docker/build-push-action/issues/252 163 | # https://github.com/moby/buildkit/issues/1896 164 | - name: 🚚 Move cache 165 | run: | 166 | rm -rf /tmp/.buildx-cache 167 | mv /tmp/.buildx-cache-new /tmp/.buildx-cache 168 | 169 | # deploy: 170 | # name: 🚀 Deploy 171 | # runs-on: ubuntu-latest 172 | # needs: [lint, typecheck, vitest, cypress, build] 173 | # # only build/deploy main branch on pushes 174 | # if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }} 175 | 176 | # steps: 177 | # - name: 🛑 Cancel Previous Runs 178 | # uses: styfle/cancel-workflow-action@0.9.1 179 | 180 | # - name: ⬇️ Checkout repo 181 | # uses: actions/checkout@v3 182 | 183 | # - name: 👀 Read app name 184 | # uses: SebRollen/toml-action@v1.0.0 185 | # id: app_name 186 | # with: 187 | # file: "fly.toml" 188 | # field: "app" 189 | 190 | # - name: 🚀 Deploy Staging 191 | # if: ${{ github.ref == 'refs/heads/dev' }} 192 | # uses: superfly/flyctl-actions@1.3 193 | # with: 194 | # args: "deploy --app ${{ steps.app_name.outputs.value }}-staging --image registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}" 195 | # env: 196 | # FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 197 | 198 | # - name: 🚀 Deploy Production 199 | # if: ${{ github.ref == 'refs/heads/main' }} 200 | # uses: superfly/flyctl-actions@1.3 201 | # with: 202 | # args: "deploy --image registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}" 203 | # env: 204 | # FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 205 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | .env 6 | 7 | /cypress/screenshots 8 | /cypress/videos 9 | /postgres-data 10 | 11 | /app/styles/tailwind.css 12 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | .env 6 | 7 | /cypress/screenshots 8 | /cypress/videos 9 | /postgres-data 10 | 11 | /app/styles/tailwind.css 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # base node image 2 | FROM node:16-bullseye-slim as base 3 | 4 | # set for base and all layer that inherit from it 5 | ENV NODE_ENV production 6 | 7 | # Install openssl for Prisma 8 | RUN apt-get update && apt-get install -y openssl 9 | 10 | # Install all node_modules, including dev dependencies 11 | FROM base as deps 12 | 13 | WORKDIR /myapp 14 | 15 | ADD package.json package-lock.json ./ 16 | RUN npm install --production=false 17 | 18 | # Setup production node_modules 19 | FROM base as production-deps 20 | 21 | WORKDIR /myapp 22 | 23 | COPY --from=deps /myapp/node_modules /myapp/node_modules 24 | ADD package.json package-lock.json ./ 25 | RUN npm prune --production 26 | 27 | # Build the app 28 | FROM base as build 29 | 30 | WORKDIR /myapp 31 | 32 | COPY --from=deps /myapp/node_modules /myapp/node_modules 33 | 34 | ADD prisma . 35 | RUN npx prisma generate 36 | 37 | ADD . . 38 | RUN npm run postinstall 39 | RUN npm run build 40 | 41 | # Finally, build the production image with minimal footprint 42 | FROM base 43 | 44 | WORKDIR /myapp 45 | 46 | COPY --from=production-deps /myapp/node_modules /myapp/node_modules 47 | COPY --from=build /myapp/node_modules/.prisma /myapp/node_modules/.prisma 48 | 49 | COPY --from=build /myapp/build /myapp/build 50 | COPY --from=build /myapp/public /myapp/public 51 | ADD . . 52 | 53 | CMD ["npm", "start"] 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remix Techno Stack 2 | 3 | ![The Remix Techno Stack](https://user-images.githubusercontent.com/1274349/159200664-d3ea18b6-ac7e-4eb9-bcb2-68d5d7fcfe30.png) 4 | 5 | Learn more about [Remix Stacks](https://remix.run/stacks). 6 | 7 | ``` 8 | npx create-remix --template benmch/techno-stack 9 | ``` 10 | 11 | ## What's in the stack 12 | 13 | - [Generic kubernetes deployment utilizing traefik for ingres](https://k3s.io) with [Docker](https://www.docker.com/) 14 | - Healthcheck endpoint 15 | - [GitHub Actions](https://github.com/features/actions) for deploy on merge to production and staging environments 16 | - Email/Password Authentication with [cookie-based sessions](https://remix.run/docs/en/v1/api/remix#createcookiesessionstorage) 17 | - Database ORM with [Prisma](https://prisma.io) 18 | - Styling with [Tailwind](https://tailwindcss.com/) 19 | - End-to-end testing with [Cypress](https://cypress.io) 20 | - Local third party request mocking with [MSW](https://mswjs.io) 21 | - Unit testing with [Vitest](https://vitest.dev) and [Testing Library](https://testing-library.com) 22 | - Code formatting with [Prettier](https://prettier.io) 23 | - Linting with [ESLint](https://eslint.org) 24 | - Static Types with [TypeScript](https://typescriptlang.org) 25 | 26 | Not a fan of bits of the stack? Fork it, change it, and use `npx create-remix --template your/repo`! Make it your own. 27 | 28 | ## Development 29 | 30 | - Start the Postgres Database in [Docker](https://www.docker.com/get-started): 31 | 32 | ```sh 33 | npm run docker 34 | ``` 35 | 36 | - Initial setup: 37 | 38 | ```sh 39 | npm run setup 40 | ``` 41 | 42 | - Start dev server: 43 | 44 | ```sh 45 | npm run dev 46 | ``` 47 | 48 | This starts your app in development mode, rebuilding assets on file changes. 49 | 50 | The database seed script creates a new user with some data you can use to get started: 51 | 52 | - Email: `rachel@remix.run` 53 | - Password: `rachelrox` 54 | 55 | ## Relevant code: 56 | 57 | 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. 58 | 59 | - creating users, and logging in and out [./app/models/user.server.ts](./app/models/user.server.ts) 60 | - user sessions, and verifying them [./app/session.server.ts](./app/session.server.ts) 61 | - creating, and deleting notes [./app/models/note.server.ts](./app/models/note.server.ts) 62 | 63 | ## Deployment - TODO 64 | 65 | This Remix Stack comes with two GitHub Actions that handle automatically deploying your app to production and staging environments. 66 | 67 | Prior to your first deployment, you'll need to do a few things: 68 | 69 | TODO: Create example k8s instructions 70 | 71 | - Create a new [GitHub Repository](https://repo.new) 72 | 73 | - Add a `SESSION_SECRET` to your fly app secrets, to do this you can run the following commands: 74 | 75 | ```sh 76 | # TODO: Generate k8s secrets 77 | ``` 78 | 79 | If you don't have openssl installed, you can also use [1password](https://1password.com/generate-password) to generate a random secret, just replace `$(openssl rand -hex 32)` with the generated secret. 80 | 81 | ## GitHub Actions 82 | 83 | We use GitHub Actions for continuous integration and deployment. Anything that gets into the `main` branch will be deployed to production after running tests/build/etc. Anything in the `dev` branch will be deployed to staging. 84 | 85 | ## Testing 86 | 87 | ### Cypress 88 | 89 | We use 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. 90 | 91 | We use [`@testing-library/cypress`](https://testing-library.com/cypress) for selecting elements on the page semantically. 92 | 93 | 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. 94 | 95 | We have a utility for testing authenticated features without having to go through the login flow: 96 | 97 | ```ts 98 | cy.login(); 99 | // you are now logged in as a new user 100 | ``` 101 | 102 | We also have a utility to auto-delete the user at the end of your test. Just make sure to add this in each test file: 103 | 104 | ```ts 105 | afterEach(() => { 106 | cy.cleanupUser(); 107 | }); 108 | ``` 109 | 110 | That way, we can keep your local db clean and keep your tests isolated from one another. 111 | 112 | ### Vitest 113 | 114 | For lower level tests of utilities and individual components, we use `vitest`. We have DOM-specific assertion helpers via [`@testing-library/jest-dom`](https://testing-library.com/jest-dom). 115 | 116 | ### Type Checking 117 | 118 | 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`. 119 | 120 | ### Linting 121 | 122 | This project uses ESLint for linting. That is configured in `.eslintrc.js`. 123 | 124 | ### Formatting 125 | 126 | We use [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. 127 | -------------------------------------------------------------------------------- /app/db.server.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import invariant from "tiny-invariant"; 3 | 4 | let prisma: PrismaClient; 5 | 6 | declare global { 7 | var __db__: PrismaClient; 8 | } 9 | 10 | // this is needed because in development we don't want to restart 11 | // the server with every change, but we want to make sure we don't 12 | // create a new connection to the DB with every change either. 13 | // in production we'll have a single connection to the DB. 14 | if (process.env.NODE_ENV === "production") { 15 | prisma = getClient(); 16 | } else { 17 | if (!global.__db__) { 18 | global.__db__ = getClient(); 19 | } 20 | prisma = global.__db__; 21 | } 22 | 23 | function getClient() { 24 | const { DATABASE_URL } = process.env; 25 | invariant(typeof DATABASE_URL === "string", "DATABASE_URL env var not set"); 26 | 27 | const databaseUrl = new URL(DATABASE_URL); 28 | 29 | console.log(`🔌 setting up prisma client to ${databaseUrl.host}`); 30 | // NOTE: during development if you change anything in this function, remember 31 | // that this only runs once per server restart and won't automatically be 32 | // re-run per request like everything else is. So if you need to change 33 | // something in this file, you'll need to manually restart the server. 34 | const client = new PrismaClient({ 35 | datasources: { 36 | db: { 37 | url: databaseUrl.toString(), 38 | }, 39 | }, 40 | }); 41 | // connect eagerly 42 | client.$connect(); 43 | 44 | return client; 45 | } 46 | 47 | export { prisma }; 48 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { hydrate } from "react-dom"; 2 | import { RemixBrowser } from "remix"; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { renderToString } from "react-dom/server"; 2 | import { RemixServer } from "remix"; 3 | import type { EntryContext } from "remix"; 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 "@node-rs/bcrypt"; 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.verify(password, userWithPassword.password.hash); 51 | 52 | if (!isValid) { 53 | return null; 54 | } 55 | 56 | const { password: _password, ...userWithoutPassword } = userWithPassword; 57 | 58 | return userWithoutPassword; 59 | } 60 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | json, 3 | Links, 4 | LiveReload, 5 | Meta, 6 | Outlet, 7 | Scripts, 8 | ScrollRestoration, 9 | } from "remix"; 10 | import type { LinksFunction, MetaFunction, LoaderFunction } from "remix"; 11 | 12 | import tailwindStylesheetUrl from "./styles/tailwind.css"; 13 | import { getUser } from "./session.server"; 14 | 15 | export const links: LinksFunction = () => { 16 | return [{ rel: "stylesheet", href: tailwindStylesheetUrl }]; 17 | }; 18 | 19 | export const meta: MetaFunction = () => ({ 20 | charset: "utf-8", 21 | title: "Remix Notes", 22 | viewport: "width=device-width,initial-scale=1", 23 | }); 24 | 25 | type LoaderData = { 26 | user: Awaited>; 27 | }; 28 | 29 | export const loader: LoaderFunction = async ({ request }) => { 30 | return json({ 31 | user: await getUser(request), 32 | }); 33 | }; 34 | 35 | export default function App() { 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /app/routes/healthcheck.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction } from "remix"; 2 | import { prisma } from "~/db.server"; 3 | 4 | export const loader: LoaderFunction = async ({ request }) => { 5 | const host = 6 | request.headers.get("X-Forwarded-Host") ?? request.headers.get("host"); 7 | 8 | try { 9 | // if we can connect to the database and make a simple query 10 | // and make a HEAD request to ourselves, then we're good. 11 | await Promise.all([ 12 | prisma.user.count(), 13 | fetch(`http://${host}`, { method: "HEAD" }).then((r) => { 14 | if (!r.ok) return Promise.reject(r); 15 | }), 16 | ]); 17 | return new Response("OK"); 18 | } catch (error: unknown) { 19 | console.log("healthcheck ❌", { error }); 20 | return new Response("ERROR", { status: 500 }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "remix"; 2 | import { useOptionalUser } from "~/utils"; 3 | 4 | export default function Index() { 5 | const user = useOptionalUser(); 6 | return ( 7 |
8 |
9 |
10 |
11 |
12 | BB King playing blues on his Les Paul guitar 17 |
18 |
19 |
20 |

21 | 22 | Techno Stack 23 | 24 |

25 |

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

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

26 | Notes 27 |

28 |

{user.email}

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

No notes yet

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

{data.note.title}

40 |

{data.note.body}

41 |
42 |
43 | 49 |
50 |
51 | ); 52 | } 53 | 54 | export function ErrorBoundary({ error }: { error: Error }) { 55 | console.error(error); 56 | 57 | return
An unexpected error occurred: {error.message}
; 58 | } 59 | 60 | export function CatchBoundary() { 61 | const caught = useCatch(); 62 | 63 | if (caught.status === 404) { 64 | return
Note not found
; 65 | } 66 | 67 | throw new Error(`Unexpected caught response with status: ${caught.status}`); 68 | } 69 | -------------------------------------------------------------------------------- /app/routes/notes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "remix"; 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 * as React from "react"; 2 | import { Form, json, redirect, useActionData } from "remix"; 3 | import type { ActionFunction } from "remix"; 4 | import Alert from "@reach/alert"; 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 |