diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f3de6b6e..666d8d1c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -83,26 +83,13 @@ jobs: - name: 🏄 Copy test env vars run: cp .env.example .env - - name: ⎔ Setup node - uses: actions/setup-node@v3 - with: - node-version: 16 - - - name: 📥 Download deps - uses: bahmutov/npm-install@v1 - - - name: 🛠 Setup Database - run: npx prisma migrate reset --force - - - name: ⚙️ Build - run: npm run build - - name: 🌳 Cypress run uses: cypress-io/github-action@v3 with: start: npm run start:mocks wait-on: "http://localhost:8811" env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} PORT: "8811" build: @@ -117,52 +104,23 @@ jobs: - name: ⬇️ Checkout repo uses: actions/checkout@v3 - - name: 👀 Read app name - uses: SebRollen/toml-action@v1.0.0 - id: app_name + - name: ⎔ Setup node + uses: actions/setup-node@v3 with: - file: "fly.toml" - field: "app" - - - name: 🐳 Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + node-version: 16 - # Setup cache - - name: ⚡️ Cache Docker layers - uses: actions/cache@v2 - with: - path: /tmp/.buildx-cache - key: ${{ runner.os }}-buildx-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-buildx- + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 - - name: 🔑 Fly Registry Auth - uses: docker/login-action@v1 - with: - registry: registry.fly.io - username: x - password: ${{ secrets.FLY_API_TOKEN }} + - name: ⚙️ Build + run: npm run build - - name: 🐳 Docker build - uses: docker/build-push-action@v2 + - name: 👀 Read app name + uses: ashley-taylor/read-json-property-action@v1.0 + id: site_id with: - context: . - push: true - tags: registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }} - build-args: | - COMMIT_SHA=${{ github.sha }} - cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new - - # This ugly bit is necessary if you don't want your cache to grow forever - # till it hits GitHub's limit of 5GB. - # Temp fix - # https://github.com/docker/build-push-action/issues/252 - # https://github.com/moby/buildkit/issues/1896 - - name: 🚚 Move cache - run: | - rm -rf /tmp/.buildx-cache - mv /tmp/.buildx-cache-new /tmp/.buildx-cache + path: ".netlify/state.json" + property: "siteId" deploy: name: 🚀 Deploy @@ -178,25 +136,55 @@ jobs: - name: ⬇️ Checkout repo uses: actions/checkout@v3 + - name: ⎔ Setup node + uses: actions/setup-node@v3 + with: + node-version: 16 + + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 + + - name: ⚙️ Build + run: npm run build + - name: 👀 Read app name - uses: SebRollen/toml-action@v1.0.0 - id: app_name + uses: ashley-taylor/read-json-property-action@v1.0 + id: site_id with: - file: "fly.toml" - field: "app" + path: ".netlify/state.json" + property: "siteId" - name: 🚀 Deploy Staging if: ${{ github.ref == 'refs/heads/dev' }} - uses: superfly/flyctl-actions@1.3 + uses: nwtgck/actions-netlify@v1.2 with: - args: "deploy --app ${{ steps.app_name.outputs.value }}-staging --image registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}" + publish-dir: "./public" + github-token: ${{ secrets.GITHUB_TOKEN }} + deploy-message: "Deploy from GitHub Actions" + enable-pull-request-comment: false + enable-commit-comment: false + github-deployment-environment: "branch" + netlify-config-path: "netlify.toml" + functions-dir: "./.netlify/functions-internal" env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ steps.site_id.outputs.value }} + timeout-minutes: 1 - name: 🚀 Deploy Production if: ${{ github.ref == 'refs/heads/main' }} - uses: superfly/flyctl-actions@1.3 + uses: nwtgck/actions-netlify@v1.2 with: - args: "deploy --image registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}" + publish-dir: "./public" + production-branch: main + github-token: ${{ secrets.GITHUB_TOKEN }} + deploy-message: "Deploy from GitHub Actions" + enable-pull-request-comment: false + enable-commit-comment: false + overwrites-pull-request-comment: true + netlify-config-path: "netlify.toml" + functions-dir: "./.netlify/functions-internal" env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ steps.site_id.outputs.value }} + timeout-minutes: 1 diff --git a/.gitignore b/.gitignore index 05f0cbc1..02b8d09f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,10 +3,13 @@ node_modules /build /public/build .env +.cache /cypress/screenshots /cypress/videos -/prisma/data.db -/prisma/data.db-journal /app/styles/tailwind.css + +# Local Netlify folder +.netlify/* +!.netlify/state.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 0f278029..00000000 --- a/Dockerfile +++ /dev/null @@ -1,59 +0,0 @@ -# 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 ./ -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 ./ -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 -ADD . . - -CMD ["npm", "start"] diff --git a/README.md b/README.md index 9a63a6b6..8486fb67 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,17 @@ -# Remix Indie Stack +# Remix Acidcore Stack -![The Remix Indie Stack](https://repository-images.githubusercontent.com/465928257/a241fa49-bd4d-485a-a2a5-5cb8e4ee0abf) +![The Remix Acidcore Stack](https://repository-images.githubusercontent.com/465928257/a241fa49-bd4d-485a-a2a5-5cb8e4ee0abf) Learn more about [Remix Stacks](https://remix.run/stacks). ``` -npx create-remix --template remix-run/indie-stack +npx create-remix --template path23/acidcore-netlify-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) +- [Netlify deployment](https://netlify.io) - [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/docs/en/v1/api/remix#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) @@ -42,40 +38,24 @@ Not a fan of bits of the stack? Fork it, change it, and use `npx create-remix -- 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/) +- [Install Netlify](https://docs.netlify.com/cli/get-started/) -- Sign up and log in to Fly +- Log in to Netlify ```sh - fly auth signup + netlify login ``` - > **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: +- Create an app on Netlify: ```sh - fly create indie-stack-template - fly create indie-stack-template-staging + netlify sites:create --name acidcore-netlify-stack-template ``` - Initialize Git. @@ -90,33 +70,15 @@ Prior to your first deployment, you'll need to do a few things: git remote add origin ``` -- 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 `NETLIFY_AUTH_TOKEN` to your GitHub repo. To do this, go to your user settings on Netlify and create a new [token](https://app.netlify.com/user/applications#personal-access-tokens), then add it to [your repo secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) with the name `NETLIFY_AUTH_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 indie-stack-template - fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app indie-stack-template-staging - ``` - - If you don't have openssl installed, you can also use [1password](https://1password.com/generate-password) to generate a random secret, just replace `$(openssl rand -hex 32)` with the generated secret. - -- 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 indie-stack-template - fly volumes create data --size 1 --app indie-stack-template-staging - ``` +- You might need to modify the workflow permissions in your repository settings in the the Actions/General menu item. Choose "Read and write permissions" if you run into problems during deploys. 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. +If you run into any issues deploying to Netlify, 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 Netlify support](https://www.netlify.com/support/). They're normally pretty responsive over there and hopefully can help resolve any of your deployment issues and questions. ## GitHub Actions @@ -130,24 +92,7 @@ We use Cypress for our End-to-End tests in this project. You'll find those in th 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. +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. ### Vitest diff --git a/app/db.server.ts b/app/db.server.ts deleted file mode 100644 index 843cc97e..00000000 --- a/app/db.server.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { PrismaClient } from "@prisma/client"; - -let prisma: PrismaClient; - -declare global { - var __db__: PrismaClient; -} - -// 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 }; diff --git a/app/models/note.server.ts b/app/models/note.server.ts deleted file mode 100644 index ba56b532..00000000 --- a/app/models/note.server.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { User, Note } from "@prisma/client"; - -import { prisma } from "~/db.server"; - -export type { Note } from "@prisma/client"; - -export function getNote({ - id, - userId, -}: Pick & { - userId: User["id"]; -}) { - return prisma.note.findFirst({ - 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 & { - userId: User["id"]; -}) { - return prisma.note.create({ - data: { - title, - body, - user: { - connect: { - id: userId, - }, - }, - }, - }); -} - -export function deleteNote({ - id, - userId, -}: Pick & { userId: User["id"] }) { - return prisma.note.deleteMany({ - where: { id, userId }, - }); -} diff --git a/app/models/user.server.ts b/app/models/user.server.ts deleted file mode 100644 index cce401e2..00000000 --- a/app/models/user.server.ts +++ /dev/null @@ -1,62 +0,0 @@ -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; -} diff --git a/app/root.tsx b/app/root.tsx index 90204ff8..a8132590 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -14,7 +14,6 @@ import { } from "@remix-run/react"; import tailwindStylesheetUrl from "./styles/tailwind.css"; -import { getUser } from "./session.server"; export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: tailwindStylesheetUrl }]; @@ -26,14 +25,10 @@ export const meta: MetaFunction = () => ({ viewport: "width=device-width,initial-scale=1", }); -type LoaderData = { - user: Awaited>; -}; +type LoaderData = {}; export const loader: LoaderFunction = async ({ request }) => { - return json({ - user: await getUser(request), - }); + return json({}); }; export default function App() { diff --git a/app/routes/healthcheck.tsx b/app/routes/healthcheck.tsx index 01a8ef08..6cd81ed6 100644 --- a/app/routes/healthcheck.tsx +++ b/app/routes/healthcheck.tsx @@ -1,8 +1,6 @@ // learn more: https://fly.io/docs/reference/configuration/#services-http_checks import type { LoaderFunction } from "@remix-run/node"; -import { prisma } from "~/db.server"; - export const loader: LoaderFunction = async ({ request }) => { const host = request.headers.get("X-Forwarded-Host") ?? request.headers.get("host"); @@ -12,7 +10,6 @@ export const loader: LoaderFunction = async ({ request }) => { // 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); }), diff --git a/app/routes/index.tsx b/app/routes/index.tsx index b63be55b..f28c48e1 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -1,9 +1,6 @@ import { Link } from "@remix-run/react"; -import { useOptionalUser } from "~/utils"; - export default function Index() { - const user = useOptionalUser(); return (
@@ -11,47 +8,22 @@ export default function Index() {
Sonic Youth On Stage
-
-

- - Indie Stack +
+

+ + Acidcore Stack

-

+

Check the README.md file for instructions on how to get this project deployed.

-
- {user ? ( - - View Notes for {user.email} - - ) : ( -
- - Sign up - - - Log In - -
- )} -
-
-
+
+
{[ { - 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://www.netlify.com/v3/img/components/full-logo-light.svg", + alt: "Netlify", + href: "https://netlify.com", + className: "py-3", }, { src: "https://user-images.githubusercontent.com/1500684/157764276-a516a239-e377-4a20-b44a-0ac7b65c8c14.svg", @@ -125,9 +88,9 @@ export default function Index() { - {img.alt} + {img.alt} ))}
diff --git a/app/routes/join.tsx b/app/routes/join.tsx deleted file mode 100644 index 44a14d2b..00000000 --- a/app/routes/join.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import type { - ActionFunction, - LoaderFunction, - MetaFunction, -} from "@remix-run/node"; -import { json, redirect } from "@remix-run/node"; -import { Form, Link, useActionData, useSearchParams } from "@remix-run/react"; -import * as React from "react"; - -import { getUserId, createUserSession } from "~/session.server"; - -import { createUser, getUserByEmail } from "~/models/user.server"; -import { validateEmail } from "~/utils"; - -export const loader: LoaderFunction = async ({ request }) => { - const userId = await getUserId(request); - if (userId) return redirect("/"); - return json({}); -}; - -interface ActionData { - errors: { - email?: string; - password?: string; - }; -} - -export const action: ActionFunction = async ({ request }) => { - const formData = await request.formData(); - const email = formData.get("email"); - const password = formData.get("password"); - const redirectTo = formData.get("redirectTo"); - - if (!validateEmail(email)) { - return json( - { errors: { email: "Email is invalid" } }, - { status: 400 } - ); - } - - if (typeof password !== "string") { - return json( - { errors: { password: "Password is required" } }, - { status: 400 } - ); - } - - if (password.length < 8) { - return json( - { errors: { 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" } }, - { status: 400 } - ); - } - - const user = await createUser(email, password); - - return createUserSession({ - request, - userId: user.id, - remember: false, - redirectTo: typeof redirectTo === "string" ? redirectTo : "/", - }); -}; - -export const meta: MetaFunction = () => { - return { - title: "Sign Up", - }; -}; - -export default function Join() { - const [searchParams] = useSearchParams(); - const redirectTo = searchParams.get("redirectTo") ?? undefined; - const actionData = useActionData() as ActionData; - const emailRef = React.useRef(null); - const passwordRef = React.useRef(null); - - React.useEffect(() => { - if (actionData?.errors?.email) { - emailRef.current?.focus(); - } else if (actionData?.errors?.password) { - passwordRef.current?.focus(); - } - }, [actionData]); - - return ( -
-
-
-
- -
- - {actionData?.errors?.email && ( -
- {actionData.errors.email} -
- )} -
-
- -
- -
- - {actionData?.errors?.password && ( -
- {actionData.errors.password} -
- )} -
-
- - - -
-
- Already have an account?{" "} - - Log in - -
-
-
-
-
- ); -} diff --git a/app/routes/login.tsx b/app/routes/login.tsx deleted file mode 100644 index a725365e..00000000 --- a/app/routes/login.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import type { - ActionFunction, - LoaderFunction, - MetaFunction, -} from "@remix-run/node"; -import { json, redirect } from "@remix-run/node"; -import { Form, Link, useActionData, useSearchParams } from "@remix-run/react"; -import * as React from "react"; - -import { createUserSession, getUserId } from "~/session.server"; -import { verifyLogin } from "~/models/user.server"; -import { validateEmail } from "~/utils"; - -export const loader: LoaderFunction = async ({ request }) => { - const userId = await getUserId(request); - if (userId) return redirect("/"); - return json({}); -}; - -interface ActionData { - errors?: { - email?: string; - password?: string; - }; -} - -export const action: ActionFunction = async ({ request }) => { - const formData = await request.formData(); - const email = formData.get("email"); - const password = formData.get("password"); - const redirectTo = formData.get("redirectTo"); - const remember = formData.get("remember"); - - if (!validateEmail(email)) { - return json( - { errors: { email: "Email is invalid" } }, - { status: 400 } - ); - } - - if (typeof password !== "string") { - return json( - { errors: { password: "Password is required" } }, - { status: 400 } - ); - } - - if (password.length < 8) { - return json( - { errors: { password: "Password is too short" } }, - { status: 400 } - ); - } - - const user = await verifyLogin(email, password); - - if (!user) { - return json( - { errors: { email: "Invalid email or password" } }, - { status: 400 } - ); - } - - return createUserSession({ - request, - userId: user.id, - remember: remember === "on" ? true : false, - redirectTo: typeof redirectTo === "string" ? redirectTo : "/notes", - }); -}; - -export const meta: MetaFunction = () => { - return { - title: "Login", - }; -}; - -export default function LoginPage() { - const [searchParams] = useSearchParams(); - const redirectTo = searchParams.get("redirectTo") || "/notes"; - const actionData = useActionData() as ActionData; - const emailRef = React.useRef(null); - const passwordRef = React.useRef(null); - - React.useEffect(() => { - if (actionData?.errors?.email) { - emailRef.current?.focus(); - } else if (actionData?.errors?.password) { - passwordRef.current?.focus(); - } - }, [actionData]); - - return ( -
-
-
-
- -
- - {actionData?.errors?.email && ( -
- {actionData.errors.email} -
- )} -
-
- -
- -
- - {actionData?.errors?.password && ( -
- {actionData.errors.password} -
- )} -
-
- - - -
-
- - -
-
- Don't have an account?{" "} - - Sign up - -
-
-
-
-
- ); -} diff --git a/app/routes/logout.tsx b/app/routes/logout.tsx deleted file mode 100644 index 04ceac2b..00000000 --- a/app/routes/logout.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { ActionFunction, LoaderFunction } from "@remix-run/node"; -import { redirect } from "@remix-run/node"; - -import { logout } from "~/session.server"; - -export const action: ActionFunction = async ({ request }) => { - return logout(request); -}; - -export const loader: LoaderFunction = async () => { - return redirect("/"); -}; diff --git a/app/routes/notes.tsx b/app/routes/notes.tsx deleted file mode 100644 index 0860147a..00000000 --- a/app/routes/notes.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import type { LoaderFunction } from "@remix-run/node"; -import { json } from "@remix-run/node"; -import { Form, Link, NavLink, Outlet, useLoaderData } from "@remix-run/react"; - -import { requireUserId } from "~/session.server"; -import { useUser } from "~/utils"; -import { getNoteListItems } from "~/models/note.server"; - -type LoaderData = { - noteListItems: Awaited>; -}; - -export const loader: LoaderFunction = async ({ request }) => { - const userId = await requireUserId(request); - const noteListItems = await getNoteListItems({ userId }); - return json({ noteListItems }); -}; - -export default function NotesPage() { - const data = useLoaderData() as LoaderData; - const user = useUser(); - - return ( -
-
-

- Notes -

-

{user.email}

-
- -
-
- -
-
- - + New Note - - -
- - {data.noteListItems.length === 0 ? ( -

No notes yet

- ) : ( -
    - {data.noteListItems.map((note) => ( -
  1. - - `block border-b p-4 text-xl ${isActive ? "bg-white" : ""}` - } - to={note.id} - > - 📝 {note.title} - -
  2. - ))} -
- )} -
- -
- -
-
-
- ); -} diff --git a/app/routes/notes/$noteId.tsx b/app/routes/notes/$noteId.tsx deleted file mode 100644 index fe5ee262..00000000 --- a/app/routes/notes/$noteId.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { ActionFunction, LoaderFunction } from "@remix-run/node"; -import { json, redirect } from "@remix-run/node"; -import { Form, useCatch, useLoaderData } from "@remix-run/react"; -import invariant from "tiny-invariant"; - -import type { Note } from "~/models/note.server"; -import { deleteNote } from "~/models/note.server"; -import { getNote } from "~/models/note.server"; -import { requireUserId } from "~/session.server"; - -type LoaderData = { - note: Note; -}; - -export const loader: LoaderFunction = async ({ request, params }) => { - const userId = await requireUserId(request); - invariant(params.noteId, "noteId not found"); - - const note = await getNote({ userId, id: params.noteId }); - if (!note) { - throw new Response("Not Found", { status: 404 }); - } - return json({ note }); -}; - -export const action: ActionFunction = async ({ request, params }) => { - const userId = await requireUserId(request); - invariant(params.noteId, "noteId not found"); - - await deleteNote({ userId, id: params.noteId }); - - return redirect("/notes"); -}; - -export default function NoteDetailsPage() { - const data = useLoaderData() as LoaderData; - - return ( -
-

{data.note.title}

-

{data.note.body}

-
-
- -
-
- ); -} - -export function ErrorBoundary({ error }: { error: Error }) { - console.error(error); - - return
An unexpected error occurred: {error.message}
; -} - -export function CatchBoundary() { - const caught = useCatch(); - - if (caught.status === 404) { - return
Note not found
; - } - - throw new Error(`Unexpected caught response with status: ${caught.status}`); -} diff --git a/app/routes/notes/index.tsx b/app/routes/notes/index.tsx deleted file mode 100644 index aa858a99..00000000 --- a/app/routes/notes/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Link } from "@remix-run/react"; - -export default function NoteIndexPage() { - return ( -

- No note selected. Select a note on the left, or{" "} - - create a new note. - -

- ); -} diff --git a/app/routes/notes/new.tsx b/app/routes/notes/new.tsx deleted file mode 100644 index 723b04f4..00000000 --- a/app/routes/notes/new.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import type { ActionFunction } from "@remix-run/node"; -import { json, redirect } from "@remix-run/node"; -import { Form, useActionData } from "@remix-run/react"; -import * as React from "react"; - -import { createNote } from "~/models/note.server"; -import { requireUserId } from "~/session.server"; - -type ActionData = { - errors?: { - title?: string; - body?: string; - }; -}; - -export const action: ActionFunction = async ({ request }) => { - 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: { title: "Title is required" } }, - { status: 400 } - ); - } - - if (typeof body !== "string" || body.length === 0) { - return json( - { errors: { body: "Body is required" } }, - { status: 400 } - ); - } - - const note = await createNote({ title, body, userId }); - - return redirect(`/notes/${note.id}`); -}; - -export default function NewNotePage() { - const actionData = useActionData() as ActionData; - const titleRef = React.useRef(null); - const bodyRef = React.useRef(null); - - React.useEffect(() => { - if (actionData?.errors?.title) { - titleRef.current?.focus(); - } else if (actionData?.errors?.body) { - bodyRef.current?.focus(); - } - }, [actionData]); - - return ( -
-
- - {actionData?.errors?.title && ( -
- {actionData.errors.title} -
- )} -
- -
-