Initial commit of scaffolded project. For details see https://github.com/remix-run/indie-stack.
This commit is contained in:
commit
bef30a003c
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/node_modules
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
/.cache
|
||||||
|
/public/build
|
||||||
|
/build
|
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
DATABASE_URL="file:./data.db?connection_limit=1"
|
||||||
|
SESSION_SECRET="super-duper-s3cret"
|
22
.eslintrc.js
Normal file
22
.eslintrc.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
/** @type {import('eslint').Linter.Config} */
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
extends: [
|
||||||
|
"@remix-run/eslint-config",
|
||||||
|
"@remix-run/eslint-config/node",
|
||||||
|
"@remix-run/eslint-config/jest-testing-library",
|
||||||
|
"prettier",
|
||||||
|
],
|
||||||
|
env: {
|
||||||
|
"cypress/globals": true,
|
||||||
|
},
|
||||||
|
plugins: ["cypress"],
|
||||||
|
// we're using vitest which has a very similar API to jest
|
||||||
|
// (so the linting plugins work nicely), but it means we have to explicitly
|
||||||
|
// set the jest version.
|
||||||
|
settings: {
|
||||||
|
jest: {
|
||||||
|
version: 28,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
145
.github/workflows/deploy.yml
vendored
Normal file
145
.github/workflows/deploy.yml
vendored
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
name: 🚀 Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: ⬣ ESLint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: ⬇️ Checkout repo
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: ⎔ Setup node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: ./package.json
|
||||||
|
node-version: 18
|
||||||
|
|
||||||
|
- name: 📥 Install deps
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: 🔬 Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
name: ʦ TypeScript
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: ⬇️ Checkout repo
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: ⎔ Setup node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: ./package.json
|
||||||
|
node-version: 18
|
||||||
|
|
||||||
|
- name: 📥 Install deps
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: 🔎 Type check
|
||||||
|
run: npm run typecheck --if-present
|
||||||
|
|
||||||
|
vitest:
|
||||||
|
name: ⚡ Vitest
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: ⬇️ Checkout repo
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: ⎔ Setup node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: ./package.json
|
||||||
|
node-version: 18
|
||||||
|
|
||||||
|
- name: 📥 Install deps
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: ⚡ Run vitest
|
||||||
|
run: npm run test -- --coverage
|
||||||
|
|
||||||
|
cypress:
|
||||||
|
name: ⚫️ Cypress
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: ⬇️ Checkout repo
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: 🏄 Copy test env vars
|
||||||
|
run: cp .env.example .env
|
||||||
|
|
||||||
|
- name: ⎔ Setup node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: ./package.json
|
||||||
|
node-version: 18
|
||||||
|
|
||||||
|
- name: 📥 Install deps
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: 🛠 Setup Database
|
||||||
|
run: npx prisma migrate reset --force
|
||||||
|
|
||||||
|
- name: ⚙️ Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: 🌳 Cypress run
|
||||||
|
uses: cypress-io/github-action@v5
|
||||||
|
with:
|
||||||
|
start: npm run start:mocks
|
||||||
|
wait-on: http://localhost:8811
|
||||||
|
env:
|
||||||
|
PORT: 8811
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: 🚀 Deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint, typecheck, vitest, cypress]
|
||||||
|
# only build/deploy main branch on pushes
|
||||||
|
if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: ⬇️ Checkout repo
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: 👀 Read app name
|
||||||
|
uses: SebRollen/toml-action@v1.0.2
|
||||||
|
id: app_name
|
||||||
|
with:
|
||||||
|
file: fly.toml
|
||||||
|
field: app
|
||||||
|
|
||||||
|
- name: 🚀 Deploy Staging
|
||||||
|
if: ${{ github.ref == 'refs/heads/dev' }}
|
||||||
|
uses: superfly/flyctl-actions@1.3
|
||||||
|
with:
|
||||||
|
args: deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} --app ${{ steps.app_name.outputs.value }}-staging
|
||||||
|
env:
|
||||||
|
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||||
|
|
||||||
|
- name: 🚀 Deploy Production
|
||||||
|
if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
uses: superfly/flyctl-actions@1.3
|
||||||
|
with:
|
||||||
|
args: deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} --app ${{ steps.app_name.outputs.value }}
|
||||||
|
env:
|
||||||
|
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
/build
|
||||||
|
/public/build
|
||||||
|
.env
|
||||||
|
|
||||||
|
/cypress/screenshots
|
||||||
|
/cypress/videos
|
||||||
|
/prisma/data.db
|
||||||
|
/prisma/data.db-journal
|
9
.gitpod.Dockerfile
vendored
Normal file
9
.gitpod.Dockerfile
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
FROM gitpod/workspace-full
|
||||||
|
|
||||||
|
# Install Fly
|
||||||
|
RUN curl -L https://fly.io/install.sh | sh
|
||||||
|
ENV FLYCTL_INSTALL="/home/gitpod/.fly"
|
||||||
|
ENV PATH="$FLYCTL_INSTALL/bin:$PATH"
|
||||||
|
|
||||||
|
# Install GitHub CLI
|
||||||
|
RUN brew install gh
|
48
.gitpod.yml
Normal file
48
.gitpod.yml
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# https://www.gitpod.io/docs/config-gitpod-file
|
||||||
|
|
||||||
|
image:
|
||||||
|
file: .gitpod.Dockerfile
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- port: 3000
|
||||||
|
onOpen: notify
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Restore .env file
|
||||||
|
command: |
|
||||||
|
if [ -f .env ]; then
|
||||||
|
# If this workspace already has a .env, don't override it
|
||||||
|
# Local changes survive a workspace being opened and closed
|
||||||
|
# but they will not persist between separate workspaces for the same repo
|
||||||
|
|
||||||
|
echo "Found .env in workspace"
|
||||||
|
else
|
||||||
|
# There is no .env
|
||||||
|
if [ ! -n "${ENV}" ]; then
|
||||||
|
# There is no $ENV from a previous workspace
|
||||||
|
# Default to the example .env
|
||||||
|
echo "Setting example .env"
|
||||||
|
|
||||||
|
cp .env.example .env
|
||||||
|
else
|
||||||
|
# After making changes to .env, run this line to persist it to $ENV
|
||||||
|
# eval $(gp env -e ENV="$(base64 .env | tr -d '\n')")
|
||||||
|
#
|
||||||
|
# Environment variables set this way are shared between all your workspaces for this repo
|
||||||
|
# The lines below will read $ENV and print a .env file
|
||||||
|
|
||||||
|
echo "Restoring .env from Gitpod"
|
||||||
|
|
||||||
|
echo "${ENV}" | base64 -d | tee .env > /dev/null
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- init: npm install
|
||||||
|
command: npm run setup && npm run dev
|
||||||
|
|
||||||
|
vscode:
|
||||||
|
extensions:
|
||||||
|
- ms-azuretools.vscode-docker
|
||||||
|
- esbenp.prettier-vscode
|
||||||
|
- dbaeumer.vscode-eslint
|
||||||
|
- bradlc.vscode-tailwindcss
|
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
/build
|
||||||
|
/public/build
|
||||||
|
.env
|
||||||
|
|
||||||
|
/app/styles/tailwind.css
|
61
Dockerfile
Normal file
61
Dockerfile
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# base node image
|
||||||
|
FROM node:16-bullseye-slim as base
|
||||||
|
|
||||||
|
# set for base and all layer that inherit from it
|
||||||
|
ENV NODE_ENV production
|
||||||
|
|
||||||
|
# Install openssl for Prisma
|
||||||
|
RUN apt-get update && apt-get install -y openssl sqlite3
|
||||||
|
|
||||||
|
# Install all node_modules, including dev dependencies
|
||||||
|
FROM base as deps
|
||||||
|
|
||||||
|
WORKDIR /myapp
|
||||||
|
|
||||||
|
ADD package.json package-lock.json .npmrc ./
|
||||||
|
RUN npm install --production=false
|
||||||
|
|
||||||
|
# Setup production node_modules
|
||||||
|
FROM base as production-deps
|
||||||
|
|
||||||
|
WORKDIR /myapp
|
||||||
|
|
||||||
|
COPY --from=deps /myapp/node_modules /myapp/node_modules
|
||||||
|
ADD package.json package-lock.json .npmrc ./
|
||||||
|
RUN npm prune --production
|
||||||
|
|
||||||
|
# Build the app
|
||||||
|
FROM base as build
|
||||||
|
|
||||||
|
WORKDIR /myapp
|
||||||
|
|
||||||
|
COPY --from=deps /myapp/node_modules /myapp/node_modules
|
||||||
|
|
||||||
|
ADD prisma .
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
ADD . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Finally, build the production image with minimal footprint
|
||||||
|
FROM base
|
||||||
|
|
||||||
|
ENV DATABASE_URL=file:/data/sqlite.db
|
||||||
|
ENV PORT="8080"
|
||||||
|
ENV NODE_ENV="production"
|
||||||
|
|
||||||
|
# add shortcut for connecting to database CLI
|
||||||
|
RUN echo "#!/bin/sh\nset -x\nsqlite3 \$DATABASE_URL" > /usr/local/bin/database-cli && chmod +x /usr/local/bin/database-cli
|
||||||
|
|
||||||
|
WORKDIR /myapp
|
||||||
|
|
||||||
|
COPY --from=production-deps /myapp/node_modules /myapp/node_modules
|
||||||
|
COPY --from=build /myapp/node_modules/.prisma /myapp/node_modules/.prisma
|
||||||
|
|
||||||
|
COPY --from=build /myapp/build /myapp/build
|
||||||
|
COPY --from=build /myapp/public /myapp/public
|
||||||
|
COPY --from=build /myapp/package.json /myapp/package.json
|
||||||
|
COPY --from=build /myapp/start.sh /myapp/start.sh
|
||||||
|
COPY --from=build /myapp/prisma /myapp/prisma
|
||||||
|
|
||||||
|
ENTRYPOINT [ "./start.sh" ]
|
180
README.md
Normal file
180
README.md
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
# Remix Indie Stack
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Learn more about [Remix Stacks](https://remix.run/stacks).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx create-remix@latest --template remix-run/indie-stack
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's in the stack
|
||||||
|
|
||||||
|
- [Fly app deployment](https://fly.io) with [Docker](https://www.docker.com/)
|
||||||
|
- Production-ready [SQLite Database](https://sqlite.org)
|
||||||
|
- Healthcheck endpoint for [Fly backups region fallbacks](https://fly.io/docs/reference/configuration/#services-http_checks)
|
||||||
|
- [GitHub Actions](https://github.com/features/actions) for deploy on merge to production and staging environments
|
||||||
|
- Email/Password Authentication with [cookie-based sessions](https://remix.run/utils/sessions#md-createcookiesessionstorage)
|
||||||
|
- Database ORM with [Prisma](https://prisma.io)
|
||||||
|
- Styling with [Tailwind](https://tailwindcss.com/)
|
||||||
|
- End-to-end testing with [Cypress](https://cypress.io)
|
||||||
|
- Local third party request mocking with [MSW](https://mswjs.io)
|
||||||
|
- Unit testing with [Vitest](https://vitest.dev) and [Testing Library](https://testing-library.com)
|
||||||
|
- Code formatting with [Prettier](https://prettier.io)
|
||||||
|
- Linting with [ESLint](https://eslint.org)
|
||||||
|
- Static Types with [TypeScript](https://typescriptlang.org)
|
||||||
|
|
||||||
|
Not a fan of bits of the stack? Fork it, change it, and use `npx create-remix --template your/repo`! Make it your own.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
Click this button to create a [Gitpod](https://gitpod.io) workspace with the project set up and Fly pre-installed
|
||||||
|
|
||||||
|
[](https://gitpod.io/#https://github.com/remix-run/indie-stack/tree/main)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
- This step only applies if you've opted out of having the CLI install dependencies for you:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx remix init
|
||||||
|
```
|
||||||
|
|
||||||
|
- Initial setup: _If you just generated this project, this step has been done for you._
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run setup
|
||||||
|
```
|
||||||
|
|
||||||
|
- Start dev server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts your app in development mode, rebuilding assets on file changes.
|
||||||
|
|
||||||
|
The database seed script creates a new user with some data you can use to get started:
|
||||||
|
|
||||||
|
- Email: `rachel@remix.run`
|
||||||
|
- Password: `racheliscool`
|
||||||
|
|
||||||
|
### Relevant code:
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
- creating users, and logging in and out [./app/models/user.server.ts](./app/models/user.server.ts)
|
||||||
|
- user sessions, and verifying them [./app/session.server.ts](./app/session.server.ts)
|
||||||
|
- creating, and deleting notes [./app/models/note.server.ts](./app/models/note.server.ts)
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
This Remix Stack comes with two GitHub Actions that handle automatically deploying your app to production and staging environments.
|
||||||
|
|
||||||
|
Prior to your first deployment, you'll need to do a few things:
|
||||||
|
|
||||||
|
- [Install Fly](https://fly.io/docs/getting-started/installing-flyctl/)
|
||||||
|
|
||||||
|
- Sign up and log in to Fly
|
||||||
|
|
||||||
|
```sh
|
||||||
|
fly auth signup
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** If you have more than one Fly account, ensure that you are signed into the same account in the Fly CLI as you are in the browser. In your terminal, run `fly auth whoami` and ensure the email matches the Fly account signed into the browser.
|
||||||
|
|
||||||
|
- Create two apps on Fly, one for staging and one for production:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
fly apps create tunein-radio-stations-1ae3
|
||||||
|
fly apps create tunein-radio-stations-1ae3-staging
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** Make sure this name matches the `app` set in your `fly.toml` file. Otherwise, you will not be able to deploy.
|
||||||
|
|
||||||
|
- Initialize Git.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git init
|
||||||
|
```
|
||||||
|
|
||||||
|
- Create a new [GitHub Repository](https://repo.new), and then add it as the remote for your project. **Do not push your app yet!**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git remote add origin <ORIGIN_URL>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Add a `FLY_API_TOKEN` to your GitHub repo. To do this, go to your user settings on Fly and create a new [token](https://web.fly.io/user/personal_access_tokens/new), then add it to [your repo secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) with the name `FLY_API_TOKEN`.
|
||||||
|
|
||||||
|
- Add a `SESSION_SECRET` to your fly app secrets, to do this you can run the following commands:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app tunein-radio-stations-1ae3
|
||||||
|
fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app tunein-radio-stations-1ae3-staging
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
- Create a persistent volume for the sqlite database for both your staging and production environments. Run the following:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
fly volumes create data --size 1 --app tunein-radio-stations-1ae3
|
||||||
|
fly volumes create data --size 1 --app tunein-radio-stations-1ae3-staging
|
||||||
|
```
|
||||||
|
|
||||||
|
Now that everything is set up you can commit and push your changes to your repo. Every commit to your `main` branch will trigger a deployment to your production environment, and every commit to your `dev` branch will trigger a deployment to your staging environment.
|
||||||
|
|
||||||
|
### Connecting to your database
|
||||||
|
|
||||||
|
The sqlite database lives at `/data/sqlite.db` in your deployed application. You can connect to the live database by running `fly ssh console -C database-cli`.
|
||||||
|
|
||||||
|
### Getting Help with Deployment
|
||||||
|
|
||||||
|
If you run into any issues deploying to Fly, make sure you've followed all of the steps above and if you have, then post as many details about your deployment (including your app name) to [the Fly support community](https://community.fly.io). They're normally pretty responsive over there and hopefully can help resolve any of your deployment issues and questions.
|
||||||
|
|
||||||
|
## GitHub Actions
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Cypress
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
We use [`@testing-library/cypress`](https://testing-library.com/cypress) for selecting elements on the page semantically.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
We have a utility for testing authenticated features without having to go through the login flow:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
cy.login();
|
||||||
|
// you are now logged in as a new user
|
||||||
|
```
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
afterEach(() => {
|
||||||
|
cy.cleanupUser();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
That way, we can keep your local db clean and keep your tests isolated from one another.
|
||||||
|
|
||||||
|
### Vitest
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
### Type Checking
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
### Linting
|
||||||
|
|
||||||
|
This project uses ESLint for linting. That is configured in `.eslintrc.js`.
|
||||||
|
|
||||||
|
### Formatting
|
||||||
|
|
||||||
|
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.
|
23
app/db.server.ts
Normal file
23
app/db.server.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var __db__: PrismaClient | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is needed because in development we don't want to restart
|
||||||
|
// the server with every change, but we want to make sure we don't
|
||||||
|
// create a new connection to the DB with every change either.
|
||||||
|
// In production, we'll have a single connection to the DB.
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
prisma = new PrismaClient();
|
||||||
|
} else {
|
||||||
|
if (!global.__db__) {
|
||||||
|
global.__db__ = new PrismaClient();
|
||||||
|
}
|
||||||
|
prisma = global.__db__;
|
||||||
|
prisma.$connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { prisma };
|
18
app/entry.client.tsx
Normal file
18
app/entry.client.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* By default, Remix will handle hydrating your app on the client for you.
|
||||||
|
* 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` ✨
|
||||||
|
* For more information, see https://remix.run/docs/en/main/file-conventions/entry.client
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { RemixBrowser } from "@remix-run/react";
|
||||||
|
import { startTransition, StrictMode } from "react";
|
||||||
|
import { hydrateRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
hydrateRoot(
|
||||||
|
document,
|
||||||
|
<StrictMode>
|
||||||
|
<RemixBrowser />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
});
|
120
app/entry.server.tsx
Normal file
120
app/entry.server.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* By default, Remix will handle generating the HTTP Response for you.
|
||||||
|
* 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` ✨
|
||||||
|
* For more information, see https://remix.run/docs/en/main/file-conventions/entry.server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PassThrough } from "node:stream";
|
||||||
|
|
||||||
|
import type { EntryContext } from "@remix-run/node";
|
||||||
|
import { Response } from "@remix-run/node";
|
||||||
|
import { RemixServer } from "@remix-run/react";
|
||||||
|
import isbot from "isbot";
|
||||||
|
import { renderToPipeableStream } from "react-dom/server";
|
||||||
|
|
||||||
|
const ABORT_DELAY = 5_000;
|
||||||
|
|
||||||
|
export default function handleRequest(
|
||||||
|
request: Request,
|
||||||
|
responseStatusCode: number,
|
||||||
|
responseHeaders: Headers,
|
||||||
|
remixContext: EntryContext
|
||||||
|
) {
|
||||||
|
return isbot(request.headers.get("user-agent"))
|
||||||
|
? handleBotRequest(
|
||||||
|
request,
|
||||||
|
responseStatusCode,
|
||||||
|
responseHeaders,
|
||||||
|
remixContext
|
||||||
|
)
|
||||||
|
: handleBrowserRequest(
|
||||||
|
request,
|
||||||
|
responseStatusCode,
|
||||||
|
responseHeaders,
|
||||||
|
remixContext
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBotRequest(
|
||||||
|
request: Request,
|
||||||
|
responseStatusCode: number,
|
||||||
|
responseHeaders: Headers,
|
||||||
|
remixContext: EntryContext
|
||||||
|
) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const { pipe, abort } = renderToPipeableStream(
|
||||||
|
<RemixServer
|
||||||
|
context={remixContext}
|
||||||
|
url={request.url}
|
||||||
|
abortDelay={ABORT_DELAY}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
onAllReady() {
|
||||||
|
const body = new PassThrough();
|
||||||
|
|
||||||
|
responseHeaders.set("Content-Type", "text/html");
|
||||||
|
|
||||||
|
resolve(
|
||||||
|
new Response(body, {
|
||||||
|
headers: responseHeaders,
|
||||||
|
status: responseStatusCode,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
pipe(body);
|
||||||
|
},
|
||||||
|
onShellError(error: unknown) {
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
|
onError(error: unknown) {
|
||||||
|
responseStatusCode = 500;
|
||||||
|
console.error(error);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setTimeout(abort, ABORT_DELAY);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBrowserRequest(
|
||||||
|
request: Request,
|
||||||
|
responseStatusCode: number,
|
||||||
|
responseHeaders: Headers,
|
||||||
|
remixContext: EntryContext
|
||||||
|
) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const { pipe, abort } = renderToPipeableStream(
|
||||||
|
<RemixServer
|
||||||
|
context={remixContext}
|
||||||
|
url={request.url}
|
||||||
|
abortDelay={ABORT_DELAY}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
onShellReady() {
|
||||||
|
const body = new PassThrough();
|
||||||
|
|
||||||
|
responseHeaders.set("Content-Type", "text/html");
|
||||||
|
|
||||||
|
resolve(
|
||||||
|
new Response(body, {
|
||||||
|
headers: responseHeaders,
|
||||||
|
status: responseStatusCode,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
pipe(body);
|
||||||
|
},
|
||||||
|
onShellError(error: unknown) {
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
|
onError(error: unknown) {
|
||||||
|
console.error(error);
|
||||||
|
responseStatusCode = 500;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setTimeout(abort, ABORT_DELAY);
|
||||||
|
});
|
||||||
|
}
|
52
app/models/note.server.ts
Normal file
52
app/models/note.server.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import type { User, Note } from "@prisma/client";
|
||||||
|
|
||||||
|
import { prisma } from "~/db.server";
|
||||||
|
|
||||||
|
export function getNote({
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
}: Pick<Note, "id"> & {
|
||||||
|
userId: User["id"];
|
||||||
|
}) {
|
||||||
|
return prisma.note.findFirst({
|
||||||
|
select: { id: true, body: true, title: true },
|
||||||
|
where: { id, userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNoteListItems({ userId }: { userId: User["id"] }) {
|
||||||
|
return prisma.note.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { id: true, title: true },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNote({
|
||||||
|
body,
|
||||||
|
title,
|
||||||
|
userId,
|
||||||
|
}: Pick<Note, "body" | "title"> & {
|
||||||
|
userId: User["id"];
|
||||||
|
}) {
|
||||||
|
return prisma.note.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteNote({
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
}: Pick<Note, "id"> & { userId: User["id"] }) {
|
||||||
|
return prisma.note.deleteMany({
|
||||||
|
where: { id, userId },
|
||||||
|
});
|
||||||
|
}
|
62
app/models/user.server.ts
Normal file
62
app/models/user.server.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import type { Password, User } from "@prisma/client";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
import { prisma } from "~/db.server";
|
||||||
|
|
||||||
|
export type { User } from "@prisma/client";
|
||||||
|
|
||||||
|
export async function getUserById(id: User["id"]) {
|
||||||
|
return prisma.user.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserByEmail(email: User["email"]) {
|
||||||
|
return prisma.user.findUnique({ where: { email } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(email: User["email"], password: string) {
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
return prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
password: {
|
||||||
|
create: {
|
||||||
|
hash: hashedPassword,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserByEmail(email: User["email"]) {
|
||||||
|
return prisma.user.delete({ where: { email } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyLogin(
|
||||||
|
email: User["email"],
|
||||||
|
password: Password["hash"]
|
||||||
|
) {
|
||||||
|
const userWithPassword = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
include: {
|
||||||
|
password: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userWithPassword || !userWithPassword.password) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await bcrypt.compare(
|
||||||
|
password,
|
||||||
|
userWithPassword.password.hash
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password: _password, ...userWithoutPassword } = userWithPassword;
|
||||||
|
|
||||||
|
return userWithoutPassword;
|
||||||
|
}
|
42
app/root.tsx
Normal file
42
app/root.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { cssBundleHref } from "@remix-run/css-bundle";
|
||||||
|
import type { LinksFunction, LoaderArgs } from "@remix-run/node";
|
||||||
|
import { json } from "@remix-run/node";
|
||||||
|
import {
|
||||||
|
Links,
|
||||||
|
LiveReload,
|
||||||
|
Meta,
|
||||||
|
Outlet,
|
||||||
|
Scripts,
|
||||||
|
ScrollRestoration,
|
||||||
|
} from "@remix-run/react";
|
||||||
|
|
||||||
|
import { getUser } from "~/session.server";
|
||||||
|
import stylesheet from "~/tailwind.css";
|
||||||
|
|
||||||
|
export const links: LinksFunction = () => [
|
||||||
|
{ rel: "stylesheet", href: stylesheet },
|
||||||
|
...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderArgs) => {
|
||||||
|
return json({ user: await getUser(request) });
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<html lang="en" className="h-full">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<Meta />
|
||||||
|
<Links />
|
||||||
|
</head>
|
||||||
|
<body className="h-full">
|
||||||
|
<Outlet />
|
||||||
|
<ScrollRestoration />
|
||||||
|
<Scripts />
|
||||||
|
<LiveReload />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
141
app/routes/_index.tsx
Normal file
141
app/routes/_index.tsx
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import type { V2_MetaFunction } from "@remix-run/node";
|
||||||
|
import { Link } from "@remix-run/react";
|
||||||
|
|
||||||
|
import { useOptionalUser } from "~/utils";
|
||||||
|
|
||||||
|
export const meta: V2_MetaFunction = () => [{ title: "Remix Notes" }];
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const user = useOptionalUser();
|
||||||
|
return (
|
||||||
|
<main className="relative min-h-screen bg-white sm:flex sm:items-center sm:justify-center">
|
||||||
|
<div className="relative sm:pb-16 sm:pt-8">
|
||||||
|
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||||
|
<div className="relative shadow-xl sm:overflow-hidden sm:rounded-2xl">
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<img
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
src="https://user-images.githubusercontent.com/1500684/157774694-99820c51-8165-4908-a031-34fc371ac0d6.jpg"
|
||||||
|
alt="Sonic Youth On Stage"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-[color:rgba(254,204,27,0.5)] mix-blend-multiply" />
|
||||||
|
</div>
|
||||||
|
<div className="relative px-4 pb-8 pt-16 sm:px-6 sm:pb-14 sm:pt-24 lg:px-8 lg:pb-20 lg:pt-32">
|
||||||
|
<h1 className="text-center text-6xl font-extrabold tracking-tight sm:text-8xl lg:text-9xl">
|
||||||
|
<span className="block uppercase text-yellow-500 drop-shadow-md">
|
||||||
|
Indie Stack
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mx-auto mt-6 max-w-lg text-center text-xl text-white sm:max-w-3xl">
|
||||||
|
Check the README.md file for instructions on how to get this
|
||||||
|
project deployed.
|
||||||
|
</p>
|
||||||
|
<div className="mx-auto mt-10 max-w-sm sm:flex sm:max-w-none sm:justify-center">
|
||||||
|
{user ? (
|
||||||
|
<Link
|
||||||
|
to="/notes"
|
||||||
|
className="flex items-center justify-center rounded-md border border-transparent bg-white px-4 py-3 text-base font-medium text-yellow-700 shadow-sm hover:bg-yellow-50 sm:px-8"
|
||||||
|
>
|
||||||
|
View Notes for {user.email}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 sm:mx-auto sm:inline-grid sm:grid-cols-2 sm:gap-5 sm:space-y-0">
|
||||||
|
<Link
|
||||||
|
to="/join"
|
||||||
|
className="flex items-center justify-center rounded-md border border-transparent bg-white px-4 py-3 text-base font-medium text-yellow-700 shadow-sm hover:bg-yellow-50 sm:px-8"
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="flex items-center justify-center rounded-md bg-yellow-500 px-4 py-3 font-medium text-white hover:bg-yellow-600"
|
||||||
|
>
|
||||||
|
Log In
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<a href="https://remix.run">
|
||||||
|
<img
|
||||||
|
src="https://user-images.githubusercontent.com/1500684/158298926-e45dafff-3544-4b69-96d6-d3bcc33fc76a.svg"
|
||||||
|
alt="Remix"
|
||||||
|
className="mx-auto mt-16 w-full max-w-[12rem] md:max-w-[16rem]"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-2 sm:px-6 lg:px-8">
|
||||||
|
<div className="mt-6 flex flex-wrap justify-center gap-8">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
src: "https://user-images.githubusercontent.com/1500684/157764397-ccd8ea10-b8aa-4772-a99b-35de937319e1.svg",
|
||||||
|
alt: "Fly.io",
|
||||||
|
href: "https://fly.io",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "https://user-images.githubusercontent.com/1500684/157764395-137ec949-382c-43bd-a3c0-0cb8cb22e22d.svg",
|
||||||
|
alt: "SQLite",
|
||||||
|
href: "https://sqlite.org",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "https://user-images.githubusercontent.com/1500684/157764484-ad64a21a-d7fb-47e3-8669-ec046da20c1f.svg",
|
||||||
|
alt: "Prisma",
|
||||||
|
href: "https://prisma.io",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "https://user-images.githubusercontent.com/1500684/157764276-a516a239-e377-4a20-b44a-0ac7b65c8c14.svg",
|
||||||
|
alt: "Tailwind",
|
||||||
|
href: "https://tailwindcss.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "https://user-images.githubusercontent.com/1500684/157764454-48ac8c71-a2a9-4b5e-b19c-edef8b8953d6.svg",
|
||||||
|
alt: "Cypress",
|
||||||
|
href: "https://www.cypress.io",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "https://user-images.githubusercontent.com/1500684/157772386-75444196-0604-4340-af28-53b236faa182.svg",
|
||||||
|
alt: "MSW",
|
||||||
|
href: "https://mswjs.io",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "https://user-images.githubusercontent.com/1500684/157772447-00fccdce-9d12-46a3-8bb4-fac612cdc949.svg",
|
||||||
|
alt: "Vitest",
|
||||||
|
href: "https://vitest.dev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "https://user-images.githubusercontent.com/1500684/157772662-92b0dd3a-453f-4d18-b8be-9fa6efde52cf.png",
|
||||||
|
alt: "Testing Library",
|
||||||
|
href: "https://testing-library.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "https://user-images.githubusercontent.com/1500684/157772934-ce0a943d-e9d0-40f8-97f3-f464c0811643.svg",
|
||||||
|
alt: "Prettier",
|
||||||
|
href: "https://prettier.io",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "https://user-images.githubusercontent.com/1500684/157772990-3968ff7c-b551-4c55-a25c-046a32709a8e.svg",
|
||||||
|
alt: "ESLint",
|
||||||
|
href: "https://eslint.org",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "https://user-images.githubusercontent.com/1500684/157773063-20a0ed64-b9f8-4e0b-9d1e-0b65a3d4a6db.svg",
|
||||||
|
alt: "TypeScript",
|
||||||
|
href: "https://typescriptlang.org",
|
||||||
|
},
|
||||||
|
].map((img) => (
|
||||||
|
<a
|
||||||
|
key={img.href}
|
||||||
|
href={img.href}
|
||||||
|
className="flex h-16 w-32 justify-center p-1 grayscale transition hover:grayscale-0 focus:grayscale-0"
|
||||||
|
>
|
||||||
|
<img alt={img.alt} src={img.src} className="object-contain" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
25
app/routes/healthcheck.tsx
Normal file
25
app/routes/healthcheck.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// learn more: https://fly.io/docs/reference/configuration/#services-http_checks
|
||||||
|
import type { LoaderArgs } from "@remix-run/node";
|
||||||
|
|
||||||
|
import { prisma } from "~/db.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderArgs) => {
|
||||||
|
const host =
|
||||||
|
request.headers.get("X-Forwarded-Host") ?? request.headers.get("host");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL("/", `http://${host}`);
|
||||||
|
// if we can connect to the database and make a simple query
|
||||||
|
// and make a HEAD request to ourselves, then we're good.
|
||||||
|
await Promise.all([
|
||||||
|
prisma.user.count(),
|
||||||
|
fetch(url.toString(), { method: "HEAD" }).then((r) => {
|
||||||
|
if (!r.ok) return Promise.reject(r);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return new Response("OK");
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.log("healthcheck ❌", { error });
|
||||||
|
return new Response("ERROR", { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
166
app/routes/join.tsx
Normal file
166
app/routes/join.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import type { ActionArgs, LoaderArgs, V2_MetaFunction } from "@remix-run/node";
|
||||||
|
import { json, redirect } from "@remix-run/node";
|
||||||
|
import { Form, Link, useActionData, useSearchParams } from "@remix-run/react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { createUser, getUserByEmail } from "~/models/user.server";
|
||||||
|
import { createUserSession, getUserId } from "~/session.server";
|
||||||
|
import { safeRedirect, validateEmail } from "~/utils";
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderArgs) => {
|
||||||
|
const userId = await getUserId(request);
|
||||||
|
if (userId) return redirect("/");
|
||||||
|
return json({});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }: ActionArgs) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const email = formData.get("email");
|
||||||
|
const password = formData.get("password");
|
||||||
|
const redirectTo = safeRedirect(formData.get("redirectTo"), "/");
|
||||||
|
|
||||||
|
if (!validateEmail(email)) {
|
||||||
|
return json(
|
||||||
|
{ errors: { email: "Email is invalid", password: null } },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof password !== "string" || password.length === 0) {
|
||||||
|
return json(
|
||||||
|
{ errors: { email: null, password: "Password is required" } },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
return json(
|
||||||
|
{ errors: { email: null, password: "Password is too short" } },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await getUserByEmail(email);
|
||||||
|
if (existingUser) {
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
errors: {
|
||||||
|
email: "A user already exists with this email",
|
||||||
|
password: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await createUser(email, password);
|
||||||
|
|
||||||
|
return createUserSession({
|
||||||
|
redirectTo,
|
||||||
|
remember: false,
|
||||||
|
request,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const meta: V2_MetaFunction = () => [{ title: "Sign Up" }];
|
||||||
|
|
||||||
|
export default function Join() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const redirectTo = searchParams.get("redirectTo") ?? undefined;
|
||||||
|
const actionData = useActionData<typeof action>();
|
||||||
|
const emailRef = useRef<HTMLInputElement>(null);
|
||||||
|
const passwordRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (actionData?.errors?.email) {
|
||||||
|
emailRef.current?.focus();
|
||||||
|
} else if (actionData?.errors?.password) {
|
||||||
|
passwordRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [actionData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full flex-col justify-center">
|
||||||
|
<div className="mx-auto w-full max-w-md px-8">
|
||||||
|
<Form method="post" className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<input
|
||||||
|
ref={emailRef}
|
||||||
|
id="email"
|
||||||
|
required
|
||||||
|
autoFocus={true}
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
aria-invalid={actionData?.errors?.email ? true : undefined}
|
||||||
|
aria-describedby="email-error"
|
||||||
|
className="w-full rounded border border-gray-500 px-2 py-1 text-lg"
|
||||||
|
/>
|
||||||
|
{actionData?.errors?.email ? (
|
||||||
|
<div className="pt-1 text-red-700" id="email-error">
|
||||||
|
{actionData.errors.email}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
ref={passwordRef}
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
aria-invalid={actionData?.errors?.password ? true : undefined}
|
||||||
|
aria-describedby="password-error"
|
||||||
|
className="w-full rounded border border-gray-500 px-2 py-1 text-lg"
|
||||||
|
/>
|
||||||
|
{actionData?.errors?.password ? (
|
||||||
|
<div className="pt-1 text-red-700" id="password-error">
|
||||||
|
{actionData.errors.password}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name="redirectTo" value={redirectTo} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
|
||||||
|
>
|
||||||
|
Create Account
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="text-center text-sm text-gray-500">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link
|
||||||
|
className="text-blue-500 underline"
|
||||||
|
to={{
|
||||||
|
pathname: "/login",
|
||||||
|
search: searchParams.toString(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
175
app/routes/login.tsx
Normal file
175
app/routes/login.tsx
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import type { ActionArgs, LoaderArgs, V2_MetaFunction } from "@remix-run/node";
|
||||||
|
import { json, redirect } from "@remix-run/node";
|
||||||
|
import { Form, Link, useActionData, useSearchParams } from "@remix-run/react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { verifyLogin } from "~/models/user.server";
|
||||||
|
import { createUserSession, getUserId } from "~/session.server";
|
||||||
|
import { safeRedirect, validateEmail } from "~/utils";
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderArgs) => {
|
||||||
|
const userId = await getUserId(request);
|
||||||
|
if (userId) return redirect("/");
|
||||||
|
return json({});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }: ActionArgs) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const email = formData.get("email");
|
||||||
|
const password = formData.get("password");
|
||||||
|
const redirectTo = safeRedirect(formData.get("redirectTo"), "/");
|
||||||
|
const remember = formData.get("remember");
|
||||||
|
|
||||||
|
if (!validateEmail(email)) {
|
||||||
|
return json(
|
||||||
|
{ errors: { email: "Email is invalid", password: null } },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof password !== "string" || password.length === 0) {
|
||||||
|
return json(
|
||||||
|
{ errors: { email: null, password: "Password is required" } },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
return json(
|
||||||
|
{ errors: { email: null, password: "Password is too short" } },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await verifyLogin(email, password);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return json(
|
||||||
|
{ errors: { email: "Invalid email or password", password: null } },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createUserSession({
|
||||||
|
redirectTo,
|
||||||
|
remember: remember === "on" ? true : false,
|
||||||
|
request,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const meta: V2_MetaFunction = () => [{ title: "Login" }];
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const redirectTo = searchParams.get("redirectTo") || "/notes";
|
||||||
|
const actionData = useActionData<typeof action>();
|
||||||
|
const emailRef = useRef<HTMLInputElement>(null);
|
||||||
|
const passwordRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (actionData?.errors?.email) {
|
||||||
|
emailRef.current?.focus();
|
||||||
|
} else if (actionData?.errors?.password) {
|
||||||
|
passwordRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [actionData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full flex-col justify-center">
|
||||||
|
<div className="mx-auto w-full max-w-md px-8">
|
||||||
|
<Form method="post" className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<input
|
||||||
|
ref={emailRef}
|
||||||
|
id="email"
|
||||||
|
required
|
||||||
|
autoFocus={true}
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
aria-invalid={actionData?.errors?.email ? true : undefined}
|
||||||
|
aria-describedby="email-error"
|
||||||
|
className="w-full rounded border border-gray-500 px-2 py-1 text-lg"
|
||||||
|
/>
|
||||||
|
{actionData?.errors?.email ? (
|
||||||
|
<div className="pt-1 text-red-700" id="email-error">
|
||||||
|
{actionData.errors.email}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
ref={passwordRef}
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
aria-invalid={actionData?.errors?.password ? true : undefined}
|
||||||
|
aria-describedby="password-error"
|
||||||
|
className="w-full rounded border border-gray-500 px-2 py-1 text-lg"
|
||||||
|
/>
|
||||||
|
{actionData?.errors?.password ? (
|
||||||
|
<div className="pt-1 text-red-700" id="password-error">
|
||||||
|
{actionData.errors.password}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name="redirectTo" value={redirectTo} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
id="remember"
|
||||||
|
name="remember"
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="remember"
|
||||||
|
className="ml-2 block text-sm text-gray-900"
|
||||||
|
>
|
||||||
|
Remember me
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-sm text-gray-500">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link
|
||||||
|
className="text-blue-500 underline"
|
||||||
|
to={{
|
||||||
|
pathname: "/join",
|
||||||
|
search: searchParams.toString(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
8
app/routes/logout.tsx
Normal file
8
app/routes/logout.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import type { ActionArgs } from "@remix-run/node";
|
||||||
|
import { redirect } from "@remix-run/node";
|
||||||
|
|
||||||
|
import { logout } from "~/session.server";
|
||||||
|
|
||||||
|
export const action = async ({ request }: ActionArgs) => logout(request);
|
||||||
|
|
||||||
|
export const loader = async () => redirect("/");
|
70
app/routes/notes.$noteId.tsx
Normal file
70
app/routes/notes.$noteId.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import type { ActionArgs, LoaderArgs } from "@remix-run/node";
|
||||||
|
import { json, redirect } from "@remix-run/node";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
isRouteErrorResponse,
|
||||||
|
useLoaderData,
|
||||||
|
useRouteError,
|
||||||
|
} from "@remix-run/react";
|
||||||
|
import invariant from "tiny-invariant";
|
||||||
|
|
||||||
|
import { deleteNote, getNote } from "~/models/note.server";
|
||||||
|
import { requireUserId } from "~/session.server";
|
||||||
|
|
||||||
|
export const loader = async ({ params, request }: LoaderArgs) => {
|
||||||
|
const userId = await requireUserId(request);
|
||||||
|
invariant(params.noteId, "noteId not found");
|
||||||
|
|
||||||
|
const note = await getNote({ id: params.noteId, userId });
|
||||||
|
if (!note) {
|
||||||
|
throw new Response("Not Found", { status: 404 });
|
||||||
|
}
|
||||||
|
return json({ note });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ params, request }: ActionArgs) => {
|
||||||
|
const userId = await requireUserId(request);
|
||||||
|
invariant(params.noteId, "noteId not found");
|
||||||
|
|
||||||
|
await deleteNote({ id: params.noteId, userId });
|
||||||
|
|
||||||
|
return redirect("/notes");
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NoteDetailsPage() {
|
||||||
|
const data = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-bold">{data.note.title}</h3>
|
||||||
|
<p className="py-6">{data.note.body}</p>
|
||||||
|
<hr className="my-4" />
|
||||||
|
<Form method="post">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBoundary() {
|
||||||
|
const error = useRouteError();
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return <div>An unexpected error occurred: {error.message}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRouteErrorResponse(error)) {
|
||||||
|
return <h1>Unknown Error</h1>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.status === 404) {
|
||||||
|
return <div>Note not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div>An unexpected error occurred: {error.statusText}</div>;
|
||||||
|
}
|
12
app/routes/notes._index.tsx
Normal file
12
app/routes/notes._index.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Link } from "@remix-run/react";
|
||||||
|
|
||||||
|
export default function NoteIndexPage() {
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
No note selected. Select a note on the left, or{" "}
|
||||||
|
<Link to="new" className="text-blue-500 underline">
|
||||||
|
create a new note.
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
109
app/routes/notes.new.tsx
Normal file
109
app/routes/notes.new.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import type { ActionArgs } from "@remix-run/node";
|
||||||
|
import { json, redirect } from "@remix-run/node";
|
||||||
|
import { Form, useActionData } from "@remix-run/react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { createNote } from "~/models/note.server";
|
||||||
|
import { requireUserId } from "~/session.server";
|
||||||
|
|
||||||
|
export const action = async ({ request }: ActionArgs) => {
|
||||||
|
const userId = await requireUserId(request);
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const title = formData.get("title");
|
||||||
|
const body = formData.get("body");
|
||||||
|
|
||||||
|
if (typeof title !== "string" || title.length === 0) {
|
||||||
|
return json(
|
||||||
|
{ errors: { body: null, title: "Title is required" } },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body !== "string" || body.length === 0) {
|
||||||
|
return json(
|
||||||
|
{ errors: { body: "Body is required", title: null } },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = await createNote({ body, title, userId });
|
||||||
|
|
||||||
|
return redirect(`/notes/${note.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NewNotePage() {
|
||||||
|
const actionData = useActionData<typeof action>();
|
||||||
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
|
const bodyRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (actionData?.errors?.title) {
|
||||||
|
titleRef.current?.focus();
|
||||||
|
} else if (actionData?.errors?.body) {
|
||||||
|
bodyRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [actionData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
method="post"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="flex w-full flex-col gap-1">
|
||||||
|
<span>Title: </span>
|
||||||
|
<input
|
||||||
|
ref={titleRef}
|
||||||
|
name="title"
|
||||||
|
className="flex-1 rounded-md border-2 border-blue-500 px-3 text-lg leading-loose"
|
||||||
|
aria-invalid={actionData?.errors?.title ? true : undefined}
|
||||||
|
aria-errormessage={
|
||||||
|
actionData?.errors?.title ? "title-error" : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{actionData?.errors?.title ? (
|
||||||
|
<div className="pt-1 text-red-700" id="title-error">
|
||||||
|
{actionData.errors.title}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="flex w-full flex-col gap-1">
|
||||||
|
<span>Body: </span>
|
||||||
|
<textarea
|
||||||
|
ref={bodyRef}
|
||||||
|
name="body"
|
||||||
|
rows={8}
|
||||||
|
className="w-full flex-1 rounded-md border-2 border-blue-500 px-3 py-2 text-lg leading-6"
|
||||||
|
aria-invalid={actionData?.errors?.body ? true : undefined}
|
||||||
|
aria-errormessage={
|
||||||
|
actionData?.errors?.body ? "body-error" : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{actionData?.errors?.body ? (
|
||||||
|
<div className="pt-1 text-red-700" id="body-error">
|
||||||
|
{actionData.errors.body}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 focus:bg-blue-400"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
70
app/routes/notes.tsx
Normal file
70
app/routes/notes.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import type { LoaderArgs } from "@remix-run/node";
|
||||||
|
import { json } from "@remix-run/node";
|
||||||
|
import { Form, Link, NavLink, Outlet, useLoaderData } from "@remix-run/react";
|
||||||
|
|
||||||
|
import { getNoteListItems } from "~/models/note.server";
|
||||||
|
import { requireUserId } from "~/session.server";
|
||||||
|
import { useUser } from "~/utils";
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderArgs) => {
|
||||||
|
const userId = await requireUserId(request);
|
||||||
|
const noteListItems = await getNoteListItems({ userId });
|
||||||
|
return json({ noteListItems });
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NotesPage() {
|
||||||
|
const data = useLoaderData<typeof loader>();
|
||||||
|
const user = useUser();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-screen flex-col">
|
||||||
|
<header className="flex items-center justify-between bg-slate-800 p-4 text-white">
|
||||||
|
<h1 className="text-3xl font-bold">
|
||||||
|
<Link to=".">Notes</Link>
|
||||||
|
</h1>
|
||||||
|
<p>{user.email}</p>
|
||||||
|
<Form action="/logout" method="post">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded bg-slate-600 px-4 py-2 text-blue-100 hover:bg-blue-500 active:bg-blue-600"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</Form>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex h-full bg-white">
|
||||||
|
<div className="h-full w-80 border-r bg-gray-50">
|
||||||
|
<Link to="new" className="block p-4 text-xl text-blue-500">
|
||||||
|
+ New Note
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
{data.noteListItems.length === 0 ? (
|
||||||
|
<p className="p-4">No notes yet</p>
|
||||||
|
) : (
|
||||||
|
<ol>
|
||||||
|
{data.noteListItems.map((note) => (
|
||||||
|
<li key={note.id}>
|
||||||
|
<NavLink
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block border-b p-4 text-xl ${isActive ? "bg-white" : ""}`
|
||||||
|
}
|
||||||
|
to={note.id}
|
||||||
|
>
|
||||||
|
📝 {note.title}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 p-6">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
97
app/session.server.ts
Normal file
97
app/session.server.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { createCookieSessionStorage, redirect } from "@remix-run/node";
|
||||||
|
import invariant from "tiny-invariant";
|
||||||
|
|
||||||
|
import type { User } from "~/models/user.server";
|
||||||
|
import { getUserById } from "~/models/user.server";
|
||||||
|
|
||||||
|
invariant(process.env.SESSION_SECRET, "SESSION_SECRET must be set");
|
||||||
|
|
||||||
|
export const sessionStorage = createCookieSessionStorage({
|
||||||
|
cookie: {
|
||||||
|
name: "__session",
|
||||||
|
httpOnly: true,
|
||||||
|
path: "/",
|
||||||
|
sameSite: "lax",
|
||||||
|
secrets: [process.env.SESSION_SECRET],
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const USER_SESSION_KEY = "userId";
|
||||||
|
|
||||||
|
export async function getSession(request: Request) {
|
||||||
|
const cookie = request.headers.get("Cookie");
|
||||||
|
return sessionStorage.getSession(cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserId(
|
||||||
|
request: Request
|
||||||
|
): Promise<User["id"] | undefined> {
|
||||||
|
const session = await getSession(request);
|
||||||
|
const userId = session.get(USER_SESSION_KEY);
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUser(request: Request) {
|
||||||
|
const userId = await getUserId(request);
|
||||||
|
if (userId === undefined) return null;
|
||||||
|
|
||||||
|
const user = await getUserById(userId);
|
||||||
|
if (user) return user;
|
||||||
|
|
||||||
|
throw await logout(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireUserId(
|
||||||
|
request: Request,
|
||||||
|
redirectTo: string = new URL(request.url).pathname
|
||||||
|
) {
|
||||||
|
const userId = await getUserId(request);
|
||||||
|
if (!userId) {
|
||||||
|
const searchParams = new URLSearchParams([["redirectTo", redirectTo]]);
|
||||||
|
throw redirect(`/login?${searchParams}`);
|
||||||
|
}
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireUser(request: Request) {
|
||||||
|
const userId = await requireUserId(request);
|
||||||
|
|
||||||
|
const user = await getUserById(userId);
|
||||||
|
if (user) return user;
|
||||||
|
|
||||||
|
throw await logout(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUserSession({
|
||||||
|
request,
|
||||||
|
userId,
|
||||||
|
remember,
|
||||||
|
redirectTo,
|
||||||
|
}: {
|
||||||
|
request: Request;
|
||||||
|
userId: string;
|
||||||
|
remember: boolean;
|
||||||
|
redirectTo: string;
|
||||||
|
}) {
|
||||||
|
const session = await getSession(request);
|
||||||
|
session.set(USER_SESSION_KEY, userId);
|
||||||
|
return redirect(redirectTo, {
|
||||||
|
headers: {
|
||||||
|
"Set-Cookie": await sessionStorage.commitSession(session, {
|
||||||
|
maxAge: remember
|
||||||
|
? 60 * 60 * 24 * 7 // 7 days
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(request: Request) {
|
||||||
|
const session = await getSession(request);
|
||||||
|
return redirect("/", {
|
||||||
|
headers: {
|
||||||
|
"Set-Cookie": await sessionStorage.destroySession(session),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
3
app/tailwind.css
Normal file
3
app/tailwind.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
13
app/utils.test.ts
Normal file
13
app/utils.test.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { validateEmail } from "./utils";
|
||||||
|
|
||||||
|
test("validateEmail returns false for non-emails", () => {
|
||||||
|
expect(validateEmail(undefined)).toBe(false);
|
||||||
|
expect(validateEmail(null)).toBe(false);
|
||||||
|
expect(validateEmail("")).toBe(false);
|
||||||
|
expect(validateEmail("not-an-email")).toBe(false);
|
||||||
|
expect(validateEmail("n@")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateEmail returns true for emails", () => {
|
||||||
|
expect(validateEmail("kody@example.com")).toBe(true);
|
||||||
|
});
|
71
app/utils.ts
Normal file
71
app/utils.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { useMatches } from "@remix-run/react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import type { User } from "~/models/user.server";
|
||||||
|
|
||||||
|
const DEFAULT_REDIRECT = "/";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This should be used any time the redirect path is user-provided
|
||||||
|
* (Like the query string on our login/signup pages). This avoids
|
||||||
|
* open-redirect vulnerabilities.
|
||||||
|
* @param {string} to The redirect destination
|
||||||
|
* @param {string} defaultRedirect The redirect to use if the to is unsafe.
|
||||||
|
*/
|
||||||
|
export function safeRedirect(
|
||||||
|
to: FormDataEntryValue | string | null | undefined,
|
||||||
|
defaultRedirect: string = DEFAULT_REDIRECT
|
||||||
|
) {
|
||||||
|
if (!to || typeof to !== "string") {
|
||||||
|
return defaultRedirect;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!to.startsWith("/") || to.startsWith("//")) {
|
||||||
|
return defaultRedirect;
|
||||||
|
}
|
||||||
|
|
||||||
|
return to;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This base hook is used in other hooks to quickly search for specific data
|
||||||
|
* across all loader data using useMatches.
|
||||||
|
* @param {string} id The route id
|
||||||
|
* @returns {JSON|undefined} The router data or undefined if not found
|
||||||
|
*/
|
||||||
|
export function useMatchesData(
|
||||||
|
id: string
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
const matchingRoutes = useMatches();
|
||||||
|
const route = useMemo(
|
||||||
|
() => matchingRoutes.find((route) => route.id === id),
|
||||||
|
[matchingRoutes, id]
|
||||||
|
);
|
||||||
|
return route?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUser(user: any): user is User {
|
||||||
|
return user && typeof user === "object" && typeof user.email === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOptionalUser(): User | undefined {
|
||||||
|
const data = useMatchesData("root");
|
||||||
|
if (!data || !isUser(data.user)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return data.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUser(): User {
|
||||||
|
const maybeUser = useOptionalUser();
|
||||||
|
if (!maybeUser) {
|
||||||
|
throw new Error(
|
||||||
|
"No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return maybeUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEmail(email: unknown): email is string {
|
||||||
|
return typeof email === "string" && email.length > 3 && email.includes("@");
|
||||||
|
}
|
27
cypress.config.ts
Normal file
27
cypress.config.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { defineConfig } from "cypress";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
e2e: {
|
||||||
|
setupNodeEvents: (on, config) => {
|
||||||
|
const isDev = config.watchForFileChanges;
|
||||||
|
const port = process.env.PORT ?? (isDev ? "3000" : "8811");
|
||||||
|
const configOverrides: Partial<Cypress.PluginConfigOptions> = {
|
||||||
|
baseUrl: `http://localhost:${port}`,
|
||||||
|
video: !process.env.CI,
|
||||||
|
screenshotOnRunFailure: !process.env.CI,
|
||||||
|
};
|
||||||
|
|
||||||
|
// To use this:
|
||||||
|
// cy.task('log', whateverYouWantInTheTerminal)
|
||||||
|
on("task", {
|
||||||
|
log: (message) => {
|
||||||
|
console.log(message);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...config, ...configOverrides };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
6
cypress/.eslintrc.js
Normal file
6
cypress/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
parserOptions: {
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
project: "./tsconfig.json",
|
||||||
|
},
|
||||||
|
};
|
51
cypress/e2e/smoke.cy.ts
Normal file
51
cypress/e2e/smoke.cy.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { faker } from "@faker-js/faker";
|
||||||
|
|
||||||
|
describe("smoke tests", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cy.cleanupUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow you to register and login", () => {
|
||||||
|
const loginForm = {
|
||||||
|
email: `${faker.internet.userName()}@example.com`,
|
||||||
|
password: faker.internet.password(),
|
||||||
|
};
|
||||||
|
|
||||||
|
cy.then(() => ({ email: loginForm.email })).as("user");
|
||||||
|
|
||||||
|
cy.visitAndCheck("/");
|
||||||
|
|
||||||
|
cy.findByRole("link", { name: /sign up/i }).click();
|
||||||
|
|
||||||
|
cy.findByRole("textbox", { name: /email/i }).type(loginForm.email);
|
||||||
|
cy.findByLabelText(/password/i).type(loginForm.password);
|
||||||
|
cy.findByRole("button", { name: /create account/i }).click();
|
||||||
|
|
||||||
|
cy.findByRole("link", { name: /notes/i }).click();
|
||||||
|
cy.findByRole("button", { name: /logout/i }).click();
|
||||||
|
cy.findByRole("link", { name: /log in/i });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow you to make a note", () => {
|
||||||
|
const testNote = {
|
||||||
|
title: faker.lorem.words(1),
|
||||||
|
body: faker.lorem.sentences(1),
|
||||||
|
};
|
||||||
|
cy.login();
|
||||||
|
|
||||||
|
cy.visitAndCheck("/");
|
||||||
|
|
||||||
|
cy.findByRole("link", { name: /notes/i }).click();
|
||||||
|
cy.findByText("No notes yet");
|
||||||
|
|
||||||
|
cy.findByRole("link", { name: /\+ new note/i }).click();
|
||||||
|
|
||||||
|
cy.findByRole("textbox", { name: /title/i }).type(testNote.title);
|
||||||
|
cy.findByRole("textbox", { name: /body/i }).type(testNote.body);
|
||||||
|
cy.findByRole("button", { name: /save/i }).click();
|
||||||
|
|
||||||
|
cy.findByRole("button", { name: /delete/i }).click();
|
||||||
|
|
||||||
|
cy.findByText("No notes yet");
|
||||||
|
});
|
||||||
|
});
|
5
cypress/fixtures/example.json
Normal file
5
cypress/fixtures/example.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "Using fixtures to represent data",
|
||||||
|
"email": "hello@cypress.io",
|
||||||
|
"body": "Fixtures are a great way to mock data for responses to routes"
|
||||||
|
}
|
95
cypress/support/commands.ts
Normal file
95
cypress/support/commands.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { faker } from "@faker-js/faker";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Cypress {
|
||||||
|
interface Chainable {
|
||||||
|
/**
|
||||||
|
* Logs in with a random user. Yields the user and adds an alias to the user
|
||||||
|
*
|
||||||
|
* @returns {typeof login}
|
||||||
|
* @memberof Chainable
|
||||||
|
* @example
|
||||||
|
* cy.login()
|
||||||
|
* @example
|
||||||
|
* cy.login({ email: 'whatever@example.com' })
|
||||||
|
*/
|
||||||
|
login: typeof login;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the current @user
|
||||||
|
*
|
||||||
|
* @returns {typeof cleanupUser}
|
||||||
|
* @memberof Chainable
|
||||||
|
* @example
|
||||||
|
* cy.cleanupUser()
|
||||||
|
* @example
|
||||||
|
* cy.cleanupUser({ email: 'whatever@example.com' })
|
||||||
|
*/
|
||||||
|
cleanupUser: typeof cleanupUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends the standard visit command to wait for the page to load
|
||||||
|
*
|
||||||
|
* @returns {typeof visitAndCheck}
|
||||||
|
* @memberof Chainable
|
||||||
|
* @example
|
||||||
|
* cy.visitAndCheck('/')
|
||||||
|
* @example
|
||||||
|
* cy.visitAndCheck('/', 500)
|
||||||
|
*/
|
||||||
|
visitAndCheck: typeof visitAndCheck;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function login({
|
||||||
|
email = faker.internet.email(undefined, undefined, "example.com"),
|
||||||
|
}: {
|
||||||
|
email?: string;
|
||||||
|
} = {}) {
|
||||||
|
cy.then(() => ({ email })).as("user");
|
||||||
|
cy.exec(
|
||||||
|
`npx ts-node --require tsconfig-paths/register ./cypress/support/create-user.ts "${email}"`
|
||||||
|
).then(({ stdout }) => {
|
||||||
|
const cookieValue = stdout
|
||||||
|
.replace(/.*<cookie>(?<cookieValue>.*)<\/cookie>.*/s, "$<cookieValue>")
|
||||||
|
.trim();
|
||||||
|
cy.setCookie("__session", cookieValue);
|
||||||
|
});
|
||||||
|
return cy.get("@user");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupUser({ email }: { email?: string } = {}) {
|
||||||
|
if (email) {
|
||||||
|
deleteUserByEmail(email);
|
||||||
|
} else {
|
||||||
|
cy.get("@user").then((user) => {
|
||||||
|
const email = (user as { email?: string }).email;
|
||||||
|
if (email) {
|
||||||
|
deleteUserByEmail(email);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cy.clearCookie("__session");
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteUserByEmail(email: string) {
|
||||||
|
cy.exec(
|
||||||
|
`npx ts-node --require tsconfig-paths/register ./cypress/support/delete-user.ts "${email}"`
|
||||||
|
);
|
||||||
|
cy.clearCookie("__session");
|
||||||
|
}
|
||||||
|
|
||||||
|
// We're waiting a second because of this issue happen randomly
|
||||||
|
// https://github.com/cypress-io/cypress/issues/7306
|
||||||
|
// Also added custom types to avoid getting detached
|
||||||
|
// https://github.com/cypress-io/cypress/issues/7306#issuecomment-1152752612
|
||||||
|
// ===========================================================
|
||||||
|
function visitAndCheck(url: string, waitTime: number = 1000) {
|
||||||
|
cy.visit(url);
|
||||||
|
cy.location("pathname").should("contain", url).wait(waitTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
Cypress.Commands.add("login", login);
|
||||||
|
Cypress.Commands.add("cleanupUser", cleanupUser);
|
||||||
|
Cypress.Commands.add("visitAndCheck", visitAndCheck);
|
48
cypress/support/create-user.ts
Normal file
48
cypress/support/create-user.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
// Use this to create a new user and login with that user
|
||||||
|
// Simply call this with:
|
||||||
|
// npx ts-node --require tsconfig-paths/register ./cypress/support/create-user.ts username@example.com
|
||||||
|
// and it will log out the cookie value you can use to interact with the server
|
||||||
|
// as that new user.
|
||||||
|
|
||||||
|
import { installGlobals } from "@remix-run/node";
|
||||||
|
import { parse } from "cookie";
|
||||||
|
|
||||||
|
import { createUser } from "~/models/user.server";
|
||||||
|
import { createUserSession } from "~/session.server";
|
||||||
|
|
||||||
|
installGlobals();
|
||||||
|
|
||||||
|
async function createAndLogin(email: string) {
|
||||||
|
if (!email) {
|
||||||
|
throw new Error("email required for login");
|
||||||
|
}
|
||||||
|
if (!email.endsWith("@example.com")) {
|
||||||
|
throw new Error("All test emails must end in @example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await createUser(email, "myreallystrongpassword");
|
||||||
|
|
||||||
|
const response = await createUserSession({
|
||||||
|
request: new Request("test://test"),
|
||||||
|
userId: user.id,
|
||||||
|
remember: false,
|
||||||
|
redirectTo: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
const cookieValue = response.headers.get("Set-Cookie");
|
||||||
|
if (!cookieValue) {
|
||||||
|
throw new Error("Cookie missing from createUserSession response");
|
||||||
|
}
|
||||||
|
const parsedCookie = parse(cookieValue);
|
||||||
|
// we log it like this so our cypress command can parse it out and set it as
|
||||||
|
// the cookie value.
|
||||||
|
console.log(
|
||||||
|
`
|
||||||
|
<cookie>
|
||||||
|
${parsedCookie.__session}
|
||||||
|
</cookie>
|
||||||
|
`.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createAndLogin(process.argv[2]);
|
37
cypress/support/delete-user.ts
Normal file
37
cypress/support/delete-user.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
// Use this to delete a user by their email
|
||||||
|
// Simply call this with:
|
||||||
|
// npx ts-node --require tsconfig-paths/register ./cypress/support/delete-user.ts username@example.com
|
||||||
|
// and that user will get deleted
|
||||||
|
|
||||||
|
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||||
|
import { installGlobals } from "@remix-run/node";
|
||||||
|
|
||||||
|
import { prisma } from "~/db.server";
|
||||||
|
|
||||||
|
installGlobals();
|
||||||
|
|
||||||
|
async function deleteUser(email: string) {
|
||||||
|
if (!email) {
|
||||||
|
throw new Error("email required for login");
|
||||||
|
}
|
||||||
|
if (!email.endsWith("@example.com")) {
|
||||||
|
throw new Error("All test emails must end in @example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.user.delete({ where: { email } });
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof PrismaClientKnownRequestError &&
|
||||||
|
error.code === "P2025"
|
||||||
|
) {
|
||||||
|
console.log("User not found, so no need to delete");
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteUser(process.argv[2]);
|
15
cypress/support/e2e.ts
Normal file
15
cypress/support/e2e.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import "@testing-library/cypress/add-commands";
|
||||||
|
import "./commands";
|
||||||
|
|
||||||
|
Cypress.on("uncaught:exception", (err) => {
|
||||||
|
// Cypress and React Hydrating the document don't get along
|
||||||
|
// for some unknown reason. Hopefully we figure out why eventually
|
||||||
|
// so we can remove this.
|
||||||
|
if (
|
||||||
|
/hydrat/i.test(err.message) ||
|
||||||
|
/Minified React error #418/.test(err.message) ||
|
||||||
|
/Minified React error #423/.test(err.message)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
29
cypress/tsconfig.json
Normal file
29
cypress/tsconfig.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"exclude": [
|
||||||
|
"../node_modules/@types/jest",
|
||||||
|
"../node_modules/@testing-library/jest-dom"
|
||||||
|
],
|
||||||
|
"include": [
|
||||||
|
"e2e/**/*",
|
||||||
|
"support/**/*",
|
||||||
|
"../node_modules/cypress",
|
||||||
|
"../node_modules/@testing-library/cypress"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node", "cypress", "@testing-library/cypress"],
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"target": "es2019",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"typeRoots": ["../types", "../node_modules/@types"],
|
||||||
|
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["../app/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
50
fly.toml
Normal file
50
fly.toml
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
app = "tunein-radio-stations-1ae3"
|
||||||
|
kill_signal = "SIGINT"
|
||||||
|
kill_timeout = 5
|
||||||
|
processes = [ ]
|
||||||
|
|
||||||
|
[experimental]
|
||||||
|
allowed_public_ports = [ ]
|
||||||
|
auto_rollback = true
|
||||||
|
cmd = "start.sh"
|
||||||
|
entrypoint = "sh"
|
||||||
|
|
||||||
|
[mounts]
|
||||||
|
source = "data"
|
||||||
|
destination = "/data"
|
||||||
|
|
||||||
|
[[services]]
|
||||||
|
internal_port = 8_080
|
||||||
|
processes = [ "app" ]
|
||||||
|
protocol = "tcp"
|
||||||
|
script_checks = [ ]
|
||||||
|
|
||||||
|
[services.concurrency]
|
||||||
|
hard_limit = 25
|
||||||
|
soft_limit = 20
|
||||||
|
type = "connections"
|
||||||
|
|
||||||
|
[[services.ports]]
|
||||||
|
handlers = [ "http" ]
|
||||||
|
port = 80
|
||||||
|
force_https = true
|
||||||
|
|
||||||
|
[[services.ports]]
|
||||||
|
handlers = [ "tls", "http" ]
|
||||||
|
port = 443
|
||||||
|
|
||||||
|
[[services.tcp_checks]]
|
||||||
|
grace_period = "1s"
|
||||||
|
interval = "15s"
|
||||||
|
restart_limit = 0
|
||||||
|
timeout = "2s"
|
||||||
|
|
||||||
|
[[services.http_checks]]
|
||||||
|
interval = "10s"
|
||||||
|
grace_period = "5s"
|
||||||
|
method = "get"
|
||||||
|
path = "/healthcheck"
|
||||||
|
protocol = "http"
|
||||||
|
timeout = "2s"
|
||||||
|
tls_skip_verify = false
|
||||||
|
headers = { }
|
7
mocks/README.md
Normal file
7
mocks/README.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Mocks
|
||||||
|
|
||||||
|
Use this to mock any third party HTTP resources that you don't have running locally and want to have mocked for local development as well as tests.
|
||||||
|
|
||||||
|
Learn more about how to use this at [mswjs.io](https://mswjs.io/)
|
||||||
|
|
||||||
|
For an extensive example, see the [source code for kentcdodds.com](https://github.com/kentcdodds/kentcdodds.com/blob/main/mocks/index.ts)
|
9
mocks/index.js
Normal file
9
mocks/index.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
const { setupServer } = require("msw/node");
|
||||||
|
|
||||||
|
const server = setupServer();
|
||||||
|
|
||||||
|
server.listen({ onUnhandledRequest: "bypass" });
|
||||||
|
console.info("🔶 Mock server running");
|
||||||
|
|
||||||
|
process.once("SIGINT", () => server.close());
|
||||||
|
process.once("SIGTERM", () => server.close());
|
16470
package-lock.json
generated
Normal file
16470
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
84
package.json
Normal file
84
package.json
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"name": "tunein-radio-stations-1ae3",
|
||||||
|
"private": true,
|
||||||
|
"sideEffects": false,
|
||||||
|
"scripts": {
|
||||||
|
"build": "remix build",
|
||||||
|
"dev": "cross-env NODE_ENV=development binode --require ./mocks -- @remix-run/dev:remix dev",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
|
||||||
|
"setup": "prisma generate && prisma migrate deploy && prisma db seed",
|
||||||
|
"start": "remix-serve build",
|
||||||
|
"start:mocks": "binode --require ./mocks -- @remix-run/serve:remix-serve build",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:e2e:dev": "start-server-and-test dev http://localhost:3000 \"npx cypress open\"",
|
||||||
|
"pretest:e2e:run": "npm run build",
|
||||||
|
"test:e2e:run": "cross-env PORT=8811 start-server-and-test start:mocks http://localhost:8811 \"npx cypress run\"",
|
||||||
|
"typecheck": "tsc && tsc -p cypress",
|
||||||
|
"validate": "run-p \"test -- --run\" lint typecheck test:e2e:run"
|
||||||
|
},
|
||||||
|
"prettier": {},
|
||||||
|
"eslintIgnore": [
|
||||||
|
"/node_modules",
|
||||||
|
"/build",
|
||||||
|
"/public/build"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^4.12.0",
|
||||||
|
"@remix-run/css-bundle": "^1.16.0",
|
||||||
|
"@remix-run/node": "^1.16.0",
|
||||||
|
"@remix-run/react": "^1.16.0",
|
||||||
|
"@remix-run/serve": "^1.16.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"isbot": "^3.6.8",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"tiny-invariant": "^1.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@faker-js/faker": "^7.6.0",
|
||||||
|
"@remix-run/dev": "^1.16.0",
|
||||||
|
"@remix-run/eslint-config": "^1.16.0",
|
||||||
|
"@testing-library/cypress": "^9.0.0",
|
||||||
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
|
"@testing-library/react": "^14.0.0",
|
||||||
|
"@testing-library/user-event": "^14.4.3",
|
||||||
|
"@types/bcryptjs": "^2.4.2",
|
||||||
|
"@types/eslint": "^8.37.0",
|
||||||
|
"@types/node": "^18.15.11",
|
||||||
|
"@types/react": "^18.0.37",
|
||||||
|
"@types/react-dom": "^18.0.11",
|
||||||
|
"@vitejs/plugin-react": "^3.1.0",
|
||||||
|
"@vitest/coverage-c8": "^0.30.1",
|
||||||
|
"autoprefixer": "^10.4.14",
|
||||||
|
"binode": "^1.0.5",
|
||||||
|
"c8": "^7.13.0",
|
||||||
|
"cookie": "^0.5.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"cypress": "^12.10.0",
|
||||||
|
"eslint": "^8.38.0",
|
||||||
|
"eslint-config-prettier": "^8.8.0",
|
||||||
|
"eslint-plugin-cypress": "^2.13.2",
|
||||||
|
"happy-dom": "^9.8.0",
|
||||||
|
"msw": "^1.2.1",
|
||||||
|
"npm-run-all": "^4.1.5",
|
||||||
|
"postcss": "^8.4.22",
|
||||||
|
"prettier": "2.8.7",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.2.7",
|
||||||
|
"prisma": "^4.12.0",
|
||||||
|
"start-server-and-test": "^2.0.0",
|
||||||
|
"tailwindcss": "^3.3.1",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.0.4",
|
||||||
|
"vite": "^4.2.1",
|
||||||
|
"vite-tsconfig-paths": "^3.6.0",
|
||||||
|
"vitest": "^0.30.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "ts-node --require tsconfig-paths/register prisma/seed.ts"
|
||||||
|
}
|
||||||
|
}
|
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
31
prisma/migrations/20220713162558_init/migration.sql
Normal file
31
prisma/migrations/20220713162558_init/migration.sql
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Password" (
|
||||||
|
"hash" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "Password_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Note" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Password_userId_key" ON "Password"("userId");
|
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "sqlite"
|
38
prisma/schema.prisma
Normal file
38
prisma/schema.prisma
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
password Password?
|
||||||
|
notes Note[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Password {
|
||||||
|
hash String
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
userId String @unique
|
||||||
|
}
|
||||||
|
|
||||||
|
model Note {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
body String
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
userId String
|
||||||
|
}
|
53
prisma/seed.ts
Normal file
53
prisma/seed.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
const email = "rachel@remix.run";
|
||||||
|
|
||||||
|
// cleanup the existing database
|
||||||
|
await prisma.user.delete({ where: { email } }).catch(() => {
|
||||||
|
// no worries if it doesn't exist yet
|
||||||
|
});
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash("racheliscool", 10);
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
password: {
|
||||||
|
create: {
|
||||||
|
hash: hashedPassword,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.note.create({
|
||||||
|
data: {
|
||||||
|
title: "My first note",
|
||||||
|
body: "Hello, world!",
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.note.create({
|
||||||
|
data: {
|
||||||
|
title: "My second note",
|
||||||
|
body: "Hello, world!",
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Database has been seeded. 🌱`);
|
||||||
|
}
|
||||||
|
|
||||||
|
seed()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
13
remix.config.js
Normal file
13
remix.config.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
/** @type {import('@remix-run/dev').AppConfig} */
|
||||||
|
module.exports = {
|
||||||
|
cacheDirectory: "./node_modules/.cache/remix",
|
||||||
|
future: {
|
||||||
|
v2_errorBoundary: true,
|
||||||
|
v2_meta: true,
|
||||||
|
v2_normalizeFormMethod: true,
|
||||||
|
v2_routeConvention: true,
|
||||||
|
},
|
||||||
|
ignoredRouteFiles: ["**/.*", "**/*.test.{js,jsx,ts,tsx}"],
|
||||||
|
postcss: true,
|
||||||
|
tailwind: true,
|
||||||
|
};
|
2
remix.env.d.ts
vendored
Normal file
2
remix.env.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="@remix-run/dev" />
|
||||||
|
/// <reference types="@remix-run/node" />
|
10
start.sh
Executable file
10
start.sh
Executable file
@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# This file is how Fly starts the server (configured in fly.toml). Before starting
|
||||||
|
# the server though, we need to run any prisma migrations that haven't yet been
|
||||||
|
# run, which is why this file exists in the first place.
|
||||||
|
# Learn more: https://community.fly.io/t/sqlite-not-getting-setup-properly/4386
|
||||||
|
|
||||||
|
set -ex
|
||||||
|
npx prisma migrate deploy
|
||||||
|
npm run start
|
9
tailwind.config.ts
Normal file
9
tailwind.config.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: ["./app/**/*.{js,jsx,ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
} satisfies Config;
|
4
test/setup-test-env.ts
Normal file
4
test/setup-test-env.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { installGlobals } from "@remix-run/node";
|
||||||
|
import "@testing-library/jest-dom/extend-expect";
|
||||||
|
|
||||||
|
installGlobals();
|
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"exclude": ["./cypress", "./cypress.config.ts"],
|
||||||
|
"include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2019"],
|
||||||
|
"types": ["vitest/globals"],
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"target": "ES2019",
|
||||||
|
"strict": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["./app/*"]
|
||||||
|
},
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
// Remix takes care of building everything in `remix build`.
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
15
vitest.config.ts
Normal file
15
vitest.config.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/// <reference types="vitest" />
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tsconfigPaths()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: "happy-dom",
|
||||||
|
setupFiles: ["./test/setup-test-env.ts"],
|
||||||
|
},
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user