switch to remix

This commit is contained in:
jane 2022-03-22 13:42:07 -04:00
commit 52a0ba1b3b
77 changed files with 13468 additions and 0 deletions

62
app/db.server.ts Normal file
View 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
View file

@ -0,0 +1,4 @@
import { hydrate } from "react-dom";
import { RemixBrowser } from "remix";
hydrate(<RemixBrowser />, document);

21
app/entry.server.tsx Normal file
View 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
View 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
View 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
View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View 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}`);
}

View 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
View 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
View 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
View 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
View 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("@");
}