feat: initial commit, bootstrap project and add pages and db

This commit is contained in:
Nicola Zambello 2022-02-10 10:44:44 +01:00
commit a65b288107
28 changed files with 7151 additions and 0 deletions

12
.editorConfig Normal file
View 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
View 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
View file

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn commitlint --edit $1

39
.release-it.json Normal file
View 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
View 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
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
});
}

96
app/root.tsx Normal file
View 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
View 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>
);
}

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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1 @@
module.exports = {extends: ['@commitlint/config-conventional']}

48
package.json Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
public/social.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

11
remix.config.js Normal file
View 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
View file

@ -0,0 +1,2 @@
/// <reference types="@remix-run/dev" />
/// <reference types="@remix-run/node/globals" />

8
tailwind.config.js Normal file
View file

@ -0,0 +1,8 @@
module.exports = {
content: ["./app/**/*.{ts,tsx,jsx,js}"],
theme: {
extend: {},
},
variants: {},
plugins: [require("daisyui")],
};

20
tsconfig.json Normal file
View 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
}
}

5813
yarn.lock Normal file

File diff suppressed because it is too large Load diff