feat: initial commit, bootstrap project and add pages and db
This commit is contained in:
commit
a65b288107
12
.editorConfig
Normal file
12
.editorConfig
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[*]
|
||||
indent_style = space
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
charset = utf-8
|
||||
|
||||
[{*.css,*.scss,*.less,*.overrides,*.variables}]
|
||||
indent_size = 4
|
||||
|
||||
[{*.js*.jsx,*.json,*.ts,*.tsx}]
|
||||
indent_size = 2
|
||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
node_modules
|
||||
.DS_Store
|
||||
|
||||
/.cache
|
||||
/build
|
||||
/public/build
|
||||
|
||||
/prisma/dev.db
|
||||
.env
|
||||
|
||||
/app/tailwind.css
|
||||
4
.husky/commit-msg
Executable file
4
.husky/commit-msg
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
yarn commitlint --edit $1
|
||||
39
.release-it.json
Normal file
39
.release-it.json
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"git": {
|
||||
"tagName": "v${version}",
|
||||
"commitMessage": "chore: release v${version}"
|
||||
},
|
||||
"npm": {
|
||||
"publish": false
|
||||
},
|
||||
"github": {
|
||||
"release": true,
|
||||
"releaseName": "${version}"
|
||||
},
|
||||
"plugins": {
|
||||
"@release-it/conventional-changelog": {
|
||||
"infile": "CHANGELOG.md",
|
||||
"preset": {
|
||||
"name": "conventionalcommits",
|
||||
"types": [
|
||||
{
|
||||
"type": "feat",
|
||||
"section": "Features"
|
||||
},
|
||||
{
|
||||
"type": "fix",
|
||||
"section": "Bug Fixes"
|
||||
},
|
||||
{
|
||||
"type": "refactor",
|
||||
"section": "Changes"
|
||||
},
|
||||
{
|
||||
"type": "chore",
|
||||
"section": "Maintenance"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
53
README.md
Normal file
53
README.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# Welcome to Remix!
|
||||
|
||||
- [Remix Docs](https://remix.run/docs)
|
||||
|
||||
## Development
|
||||
|
||||
From your terminal:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This starts your app in development mode, rebuilding assets on file changes.
|
||||
|
||||
## Deployment
|
||||
|
||||
First, build your app for production:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
Then run the app in production mode:
|
||||
|
||||
```sh
|
||||
npm start
|
||||
```
|
||||
|
||||
Now you'll need to pick a host to deploy it to.
|
||||
|
||||
### DIY
|
||||
|
||||
If you're familiar with deploying node applications, the built-in Remix app server is production-ready.
|
||||
|
||||
Make sure to deploy the output of `remix build`
|
||||
|
||||
- `build/`
|
||||
- `public/build/`
|
||||
|
||||
### Using a Template
|
||||
|
||||
When you ran `npx create-remix@latest` there were a few choices for hosting. You can run that again to create a new project, then copy over your `app/` folder to the new project that's pre-configured for your target server.
|
||||
|
||||
```sh
|
||||
cd ..
|
||||
# create a new project, and pick a pre-configured host
|
||||
npx create-remix@latest
|
||||
cd my-new-remix-app
|
||||
# remove the new project's app (not the old one!)
|
||||
rm -rf app
|
||||
# copy your app over
|
||||
cp -R ../my-old-remix-app/app app
|
||||
```
|
||||
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
|
||||
});
|
||||
}
|
||||
96
app/root.tsx
Normal file
96
app/root.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import type { LinksFunction, MetaFunction } from "remix";
|
||||
import { Links, LiveReload, Outlet, useCatch, Meta, Scripts } from "remix";
|
||||
|
||||
import styles from "./tailwind.css";
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [{ rel: "stylesheet", href: styles }];
|
||||
// return [
|
||||
// {
|
||||
// rel: "stylesheet",
|
||||
// href: globalStylesUrl,
|
||||
// },
|
||||
// {
|
||||
// rel: "stylesheet",
|
||||
// href: globalMediumStylesUrl,
|
||||
// media: "print, (min-width: 640px)",
|
||||
// },
|
||||
// {
|
||||
// rel: "stylesheet",
|
||||
// href: globalLargeStylesUrl,
|
||||
// media: "screen and (min-width: 1024px)",
|
||||
// },
|
||||
// ];
|
||||
};
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
const description = `Track and split shared expenses with friends and family.`;
|
||||
return {
|
||||
description,
|
||||
keywords:
|
||||
"Explit,expenses,split,flatmate,friends,family,payments,debts,money",
|
||||
"twitter:creator": "@rawmaterial_it",
|
||||
"twitter:site": "@rawmaterial_it",
|
||||
"twitter:title": "Explit",
|
||||
"twitter:description": description,
|
||||
};
|
||||
};
|
||||
|
||||
function Document({
|
||||
children,
|
||||
title = `Explit`,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<Meta />
|
||||
<title>{title}</title>
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<Scripts />
|
||||
{process.env.NODE_ENV === "development" ? <LiveReload /> : null}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Document>
|
||||
<Outlet />
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatchBoundary() {
|
||||
const caught = useCatch();
|
||||
|
||||
return (
|
||||
<Document title={`${caught.status} ${caught.statusText}`}>
|
||||
<div className="error-container">
|
||||
<h1>
|
||||
{caught.status} {caught.statusText}
|
||||
</h1>
|
||||
</div>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: { error: Error }) {
|
||||
console.error(error);
|
||||
|
||||
return (
|
||||
<Document title="Uh-oh!">
|
||||
<div className="error-container">
|
||||
<h1>App Error</h1>
|
||||
<pre>{error.message}</pre>
|
||||
</div>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
81
app/routes/expenses.tsx
Normal file
81
app/routes/expenses.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import type { Expense, User } from "@prisma/client";
|
||||
import type { LinksFunction, LoaderFunction } from "remix";
|
||||
import { Link, Outlet, useLoaderData, Form, redirect } from "remix";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { getUser } from "~/utils/session.server";
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [];
|
||||
};
|
||||
|
||||
type LoaderData = {
|
||||
user: User | null;
|
||||
expenseListItems: Array<Expense>;
|
||||
};
|
||||
|
||||
export const loader: LoaderFunction = async ({ request }) => {
|
||||
const user = await getUser(request);
|
||||
if (!user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const expenseListItems = await db.expense.findMany({
|
||||
take: 25,
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
const data: LoaderData = {
|
||||
expenseListItems,
|
||||
user,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export default function ExpensesRoute() {
|
||||
const data = useLoaderData<LoaderData>();
|
||||
|
||||
return (
|
||||
<div className="expenses-layout">
|
||||
<header className="expenses-header">
|
||||
<div className="container">
|
||||
<h1 className="home-link">
|
||||
<Link to="/">
|
||||
<span>Expenses</span>
|
||||
</Link>
|
||||
</h1>
|
||||
{data.user ? (
|
||||
<div className="user-info">
|
||||
<span>{`Hi ${data.user.username}`}</span>
|
||||
<Form action="/logout" method="post">
|
||||
<button type="submit" className="button">
|
||||
Logout
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
) : (
|
||||
<Link to="/login">Login</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main className="jexpensesokes-main">
|
||||
<div className="container">
|
||||
<div className="expenses-list">
|
||||
<p>Last expenses:</p>
|
||||
<ul>
|
||||
{data.expenseListItems.map((exp) => (
|
||||
<li key={exp.id}>
|
||||
<Link prefetch="intent" to={exp.id}>
|
||||
{exp.amount}€ - {exp.description}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="expenses-outlet">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
app/routes/expenses/$expenseId.tsx
Normal file
132
app/routes/expenses/$expenseId.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import type { ActionFunction, LoaderFunction, MetaFunction } from "remix";
|
||||
import {
|
||||
Link,
|
||||
useLoaderData,
|
||||
useParams,
|
||||
useCatch,
|
||||
redirect,
|
||||
Form,
|
||||
} from "remix";
|
||||
import type { Expense, User } from "@prisma/client";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { requireUserId, getUserId } from "~/utils/session.server";
|
||||
|
||||
type LoaderData = { expense: Expense; user: User; isOwner: boolean };
|
||||
|
||||
export const meta: MetaFunction = ({
|
||||
data,
|
||||
}: {
|
||||
data: LoaderData | undefined;
|
||||
}) => {
|
||||
if (!data) {
|
||||
return {
|
||||
title: "No expense",
|
||||
description: "No expense found",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: `Expense: ${data.expense.description} | Explit`,
|
||||
description: `Details of expense: ${data.expense.description}`,
|
||||
};
|
||||
};
|
||||
|
||||
export const loader: LoaderFunction = async ({ request, params }) => {
|
||||
const userId = await getUserId(request);
|
||||
if (!userId) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const expense = await db.expense.findUnique({
|
||||
where: { id: params.expenseId },
|
||||
});
|
||||
if (!expense) {
|
||||
throw new Response("What an expense! Not found.", {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
const expenseUser = await db.user.findUnique({
|
||||
where: { id: expense.userId },
|
||||
});
|
||||
if (!expenseUser) {
|
||||
throw new Response("Oupsie! Not found.", {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
const data: LoaderData = {
|
||||
expense,
|
||||
user: expenseUser,
|
||||
isOwner: userId === expense.userId,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const form = await request.formData();
|
||||
if (form.get("_method") === "delete") {
|
||||
const userId = await requireUserId(request);
|
||||
const expense = await db.expense.findUnique({
|
||||
where: { id: params.expenseId },
|
||||
});
|
||||
if (!expense) {
|
||||
throw new Response("Can't delete what does not exist", { status: 404 });
|
||||
}
|
||||
if (expense.userId !== userId) {
|
||||
throw new Response("Pssh, nice try. That's not your expense", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
await db.expense.delete({ where: { id: params.expenseId } });
|
||||
return redirect("/expenses");
|
||||
}
|
||||
};
|
||||
|
||||
export default function ExpenseRoute() {
|
||||
const data = useLoaderData<LoaderData>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Description: {data.expense.description}</p>
|
||||
<p>Amount: {data.expense.amount}€</p>
|
||||
<p>User: {data.user.username}</p>
|
||||
{data.isOwner && (
|
||||
<form method="post">
|
||||
<input type="hidden" name="_method" value="delete" />
|
||||
<button type="submit" className="button">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatchBoundary() {
|
||||
const caught = useCatch();
|
||||
const params = useParams();
|
||||
switch (caught.status) {
|
||||
case 404: {
|
||||
return (
|
||||
<div className="error-container">
|
||||
Huh? What the heck is {params.expenseId}?
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 401: {
|
||||
return (
|
||||
<div className="error-container">
|
||||
Sorry, but {params.expenseId} is not your sheet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unhandled error: ${caught.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const { expenseId } = useParams();
|
||||
return (
|
||||
<div className="error-container">{`There was an error loading expense by the id ${expenseId}. Sorry.`}</div>
|
||||
);
|
||||
}
|
||||
45
app/routes/expenses/index.tsx
Normal file
45
app/routes/expenses/index.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { LoaderFunction } from "remix";
|
||||
import { useLoaderData, Link, useCatch } from "remix";
|
||||
import type { Expense } from "@prisma/client";
|
||||
import { db } from "~/utils/db.server";
|
||||
|
||||
type LoaderData = { lastExpenses: Expense[] };
|
||||
|
||||
export const loader: LoaderFunction = async () => {
|
||||
const lastExpenses = await db.expense.findMany({
|
||||
take: 25,
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
const data: LoaderData = { lastExpenses };
|
||||
return data;
|
||||
};
|
||||
|
||||
export default function JokesIndexRoute() {
|
||||
const data = useLoaderData<LoaderData>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Here show statistics</p>
|
||||
|
||||
<Link to="new" className="btn btn-primary">
|
||||
Add an expense
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatchBoundary() {
|
||||
const caught = useCatch();
|
||||
|
||||
if (caught.status === 404) {
|
||||
return (
|
||||
<div className="error-container">There are no expenses to display.</div>
|
||||
);
|
||||
}
|
||||
throw new Error(`Unexpected caught response with status: ${caught.status}`);
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return <div className="error-container">I did a whoopsies.</div>;
|
||||
}
|
||||
165
app/routes/expenses/new.tsx
Normal file
165
app/routes/expenses/new.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import type { ActionFunction, LoaderFunction } from "remix";
|
||||
import {
|
||||
useActionData,
|
||||
redirect,
|
||||
json,
|
||||
useCatch,
|
||||
Link,
|
||||
Form,
|
||||
useTransition,
|
||||
useLoaderData,
|
||||
} from "remix";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { requireUserId, getUserId } from "~/utils/session.server";
|
||||
|
||||
function validateExpenseDescription(description: string) {
|
||||
if (description.length < 2) {
|
||||
return `That expense's description is too short`;
|
||||
}
|
||||
}
|
||||
|
||||
type ActionData = {
|
||||
formError?: string;
|
||||
fieldErrors?: {
|
||||
description: string | undefined;
|
||||
};
|
||||
fields?: {
|
||||
description: string;
|
||||
amount: number;
|
||||
};
|
||||
};
|
||||
|
||||
type LoaderData = {
|
||||
userId: string | null;
|
||||
};
|
||||
|
||||
const badRequest = (data: ActionData) => json(data, { status: 400 });
|
||||
|
||||
export const loader: LoaderFunction = async ({ request }) => {
|
||||
const userId = await getUserId(request);
|
||||
if (!userId) {
|
||||
throw new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
const data: LoaderData = { userId };
|
||||
return data;
|
||||
};
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const form = await request.formData();
|
||||
const description = form.get("description");
|
||||
const amount = form.get("amount");
|
||||
if (typeof description !== "string" || typeof amount !== "number") {
|
||||
return badRequest({
|
||||
formError: `Form not submitted correctly.`,
|
||||
});
|
||||
}
|
||||
|
||||
const fieldErrors = {
|
||||
description: validateExpenseDescription(description),
|
||||
};
|
||||
const fields = { description, amount };
|
||||
if (Object.values(fieldErrors).some(Boolean)) {
|
||||
return badRequest({ fieldErrors, fields });
|
||||
}
|
||||
|
||||
const expense = await db.expense.create({
|
||||
data: { ...fields, userId: userId },
|
||||
});
|
||||
return redirect(`/expenses/${expense.id}`);
|
||||
};
|
||||
|
||||
export default function NewExpenseRoute() {
|
||||
const data = useLoaderData<LoaderData>();
|
||||
const actionData = useActionData<ActionData>();
|
||||
const transition = useTransition();
|
||||
|
||||
if (transition.submission) {
|
||||
const description = transition.submission.formData.get("description");
|
||||
const amount = transition.submission.formData.get("content");
|
||||
if (
|
||||
typeof description === "string" &&
|
||||
typeof amount === "number" &&
|
||||
!validateExpenseDescription(description)
|
||||
) {
|
||||
return (
|
||||
<div>
|
||||
<p>Description: {description}</p>
|
||||
<p>Amount: {amount}€</p>
|
||||
<p>User: {data.userId}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Add an expense</p>
|
||||
<Form method="post">
|
||||
<div>
|
||||
<label>
|
||||
Description:{" "}
|
||||
<input
|
||||
type="text"
|
||||
name="description"
|
||||
defaultValue={actionData?.fields?.description}
|
||||
aria-invalid={
|
||||
Boolean(actionData?.fieldErrors?.description) || undefined
|
||||
}
|
||||
aria-describedby={
|
||||
actionData?.fieldErrors?.description
|
||||
? "description-error"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{actionData?.fieldErrors?.description && (
|
||||
<p
|
||||
className="form-validation-error"
|
||||
role="alert"
|
||||
id="description-error"
|
||||
>
|
||||
{actionData.fieldErrors.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
Amount:{" "}
|
||||
<input
|
||||
type="number"
|
||||
name="content"
|
||||
defaultValue={actionData?.fields?.amount}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" className="button">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatchBoundary() {
|
||||
const caught = useCatch();
|
||||
|
||||
if (caught.status === 401) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<p>You must be logged in to submit an expense.</p>
|
||||
<Link to="/login">Login</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return (
|
||||
<div className="error-container">
|
||||
Something unexpected went wrong. Sorry about that.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
app/routes/index.tsx
Normal file
48
app/routes/index.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { LinksFunction, MetaFunction, LoaderFunction } from "remix";
|
||||
import { Link, useLoaderData } from "remix";
|
||||
import { getUserId } from "~/utils/session.server";
|
||||
|
||||
type LoaderData = { userId: string | null };
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [];
|
||||
};
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return {
|
||||
title: "Explit: track and split shared expenses",
|
||||
description:
|
||||
"Explit: track and split shared expenses with friends and family",
|
||||
};
|
||||
};
|
||||
|
||||
export const loader: LoaderFunction = async ({ request }) => {
|
||||
const userId = await getUserId(request);
|
||||
const data: LoaderData = { userId };
|
||||
return data;
|
||||
};
|
||||
|
||||
export default function Index() {
|
||||
const data = useLoaderData<LoaderData>();
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="content">
|
||||
<h1>Explit</h1>
|
||||
<nav>
|
||||
<ul>
|
||||
{data.userId ? (
|
||||
<li>
|
||||
<Link to="expenses">See expenses</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li>
|
||||
<Link to="login">Login</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
app/routes/login.tsx
Normal file
267
app/routes/login.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import type { ActionFunction, LinksFunction, MetaFunction } from "remix";
|
||||
import { useActionData, json, Link, useSearchParams, Form } from "remix";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { login, createUserSession, register } from "~/utils/session.server";
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [];
|
||||
};
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return {
|
||||
title: "Explit | Login",
|
||||
description: "Login to track and split your expenses!",
|
||||
};
|
||||
};
|
||||
|
||||
function validateUsername(username: unknown) {
|
||||
if (typeof username !== "string" || username.length < 3) {
|
||||
return `Usernames must be at least 3 characters long`;
|
||||
}
|
||||
}
|
||||
|
||||
function validatePassword(password: unknown) {
|
||||
if (typeof password !== "string" || password.length < 6) {
|
||||
return `Passwords must be at least 6 characters long`;
|
||||
}
|
||||
}
|
||||
|
||||
function validateTeamId(teamId: unknown) {
|
||||
if (typeof teamId !== "string" || teamId.length < 1) {
|
||||
return "You must indicate an arbitrary team ID";
|
||||
}
|
||||
}
|
||||
|
||||
type ActionData = {
|
||||
formError?: string;
|
||||
fieldErrors?: {
|
||||
username: string | undefined;
|
||||
password: string | undefined;
|
||||
teamId: string | undefined;
|
||||
};
|
||||
fields?: {
|
||||
loginType: string;
|
||||
username: string;
|
||||
password: string;
|
||||
teamId: string;
|
||||
icon?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const badRequest = (data: ActionData) => json(data, { status: 400 });
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const loginType = form.get("loginType");
|
||||
const username = form.get("username");
|
||||
const password = form.get("password");
|
||||
const icon = form.get("icon");
|
||||
const teamId = form.get("teamId");
|
||||
const redirectTo = form.get("redirectTo") || "/expenses";
|
||||
if (
|
||||
typeof loginType !== "string" ||
|
||||
typeof username !== "string" ||
|
||||
typeof password !== "string" ||
|
||||
typeof icon !== "string" ||
|
||||
typeof teamId !== "string" ||
|
||||
typeof redirectTo !== "string"
|
||||
) {
|
||||
return badRequest({
|
||||
formError: `Form not submitted correctly.`,
|
||||
});
|
||||
}
|
||||
|
||||
const fields = { loginType, username, password, teamId };
|
||||
const fieldErrors = {
|
||||
username: validateUsername(username),
|
||||
password: validatePassword(password),
|
||||
teamId: validateTeamId(teamId),
|
||||
};
|
||||
if (Object.values(fieldErrors).some(Boolean))
|
||||
return badRequest({ fieldErrors, fields });
|
||||
|
||||
switch (loginType) {
|
||||
case "login": {
|
||||
const user = await login({ username, password, icon, teamId });
|
||||
if (!user) {
|
||||
return badRequest({
|
||||
fields,
|
||||
formError: `Username/Password combination is incorrect`,
|
||||
});
|
||||
}
|
||||
return createUserSession(user.id, redirectTo);
|
||||
}
|
||||
case "register": {
|
||||
const userExists = await db.user.findFirst({
|
||||
where: { username },
|
||||
});
|
||||
if (userExists) {
|
||||
console.error(userExists);
|
||||
return badRequest({
|
||||
fields,
|
||||
formError: `User with username ${username} already exists`,
|
||||
});
|
||||
}
|
||||
const user = await register({ username, password, icon, teamId });
|
||||
if (!user) {
|
||||
return badRequest({
|
||||
fields,
|
||||
formError: `Something went wrong trying to create a new user.`,
|
||||
});
|
||||
}
|
||||
return createUserSession(user.id, redirectTo);
|
||||
}
|
||||
default: {
|
||||
return badRequest({
|
||||
fields,
|
||||
formError: `Login type invalid`,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default function Login() {
|
||||
const actionData = useActionData<ActionData>();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="content" data-light="">
|
||||
<h1>Login</h1>
|
||||
<Form
|
||||
method="post"
|
||||
aria-describedby={
|
||||
actionData?.formError ? "form-error-message" : undefined
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="redirectTo"
|
||||
value={searchParams.get("redirectTo") ?? undefined}
|
||||
/>
|
||||
<fieldset>
|
||||
<legend className="sr-only">Login or Register?</legend>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="loginType"
|
||||
value="login"
|
||||
defaultChecked={
|
||||
!actionData?.fields?.loginType ||
|
||||
actionData?.fields?.loginType === "login"
|
||||
}
|
||||
/>{" "}
|
||||
Login
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="loginType"
|
||||
value="register"
|
||||
defaultChecked={actionData?.fields?.loginType === "register"}
|
||||
/>{" "}
|
||||
Register
|
||||
</label>
|
||||
</fieldset>
|
||||
<div>
|
||||
<label htmlFor="username-input">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username-input"
|
||||
name="username"
|
||||
defaultValue={actionData?.fields?.username}
|
||||
aria-invalid={Boolean(actionData?.fieldErrors?.username)}
|
||||
aria-describedby={
|
||||
actionData?.fieldErrors?.username ? "username-error" : undefined
|
||||
}
|
||||
/>
|
||||
{actionData?.fieldErrors?.username ? (
|
||||
<p
|
||||
className="form-validation-error"
|
||||
role="alert"
|
||||
id="username-error"
|
||||
>
|
||||
{actionData?.fieldErrors.username}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="username-input">Team ID</label>
|
||||
<input
|
||||
type="text"
|
||||
id="team-id-input"
|
||||
name="teamId"
|
||||
defaultValue={actionData?.fields?.teamId}
|
||||
aria-hidden={actionData?.fields?.loginType === "login"}
|
||||
aria-invalid={Boolean(actionData?.fieldErrors?.teamId)}
|
||||
aria-describedby={
|
||||
actionData?.fieldErrors?.teamId ? "teamId-error" : undefined
|
||||
}
|
||||
/>
|
||||
{actionData?.fieldErrors?.teamId ? (
|
||||
<p
|
||||
className="form-validation-error"
|
||||
role="alert"
|
||||
id="teamId-error"
|
||||
>
|
||||
{actionData?.fieldErrors.teamId}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="username-input">Icon (letter or emoji)</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={1}
|
||||
aria-hidden={actionData?.fields?.loginType === "login"}
|
||||
id="icon-input"
|
||||
name="icon"
|
||||
defaultValue={actionData?.fields?.icon}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password-input">Password</label>
|
||||
<input
|
||||
id="password-input"
|
||||
name="password"
|
||||
defaultValue={actionData?.fields?.password}
|
||||
type="password"
|
||||
aria-invalid={
|
||||
Boolean(actionData?.fieldErrors?.password) || undefined
|
||||
}
|
||||
aria-describedby={
|
||||
actionData?.fieldErrors?.password ? "password-error" : undefined
|
||||
}
|
||||
/>
|
||||
{actionData?.fieldErrors?.password ? (
|
||||
<p
|
||||
className="form-validation-error"
|
||||
role="alert"
|
||||
id="password-error"
|
||||
>
|
||||
{actionData?.fieldErrors.password}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div id="form-error-message">
|
||||
{actionData?.formError ? (
|
||||
<p className="form-validation-error" role="alert">
|
||||
{actionData?.formError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button type="submit" className="button">
|
||||
Submit
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
<div className="links">
|
||||
<ul>
|
||||
<li>
|
||||
<Link to="/">Home</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</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 "~/utils/session.server";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
return logout(request);
|
||||
};
|
||||
|
||||
export const loader: LoaderFunction = async () => {
|
||||
return redirect("/");
|
||||
};
|
||||
23
app/utils/db.server.ts
Normal file
23
app/utils/db.server.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
let db: PrismaClient
|
||||
|
||||
declare global {
|
||||
var __db: PrismaClient | undefined
|
||||
}
|
||||
|
||||
// this is needed because in development we don't want to restart
|
||||
// the server with every change, but we want to make sure we don't
|
||||
// create a new connection to the DB with every change either.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
db = new PrismaClient()
|
||||
db.$connect()
|
||||
} else {
|
||||
if (!global.__db) {
|
||||
global.__db = new PrismaClient()
|
||||
global.__db.$connect()
|
||||
}
|
||||
db = global.__db
|
||||
}
|
||||
|
||||
export { db }
|
||||
121
app/utils/session.server.ts
Normal file
121
app/utils/session.server.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import bcrypt from "bcryptjs";
|
||||
import { createCookieSessionStorage, redirect } from "remix";
|
||||
import { db } from "./db.server";
|
||||
|
||||
type LoginForm = {
|
||||
username: string;
|
||||
password: string;
|
||||
icon?: string;
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
export async function register({
|
||||
username,
|
||||
password,
|
||||
icon,
|
||||
teamId,
|
||||
}: LoginForm) {
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const team = await db.team.findUnique({ where: { id: teamId } });
|
||||
if (!team) {
|
||||
await db.team.create({
|
||||
data: {
|
||||
id: teamId,
|
||||
icon: teamId[0],
|
||||
},
|
||||
});
|
||||
}
|
||||
const user = await db.user.create({
|
||||
data: { username, passwordHash, icon: icon ?? username[0], teamId },
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function login({ username, password }: LoginForm) {
|
||||
const user = await db.user.findUnique({
|
||||
where: { username },
|
||||
});
|
||||
if (!user) return null;
|
||||
const isCorrectPassword = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!isCorrectPassword) return null;
|
||||
return user;
|
||||
}
|
||||
|
||||
const sessionSecret = process.env.SESSION_SECRET;
|
||||
if (!sessionSecret) {
|
||||
throw new Error("SESSION_SECRET must be set");
|
||||
}
|
||||
|
||||
const storage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: "RJ_session",
|
||||
// normally you want this to be `secure: true`
|
||||
// but that doesn't work on localhost for Safari
|
||||
// https://web.dev/when-to-use-local-https/
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
secrets: [sessionSecret],
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
httpOnly: true,
|
||||
},
|
||||
});
|
||||
|
||||
export function getUserSession(request: Request) {
|
||||
return storage.getSession(request.headers.get("Cookie"));
|
||||
}
|
||||
|
||||
export async function getUserId(request: Request) {
|
||||
const session = await getUserSession(request);
|
||||
const userId = session.get("userId");
|
||||
if (!userId || typeof userId !== "string") return null;
|
||||
return userId;
|
||||
}
|
||||
|
||||
export async function requireUserId(
|
||||
request: Request,
|
||||
redirectTo: string = new URL(request.url).pathname
|
||||
) {
|
||||
const session = await getUserSession(request);
|
||||
const userId = session.get("userId");
|
||||
if (!userId || typeof userId !== "string") {
|
||||
const searchParams = new URLSearchParams([["redirectTo", redirectTo]]);
|
||||
throw redirect(`/login?${searchParams}`);
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
export async function getUser(request: Request) {
|
||||
const userId = await getUserId(request);
|
||||
if (typeof userId !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
return user;
|
||||
} catch {
|
||||
throw logout(request);
|
||||
}
|
||||
}
|
||||
|
||||
export async function logout(request: Request) {
|
||||
const session = await storage.getSession(request.headers.get("Cookie"));
|
||||
return redirect("/login", {
|
||||
headers: {
|
||||
"Set-Cookie": await storage.destroySession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createUserSession(userId: string, redirectTo: string) {
|
||||
const session = await storage.getSession();
|
||||
session.set("userId", userId);
|
||||
return redirect(redirectTo, {
|
||||
headers: {
|
||||
"Set-Cookie": await storage.commitSession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
1
commitlint.config.js
Normal file
1
commitlint.config.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
module.exports = {extends: ['@commitlint/config-conventional']}
|
||||
48
package.json
Normal file
48
package.json
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"private": true,
|
||||
"name": "explit",
|
||||
"description": "Track and split shared expenses",
|
||||
"prisma": {
|
||||
"seed": "node --require esbuild-register prisma/seed.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build:css && remix build",
|
||||
"build:css": "tailwindcss -o ./app/tailwind.css",
|
||||
"dev": "concurrently \"npm run dev:css\" \"remix dev\"",
|
||||
"dev:css": "tailwindcss -o ./app/tailwind.css --watch",
|
||||
"postinstall": "remix setup node",
|
||||
"prepare": "husky install",
|
||||
"start": "remix-serve build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "3.9.1",
|
||||
"@remix-run/react": "^1.1.3",
|
||||
"@remix-run/serve": "^1.1.3",
|
||||
"bcryptjs": "2.4.3",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"remix": "^1.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "16.1.0",
|
||||
"@commitlint/config-conventional": "16.0.0",
|
||||
"@release-it/conventional-changelog": "4.1.0",
|
||||
"@remix-run/dev": "^1.1.3",
|
||||
"@types/bcryptjs": "2.4.2",
|
||||
"@types/react": "^17.0.24",
|
||||
"@types/react-dom": "^17.0.9",
|
||||
"concurrently": "7.0.0",
|
||||
"daisyui": "1.25.4",
|
||||
"esbuild-register": "3.3.2",
|
||||
"husky": "7.0.4",
|
||||
"postcss": "8.4.6",
|
||||
"prisma": "3.9.1",
|
||||
"release-it": "14.12.4",
|
||||
"tailwindcss": "3.0.19",
|
||||
"typescript": "^4.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"sideEffects": false
|
||||
}
|
||||
40
prisma/schema.prisma
Normal file
40
prisma/schema.prisma
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id
|
||||
icon String
|
||||
description String?
|
||||
members User[]
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
username String @unique
|
||||
icon String
|
||||
passwordHash String
|
||||
teamId String
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
expenses Expense[]
|
||||
}
|
||||
|
||||
model Expense {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
description String
|
||||
amount Float
|
||||
}
|
||||
75
prisma/seed.ts
Normal file
75
prisma/seed.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
const db = new PrismaClient();
|
||||
|
||||
async function seed() {
|
||||
const famiglia = await db.team.create({
|
||||
data: {
|
||||
id: "Famiglia",
|
||||
description: "La mia famiglia",
|
||||
icon: "♥️",
|
||||
},
|
||||
});
|
||||
const nicola = await db.user.create({
|
||||
data: {
|
||||
username: "nicola",
|
||||
passwordHash:
|
||||
"$2b$10$K7L1OJ45/4Y2nIvhRVpCe.FSmhDdWoXehVzJptJ/op0lSsvqNu/1u",
|
||||
teamId: famiglia.id,
|
||||
icon: "🧑💻",
|
||||
},
|
||||
});
|
||||
const shahra = await db.user.create({
|
||||
data: {
|
||||
username: "shahra",
|
||||
passwordHash:
|
||||
"$2b$10$K7L1OJ45/4Y2nIvhRVpCe.FSmhDdWoXehVzJptJ/op0lSsvqNu/1u",
|
||||
teamId: famiglia.id,
|
||||
icon: "💃",
|
||||
},
|
||||
});
|
||||
|
||||
const expenses = [
|
||||
{
|
||||
description: "Spesa",
|
||||
amount: 100,
|
||||
userId: nicola.id,
|
||||
},
|
||||
{
|
||||
description: "Spesa",
|
||||
amount: 70,
|
||||
userId: shahra.id,
|
||||
},
|
||||
{
|
||||
description: "Affitto",
|
||||
amount: 500,
|
||||
userId: shahra.id,
|
||||
},
|
||||
|
||||
// transaction between users
|
||||
{
|
||||
description: "Affitto",
|
||||
amount: 250,
|
||||
userId: nicola.id,
|
||||
},
|
||||
{
|
||||
description: "Affitto",
|
||||
amount: -250,
|
||||
userId: shahra.id,
|
||||
},
|
||||
|
||||
{
|
||||
description: "Cena",
|
||||
amount: 50,
|
||||
userId: nicola.id,
|
||||
},
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
expenses.map((exp) => {
|
||||
const data = { ...exp };
|
||||
return db.expense.create({ data });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
seed();
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
public/social.png
Normal file
BIN
public/social.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
11
remix.config.js
Normal file
11
remix.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* @type {import('@remix-run/dev/config').AppConfig}
|
||||
*/
|
||||
module.exports = {
|
||||
appDirectory: "app",
|
||||
assetsBuildDirectory: "public/build",
|
||||
publicPath: "/build/",
|
||||
serverBuildDirectory: "build",
|
||||
devServerPort: 8002,
|
||||
ignoredRouteFiles: [".*"]
|
||||
};
|
||||
2
remix.env.d.ts
vendored
Normal file
2
remix.env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/// <reference types="@remix-run/dev" />
|
||||
/// <reference types="@remix-run/node/globals" />
|
||||
8
tailwind.config.js
Normal file
8
tailwind.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
module.exports = {
|
||||
content: ["./app/**/*.{ts,tsx,jsx,js}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
variants: {},
|
||||
plugins: [require("daisyui")],
|
||||
};
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "es2019", "es2021.string"],
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"target": "es2021",
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./app/*"]
|
||||
},
|
||||
|
||||
// Remix takes care of building everything in `remix build`.
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue