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