switch to remix
This commit is contained in:
		
						commit
						52a0ba1b3b
					
				
					 77 changed files with 13468 additions and 0 deletions
				
			
		
							
								
								
									
										62
									
								
								app/db.server.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								app/db.server.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,62 @@ | |||
| import { PrismaClient } from "@prisma/client"; | ||||
| import invariant from "tiny-invariant"; | ||||
| 
 | ||||
| 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 = getClient(); | ||||
| } else { | ||||
|   if (!global.__db__) { | ||||
|     global.__db__ = getClient(); | ||||
|   } | ||||
|   prisma = global.__db__; | ||||
| } | ||||
| 
 | ||||
| function getClient() { | ||||
|   const { DATABASE_URL } = process.env; | ||||
|   invariant(typeof DATABASE_URL === "string", "DATABASE_URL env var not set"); | ||||
| 
 | ||||
|   const databaseUrl = new URL(DATABASE_URL); | ||||
| 
 | ||||
|   const isLocalHost = databaseUrl.hostname === "localhost"; | ||||
| 
 | ||||
|   const PRIMARY_REGION = isLocalHost ? null : process.env.PRIMARY_REGION; | ||||
|   const FLY_REGION = isLocalHost ? null : process.env.FLY_REGION; | ||||
| 
 | ||||
|   const isReadReplicaRegion = !PRIMARY_REGION || PRIMARY_REGION === FLY_REGION; | ||||
| 
 | ||||
|   if (!isLocalHost) { | ||||
|     databaseUrl.host = `${FLY_REGION}.${databaseUrl.host}`; | ||||
|     if (!isReadReplicaRegion) { | ||||
|       // 5433 is the read-replica port
 | ||||
|       databaseUrl.port = "5433"; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   console.log(`🔌 setting up prisma client to ${databaseUrl.host}`); | ||||
|   // NOTE: during development if you change anything in this function, remember
 | ||||
|   // that this only runs once per server restart and won't automatically be
 | ||||
|   // re-run per request like everything else is. So if you need to change
 | ||||
|   // something in this file, you'll need to manually restart the server.
 | ||||
|   const client = new PrismaClient({ | ||||
|     datasources: { | ||||
|       db: { | ||||
|         url: databaseUrl.toString(), | ||||
|       }, | ||||
|     }, | ||||
|   }); | ||||
|   // connect eagerly
 | ||||
|   client.$connect(); | ||||
| 
 | ||||
|   return client; | ||||
| } | ||||
| 
 | ||||
| export { prisma }; | ||||
							
								
								
									
										4
									
								
								app/entry.client.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								app/entry.client.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | |||
| import { hydrate } from "react-dom"; | ||||
| import { RemixBrowser } from "remix"; | ||||
| 
 | ||||
| hydrate(<RemixBrowser />, document); | ||||
							
								
								
									
										21
									
								
								app/entry.server.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								app/entry.server.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,21 @@ | |||
| import { renderToString } from "react-dom/server"; | ||||
| import { RemixServer } from "remix"; | ||||
| import type { EntryContext } from "remix"; | ||||
| 
 | ||||
| export default function handleRequest( | ||||
|   request: Request, | ||||
|   responseStatusCode: number, | ||||
|   responseHeaders: Headers, | ||||
|   remixContext: EntryContext | ||||
| ) { | ||||
|   const markup = renderToString( | ||||
|     <RemixServer context={remixContext} url={request.url} /> | ||||
|   ); | ||||
| 
 | ||||
|   responseHeaders.set("Content-Type", "text/html"); | ||||
| 
 | ||||
|   return new Response("<!DOCTYPE html>" + markup, { | ||||
|     status: responseStatusCode, | ||||
|     headers: responseHeaders, | ||||
|   }); | ||||
| } | ||||
							
								
								
									
										53
									
								
								app/models/note.server.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								app/models/note.server.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,53 @@ | |||
| import type { User, Note } from "@prisma/client"; | ||||
| 
 | ||||
| import { prisma } from "~/db.server"; | ||||
| 
 | ||||
| export type { Note } from "@prisma/client"; | ||||
| 
 | ||||
| export function getNote({ | ||||
|   id, | ||||
|   userId, | ||||
| }: Pick<Note, "id"> & { | ||||
|   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<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 }, | ||||
|   }); | ||||
| } | ||||
							
								
								
									
										59
									
								
								app/models/user.server.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								app/models/user.server.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,59 @@ | |||
| import type { Password, User } from "@prisma/client"; | ||||
| import bcrypt from "@node-rs/bcrypt"; | ||||
| 
 | ||||
| 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.verify(password, userWithPassword.password.hash); | ||||
| 
 | ||||
|   if (!isValid) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   const { password: _password, ...userWithoutPassword } = userWithPassword; | ||||
| 
 | ||||
|   return userWithoutPassword; | ||||
| } | ||||
							
								
								
									
										50
									
								
								app/root.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								app/root.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,50 @@ | |||
| import { | ||||
|   json, | ||||
|   Links, | ||||
|   LiveReload, | ||||
|   Meta, | ||||
|   Outlet, | ||||
|   Scripts, | ||||
|   ScrollRestoration, | ||||
| } from "remix"; | ||||
| import type { LinksFunction, MetaFunction, LoaderFunction } from "remix"; | ||||
| 
 | ||||
| import tailwindStylesheetUrl from "./styles/tailwind.css"; | ||||
| import { getUser } from "./session.server"; | ||||
| 
 | ||||
| export const links: LinksFunction = () => { | ||||
|   return [{ rel: "stylesheet", href: tailwindStylesheetUrl }]; | ||||
| }; | ||||
| 
 | ||||
| export const meta: MetaFunction = () => ({ | ||||
|   charset: "utf-8", | ||||
|   title: "Remix Notes", | ||||
|   viewport: "width=device-width,initial-scale=1", | ||||
| }); | ||||
| 
 | ||||
| type LoaderData = { | ||||
|   user: Awaited<ReturnType<typeof getUser>>; | ||||
| }; | ||||
| 
 | ||||
| export const loader: LoaderFunction = async ({ request }) => { | ||||
|   return json<LoaderData>({ | ||||
|     user: await getUser(request), | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| export default function App() { | ||||
|   return ( | ||||
|     <html lang="en" className="h-full"> | ||||
|       <head> | ||||
|         <Meta /> | ||||
|         <Links /> | ||||
|       </head> | ||||
|       <body className="h-full"> | ||||
|         <Outlet /> | ||||
|         <ScrollRestoration /> | ||||
|         <Scripts /> | ||||
|         <LiveReload /> | ||||
|       </body> | ||||
|     </html> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										23
									
								
								app/routes/healthcheck.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								app/routes/healthcheck.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,23 @@ | |||
| // learn more: https://fly.io/docs/reference/configuration/#services-http_checks
 | ||||
| import type { LoaderFunction } from "remix"; | ||||
| import { prisma } from "~/db.server"; | ||||
| 
 | ||||
| export const loader: LoaderFunction = async ({ request }) => { | ||||
|   const host = | ||||
|     request.headers.get("X-Forwarded-Host") ?? request.headers.get("host"); | ||||
| 
 | ||||
|   try { | ||||
|     // 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(`http://${host}`, { 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 }); | ||||
|   } | ||||
| }; | ||||
							
								
								
									
										137
									
								
								app/routes/index.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								app/routes/index.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,137 @@ | |||
| import { Link } from "remix"; | ||||
| import { useOptionalUser } from "~/utils"; | ||||
| 
 | ||||
| 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/158276320-c46b661b-8eff-4a4d-82c6-cf296c987a12.jpg" | ||||
|                 alt="BB King playing blues on his Les Paul guitar" | ||||
|               /> | ||||
|               <div className="absolute inset-0 bg-[color:rgba(27,167,254,0.5)] mix-blend-multiply" /> | ||||
|             </div> | ||||
|             <div className="lg:pb-18 relative px-4 pt-16 pb-8 sm:px-6 sm:pt-24 sm:pb-14 lg:px-8 lg:pt-32"> | ||||
|               <h1 className="text-center text-6xl font-extrabold tracking-tight sm:text-8xl lg:text-9xl"> | ||||
|                 <span className="block uppercase text-blue-500 drop-shadow-md"> | ||||
|                   Blues 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-blue-700 shadow-sm hover:bg-blue-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-blue-700 shadow-sm hover:bg-blue-50 sm:px-8" | ||||
|                     > | ||||
|                       Sign up | ||||
|                     </Link> | ||||
|                     <Link | ||||
|                       to="/login" | ||||
|                       className="flex items-center justify-center rounded-md bg-blue-500 px-4 py-3 font-medium text-white hover:bg-blue-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 py-2 px-4 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/158238105-e7279a0c-1640-40db-86b0-3d3a10aab824.svg", | ||||
|                 alt: "PostgreSQL", | ||||
|                 href: "https://www.postgresql.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} /> | ||||
|               </a> | ||||
|             ))} | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </main> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										179
									
								
								app/routes/join.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								app/routes/join.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,179 @@ | |||
| import * as React from "react"; | ||||
| import type { ActionFunction, LoaderFunction, MetaFunction } from "remix"; | ||||
| import { | ||||
|   Form, | ||||
|   Link, | ||||
|   redirect, | ||||
|   useSearchParams, | ||||
|   json, | ||||
|   useActionData, | ||||
| } from "remix"; | ||||
| 
 | ||||
| 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<ActionData>( | ||||
|       { errors: { email: "Email is invalid" } }, | ||||
|       { status: 400 } | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   if (typeof password !== "string") { | ||||
|     return json<ActionData>( | ||||
|       { errors: { password: "Password is required" } }, | ||||
|       { status: 400 } | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   if (password.length < 8) { | ||||
|     return json<ActionData>( | ||||
|       { errors: { password: "Password is too short" } }, | ||||
|       { status: 400 } | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   const existingUser = await getUserByEmail(email); | ||||
|   if (existingUser) { | ||||
|     return json<ActionData>( | ||||
|       { 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<HTMLInputElement>(null); | ||||
|   const passwordRef = React.useRef<HTMLInputElement>(null); | ||||
| 
 | ||||
|   React.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" noValidate> | ||||
|           <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> | ||||
|               )} | ||||
|             </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> | ||||
|               )} | ||||
|             </div> | ||||
|           </div> | ||||
| 
 | ||||
|           <input type="hidden" name="redirectTo" value={redirectTo} /> | ||||
|           <button | ||||
|             type="submit" | ||||
|             className="w-full rounded bg-blue-500  py-2 px-4 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> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										192
									
								
								app/routes/login.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										192
									
								
								app/routes/login.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,192 @@ | |||
| import * as React from "react"; | ||||
| import type { ActionFunction, LoaderFunction, MetaFunction } from "remix"; | ||||
| import { | ||||
|   Form, | ||||
|   json, | ||||
|   Link, | ||||
|   useActionData, | ||||
|   redirect, | ||||
|   useSearchParams, | ||||
| } from "remix"; | ||||
| 
 | ||||
| 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<ActionData>( | ||||
|       { errors: { email: "Email is invalid" } }, | ||||
|       { status: 400 } | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   if (typeof password !== "string") { | ||||
|     return json<ActionData>( | ||||
|       { errors: { password: "Password is required" } }, | ||||
|       { status: 400 } | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   if (password.length < 8) { | ||||
|     return json<ActionData>( | ||||
|       { errors: { password: "Password is too short" } }, | ||||
|       { status: 400 } | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   const user = await verifyLogin(email, password); | ||||
| 
 | ||||
|   if (!user) { | ||||
|     return json<ActionData>( | ||||
|       { 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<HTMLInputElement>(null); | ||||
|   const passwordRef = React.useRef<HTMLInputElement>(null); | ||||
| 
 | ||||
|   React.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" noValidate> | ||||
|           <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> | ||||
|               )} | ||||
|             </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> | ||||
|               )} | ||||
|             </div> | ||||
|           </div> | ||||
| 
 | ||||
|           <input type="hidden" name="redirectTo" value={redirectTo} /> | ||||
|           <button | ||||
|             type="submit" | ||||
|             className="w-full rounded bg-blue-500  py-2 px-4 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> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										11
									
								
								app/routes/logout.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								app/routes/logout.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,11 @@ | |||
| import type { ActionFunction, LoaderFunction } from "remix"; | ||||
| import { redirect } from "remix"; | ||||
| import { logout } from "~/session.server"; | ||||
| 
 | ||||
| export const action: ActionFunction = async ({ request }) => { | ||||
|   return logout(request); | ||||
| }; | ||||
| 
 | ||||
| export const loader: LoaderFunction = async () => { | ||||
|   return redirect("/"); | ||||
| }; | ||||
							
								
								
									
										73
									
								
								app/routes/notes.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								app/routes/notes.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,73 @@ | |||
| import { Form, json, useLoaderData, Outlet, Link, NavLink } from "remix"; | ||||
| import type { LoaderFunction } from "remix"; | ||||
| 
 | ||||
| import { requireUserId } from "~/session.server"; | ||||
| import { useUser } from "~/utils"; | ||||
| import { getNoteListItems } from "~/models/note.server"; | ||||
| 
 | ||||
| type LoaderData = { | ||||
|   noteListItems: Awaited<ReturnType<typeof getNoteListItems>>; | ||||
| }; | ||||
| 
 | ||||
| export const loader: LoaderFunction = async ({ request }) => { | ||||
|   const userId = await requireUserId(request); | ||||
|   const noteListItems = await getNoteListItems({ userId }); | ||||
|   return json<LoaderData>({ noteListItems }); | ||||
| }; | ||||
| 
 | ||||
| export default function NotesPage() { | ||||
|   const data = useLoaderData() as LoaderData; | ||||
|   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 py-2 px-4 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> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										68
									
								
								app/routes/notes/$noteId.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								app/routes/notes/$noteId.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,68 @@ | |||
| import type { LoaderFunction, ActionFunction } from "remix"; | ||||
| import { redirect } from "remix"; | ||||
| import { json, useLoaderData, useCatch, Form } from "remix"; | ||||
| 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<LoaderData>({ 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 ( | ||||
|     <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  py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400" | ||||
|         > | ||||
|           Delete | ||||
|         </button> | ||||
|       </Form> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export function ErrorBoundary({ error }: { error: Error }) { | ||||
|   console.error(error); | ||||
| 
 | ||||
|   return <div>An unexpected error occurred: {error.message}</div>; | ||||
| } | ||||
| 
 | ||||
| export function CatchBoundary() { | ||||
|   const caught = useCatch(); | ||||
| 
 | ||||
|   if (caught.status === 404) { | ||||
|     return <div>Note not found</div>; | ||||
|   } | ||||
| 
 | ||||
|   throw new Error(`Unexpected caught response with status: ${caught.status}`); | ||||
| } | ||||
							
								
								
									
										12
									
								
								app/routes/notes/index.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								app/routes/notes/index.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,12 @@ | |||
| import { Link } from "remix"; | ||||
| 
 | ||||
| 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> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										116
									
								
								app/routes/notes/new.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								app/routes/notes/new.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,116 @@ | |||
| import * as React from "react"; | ||||
| import { Form, json, redirect, useActionData } from "remix"; | ||||
| import type { ActionFunction } from "remix"; | ||||
| import Alert from "@reach/alert"; | ||||
| 
 | ||||
| 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<ActionData>( | ||||
|       { errors: { title: "Title is required" } }, | ||||
|       { status: 400 } | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   if (typeof body !== "string" || body.length === 0) { | ||||
|     return json<ActionData>( | ||||
|       { 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<HTMLInputElement>(null); | ||||
|   const bodyRef = React.useRef<HTMLTextAreaElement>(null); | ||||
| 
 | ||||
|   React.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 && ( | ||||
|           <Alert className="pt-1 text-red-700" id="title=error"> | ||||
|             {actionData.errors.title} | ||||
|           </Alert> | ||||
|         )} | ||||
|       </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 py-2 px-3 text-lg leading-6" | ||||
|             aria-invalid={actionData?.errors?.body ? true : undefined} | ||||
|             aria-errormessage={ | ||||
|               actionData?.errors?.body ? "body-error" : undefined | ||||
|             } | ||||
|           /> | ||||
|         </label> | ||||
|         {actionData?.errors?.body && ( | ||||
|           <Alert className="pt-1 text-red-700" id="body=error"> | ||||
|             {actionData.errors.body} | ||||
|           </Alert> | ||||
|         )} | ||||
|       </div> | ||||
| 
 | ||||
|       <div className="text-right"> | ||||
|         <button | ||||
|           type="submit" | ||||
|           className="rounded bg-blue-500  py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400" | ||||
|         > | ||||
|           Save | ||||
|         </button> | ||||
|       </div> | ||||
|     </Form> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										96
									
								
								app/session.server.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								app/session.server.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,96 @@ | |||
| import { createCookieSessionStorage, redirect } from "remix"; | ||||
| 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, | ||||
|     maxAge: 0, | ||||
|     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<string | undefined> { | ||||
|   const session = await getSession(request); | ||||
|   const userId = session.get(USER_SESSION_KEY); | ||||
|   return userId; | ||||
| } | ||||
| 
 | ||||
| export async function getUser(request: Request): Promise<null | User> { | ||||
|   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 | ||||
| ): Promise<string> { | ||||
|   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), | ||||
|     }, | ||||
|   }); | ||||
| } | ||||
							
								
								
									
										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); | ||||
| }); | ||||
							
								
								
									
										47
									
								
								app/utils.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								app/utils.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,47 @@ | |||
| import { useMemo } from "react"; | ||||
| import { useMatches } from "remix"; | ||||
| 
 | ||||
| import type { User } from "~/models/user.server"; | ||||
| 
 | ||||
| /** | ||||
|  * 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("@"); | ||||
| } | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue