feat: working on ui
This commit is contained in:
parent
11d58a9adb
commit
85cfb319b0
101
app/components/Header.tsx
Normal file
101
app/components/Header.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import type { User } from "@prisma/client";
|
||||
import { Link, Form } from "remix";
|
||||
import People from "~/icons/People";
|
||||
import MoneyAdd from "~/icons/MoneyAdd";
|
||||
import LoginSVG from "../icons/Login";
|
||||
import Percentage from "~/icons/Percentage";
|
||||
|
||||
interface Props {
|
||||
user?: User | null;
|
||||
route?: string;
|
||||
}
|
||||
|
||||
const Header = ({ user, route }: Props) => {
|
||||
return (
|
||||
<header className="p-3">
|
||||
<nav className="navbar shadow-lg bg-neutral text-neutral-content rounded-box">
|
||||
<div className="flex-1 px-2 mx-2">
|
||||
<Link to="/" className="btn btn-ghost rounded-btn">
|
||||
<span className="text-lg font-bold">Explit</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-none px-2 mx-2 lg:flex">
|
||||
<div className="flex items-stretch">
|
||||
<Link to="/expenses" className="btn btn-ghost rounded-btn">
|
||||
<MoneyAdd className="lg:mr-2" />
|
||||
<span
|
||||
className={`hidden lg:inline-block ${
|
||||
route === "/expenses" ? "underline" : ""
|
||||
}`}
|
||||
>
|
||||
Expenses
|
||||
</span>
|
||||
</Link>
|
||||
<Link to="#" className="btn btn-ghost rounded-btn">
|
||||
<People className="lg:mr-2" />
|
||||
<span
|
||||
className={`hidden lg:inline-block ${
|
||||
route === "/team" ? "underline" : ""
|
||||
}`}
|
||||
>
|
||||
Your team
|
||||
</span>
|
||||
</Link>
|
||||
<Link to="#" className="btn btn-ghost rounded-btn">
|
||||
<Percentage className="lg:mr-2" />
|
||||
<span
|
||||
className={`hidden lg:inline-block ${
|
||||
route === "/statistics" ? "underline" : ""
|
||||
}`}
|
||||
>
|
||||
Statistics
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-none">
|
||||
{user ? (
|
||||
<div className="dropdown dropdown-end">
|
||||
<div tabIndex={0} className="btn btn-ghost rounded-full p-0">
|
||||
<div className="rounded-full w-10 h-10 m-1 inline-flex justify-center items-center bg-white text-3xl">
|
||||
{user.icon ?? user.username[0]}
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="p-2 shadow-lg menu dropdown-content bg-base-100 rounded-box w-52"
|
||||
>
|
||||
<li>
|
||||
<Link to="#" className="text-base-content">
|
||||
<span className="text-base-content">Account</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Form action="/logout" method="post">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-ghost rounded-btn w-full text-base-content capitalize justify-start py-[0.75rem] px-[1.25rem] text-base font-normal"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</Form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="btn btn-square btn-ghost"
|
||||
title="Login"
|
||||
>
|
||||
<span className="hidden lg:inline-block mr-1">Login</span>
|
||||
<LoginSVG />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
44
app/icons/ChartSquare.tsx
Normal file
44
app/icons/ChartSquare.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
const ChartSquare = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M10.11 11.1501H7.46005C6.83005 11.1501 6.32007 11.6601 6.32007 12.2901V17.4101H10.11V11.1501V11.1501Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12.7613 6.6001H11.2413C10.6113 6.6001 10.1013 7.11011 10.1013 7.74011V17.4001H13.8913V7.74011C13.8913 7.11011 13.3913 6.6001 12.7613 6.6001Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M16.5482 12.8501H13.8982V17.4001H17.6882V13.9901C17.6782 13.3601 17.1682 12.8501 16.5482 12.8501Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9 22H15C20 22 22 20 22 15V9C22 4 20 2 15 2H9C4 2 2 4 2 9V15C2 20 4 22 9 22Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default ChartSquare;
|
||||
28
app/icons/Filter.tsx
Normal file
28
app/icons/Filter.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
const Filter = () => (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5.40002 2.09998H18.6C19.7 2.09998 20.6 2.99998 20.6 4.09998V6.29998C20.6 7.09998 20.1 8.09998 19.6 8.59998L15.3 12.4C14.7 12.9 14.3 13.9 14.3 14.7V19C14.3 19.6 13.9 20.4 13.4 20.7L12 21.6C10.7 22.4 8.90002 21.5 8.90002 19.9V14.6C8.90002 13.9 8.50002 13 8.10002 12.5L4.30002 8.49998C3.80002 7.99998 3.40002 7.09998 3.40002 6.49998V4.19998C3.40002 2.99998 4.30002 2.09998 5.40002 2.09998Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.93 2.09998L6 9.99998"
|
||||
stroke="#292D32"
|
||||
strokeWidth="1.5"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default Filter;
|
||||
41
app/icons/Group.tsx
Normal file
41
app/icons/Group.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
const Group = ({ className }: { className: string }) => (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M8.57007 15.27L15.11 8.72998"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8.98001 10.3699C9.65932 10.3699 10.21 9.81923 10.21 9.13992C10.21 8.46061 9.65932 7.90991 8.98001 7.90991C8.3007 7.90991 7.75 8.46061 7.75 9.13992C7.75 9.81923 8.3007 10.3699 8.98001 10.3699Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M15.52 16.0899C16.1993 16.0899 16.75 15.5392 16.75 14.8599C16.75 14.1806 16.1993 13.6299 15.52 13.6299C14.8407 13.6299 14.29 14.1806 14.29 14.8599C14.29 15.5392 14.8407 16.0899 15.52 16.0899Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default Group;
|
||||
57
app/icons/GroupAlt.tsx
Normal file
57
app/icons/GroupAlt.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
const GroupAlt = ({ className }: { className: string }) => (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M12 5.56006H22"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14.22 2H19.78C21.56 2 22 2.44 22 4.2V8.31C22 10.07 21.56 10.51 19.78 10.51H14.22C12.44 10.51 12 10.07 12 8.31V4.2C12 2.44 12.44 2 14.22 2Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2 17.0601H12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4.22 13.5H9.78C11.56 13.5 12 13.94 12 15.7V19.81C12 21.57 11.56 22.01 9.78 22.01H4.22C2.44 22.01 2 21.57 2 19.81V15.7C2 13.94 2.44 13.5 4.22 13.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M22 15C22 18.87 18.87 22 15 22L16.05 20.25"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2 9C2 5.13 5.13 2 9 2L7.95001 3.75"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default GroupAlt;
|
||||
33
app/icons/Login.tsx
Normal file
33
app/icons/Login.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
const Login = () => (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.90002 7.55999C9.21002 3.95999 11.06 2.48999 15.11 2.48999H15.24C19.71 2.48999 21.5 4.27999 21.5 8.74999V15.27C21.5 19.74 19.71 21.53 15.24 21.53H15.11C11.09 21.53 9.24002 20.08 8.91002 16.54"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2 12H14.88"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12.65 8.6499L16 11.9999L12.65 15.3499"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default Login;
|
||||
33
app/icons/Logout.tsx
Normal file
33
app/icons/Logout.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
const Logout = () => (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.90002 7.55999C9.21002 3.95999 11.06 2.48999 15.11 2.48999H15.24C19.71 2.48999 21.5 4.27999 21.5 8.74999V15.27C21.5 19.74 19.71 21.53 15.24 21.53H15.11C11.09 21.53 9.24002 20.08 8.91002 16.54"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M15 12H3.62"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5.85 8.6499L2.5 11.9999L5.85 15.3499"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default Logout;
|
||||
61
app/icons/MoneyAdd.tsx
Normal file
61
app/icons/MoneyAdd.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
const MoneyAdd = ({ className }: { className: string }) => (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M12 14.5C13.3807 14.5 14.5 13.3807 14.5 12C14.5 10.6193 13.3807 9.5 12 9.5C10.6193 9.5 9.5 10.6193 9.5 12C9.5 13.3807 10.6193 14.5 12 14.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M18.5 9.5V14.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9 18C9 18.75 8.79001 19.46 8.42001 20.06C7.73001 21.22 6.46 22 5 22C3.54 22 2.26999 21.22 1.57999 20.06C1.20999 19.46 1 18.75 1 18C1 15.79 2.79 14 5 14C7.21 14 9 15.79 9 18Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6.4917 17.9795H3.51172"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5 16.5195V19.5095"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2 15.3V9C2 5.5 4 4 7 4H17C20 4 22 5.5 22 9V15C22 18.5 20 20 17 20H8.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default MoneyAdd;
|
||||
55
app/icons/People.tsx
Normal file
55
app/icons/People.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
const People = ({ className }: { className: string }) => (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M18 7.16C17.94 7.15 17.87 7.15 17.81 7.16C16.43 7.11 15.33 5.98 15.33 4.58C15.33 3.15 16.48 2 17.91 2C19.34 2 20.49 3.16 20.49 4.58C20.48 5.98 19.38 7.11 18 7.16Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M16.9699 14.44C18.3399 14.67 19.8499 14.43 20.9099 13.72C22.3199 12.78 22.3199 11.24 20.9099 10.3C19.8399 9.59004 18.3099 9.35003 16.9399 9.59003"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5.96998 7.16C6.02998 7.15 6.09998 7.15 6.15998 7.16C7.53998 7.11 8.63998 5.98 8.63998 4.58C8.63998 3.15 7.48998 2 6.05998 2C4.62998 2 3.47998 3.16 3.47998 4.58C3.48998 5.98 4.58998 7.11 5.96998 7.16Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6.99994 14.44C5.62994 14.67 4.11994 14.43 3.05994 13.72C1.64994 12.78 1.64994 11.24 3.05994 10.3C4.12994 9.59004 5.65994 9.35003 7.02994 9.59003"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 14.63C11.94 14.62 11.87 14.62 11.81 14.63C10.43 14.58 9.32996 13.45 9.32996 12.05C9.32996 10.62 10.48 9.46997 11.91 9.46997C13.34 9.46997 14.49 10.63 14.49 12.05C14.48 13.45 13.38 14.59 12 14.63Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.08997 17.78C7.67997 18.72 7.67997 20.26 9.08997 21.2C10.69 22.27 13.31 22.27 14.91 21.2C16.32 20.26 16.32 18.72 14.91 17.78C13.32 16.72 10.69 16.72 9.08997 17.78Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default People;
|
||||
41
app/icons/Percentage.tsx
Normal file
41
app/icons/Percentage.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
const Percentage = ({ className }: { className: string }) => (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M9 2H15C20 2 22 4 22 9V15C22 20 20 22 15 22H9C4 22 2 20 2 15V9C2 4 4 2 9 2Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8.57007 15.27L15.11 8.72998"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8.98001 10.3699C9.65932 10.3699 10.21 9.81923 10.21 9.13992C10.21 8.46061 9.65932 7.90991 8.98001 7.90991C8.3007 7.90991 7.75 8.46061 7.75 9.13992C7.75 9.81923 8.3007 10.3699 8.98001 10.3699Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M15.52 16.0899C16.1993 16.0899 16.75 15.5392 16.75 14.8599C16.75 14.1806 16.1993 13.6299 15.52 13.6299C14.8407 13.6299 14.29 14.1806 14.29 14.8599C14.29 15.5392 14.8407 16.0899 15.52 16.0899Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default Percentage;
|
||||
47
app/icons/WalletSeach.tsx
Normal file
47
app/icons/WalletSeach.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
const WalletSeach = () => (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 22H17C20 22 22 20 22 17V12C22 9.3 20.3 7.3 17.8 7C17.6 7 17.3 7 17 7H7C6.7 7 6.5 6.99998 6.2 7.09998C3.6 7.39998 2 9.3 2 12C2 12.3 2 12.7 2 13"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M17.8 6.99995C17.6 6.99995 17.3 6.99995 17 6.99995H6.99995C6.69995 6.99995 6.49995 6.99993 6.19995 7.09993C6.29995 6.79993 6.49996 6.59994 6.79996 6.29994L9.99995 2.99995C11.4 1.59995 13.6 1.59995 15 2.99995L16.8 4.79994C17.4 5.39994 17.7 6.19995 17.8 6.99995Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M22 12.5H19C17.9 12.5 17 13.4 17 14.5C17 15.6 17.9 16.5 19 16.5H22"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5.80157 21.4C7.57157 21.4 9.00156 19.97 9.00156 18.2C9.00156 16.43 7.57157 15 5.80157 15C4.03157 15 2.60156 16.43 2.60156 18.2C2.60156 19.97 4.03157 21.4 5.80157 21.4Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2 22L3 21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default WalletSeach;
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { LinksFunction, MetaFunction } from "remix";
|
||||
import { Links, LiveReload, Outlet, useCatch, Meta, Scripts } from "remix";
|
||||
import Header from "./components/Header";
|
||||
|
||||
import styles from "./tailwind.css";
|
||||
|
||||
|
|
@ -44,14 +45,14 @@ function Document({
|
|||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<Meta />
|
||||
<title>{title}</title>
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
<body className="bg-base-300 m-0 min-h-screen">
|
||||
{children}
|
||||
<Scripts />
|
||||
{process.env.NODE_ENV === "development" ? <LiveReload /> : null}
|
||||
|
|
|
|||
0
app/routes/account.tsx
Normal file
0
app/routes/account.tsx
Normal file
|
|
@ -1,31 +1,24 @@
|
|||
import type { Expense, User } from "@prisma/client";
|
||||
import type { User, Team } from "@prisma/client";
|
||||
import type { LinksFunction, LoaderFunction } from "remix";
|
||||
import { Link, Outlet, useLoaderData, Form, redirect } from "remix";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { Outlet, useLoaderData, Form, redirect, useCatch } from "remix";
|
||||
import { getUser } from "~/utils/session.server";
|
||||
import Header from "../components/Header";
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [];
|
||||
};
|
||||
|
||||
type LoaderData = {
|
||||
user: User | null;
|
||||
expenseListItems: Array<Expense>;
|
||||
user: (User & { team: Team & { members: User[] } }) | null;
|
||||
};
|
||||
|
||||
export const loader: LoaderFunction = async ({ request }) => {
|
||||
const user = await getUser(request);
|
||||
if (!user) {
|
||||
redirect("/login");
|
||||
if (!user?.id) {
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
const expenseListItems = await db.expense.findMany({
|
||||
take: 25,
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
const data: LoaderData = {
|
||||
expenseListItems,
|
||||
user,
|
||||
};
|
||||
return data;
|
||||
|
|
@ -35,47 +28,29 @@ 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">
|
||||
<>
|
||||
<Header user={data.user} route="/expenses" />
|
||||
<main>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatchBoundary() {
|
||||
const caught = useCatch();
|
||||
|
||||
if (caught.status === 401) {
|
||||
return redirect("/login");
|
||||
}
|
||||
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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,27 @@
|
|||
import type { LoaderFunction } from "remix";
|
||||
import { useLoaderData, Link, useCatch } from "remix";
|
||||
import type { Expense } from "@prisma/client";
|
||||
import { useLoaderData, Link, useCatch, redirect } from "remix";
|
||||
import type { Expense, User } from "@prisma/client";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { getUser } from "~/utils/session.server";
|
||||
import Group from "~/icons/Group";
|
||||
|
||||
type LoaderData = { lastExpenses: Expense[] };
|
||||
type LoaderData = { lastExpenses: (Expense & { user: User })[]; user: User };
|
||||
|
||||
export const loader: LoaderFunction = async () => {
|
||||
export const loader: LoaderFunction = async ({ request }) => {
|
||||
const user = await getUser(request);
|
||||
if (!user) {
|
||||
return redirect("/login");
|
||||
}
|
||||
const lastExpenses = await db.expense.findMany({
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
take: 25,
|
||||
orderBy: { createdAt: "desc" },
|
||||
where: { teamId: user.teamId },
|
||||
});
|
||||
|
||||
const data: LoaderData = { lastExpenses };
|
||||
const data: LoaderData = { lastExpenses, user };
|
||||
return data;
|
||||
};
|
||||
|
||||
|
|
@ -19,13 +29,66 @@ 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
|
||||
<div className="grid grid-cols-2 gap-4 p-4">
|
||||
<div className="card shadow-lg compact side bg-base-100">
|
||||
<div className="flex-column items-center card-body">
|
||||
<h2 className="card-title">Last expenses</h2>
|
||||
{data.lastExpenses?.map((exp) => (
|
||||
<div className="flex w-full items-center mb-2" key={exp.id}>
|
||||
<div className="rounded-full w-10 h-10 inline-flex justify-center items-center bg-white text-3xl">
|
||||
{exp.user.icon ?? exp.user.username[0]}
|
||||
</div>
|
||||
<div className="font-bold w-16 ml-2 text-right">
|
||||
<span
|
||||
className={`${
|
||||
exp.amount > 0 ? "text-error" : "text-success"
|
||||
}`}
|
||||
>
|
||||
{-exp.amount} €
|
||||
</span>
|
||||
</div>
|
||||
<div className="grow ml-3">{exp.description}</div>
|
||||
</div>
|
||||
))}
|
||||
<Link to="list" className="btn">
|
||||
<span>See all</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card shadow-lg compact side bg-base-100">
|
||||
<div className="flex-row items-center space-x-4 card-body">
|
||||
<h2 className="card-title">Who needs to pay who</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card shadow-lg compact side bg-base-100">
|
||||
<div className="flex-row items-center justify-center space-x-4 card-body">
|
||||
<Link to="new" className="btn btn-primary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="inline-block mr-2 w-6 h-6 stroke-current rotate-45"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
<span className="hidden lg:inline-block">Add an expense</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card shadow-lg compact side bg-base-100">
|
||||
<div className="flex-row items-center justify-center space-x-4 card-body">
|
||||
<Link to="new" className="btn btn-primary">
|
||||
<Group className="inline-block mr-2 w-6 h-6 stroke-current" />
|
||||
<span className="hidden lg:inline-block">Trasfer</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
0
app/routes/expenses/list.tsx
Normal file
0
app/routes/expenses/list.tsx
Normal file
|
|
@ -1,8 +1,10 @@
|
|||
import type { User } from "@prisma/client";
|
||||
import type { LinksFunction, MetaFunction, LoaderFunction } from "remix";
|
||||
import { Link, useLoaderData } from "remix";
|
||||
import { getUserId } from "~/utils/session.server";
|
||||
import Header from "~/components/Header";
|
||||
import { getUser } from "~/utils/session.server";
|
||||
|
||||
type LoaderData = { userId: string | null };
|
||||
type LoaderData = { user: User | null };
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [];
|
||||
|
|
@ -17,8 +19,8 @@ export const meta: MetaFunction = () => {
|
|||
};
|
||||
|
||||
export const loader: LoaderFunction = async ({ request }) => {
|
||||
const userId = await getUserId(request);
|
||||
const data: LoaderData = { userId };
|
||||
const user = await getUser(request);
|
||||
const data: LoaderData = { user };
|
||||
return data;
|
||||
};
|
||||
|
||||
|
|
@ -26,23 +28,13 @@ export default function Index() {
|
|||
const data = useLoaderData<LoaderData>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header user={data.user} />
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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";
|
||||
import Header from "../components/Header";
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [];
|
||||
|
|
@ -37,14 +38,10 @@ type ActionData = {
|
|||
fieldErrors?: {
|
||||
username: string | undefined;
|
||||
password: string | undefined;
|
||||
teamId: string | undefined;
|
||||
};
|
||||
fields?: {
|
||||
loginType: string;
|
||||
username: string;
|
||||
password: string;
|
||||
teamId: string;
|
||||
icon?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -52,18 +49,12 @@ 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({
|
||||
|
|
@ -71,18 +62,15 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
});
|
||||
}
|
||||
|
||||
const fields = { loginType, username, password, teamId };
|
||||
const fields = { username, password };
|
||||
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 });
|
||||
const user = await login({ username, password });
|
||||
if (!user) {
|
||||
return badRequest({
|
||||
fields,
|
||||
|
|
@ -90,34 +78,6 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
});
|
||||
}
|
||||
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() {
|
||||
|
|
@ -125,11 +85,15 @@ export default function Login() {
|
|||
const [searchParams] = useSearchParams();
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="content" data-light="">
|
||||
<h1>Login</h1>
|
||||
<>
|
||||
<Header />
|
||||
<div className="container mx-auto min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="card bg-neutral w-full shadow-lg max-w-lg">
|
||||
<div className="card-body w-full">
|
||||
<h1 className="card-title">Login</h1>
|
||||
<Form
|
||||
method="post"
|
||||
className="mt-5"
|
||||
aria-describedby={
|
||||
actionData?.formError ? "form-error-message" : undefined
|
||||
}
|
||||
|
|
@ -139,129 +103,132 @@ export default function Login() {
|
|||
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
|
||||
<div className="form-control mb-3">
|
||||
<label htmlFor="username-input" className="label">
|
||||
<span className="label-text">Username</span>
|
||||
</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"
|
||||
className="input"
|
||||
defaultValue={actionData?.fields?.username}
|
||||
aria-invalid={Boolean(actionData?.fieldErrors?.username)}
|
||||
aria-describedby={
|
||||
actionData?.fieldErrors?.username ? "username-error" : undefined
|
||||
actionData?.fieldErrors?.username
|
||||
? "username-error"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{actionData?.fieldErrors?.username ? (
|
||||
<p
|
||||
className="form-validation-error"
|
||||
role="alert"
|
||||
id="username-error"
|
||||
{actionData?.fieldErrors?.username && (
|
||||
<div className="alert alert-error" role="alert">
|
||||
<div className="flex-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-6 h-6 mx-2 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
||||
></path>
|
||||
</svg>
|
||||
<label id="username-error">
|
||||
{actionData?.fieldErrors.username}
|
||||
</p>
|
||||
) : null}
|
||||
</label>
|
||||
</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>
|
||||
<div className="form-control mb-3">
|
||||
<label htmlFor="password-input" className="label">
|
||||
<span className="label-text">Password</span>
|
||||
</label>
|
||||
<input
|
||||
id="password-input"
|
||||
name="password"
|
||||
className="input"
|
||||
defaultValue={actionData?.fields?.password}
|
||||
type="password"
|
||||
aria-invalid={
|
||||
Boolean(actionData?.fieldErrors?.password) || undefined
|
||||
}
|
||||
aria-describedby={
|
||||
actionData?.fieldErrors?.password ? "password-error" : undefined
|
||||
actionData?.fieldErrors?.password
|
||||
? "password-error"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{actionData?.fieldErrors?.password ? (
|
||||
<p
|
||||
className="form-validation-error"
|
||||
role="alert"
|
||||
id="password-error"
|
||||
{actionData?.fieldErrors?.password && (
|
||||
<div className="alert alert-error" role="alert">
|
||||
<div className="flex-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-6 h-6 mx-2 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
||||
></path>
|
||||
</svg>
|
||||
<label id="password-error">
|
||||
{actionData?.fieldErrors.password}
|
||||
</p>
|
||||
) : null}
|
||||
</label>
|
||||
</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">
|
||||
)}
|
||||
</div>
|
||||
<div id="form-error-message" className="mt-6 mb-3">
|
||||
{actionData?.formError && (
|
||||
<div className="alert alert-error" role="alert">
|
||||
<div className="flex-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-6 h-6 mx-2 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
||||
></path>
|
||||
</svg>
|
||||
<label>{actionData?.formError}</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary mt-10 block w-full"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="container">
|
||||
<div className="links">
|
||||
<ul>
|
||||
<li>
|
||||
<Link to="/">Home</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/signin">Sign-in</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
267
app/routes/signin.tsx
Normal file
267
app/routes/signin.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;
|
||||
};
|
||||
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" ||
|
||||
(loginType === "register" &&
|
||||
(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: loginType === "register" && validateTeamId(teamId),
|
||||
};
|
||||
if (Object.values(fieldErrors).some(Boolean))
|
||||
return badRequest({ fieldErrors, fields });
|
||||
|
||||
switch (loginType) {
|
||||
case "login": {
|
||||
const user = await login({ username, password });
|
||||
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>
|
||||
);
|
||||
}
|
||||
0
app/routes/statistics.tsx
Normal file
0
app/routes/statistics.tsx
Normal file
0
app/routes/team.tsx
Normal file
0
app/routes/team.tsx
Normal file
|
|
@ -5,7 +5,12 @@ import { db } from "./db.server";
|
|||
type LoginForm = {
|
||||
username: string;
|
||||
password: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
type RegisterForm = {
|
||||
username: string;
|
||||
password: string;
|
||||
icon: string;
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
|
|
@ -14,7 +19,7 @@ export async function register({
|
|||
password,
|
||||
icon,
|
||||
teamId,
|
||||
}: LoginForm) {
|
||||
}: RegisterForm) {
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const team = await db.team.findUnique({ where: { id: teamId } });
|
||||
if (!team) {
|
||||
|
|
@ -94,6 +99,13 @@ export async function getUser(request: Request) {
|
|||
try {
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return user;
|
||||
} catch {
|
||||
|
|
@ -103,7 +115,7 @@ export async function getUser(request: Request) {
|
|||
|
||||
export async function logout(request: Request) {
|
||||
const session = await storage.getSession(request.headers.get("Cookie"));
|
||||
return redirect("/login", {
|
||||
return redirect("/", {
|
||||
headers: {
|
||||
"Set-Cookie": await storage.destroySession(session),
|
||||
},
|
||||
|
|
|
|||
23
prisma/migrations/20220213201936_add_teamid/migration.sql
Normal file
23
prisma/migrations/20220213201936_add_teamid/migration.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `teamId` to the `Expense` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Expense" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"teamId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"amount" REAL NOT NULL,
|
||||
CONSTRAINT "Expense_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Expense" ("amount", "createdAt", "description", "id", "updatedAt", "userId") SELECT "amount", "createdAt", "description", "id", "updatedAt", "userId" FROM "Expense";
|
||||
DROP TABLE "Expense";
|
||||
ALTER TABLE "new_Expense" RENAME TO "Expense";
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
|
@ -33,6 +33,7 @@ model Expense {
|
|||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
teamId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
description String
|
||||
|
|
|
|||
|
|
@ -33,16 +33,19 @@ async function seed() {
|
|||
description: "Spesa",
|
||||
amount: 100,
|
||||
userId: nicola.id,
|
||||
teamId: famiglia.id,
|
||||
},
|
||||
{
|
||||
description: "Spesa",
|
||||
amount: 70,
|
||||
userId: shahra.id,
|
||||
teamId: famiglia.id,
|
||||
},
|
||||
{
|
||||
description: "Affitto",
|
||||
amount: 500,
|
||||
userId: shahra.id,
|
||||
teamId: famiglia.id,
|
||||
},
|
||||
|
||||
// transaction between users
|
||||
|
|
@ -50,17 +53,20 @@ async function seed() {
|
|||
description: "Affitto",
|
||||
amount: 250,
|
||||
userId: nicola.id,
|
||||
teamId: famiglia.id,
|
||||
},
|
||||
{
|
||||
description: "Affitto",
|
||||
amount: -250,
|
||||
userId: shahra.id,
|
||||
teamId: famiglia.id,
|
||||
},
|
||||
|
||||
{
|
||||
description: "Cena",
|
||||
amount: 50,
|
||||
userId: nicola.id,
|
||||
teamId: famiglia.id,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue