├── .env.example ├── .eslintrc.cjs ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── deploy.yml │ ├── format-repo.yml │ ├── lint-repo.yml │ └── no-response.yml ├── .gitignore ├── .gitpod.yml ├── .prettierignore ├── LICENSE.md ├── README.md ├── app.arc ├── app ├── entry.client.tsx ├── entry.server.tsx ├── models │ ├── note.server.ts │ └── user.server.ts ├── root.tsx ├── routes │ ├── _index.tsx │ ├── join.tsx │ ├── login.tsx │ ├── logout.tsx │ ├── notes.$noteId.tsx │ ├── notes._index.tsx │ ├── notes.new.tsx │ └── notes.tsx ├── session.server.ts ├── tailwind.css ├── utils.test.ts └── utils.ts ├── cypress.config.ts ├── cypress ├── .eslintrc.cjs ├── e2e │ └── smoke.cy.ts ├── fixtures │ └── example.json ├── support │ ├── commands.ts │ ├── e2e.ts │ └── test-routes │ │ └── create-user.ts └── tsconfig.json ├── mocks ├── README.md └── index.js ├── package.json ├── plugin-remix.js ├── postcss.config.cjs ├── prettier.config.js ├── public └── favicon.ico ├── remix.config.js ├── remix.env.d.ts ├── remix.init ├── gitignore ├── index.js └── package.json ├── server.ts ├── tailwind.config.ts ├── test └── setup-test-env.ts ├── tsconfig.json └── vitest.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | SESSION_SECRET="super-duper-s3cret" 2 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This is intended to be a basic starting point for linting in the Grunge Stack. 3 | * It relies on recommended configs out of the box for simplicity, but you can 4 | * and should modify this configuration to best suit your team's needs. 5 | */ 6 | 7 | /** @type {import('eslint').Linter.Config} */ 8 | module.exports = { 9 | root: true, 10 | parserOptions: { 11 | ecmaVersion: "latest", 12 | sourceType: "module", 13 | ecmaFeatures: { 14 | jsx: true, 15 | }, 16 | }, 17 | env: { 18 | browser: true, 19 | commonjs: true, 20 | es6: true, 21 | }, 22 | 23 | // Base config 24 | extends: ["eslint:recommended"], 25 | 26 | overrides: [ 27 | // React 28 | { 29 | files: ["**/*.{js,jsx,ts,tsx}"], 30 | plugins: ["react", "jsx-a11y"], 31 | extends: [ 32 | "plugin:react/recommended", 33 | "plugin:react/jsx-runtime", 34 | "plugin:react-hooks/recommended", 35 | "plugin:jsx-a11y/recommended", 36 | "prettier", 37 | ], 38 | settings: { 39 | react: { 40 | version: "detect", 41 | }, 42 | formComponents: ["Form"], 43 | linkComponents: [ 44 | { name: "Link", linkAttribute: "to" }, 45 | { name: "NavLink", linkAttribute: "to" }, 46 | ], 47 | }, 48 | rules: { 49 | "react/jsx-no-leaked-render": [ 50 | "warn", 51 | { validStrategies: ["ternary"] }, 52 | ], 53 | }, 54 | }, 55 | 56 | // Typescript 57 | { 58 | files: ["**/*.{ts,tsx}"], 59 | plugins: ["@typescript-eslint", "import"], 60 | parser: "@typescript-eslint/parser", 61 | settings: { 62 | "import/internal-regex": "^~/", 63 | "import/resolver": { 64 | node: { 65 | extensions: [".ts", ".tsx"], 66 | }, 67 | typescript: { 68 | alwaysTryTypes: true, 69 | }, 70 | }, 71 | }, 72 | extends: [ 73 | "plugin:@typescript-eslint/recommended", 74 | "plugin:@typescript-eslint/stylistic", 75 | "plugin:import/recommended", 76 | "plugin:import/typescript", 77 | "prettier", 78 | ], 79 | rules: { 80 | "import/order": [ 81 | "error", 82 | { 83 | alphabetize: { caseInsensitive: true, order: "asc" }, 84 | groups: ["builtin", "external", "internal", "parent", "sibling"], 85 | "newlines-between": "always", 86 | }, 87 | ], 88 | }, 89 | }, 90 | 91 | // Markdown 92 | { 93 | files: ["**/*.md"], 94 | plugins: ["markdown"], 95 | extends: ["plugin:markdown/recommended-legacy", "prettier"], 96 | }, 97 | 98 | // Jest/Vitest 99 | { 100 | files: ["**/*.test.{js,jsx,ts,tsx}"], 101 | plugins: ["jest", "jest-dom", "testing-library"], 102 | extends: [ 103 | "plugin:jest/recommended", 104 | "plugin:jest-dom/recommended", 105 | "plugin:testing-library/react", 106 | "prettier", 107 | ], 108 | env: { 109 | "jest/globals": true, 110 | }, 111 | settings: { 112 | jest: { 113 | // we're using vitest which has a very similar API to jest 114 | // (so the linting plugins work nicely), but it means we have to explicitly 115 | // set the jest version. 116 | version: 28, 117 | }, 118 | }, 119 | }, 120 | 121 | // Cypress 122 | { 123 | files: ["cypress/**/*.ts"], 124 | plugins: ["cypress"], 125 | extends: ["plugin:cypress/recommended", "prettier"], 126 | }, 127 | 128 | // Node 129 | { 130 | files: [ 131 | ".eslintrc.js", 132 | "plugin-remix.js", 133 | "remix.config.js", 134 | "mocks/**/*.js", 135 | ], 136 | env: { 137 | node: true, 138 | }, 139 | }, 140 | ], 141 | }; 142 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug Report 2 | description: Something is wrong with the Stack. 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: >- 7 | Thank you for helping to improve Remix! 8 | 9 | Our bandwidth on maintaining these stacks is limited. As a team, we're 10 | currently focusing our efforts on Remix itself. The good news is you can 11 | fork and adjust this stack however you'd like and start using it today 12 | as a custom stack. Learn more from 13 | [the Remix Stacks docs](https://remix.run/stacks). 14 | 15 | If you'd still like to report a bug, please fill out this form. We can't 16 | promise a timely response, but hopefully when we have the bandwidth to 17 | work on these stacks again we can take a look. Thanks! 18 | 19 | - type: input 20 | attributes: 21 | label: Have you experienced this bug with the latest version of the template? 22 | validations: 23 | required: true 24 | - type: textarea 25 | attributes: 26 | label: Steps to Reproduce 27 | description: Steps to reproduce the behavior. 28 | validations: 29 | required: true 30 | - type: textarea 31 | attributes: 32 | label: Expected Behavior 33 | description: A concise description of what you expected to happen. 34 | validations: 35 | required: true 36 | - type: textarea 37 | attributes: 38 | label: Actual Behavior 39 | description: A concise description of what you're experiencing. 40 | validations: 41 | required: true 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Get Help 4 | url: https://github.com/remix-run/remix/discussions/new?category=q-a 5 | about: 6 | If you can't get something to work the way you expect, open a question in 7 | the Remix discussions. 8 | - name: Feature Request 9 | url: https://github.com/remix-run/remix/discussions/new?category=ideas 10 | about: 11 | We appreciate you taking the time to improve Remix with your ideas, but we 12 | use the Remix Discussions for this instead of the issues tab 🙂. 13 | - name: 💬 Remix Discord Channel 14 | url: https://rmx.as/discord 15 | about: Interact with other people using Remix 💿 16 | - name: 💬 New Updates (Twitter) 17 | url: https://twitter.com/remix_run 18 | about: Stay up to date with Remix news on twitter 19 | - name: 🍿 Remix YouTube Channel 20 | url: https://rmx.as/youtube 21 | about: Are you a tech lead or wanting to learn more about Remix in depth? Checkout the Remix YouTube Channel 22 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: daily 7 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - dev 8 | pull_request: 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | permissions: 15 | actions: write 16 | contents: read 17 | 18 | defaults: 19 | run: 20 | shell: bash 21 | 22 | jobs: 23 | lint: 24 | name: ⬣ ESLint 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: ⬇️ Checkout repo 28 | uses: actions/checkout@v4 29 | 30 | - name: ⎔ Setup node 31 | uses: actions/setup-node@v4 32 | with: 33 | cache: npm 34 | cache-dependency-path: ./package.json 35 | node-version: 18 36 | 37 | - name: 📥 Install deps 38 | run: npm install 39 | 40 | - name: 🔬 Lint 41 | run: npm run lint 42 | 43 | typecheck: 44 | name: ʦ TypeScript 45 | runs-on: ubuntu-latest 46 | steps: 47 | - name: ⬇️ Checkout repo 48 | uses: actions/checkout@v4 49 | 50 | - name: ⎔ Setup node 51 | uses: actions/setup-node@v4 52 | with: 53 | cache: npm 54 | cache-dependency-path: ./package.json 55 | node-version: 18 56 | 57 | - name: 📥 Install deps 58 | run: npm install 59 | 60 | - name: 🔎 Type check 61 | run: npm run typecheck --if-present 62 | 63 | vitest: 64 | name: ⚡ Vitest 65 | runs-on: ubuntu-latest 66 | steps: 67 | - name: ⬇️ Checkout repo 68 | uses: actions/checkout@v4 69 | 70 | - name: ⎔ Setup node 71 | uses: actions/setup-node@v4 72 | with: 73 | cache: npm 74 | cache-dependency-path: ./package.json 75 | node-version: 18 76 | 77 | - name: 📥 Install deps 78 | run: npm install 79 | 80 | - name: ⚡ Run vitest 81 | run: npm run test -- --coverage 82 | 83 | cypress: 84 | name: ⚫️ Cypress 85 | runs-on: ubuntu-latest 86 | steps: 87 | - name: ⬇️ Checkout repo 88 | uses: actions/checkout@v4 89 | 90 | - name: 🏄 Copy test env vars 91 | run: cp .env.example .env 92 | 93 | - name: ⎔ Setup node 94 | uses: actions/setup-node@v4 95 | with: 96 | cache: npm 97 | cache-dependency-path: ./package.json 98 | node-version: 18 99 | 100 | - name: 📥 Install deps 101 | run: npm install 102 | 103 | - name: 🏗 Build 104 | run: npm run build 105 | 106 | - name: 🌳 Cypress run 107 | uses: cypress-io/github-action@v6 108 | with: 109 | start: npm run dev 110 | wait-on: "http://localhost:8811" 111 | env: 112 | PORT: "8811" 113 | 114 | deploy: 115 | needs: [lint, typecheck, vitest, cypress] 116 | runs-on: ubuntu-latest 117 | 118 | steps: 119 | - name: ⬇️ Checkout repo 120 | uses: actions/checkout@v4 121 | 122 | - name: ⎔ Setup node 123 | uses: actions/setup-node@v4 124 | with: 125 | cache: npm 126 | cache-dependency-path: ./package.json 127 | node-version: 18 128 | 129 | - name: 👀 Env 130 | run: | 131 | echo "Event name: ${{ github.event_name }}" 132 | echo "Git ref: ${{ github.ref }}" 133 | echo "GH actor: ${{ github.actor }}" 134 | echo "SHA: ${{ github.sha }}" 135 | VER=`node --version`; echo "Node ver: $VER" 136 | VER=`npm --version`; echo "npm ver: $VER" 137 | 138 | - name: 📥 Install deps 139 | run: npm install 140 | 141 | - name: 🏗 Build 142 | run: npm run build 143 | 144 | - name: 🛠 Install Arc 145 | run: npm i -g @architect/architect 146 | 147 | - name: 🚀 Staging Deploy 148 | if: github.ref == 'refs/heads/dev' 149 | run: arc deploy --staging --prune 150 | env: 151 | CI: true 152 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 153 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 154 | 155 | - name: 🚀 Production Deploy 156 | if: github.ref == 'refs/heads/main' 157 | run: arc deploy --production --prune 158 | env: 159 | CI: true 160 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 161 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 162 | -------------------------------------------------------------------------------- /.github/workflows/format-repo.yml: -------------------------------------------------------------------------------- 1 | name: 👔 Format 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | format: 14 | if: github.repository == 'remix-run/grunge-stack' 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: ⬇️ Checkout repo 19 | uses: actions/checkout@v4 20 | 21 | - name: ⎔ Setup node 22 | uses: actions/setup-node@v4 23 | with: 24 | cache: npm 25 | cache-dependency-path: ./package.json 26 | node-version: 18 27 | 28 | - name: 📥 Install deps 29 | run: npm install 30 | 31 | - name: 👔 Format 32 | run: npm run format:repo 33 | 34 | - name: 💪 Commit 35 | run: | 36 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 37 | git config --local user.name "github-actions[bot]" 38 | 39 | git add . 40 | if [ -z "$(git status --porcelain)" ]; then 41 | echo "💿 no formatting changed" 42 | exit 0 43 | fi 44 | git commit -m "chore: format" 45 | git push 46 | echo "💿 pushed formatting changes https://github.com/$GITHUB_REPOSITORY/commit/$(git rev-parse HEAD)" 47 | -------------------------------------------------------------------------------- /.github/workflows/lint-repo.yml: -------------------------------------------------------------------------------- 1 | name: ⬣ Lint repository 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - dev 8 | pull_request: 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | lint: 16 | name: ⬣ Lint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: ⬇️ Checkout repo 20 | uses: actions/checkout@v4 21 | 22 | - name: ⎔ Setup node 23 | uses: actions/setup-node@v4 24 | with: 25 | cache: npm 26 | cache-dependency-path: ./package.json 27 | node-version: 16 28 | 29 | - name: 📥 Install deps 30 | run: npm install 31 | 32 | - name: 🔬 Lint 33 | run: npm run lint 34 | -------------------------------------------------------------------------------- /.github/workflows/no-response.yml: -------------------------------------------------------------------------------- 1 | name: 🥺 No Response 2 | 3 | on: 4 | schedule: 5 | # Schedule for five minutes after the hour, every hour 6 | - cron: "5 * * * *" 7 | 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | 12 | jobs: 13 | stale: 14 | if: github.repository == 'remix-run/grunge-stack' 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: 🥺 Handle Ghosting 18 | uses: actions/stale@v9 19 | with: 20 | days-before-close: 10 21 | close-issue-message: > 22 | This issue has been automatically closed because we haven't received a 23 | response from the original author 🙈. This automation helps keep the issue 24 | tracker clean from issues that are unactionable. Please reach out if you 25 | have more information for us! 🙂 26 | close-pr-message: > 27 | This PR has been automatically closed because we haven't received a 28 | response from the original author 🙈. This automation helps keep the issue 29 | tracker clean from PRs that are unactionable. Please reach out if you 30 | have more information for us! 🙂 31 | # don't automatically mark issues/PRs as stale 32 | days-before-stale: -1 33 | stale-issue-label: needs-response 34 | stale-pr-label: needs-response 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # We don't want lockfiles in stacks, as people could use a different package manager 2 | # This part will be removed by `remix.init` 3 | bun.lockb 4 | package-lock.json 5 | pnpm-lock.yaml 6 | pnpm-lock.yml 7 | yarn.lock 8 | 9 | node_modules 10 | 11 | /public/build 12 | /server/index.mjs 13 | /server/index.mjs.map 14 | /server/metafile.* 15 | /server/version.txt 16 | preferences.arc 17 | sam.json 18 | sam.yaml 19 | .env 20 | 21 | /cypress/screenshots 22 | /cypress/videos 23 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | # This configuration file was automatically generated by Gitpod. 2 | # Please adjust to your needs (see https://www.gitpod.io/docs/introduction/learn-gitpod/gitpod-yaml) 3 | # and commit this file to your remote git repository to share the goodness with others. 4 | 5 | # Learn more from ready-to-use templates: https://www.gitpod.io/docs/introduction/getting-started/quickstart 6 | 7 | tasks: 8 | - name: Restore .env file 9 | # https://www.gitpod.io/guides/automate-env-files-with-gitpod-environment-variables 10 | # After making changes to .env, run this line to persist it to $DOTENV 11 | # gp env DOTENV="$(base64 .env | tr -d '\n')" 12 | command: | 13 | if [ -f .env ]; then 14 | # If this workspace already has a .env, don't override it 15 | # Local changes survive a workspace being opened and closed 16 | # but they will not persist between separate workspaces for the same repo 17 | echo "Found .env in workspace" 18 | else 19 | if [ -z "${DOTENV}" ]; then 20 | # There is no $DOTENV from a previous workspace 21 | # Default to the example .env 22 | echo "Setting example .env" 23 | cp .env.example .env 24 | else 25 | # Environment variables set this way are shared between all your workspaces for this repo 26 | # The lines below will read $DOTENV and print a .env file 27 | echo "Restoring .env from Gitpod" 28 | echo "${DOTENV}" | base64 -d > .env 29 | fi 30 | fi 31 | 32 | - name: App 33 | init: npm install && npm run build 34 | command: npm run dev 35 | 36 | vscode: 37 | extensions: 38 | - ms-azuretools.vscode-docker 39 | - esbenp.prettier-vscode 40 | - dbaeumer.vscode-eslint 41 | - bradlc.vscode-tailwindcss 42 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /server/index.js 4 | /public/build 5 | preferences.arc 6 | sam.json 7 | sam.yaml 8 | .env 9 | 10 | /app/styles/tailwind.css 11 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Remix Software Inc. 2021 4 | Copyright (c) Shopify Inc. 2022-2023 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!NOTE] 2 | > This repo has been archived. Please refer instead to: 3 | > - The official [React Router templates](https://github.com/remix-run/react-router-templates/) for simple templates to get started with 4 | > - [The Epic Stack](https://github.com/epicweb-dev/epic-stack) for a more comprehensive, batteries-included option 5 | > - [Remix Discord](https://rmx.as/discord) to ask and share community templates 6 | 7 | # Remix Grunge Stack 8 | 9 | ![The Remix Grunge Stack](https://repository-images.githubusercontent.com/463325363/edae4f5b-1a13-47ea-b90c-c05badc2a700) 10 | 11 | Learn more about [Remix Stacks](https://remix.run/stacks). 12 | 13 | ``` 14 | npx create-remix@latest --template remix-run/grunge-stack 15 | ``` 16 | 17 | ## What's in the stack 18 | 19 | - [AWS deployment](https://aws.com) with [Architect](https://arc.codes/) 20 | - Production-ready [DynamoDB Database](https://aws.amazon.com/dynamodb/) 21 | - [GitHub Actions](https://github.com/features/actions) for deploy on merge to production and staging environments 22 | - Email/Password Authentication with [cookie-based sessions](https://remix.run/utils/sessions#createcookiesessionstorage) 23 | - DynamoDB access via [`arc.tables`](https://arc.codes/docs/en/reference/runtime-helpers/node.js#arc.tables) 24 | - Styling with [Tailwind](https://tailwindcss.com/) 25 | - End-to-end testing with [Cypress](https://cypress.io) 26 | - Local third party request mocking with [MSW](https://mswjs.io) 27 | - Unit testing with [Vitest](https://vitest.dev) and [Testing Library](https://testing-library.com) 28 | - Code formatting with [Prettier](https://prettier.io) 29 | - Linting with [ESLint](https://eslint.org) 30 | - Static Types with [TypeScript](https://typescriptlang.org) 31 | 32 | Not a fan of bits of the stack? Fork it, change it, and use `npx create-remix --template your/repo`! Make it your own. 33 | 34 | ## Quickstart 35 | 36 | Click this button to create a [Gitpod](https://gitpod.io) workspace with the project set up 37 | 38 | [![Gitpod Ready-to-Code](https://img.shields.io/badge/Gitpod-Ready--to--Code-blue?logo=gitpod)](https://gitpod.io/from-referrer/) 39 | 40 | ## Development 41 | 42 | - First run this stack's `remix.init` script and commit the changes it makes to your project. 43 | 44 | ```sh 45 | npx remix init 46 | git init # if you haven't already 47 | git add . 48 | git commit -m "Initialize project" 49 | ``` 50 | 51 | - Validate the app has been set up properly (optional): 52 | 53 | ```sh 54 | npm run validate 55 | ``` 56 | 57 | - Start dev server: 58 | 59 | ```sh 60 | npm run dev 61 | ``` 62 | 63 | This starts your app in development mode, rebuilding assets on file changes. 64 | 65 | ### Relevant code: 66 | 67 | This is a pretty simple note-taking app, but it's a good example of how you can build a full stack app with Architect and Remix. The main functionality is creating users, logging in and out, and creating and deleting notes. 68 | 69 | - creating users, and logging in and out [./app/models/user.server.ts](./app/models/user.server.ts) 70 | - user sessions, and verifying them [./app/session.server.ts](./app/session.server.ts) 71 | - creating, and deleting notes [./app/models/note.server.ts](./app/models/note.server.ts) 72 | 73 | The database that comes with `arc sandbox` is an in memory database, so if you restart the server, you'll lose your data. The Staging and Production environments won't behave this way, instead they'll persist the data in DynamoDB between deployments and Lambda executions. 74 | 75 | ## Deployment 76 | 77 | This Remix Stack comes with two GitHub Actions that handle automatically deploying your app to production and staging environments. By default, Arc will deploy to the `us-west-2` region, if you wish to deploy to a different region, you'll need to change your [`app.arc`](https://arc.codes/docs/en/reference/project-manifest/aws) 78 | 79 | Prior to your first deployment, you'll need to do a few things: 80 | 81 | - Create a new [GitHub repo](https://repo.new) 82 | 83 | - [Sign up](https://portal.aws.amazon.com/billing/signup#/start) and login to your AWS account 84 | 85 | - Add `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` to [your GitHub repo's secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets). Go to your AWS [security credentials](https://console.aws.amazon.com/iam/home?region=us-west-2#/security_credentials) and click on the "Access keys" tab, and then click "Create New Access Key", then you can copy those and add them to your repo's secrets. 86 | 87 | - Install the [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html#getting-started-install-instructions). 88 | 89 | - Create an [AWS credentials file](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-quickstart.html#getting-started-quickstart-new). 90 | 91 | - Along with your AWS credentials, you'll also need to give your CloudFormation a `SESSION_SECRET` variable of its own for both staging and production environments, as well as an `ARC_APP_SECRET` for Arc itself. 92 | 93 | ```sh 94 | npx arc env --add --env staging ARC_APP_SECRET $(openssl rand -hex 32) 95 | npx arc env --add --env staging SESSION_SECRET $(openssl rand -hex 32) 96 | npx arc env --add --env production ARC_APP_SECRET $(openssl rand -hex 32) 97 | npx arc env --add --env production SESSION_SECRET $(openssl rand -hex 32) 98 | ``` 99 | 100 | If you don't have openssl installed, you can also use [1password](https://1password.com/password-generator) to generate a random secret, just replace `$(openssl rand -hex 32)` with the generated secret. 101 | 102 | ## Where do I find my CloudFormation? 103 | 104 | You can find the CloudFormation template that Architect generated for you in the sam.yaml file. 105 | 106 | To find it on AWS, you can search for [CloudFormation](https://console.aws.amazon.com/cloudformation/home) (make sure you're looking at the correct region!) and find the name of your stack (the name is a PascalCased version of what you have in `app.arc`, so by default it's RemixGrungeStackStaging and RemixGrungeStackProduction) that matches what's in `app.arc`, you can find all of your app's resources under the "Resources" tab. 107 | 108 | ## GitHub Actions 109 | 110 | 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. 111 | 112 | ## Testing 113 | 114 | ### Cypress 115 | 116 | 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. 117 | 118 | We use [`@testing-library/cypress`](https://testing-library.com/cypress) for selecting elements on the page semantically. 119 | 120 | 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. 121 | 122 | We have a utility for testing authenticated features without having to go through the login flow: 123 | 124 | ```ts 125 | cy.login(); 126 | // you are now logged in as a new user 127 | ``` 128 | 129 | ### Vitest 130 | 131 | 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). 132 | 133 | ### Type Checking 134 | 135 | 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`. 136 | 137 | ### Linting 138 | 139 | This project uses ESLint for linting. That is configured in `.eslintrc.js`. 140 | 141 | ### Formatting 142 | 143 | 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. 144 | -------------------------------------------------------------------------------- /app.arc: -------------------------------------------------------------------------------- 1 | @app 2 | grunge-stack-template 3 | 4 | @aws 5 | runtime nodejs18.x 6 | # concurrency 1 7 | # memory 1152 8 | # profile default 9 | # region us-west-1 10 | # timeout 30 11 | 12 | @http 13 | /* 14 | method any 15 | src server 16 | 17 | @plugins 18 | plugin-remix 19 | src plugin-remix.js 20 | 21 | @static 22 | 23 | @tables 24 | user 25 | pk *String 26 | 27 | password 28 | pk *String # userId 29 | 30 | note 31 | pk *String # userId 32 | sk **String # noteId 33 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/docs/en/main/file-conventions/entry.client 5 | */ 6 | 7 | import { RemixBrowser } from "@remix-run/react"; 8 | import { startTransition, StrictMode } from "react"; 9 | import { hydrateRoot } from "react-dom/client"; 10 | 11 | startTransition(() => { 12 | hydrateRoot( 13 | document, 14 | 15 | 16 | , 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle generating the HTTP Response for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/docs/en/main/file-conventions/entry.server 5 | */ 6 | 7 | import { PassThrough } from "node:stream"; 8 | 9 | import type { EntryContext } from "@remix-run/node"; 10 | import { createReadableStreamFromReadable } from "@remix-run/node"; 11 | import { RemixServer } from "@remix-run/react"; 12 | import { isbot } from "isbot"; 13 | import { renderToPipeableStream } from "react-dom/server"; 14 | 15 | const ABORT_DELAY = 5_000; 16 | 17 | export default function handleRequest( 18 | request: Request, 19 | responseStatusCode: number, 20 | responseHeaders: Headers, 21 | remixContext: EntryContext, 22 | ) { 23 | return isbot(request.headers.get("user-agent")) 24 | ? handleBotRequest( 25 | request, 26 | responseStatusCode, 27 | responseHeaders, 28 | remixContext, 29 | ) 30 | : handleBrowserRequest( 31 | request, 32 | responseStatusCode, 33 | responseHeaders, 34 | remixContext, 35 | ); 36 | } 37 | 38 | function handleBotRequest( 39 | request: Request, 40 | responseStatusCode: number, 41 | responseHeaders: Headers, 42 | remixContext: EntryContext, 43 | ) { 44 | return new Promise((resolve, reject) => { 45 | const { abort, pipe } = renderToPipeableStream( 46 | , 51 | { 52 | onAllReady() { 53 | const body = new PassThrough(); 54 | 55 | responseHeaders.set("Content-Type", "text/html"); 56 | 57 | resolve( 58 | new Response(createReadableStreamFromReadable(body), { 59 | headers: responseHeaders, 60 | status: responseStatusCode, 61 | }), 62 | ); 63 | 64 | pipe(body); 65 | }, 66 | onShellError(error: unknown) { 67 | reject(error); 68 | }, 69 | onError(error: unknown) { 70 | responseStatusCode = 500; 71 | console.error(error); 72 | }, 73 | }, 74 | ); 75 | 76 | setTimeout(abort, ABORT_DELAY); 77 | }); 78 | } 79 | 80 | function handleBrowserRequest( 81 | request: Request, 82 | responseStatusCode: number, 83 | responseHeaders: Headers, 84 | remixContext: EntryContext, 85 | ) { 86 | return new Promise((resolve, reject) => { 87 | const { abort, pipe } = renderToPipeableStream( 88 | , 93 | { 94 | onShellReady() { 95 | const body = new PassThrough(); 96 | 97 | responseHeaders.set("Content-Type", "text/html"); 98 | 99 | resolve( 100 | new Response(createReadableStreamFromReadable(body), { 101 | headers: responseHeaders, 102 | status: responseStatusCode, 103 | }), 104 | ); 105 | 106 | pipe(body); 107 | }, 108 | onShellError(error: unknown) { 109 | reject(error); 110 | }, 111 | onError(error: unknown) { 112 | console.error(error); 113 | responseStatusCode = 500; 114 | }, 115 | }, 116 | ); 117 | 118 | setTimeout(abort, ABORT_DELAY); 119 | }); 120 | } 121 | -------------------------------------------------------------------------------- /app/models/note.server.ts: -------------------------------------------------------------------------------- 1 | import arc from "@architect/functions"; 2 | import { createId } from "@paralleldrive/cuid2"; 3 | 4 | import type { User } from "./user.server"; 5 | 6 | export interface Note { 7 | id: ReturnType; 8 | userId: User["id"]; 9 | title: string; 10 | body: string; 11 | } 12 | 13 | interface NoteItem { 14 | pk: User["id"]; 15 | sk: `note#${Note["id"]}`; 16 | } 17 | 18 | const skToId = (sk: NoteItem["sk"]): Note["id"] => sk.replace(/^note#/, ""); 19 | const idToSk = (id: Note["id"]): NoteItem["sk"] => `note#${id}`; 20 | 21 | export async function getNote({ 22 | id, 23 | userId, 24 | }: Pick): Promise { 25 | const db = await arc.tables(); 26 | 27 | const result = await db.note.get({ pk: userId, sk: idToSk(id) }); 28 | 29 | if (result) { 30 | return { 31 | userId: result.pk, 32 | id: result.sk, 33 | title: result.title, 34 | body: result.body, 35 | }; 36 | } 37 | return null; 38 | } 39 | 40 | export async function getNoteListItems({ 41 | userId, 42 | }: Pick): Promise[]> { 43 | const db = await arc.tables(); 44 | 45 | const result = await db.note.query({ 46 | KeyConditionExpression: "pk = :pk", 47 | ExpressionAttributeValues: { ":pk": userId }, 48 | }); 49 | 50 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 51 | return result.Items.map((n: any) => ({ 52 | title: n.title, 53 | id: skToId(n.sk), 54 | })); 55 | } 56 | 57 | export async function createNote({ 58 | body, 59 | title, 60 | userId, 61 | }: Pick): Promise { 62 | const db = await arc.tables(); 63 | 64 | const result = await db.note.put({ 65 | pk: userId, 66 | sk: idToSk(createId()), 67 | title: title, 68 | body: body, 69 | }); 70 | return { 71 | id: skToId(result.sk), 72 | userId: result.pk, 73 | title: result.title, 74 | body: result.body, 75 | }; 76 | } 77 | 78 | export async function deleteNote({ id, userId }: Pick) { 79 | const db = await arc.tables(); 80 | return db.note.delete({ pk: userId, sk: idToSk(id) }); 81 | } 82 | -------------------------------------------------------------------------------- /app/models/user.server.ts: -------------------------------------------------------------------------------- 1 | import arc from "@architect/functions"; 2 | import bcrypt from "bcryptjs"; 3 | import invariant from "tiny-invariant"; 4 | 5 | export interface User { 6 | id: `email#${string}`; 7 | email: string; 8 | } 9 | export interface Password { 10 | password: string; 11 | } 12 | 13 | export async function getUserById(id: User["id"]): Promise { 14 | const db = await arc.tables(); 15 | const result = await db.user.query({ 16 | KeyConditionExpression: "pk = :pk", 17 | ExpressionAttributeValues: { ":pk": id }, 18 | }); 19 | 20 | const [record] = result.Items; 21 | if (record) return { id: record.pk, email: record.email }; 22 | return null; 23 | } 24 | 25 | export async function getUserByEmail(email: User["email"]) { 26 | return getUserById(`email#${email}`); 27 | } 28 | 29 | async function getUserPasswordByEmail(email: User["email"]) { 30 | const db = await arc.tables(); 31 | const result = await db.password.query({ 32 | KeyConditionExpression: "pk = :pk", 33 | ExpressionAttributeValues: { ":pk": `email#${email}` }, 34 | }); 35 | 36 | const [record] = result.Items; 37 | 38 | if (record) return { hash: record.password }; 39 | return null; 40 | } 41 | 42 | export async function createUser( 43 | email: User["email"], 44 | password: Password["password"], 45 | ) { 46 | const hashedPassword = await bcrypt.hash(password, 10); 47 | const db = await arc.tables(); 48 | await db.password.put({ 49 | pk: `email#${email}`, 50 | password: hashedPassword, 51 | }); 52 | 53 | await db.user.put({ 54 | pk: `email#${email}`, 55 | email, 56 | }); 57 | 58 | const user = await getUserByEmail(email); 59 | invariant(user, `User not found after being created. This should not happen`); 60 | 61 | return user; 62 | } 63 | 64 | export async function deleteUser(email: User["email"]) { 65 | const db = await arc.tables(); 66 | await db.password.delete({ pk: `email#${email}` }); 67 | await db.user.delete({ pk: `email#${email}` }); 68 | } 69 | 70 | export async function verifyLogin( 71 | email: User["email"], 72 | password: Password["password"], 73 | ) { 74 | const userPassword = await getUserPasswordByEmail(email); 75 | 76 | if (!userPassword) { 77 | return undefined; 78 | } 79 | 80 | const isValid = await bcrypt.compare(password, userPassword.hash); 81 | if (!isValid) { 82 | return undefined; 83 | } 84 | 85 | return getUserByEmail(email); 86 | } 87 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { cssBundleHref } from "@remix-run/css-bundle"; 2 | import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node"; 3 | import { json } from "@remix-run/node"; 4 | import { 5 | Links, 6 | LiveReload, 7 | Meta, 8 | Outlet, 9 | Scripts, 10 | ScrollRestoration, 11 | } from "@remix-run/react"; 12 | 13 | import { getUser } from "~/session.server"; 14 | import stylesheet from "~/tailwind.css"; 15 | 16 | export const links: LinksFunction = () => [ 17 | { rel: "stylesheet", href: stylesheet }, 18 | ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), 19 | ]; 20 | 21 | export const loader = async ({ request }: LoaderFunctionArgs) => { 22 | return json({ user: await getUser(request) }); 23 | }; 24 | 25 | export default function App() { 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from "@remix-run/node"; 2 | import { Link } from "@remix-run/react"; 3 | 4 | import { useOptionalUser } from "~/utils"; 5 | 6 | export const meta: MetaFunction = () => [{ title: "Remix Notes" }]; 7 | 8 | export default function Index() { 9 | const user = useOptionalUser(); 10 | return ( 11 |
12 |
13 |
14 |
15 |
16 | Nirvana playing on stage with Kurt's jagstang guitar 21 |
22 |
23 |
24 |

25 | 26 | Grunge Stack 27 | 28 |

29 |

30 | Check the README.md file for instructions on how to get this 31 | project deployed. 32 |

33 |
34 | {user ? ( 35 | 39 | View Notes for {user.email} 40 | 41 | ) : ( 42 |
43 | 47 | Sign up 48 | 49 | 53 | Log In 54 | 55 |
56 | )} 57 |
58 | 59 | Remix 64 | 65 |
66 |
67 |
68 | 69 |
70 |
71 | {[ 72 | { 73 | src: "https://user-images.githubusercontent.com/1500684/157991167-651c8fc5-2f72-4afa-94d8-2520ecbc5ebc.svg", 74 | alt: "AWS", 75 | href: "https://aws.com", 76 | }, 77 | { 78 | src: "https://user-images.githubusercontent.com/1500684/157991935-26c0d587-b866-49f5-af34-8f04be1c9df2.svg", 79 | alt: "DynamoDB", 80 | href: "https://aws.amazon.com/dynamodb/", 81 | }, 82 | { 83 | src: "https://user-images.githubusercontent.com/1500684/157990874-31f015c3-2af7-4669-9d61-519e5ecfdea6.svg", 84 | alt: "Architect", 85 | href: "https://arc.codes", 86 | }, 87 | { 88 | src: "https://user-images.githubusercontent.com/1500684/157764276-a516a239-e377-4a20-b44a-0ac7b65c8c14.svg", 89 | alt: "Tailwind", 90 | href: "https://tailwindcss.com", 91 | }, 92 | { 93 | src: "https://user-images.githubusercontent.com/1500684/157764454-48ac8c71-a2a9-4b5e-b19c-edef8b8953d6.svg", 94 | alt: "Cypress", 95 | href: "https://www.cypress.io", 96 | }, 97 | { 98 | src: "https://user-images.githubusercontent.com/1500684/157772386-75444196-0604-4340-af28-53b236faa182.svg", 99 | alt: "MSW", 100 | href: "https://mswjs.io", 101 | }, 102 | { 103 | src: "https://user-images.githubusercontent.com/1500684/157772447-00fccdce-9d12-46a3-8bb4-fac612cdc949.svg", 104 | alt: "Vitest", 105 | href: "https://vitest.dev", 106 | }, 107 | { 108 | src: "https://user-images.githubusercontent.com/1500684/157772662-92b0dd3a-453f-4d18-b8be-9fa6efde52cf.png", 109 | alt: "Testing Library", 110 | href: "https://testing-library.com", 111 | }, 112 | { 113 | src: "https://user-images.githubusercontent.com/1500684/157772934-ce0a943d-e9d0-40f8-97f3-f464c0811643.svg", 114 | alt: "Prettier", 115 | href: "https://prettier.io", 116 | }, 117 | { 118 | src: "https://user-images.githubusercontent.com/1500684/157772990-3968ff7c-b551-4c55-a25c-046a32709a8e.svg", 119 | alt: "ESLint", 120 | href: "https://eslint.org", 121 | }, 122 | { 123 | src: "https://user-images.githubusercontent.com/1500684/157773063-20a0ed64-b9f8-4e0b-9d1e-0b65a3d4a6db.svg", 124 | alt: "TypeScript", 125 | href: "https://typescriptlang.org", 126 | }, 127 | ].map((img) => ( 128 | 133 | {img.alt} 134 | 135 | ))} 136 |
137 |
138 |
139 |
140 | ); 141 | } 142 | -------------------------------------------------------------------------------- /app/routes/join.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | ActionFunctionArgs, 3 | LoaderFunctionArgs, 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 { useEffect, useRef } from "react"; 9 | 10 | import { createUser, getUserByEmail } from "~/models/user.server"; 11 | import { createUserSession, getUserId } from "~/session.server"; 12 | import { safeRedirect, validateEmail } from "~/utils"; 13 | 14 | export const loader = async ({ request }: LoaderFunctionArgs) => { 15 | const userId = await getUserId(request); 16 | if (userId) return redirect("/"); 17 | return json({}); 18 | }; 19 | 20 | export const action = async ({ request }: ActionFunctionArgs) => { 21 | const formData = await request.formData(); 22 | const email = formData.get("email"); 23 | const password = formData.get("password"); 24 | const redirectTo = safeRedirect(formData.get("redirectTo"), "/"); 25 | 26 | if (!validateEmail(email)) { 27 | return json( 28 | { errors: { email: "Email is invalid", password: null } }, 29 | { status: 400 }, 30 | ); 31 | } 32 | 33 | if (typeof password !== "string" || password.length === 0) { 34 | return json( 35 | { errors: { email: null, password: "Password is required" } }, 36 | { status: 400 }, 37 | ); 38 | } 39 | 40 | if (password.length < 8) { 41 | return json( 42 | { errors: { email: null, password: "Password is too short" } }, 43 | { status: 400 }, 44 | ); 45 | } 46 | 47 | const existingUser = await getUserByEmail(email); 48 | if (existingUser) { 49 | return json( 50 | { 51 | errors: { 52 | email: "A user already exists with this email", 53 | password: null, 54 | }, 55 | }, 56 | { status: 400 }, 57 | ); 58 | } 59 | 60 | const user = await createUser(email, password); 61 | 62 | return createUserSession({ 63 | redirectTo, 64 | remember: false, 65 | request, 66 | userId: user.id, 67 | }); 68 | }; 69 | 70 | export const meta: MetaFunction = () => [{ title: "Sign Up" }]; 71 | 72 | export default function Join() { 73 | const [searchParams] = useSearchParams(); 74 | const redirectTo = searchParams.get("redirectTo") ?? undefined; 75 | const actionData = useActionData(); 76 | const emailRef = useRef(null); 77 | const passwordRef = useRef(null); 78 | 79 | useEffect(() => { 80 | if (actionData?.errors?.email) { 81 | emailRef.current?.focus(); 82 | } else if (actionData?.errors?.password) { 83 | passwordRef.current?.focus(); 84 | } 85 | }, [actionData]); 86 | 87 | return ( 88 |
89 |
90 |
91 |
92 | 98 |
99 | 112 | {actionData?.errors?.email ? ( 113 |
114 | {actionData.errors.email} 115 |
116 | ) : null} 117 |
118 |
119 | 120 |
121 | 127 |
128 | 138 | {actionData?.errors?.password ? ( 139 |
140 | {actionData.errors.password} 141 |
142 | ) : null} 143 |
144 |
145 | 146 | 147 | 153 |
154 |
155 | Already have an account?{" "} 156 | 163 | Log in 164 | 165 |
166 |
167 |
168 |
169 |
170 | ); 171 | } 172 | -------------------------------------------------------------------------------- /app/routes/login.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | ActionFunctionArgs, 3 | LoaderFunctionArgs, 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 { useEffect, useRef } from "react"; 9 | 10 | import { verifyLogin } from "~/models/user.server"; 11 | import { createUserSession, getUserId } from "~/session.server"; 12 | import { safeRedirect, validateEmail } from "~/utils"; 13 | 14 | export const loader = async ({ request }: LoaderFunctionArgs) => { 15 | const userId = await getUserId(request); 16 | if (userId) return redirect("/"); 17 | return json({}); 18 | }; 19 | 20 | export const action = async ({ request }: ActionFunctionArgs) => { 21 | const formData = await request.formData(); 22 | const email = formData.get("email"); 23 | const password = formData.get("password"); 24 | const redirectTo = safeRedirect(formData.get("redirectTo"), "/"); 25 | const remember = formData.get("remember"); 26 | 27 | if (!validateEmail(email)) { 28 | return json( 29 | { errors: { email: "Email is invalid", password: null } }, 30 | { status: 400 }, 31 | ); 32 | } 33 | 34 | if (typeof password !== "string" || password.length === 0) { 35 | return json( 36 | { errors: { email: null, password: "Password is required" } }, 37 | { status: 400 }, 38 | ); 39 | } 40 | 41 | if (password.length < 8) { 42 | return json( 43 | { errors: { email: null, password: "Password is too short" } }, 44 | { status: 400 }, 45 | ); 46 | } 47 | 48 | const user = await verifyLogin(email, password); 49 | 50 | if (!user) { 51 | return json( 52 | { errors: { email: "Invalid email or password", password: null } }, 53 | { status: 400 }, 54 | ); 55 | } 56 | 57 | return createUserSession({ 58 | redirectTo, 59 | remember: remember === "on" ? true : false, 60 | request, 61 | userId: user.id, 62 | }); 63 | }; 64 | 65 | export const meta: MetaFunction = () => [{ title: "Login" }]; 66 | 67 | export default function LoginPage() { 68 | const [searchParams] = useSearchParams(); 69 | const redirectTo = searchParams.get("redirectTo") || "/notes"; 70 | const actionData = useActionData(); 71 | const emailRef = useRef(null); 72 | const passwordRef = useRef(null); 73 | 74 | useEffect(() => { 75 | if (actionData?.errors?.email) { 76 | emailRef.current?.focus(); 77 | } else if (actionData?.errors?.password) { 78 | passwordRef.current?.focus(); 79 | } 80 | }, [actionData]); 81 | 82 | return ( 83 |
84 |
85 |
86 |
87 | 93 |
94 | 107 | {actionData?.errors?.email ? ( 108 |
109 | {actionData.errors.email} 110 |
111 | ) : null} 112 |
113 |
114 | 115 |
116 | 122 |
123 | 133 | {actionData?.errors?.password ? ( 134 |
135 | {actionData.errors.password} 136 |
137 | ) : null} 138 |
139 |
140 | 141 | 142 | 148 |
149 |
150 | 156 | 162 |
163 |
164 | Don't have an account?{" "} 165 | 172 | Sign up 173 | 174 |
175 |
176 |
177 |
178 |
179 | ); 180 | } 181 | -------------------------------------------------------------------------------- /app/routes/logout.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionFunctionArgs } from "@remix-run/node"; 2 | import { redirect } from "@remix-run/node"; 3 | 4 | import { logout } from "~/session.server"; 5 | 6 | export const action = async ({ request }: ActionFunctionArgs) => 7 | logout(request); 8 | 9 | export const loader = async () => redirect("/"); 10 | -------------------------------------------------------------------------------- /app/routes/notes.$noteId.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; 2 | import { json, redirect } from "@remix-run/node"; 3 | import { 4 | Form, 5 | isRouteErrorResponse, 6 | useLoaderData, 7 | useRouteError, 8 | } from "@remix-run/react"; 9 | import invariant from "tiny-invariant"; 10 | 11 | import { deleteNote, getNote } from "~/models/note.server"; 12 | import { requireUserId } from "~/session.server"; 13 | 14 | export const loader = async ({ params, request }: LoaderFunctionArgs) => { 15 | const userId = await requireUserId(request); 16 | invariant(params.noteId, "noteId not found"); 17 | 18 | const note = await getNote({ id: params.noteId, userId }); 19 | if (!note) { 20 | throw new Response("Not Found", { status: 404 }); 21 | } 22 | return json({ note }); 23 | }; 24 | 25 | export const action = async ({ params, request }: ActionFunctionArgs) => { 26 | const userId = await requireUserId(request); 27 | invariant(params.noteId, "noteId not found"); 28 | 29 | await deleteNote({ id: params.noteId, userId }); 30 | 31 | return redirect("/notes"); 32 | }; 33 | 34 | export default function NoteDetailsPage() { 35 | const data = useLoaderData(); 36 | 37 | return ( 38 |
39 |

{data.note.title}

40 |

{data.note.body}

41 |
42 |
43 | 49 |
50 |
51 | ); 52 | } 53 | 54 | export function ErrorBoundary() { 55 | const error = useRouteError(); 56 | 57 | if (error instanceof Error) { 58 | return
An unexpected error occurred: {error.message}
; 59 | } 60 | 61 | if (!isRouteErrorResponse(error)) { 62 | return

Unknown Error

; 63 | } 64 | 65 | if (error.status === 404) { 66 | return
Note not found
; 67 | } 68 | 69 | return
An unexpected error occurred: {error.statusText}
; 70 | } 71 | -------------------------------------------------------------------------------- /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 { ActionFunctionArgs } from "@remix-run/node"; 2 | import { json, redirect } from "@remix-run/node"; 3 | import { Form, useActionData } from "@remix-run/react"; 4 | import { useEffect, useRef } from "react"; 5 | 6 | import { createNote } from "~/models/note.server"; 7 | import { requireUserId } from "~/session.server"; 8 | 9 | export const action = async ({ request }: ActionFunctionArgs) => { 10 | const userId = await requireUserId(request); 11 | 12 | const formData = await request.formData(); 13 | const title = formData.get("title"); 14 | const body = formData.get("body"); 15 | 16 | if (typeof title !== "string" || title.length === 0) { 17 | return json( 18 | { errors: { body: null, title: "Title is required" } }, 19 | { status: 400 }, 20 | ); 21 | } 22 | 23 | if (typeof body !== "string" || body.length === 0) { 24 | return json( 25 | { errors: { body: "Body is required", title: null } }, 26 | { status: 400 }, 27 | ); 28 | } 29 | 30 | const note = await createNote({ body, title, userId }); 31 | 32 | return redirect(`/notes/${note.id}`); 33 | }; 34 | 35 | export default function NewNotePage() { 36 | const actionData = useActionData(); 37 | const titleRef = useRef(null); 38 | const bodyRef = useRef(null); 39 | 40 | useEffect(() => { 41 | if (actionData?.errors?.title) { 42 | titleRef.current?.focus(); 43 | } else if (actionData?.errors?.body) { 44 | bodyRef.current?.focus(); 45 | } 46 | }, [actionData]); 47 | 48 | return ( 49 |
58 |
59 | 71 | {actionData?.errors?.title ? ( 72 |
73 | {actionData.errors.title} 74 |
75 | ) : null} 76 |
77 | 78 |
79 |