diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..712705f673f3b5c5a47dc92c5d8f884dc99d1728 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_REST_API_URL= \ No newline at end of file diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000000000000000000000000000000000000..712705f673f3b5c5a47dc92c5d8f884dc99d1728 --- /dev/null +++ b/.env.production.example @@ -0,0 +1 @@ +VITE_REST_API_URL= \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c827fa75e7f208dfd14ac2ba29f438367d6fbf39..e4bed569adb50577640f26fcffa4f3a9a88d1fc8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,8 @@ RUN npm run build FROM nginx:1.24.0-alpine +COPY nginx.conf /etc/nginx/nginx.conf + COPY --from=build /tonality/tonality-client/dist /usr/share/nginx/html EXPOSE 80 diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000000000000000000000000000000000000..a8aabfcca1421931ff363d32472fd89ef7331db4 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,21 @@ +events {} + +http { + include /etc/nginx/mime.types; # Include MIME types + + server { + listen 80; + + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; # Handle client-side routing + } + } + + # Specify MIME types for JavaScript and CSS files + types { + text/javascript js; + text/css css; + } +} diff --git a/src/App.css b/src/App.css index b9d355df2a5956b526c004531b7b0ffe412461e0..8a02e97c06f714f1409d24f9955385029eec21cd 100644 --- a/src/App.css +++ b/src/App.css @@ -1,42 +1,42 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} +/*#root {*/ +/* max-width: 1280px;*/ +/* margin: 0 auto;*/ +/* padding: 2rem;*/ +/* text-align: center;*/ +/*}*/ -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} +/*.logo {*/ +/* height: 6em;*/ +/* padding: 1.5em;*/ +/* will-change: filter;*/ +/* transition: filter 300ms;*/ +/*}*/ +/*.logo:hover {*/ +/* filter: drop-shadow(0 0 2em #646cffaa);*/ +/*}*/ +/*.logo.react:hover {*/ +/* filter: drop-shadow(0 0 2em #61dafbaa);*/ +/*}*/ -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} +/*@keyframes logo-spin {*/ +/* from {*/ +/* transform: rotate(0deg);*/ +/* }*/ +/* to {*/ +/* transform: rotate(360deg);*/ +/* }*/ +/*}*/ -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} +/*@media (prefers-reduced-motion: no-preference) {*/ +/* a:nth-of-type(2) .logo {*/ +/* animation: logo-spin infinite 20s linear;*/ +/* }*/ +/*}*/ -.card { - padding: 2em; -} +/*.card {*/ +/* padding: 2em;*/ +/*}*/ -.read-the-docs { - color: #888; -} +/*.read-the-docs {*/ +/* color: #888;*/ +/*}*/ diff --git a/src/TonalityApp.tsx b/src/TonalityApp.tsx index 7a6f9b8b9d4afd020fc93c429a92ef310eabd13f..16bda2f721bf828d8e870e153b9de4d4209031cc 100644 --- a/src/TonalityApp.tsx +++ b/src/TonalityApp.tsx @@ -1,21 +1,20 @@ -import './App.css' -import React from 'react' +import "./App.css"; +import React from "react"; import { RenderRoutes } from "@/routes/RenderRoutes.tsx"; -import {routes} from "@/routes/routes.ts"; +import { routes } from "@/routes/routes.ts"; import AuthProvider from "@/context/AuthProvider.tsx"; -export const AuthContext = React.createContext(null) +export const AuthContext = React.createContext(null); -export const useAuth = () => React.useContext(AuthContext) +export const useAuth = () => React.useContext(AuthContext); -export const Routes = RenderRoutes(routes) +export const Routes: React.ReactNode = RenderRoutes(routes); const TonalityApp = () => { - return ( <AuthProvider> - <Routes/> + <Routes /> </AuthProvider> - ) -} -export default TonalityApp + ); +}; +export default TonalityApp; diff --git a/src/assets/images/logo.svg b/src/assets/images/logo.svg index 4753de8fd2574faf05e7f4e7d69fe27bea9153c8..3e7c6daa1ccd620264136fc19f45a5ba3e3d8199 100644 --- a/src/assets/images/logo.svg +++ b/src/assets/images/logo.svg @@ -1,12 +1,4 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- Generator: Adobe Illustrator 27.8.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> -<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" - viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve"> -<style type="text/css"> - .st0{fill:#FFFFFF;} -</style> -<path class="st0" d="M0,256C0,114.6,114.6,0,256,0s256,114.6,256,256S397.4,512,256,512S0,397.4,0,256z M256,288 - c-17.7,0-32-14.3-32-32s14.3-32,32-32s32,14.3,32,32S273.7,288,256,288z M160,256c0,53,43,96,96,96s96-43,96-96s-43-96-96-96 - S160,203,160,256z M96,240c0-35,17.5-71.1,45.2-98.8S205,96,240,96c8.8,0,16-7.2,16-16s-7.2-16-16-16c-45.4,0-89.2,22.3-121.5,54.5 - S64,194.6,64,240c0,8.8,7.2,16,16,16S96,248.8,96,240z"/> +<svg width="176" height="50" viewBox="0 0 176 50" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M0 25C0 18.3696 2.63392 12.0107 7.32233 7.32233C12.0107 2.63392 18.3696 0 25 0C31.6304 0 37.9893 2.63392 42.6777 7.32233C47.3661 12.0107 50 18.3696 50 25C50 31.6304 47.3661 37.9893 42.6777 42.6777C37.9893 47.3661 31.6304 50 25 50C18.3696 50 12.0107 47.3661 7.32233 42.6777C2.63392 37.9893 0 31.6304 0 25ZM25 28.125C24.1712 28.125 23.3763 27.7958 22.7903 27.2097C22.2042 26.6237 21.875 25.8288 21.875 25C21.875 24.1712 22.2042 23.3763 22.7903 22.7903C23.3763 22.2042 24.1712 21.875 25 21.875C25.8288 21.875 26.6237 22.2042 27.2097 22.7903C27.7958 23.3763 28.125 24.1712 28.125 25C28.125 25.8288 27.7958 26.6237 27.2097 27.2097C26.6237 27.7958 25.8288 28.125 25 28.125ZM15.625 25C15.625 27.4864 16.6127 29.871 18.3709 31.6291C20.129 33.3873 22.5136 34.375 25 34.375C27.4864 34.375 29.871 33.3873 31.6291 31.6291C33.3873 29.871 34.375 27.4864 34.375 25C34.375 22.5136 33.3873 20.129 31.6291 18.3709C29.871 16.6127 27.4864 15.625 25 15.625C22.5136 15.625 20.129 16.6127 18.3709 18.3709C16.6127 20.129 15.625 22.5136 15.625 25ZM9.375 23.4375C9.375 20.0195 11.084 16.4941 13.7891 13.7891C16.4941 11.084 20.0195 9.375 23.4375 9.375C24.2969 9.375 25 8.67188 25 7.8125C25 6.95312 24.2969 6.25 23.4375 6.25C19.0039 6.25 14.7266 8.42773 11.5723 11.5723C8.41797 14.7168 6.25 19.0039 6.25 23.4375C6.25 24.2969 6.95312 25 7.8125 25C8.67188 25 9.375 24.2969 9.375 23.4375Z" fill="white"/> +<path d="M59.0653 17.9851V14.1818H76.9844V17.9851H70.3047V36H65.745V17.9851H59.0653ZM84.8706 36.3196C83.2157 36.3196 81.7846 35.968 80.5772 35.2649C79.377 34.5547 78.4501 33.5675 77.7967 32.3033C77.1433 31.032 76.8166 29.5582 76.8166 27.8821C76.8166 26.1918 77.1433 24.7145 77.7967 23.4503C78.4501 22.179 79.377 21.1918 80.5772 20.4886C81.7846 19.7784 83.2157 19.4233 84.8706 19.4233C86.5254 19.4233 87.9529 19.7784 89.1532 20.4886C90.3606 21.1918 91.291 22.179 91.9444 23.4503C92.5978 24.7145 92.9245 26.1918 92.9245 27.8821C92.9245 29.5582 92.5978 31.032 91.9444 32.3033C91.291 33.5675 90.3606 34.5547 89.1532 35.2649C87.9529 35.968 86.5254 36.3196 84.8706 36.3196ZM84.8919 32.804C85.6447 32.804 86.2733 32.5909 86.7775 32.1648C87.2818 31.7315 87.6618 31.142 87.9174 30.3963C88.1802 29.6506 88.3116 28.8018 88.3116 27.8501C88.3116 26.8984 88.1802 26.0497 87.9174 25.304C87.6618 24.5582 87.2818 23.9687 86.7775 23.5355C86.2733 23.1023 85.6447 22.8857 84.8919 22.8857C84.1319 22.8857 83.4927 23.1023 82.9743 23.5355C82.4629 23.9687 82.0758 24.5582 81.813 25.304C81.5574 26.0497 81.4295 26.8984 81.4295 27.8501C81.4295 28.8018 81.5574 29.6506 81.813 30.3963C82.0758 31.142 82.4629 31.7315 82.9743 32.1648C83.4927 32.5909 84.1319 32.804 84.8919 32.804ZM100.414 26.5398V36H95.8755V19.6364H100.201V22.5234H100.393C100.755 21.5717 101.362 20.8189 102.214 20.2649C103.067 19.7038 104.1 19.4233 105.314 19.4233C106.451 19.4233 107.442 19.6719 108.287 20.169C109.132 20.6662 109.789 21.3764 110.258 22.2997C110.726 23.2159 110.961 24.3097 110.961 25.581V36H106.422V26.3906C106.43 25.3892 106.174 24.608 105.655 24.0469C105.137 23.4787 104.423 23.1946 103.514 23.1946C102.903 23.1946 102.363 23.326 101.895 23.5888C101.433 23.8516 101.071 24.2351 100.808 24.7393C100.552 25.2365 100.421 25.8366 100.414 26.5398ZM119.204 36.3089C118.16 36.3089 117.229 36.1278 116.413 35.7656C115.596 35.3963 114.95 34.853 114.474 34.1357C114.005 33.4112 113.771 32.5092 113.771 31.4297C113.771 30.5206 113.938 29.7571 114.271 29.1392C114.605 28.5213 115.06 28.0241 115.635 27.6477C116.21 27.2713 116.864 26.9872 117.595 26.7955C118.334 26.6037 119.108 26.4688 119.918 26.3906C120.869 26.2912 121.636 26.1989 122.219 26.1136C122.801 26.0213 123.224 25.8864 123.487 25.7088C123.749 25.5312 123.881 25.2685 123.881 24.9205V24.8565C123.881 24.1818 123.668 23.6598 123.241 23.2905C122.822 22.9212 122.226 22.7365 121.452 22.7365C120.635 22.7365 119.985 22.9176 119.502 23.2798C119.019 23.6349 118.7 24.0824 118.543 24.6222L114.346 24.2812C114.559 23.2869 114.978 22.4276 115.603 21.7031C116.228 20.9716 117.034 20.4105 118.021 20.0199C119.016 19.6222 120.166 19.4233 121.473 19.4233C122.382 19.4233 123.252 19.5298 124.083 19.7429C124.921 19.956 125.663 20.2862 126.31 20.7337C126.963 21.1811 127.478 21.7564 127.854 22.4595C128.231 23.1555 128.419 23.9901 128.419 24.9631V36H124.115V33.7308H123.987C123.724 34.2422 123.373 34.6932 122.933 35.0838C122.492 35.4673 121.963 35.7692 121.345 35.9893C120.727 36.2024 120.013 36.3089 119.204 36.3089ZM120.504 33.1768C121.171 33.1768 121.761 33.0455 122.272 32.7827C122.783 32.5128 123.185 32.1506 123.476 31.696C123.767 31.2415 123.913 30.7266 123.913 30.1513V28.4148C123.771 28.5071 123.575 28.5923 123.327 28.6705C123.085 28.7415 122.812 28.8089 122.506 28.8729C122.201 28.9297 121.896 28.983 121.59 29.0327C121.285 29.0753 121.008 29.1143 120.759 29.1499C120.227 29.228 119.761 29.3523 119.364 29.5227C118.966 29.6932 118.657 29.924 118.437 30.2152C118.217 30.4993 118.107 30.8544 118.107 31.2805C118.107 31.8984 118.33 32.3707 118.778 32.6974C119.232 33.017 119.808 33.1768 120.504 33.1768ZM136.478 14.1818V36H131.94V14.1818H136.478ZM140.114 36V19.6364H144.652V36H140.114ZM142.394 17.527C141.719 17.527 141.14 17.3033 140.657 16.8558C140.181 16.4013 139.943 15.858 139.943 15.2259C139.943 14.6009 140.181 14.0646 140.657 13.6172C141.14 13.1626 141.719 12.9354 142.394 12.9354C143.068 12.9354 143.644 13.1626 144.119 13.6172C144.602 14.0646 144.844 14.6009 144.844 15.2259C144.844 15.858 144.602 16.4013 144.119 16.8558C143.644 17.3033 143.068 17.527 142.394 17.527ZM156.991 19.6364V23.0455H147.137V19.6364H156.991ZM149.374 15.7159H153.913V30.9716C153.913 31.3906 153.977 31.7173 154.104 31.9517C154.232 32.179 154.41 32.3388 154.637 32.4311C154.871 32.5234 155.141 32.5696 155.447 32.5696C155.66 32.5696 155.873 32.5518 156.086 32.5163C156.299 32.4737 156.462 32.4418 156.576 32.4205L157.29 35.7976C157.063 35.8686 156.743 35.9503 156.331 36.0426C155.919 36.142 155.418 36.2024 154.829 36.2237C153.735 36.2663 152.776 36.1207 151.952 35.7869C151.136 35.4531 150.5 34.9347 150.045 34.2315C149.591 33.5284 149.367 32.6406 149.374 31.5682V15.7159ZM162.75 42.1364C162.174 42.1364 161.635 42.0902 161.13 41.9979C160.633 41.9126 160.221 41.8026 159.895 41.6676L160.917 38.2798C161.45 38.4432 161.929 38.532 162.355 38.5462C162.789 38.5604 163.162 38.4609 163.474 38.2479C163.794 38.0348 164.053 37.6726 164.252 37.1612L164.518 36.4688L158.648 19.6364H163.421L166.809 31.6534H166.979L170.399 19.6364H175.203L168.843 37.7685C168.538 38.6491 168.123 39.4162 167.597 40.0696C167.078 40.7301 166.422 41.2379 165.626 41.593C164.831 41.9553 163.872 42.1364 162.75 42.1364Z" fill="white"/> </svg> diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index a2aba9fce0f366419742622dfcb4cfe915275bcf..093fa6423fbdde69d6af7fda498c239da2bd79e3 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -9,15 +9,15 @@ const buttonVariants = cva( { variants: { variant: { - default: "bg-neutral-900 text-neutral-50 hover:bg-neutral-900/90 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/90", + default: "bg-neutral-50 text-neutral-900 hover:bg-neutral-50/90", destructive: - "bg-red-500 text-neutral-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-neutral-50 dark:hover:bg-red-900/90", + "bg-red-900 text-neutral-50 hover:bg-red-900/90", outline: - "border border-neutral-200 bg-white hover:bg-neutral-100 hover:text-neutral-900 dark:border-neutral-800 dark:bg-neutral-950 dark:hover:bg-neutral-800 dark:hover:text-neutral-50", + "border-neutral-800 bg-neutral-950 hover:bg-neutral-800 hover:text-neutral-50", secondary: - "bg-neutral-100 text-neutral-900 hover:bg-neutral-100/80 dark:bg-neutral-800 dark:text-neutral-50 dark:hover:bg-neutral-800/80", - ghost: "hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-50", - link: "text-neutral-900 underline-offset-4 hover:underline dark:text-neutral-50", + "bg-neutral-800 text-neutral-50 hover:bg-neutral-800/80", + ghost: "hover:bg-neutral-800 hover:text-neutral-50", + link: "text-neutral-50 underline-offset-4 hover:underline", }, size: { default: "h-10 px-4 py-2", diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx index 26ae398ea6a93514b2119c2d6a14456b5ca6ed9d..65b609f1397315126da3d9af67428aca7027db09 100644 --- a/src/components/ui/form.tsx +++ b/src/components/ui/form.tsx @@ -93,7 +93,7 @@ const FormLabel = React.forwardRef< return ( <Label ref={ref} - className={cn(error && "text-red-500 dark:text-red-900", className)} + className={cn(error && "text-red-900", className, "text-neutral-100")} htmlFor={formItemId} {...props} /> @@ -155,7 +155,7 @@ const FormMessage = React.forwardRef< <p ref={ref} id={formMessageId} - className={cn("text-sm font-medium text-red-500 dark:text-red-900", className)} + className={cn("text-sm font-medium text-red-900", className)} {...props} > {body} diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 8f19870290714023aede012518b659cc3392dcbb..9f3c3a3a308b7e35ab2acf25d1853e2bd8c6d646 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>( <input type={type} className={cn( - "flex h-10 w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-neutral-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-800 dark:bg-neutral-950 dark:ring-offset-neutral-950 dark:placeholder:text-neutral-400 dark:focus-visible:ring-neutral-300", + "text-neutral-100 flex h-10 w-full rounded-md border border-neutral-800 bg-neutral-950 px-3 py-2 text-sm ring-offset-neutral-950 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-neutral-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-300 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", className )} ref={ref} diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx new file mode 100644 index 0000000000000000000000000000000000000000..48da3332ffe1fcbed28364dcdde1ad3f4c93b60b --- /dev/null +++ b/src/components/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Viewport>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Viewport + ref={ref} + className={cn( + "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", + className + )} + {...props} + /> +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border border-neutral-800 p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-neutral-950 text-neutral-50", + destructive: + "destructive group border-red-900 bg-red-900 text-neutral-50", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Root>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & + VariantProps<typeof toastVariants> +>(({ className, variant, ...props }, ref) => { + return ( + <ToastPrimitives.Root + ref={ref} + className={cn(toastVariants({ variant }), className)} + {...props} + /> + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Action>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Action + ref={ref} + className={cn( + "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-neutral-800 ring-offset-neutral-950 hover:bg-neutral-800 focus:ring-neutral-300 group-[.destructive]:border-neutral-800/40 group-[.destructive]:hover:border-red-900/30 group-[.destructive]:hover:bg-red-900 group-[.destructive]:hover:text-neutral-50 group-[.destructive]:focus:ring-red-900", + className + )} + {...props} + /> +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Close>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Close + ref={ref} + className={cn( + "absolute right-2 top-2 rounded-md p-1 opacity-0 transition-opacity focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600 text-neutral-50/50 hover:text-neutral-50", + className + )} + toast-close="" + {...props} + > + <X className="h-4 w-4" /> + </ToastPrimitives.Close> +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Title>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Title + ref={ref} + className={cn("text-sm font-semibold", className)} + {...props} + /> +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Description>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Description + ref={ref} + className={cn("text-sm opacity-90", className)} + {...props} + /> +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef<typeof Toast> + +type ToastActionElement = React.ReactElement<typeof ToastAction> + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a2209ba5866d57814fc81edb2caf36065835b6df --- /dev/null +++ b/src/components/ui/toaster.tsx @@ -0,0 +1,33 @@ +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast" +import { useToast } from "@/components/ui/use-toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + <ToastProvider> + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + <Toast key={id} {...props}> + <div className="grid gap-1"> + {title && <ToastTitle>{title}</ToastTitle>} + {description && ( + <ToastDescription>{description}</ToastDescription> + )} + </div> + {action} + <ToastClose /> + </Toast> + ) + })} + <ToastViewport /> + </ToastProvider> + ) +} diff --git a/src/components/ui/use-toast.ts b/src/components/ui/use-toast.ts new file mode 100644 index 0000000000000000000000000000000000000000..90d8959bf3136de29eec362bf9d089b705c4ed3b --- /dev/null +++ b/src/components/ui/use-toast.ts @@ -0,0 +1,192 @@ +// Inspired by react-hot-toast library +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "@/components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_VALUE + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial<ToasterToast> + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit<ToasterToast, "id"> + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState<State>(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } diff --git a/src/index.css b/src/index.css index b5c61c956711f981a41e95f7fcf0038436cfbb22..bc0f3108b1308fae33b70df6628299e64bbe7e2f 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,11 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Lato&display=swap'); + @tailwind base; @tailwind components; @tailwind utilities; + +@layer base { + html { + font-family: "Inter", system-ui, sans-serif; + } +} diff --git a/src/layouts/AnonymousLayout.tsx b/src/layouts/AnonymousLayout.tsx index 14ba62ab40863a8521e218bf5a9f37f2c30b5fda..82adf2e099fc5eae1cb08734c189d2196dac9d7e 100644 --- a/src/layouts/AnonymousLayout.tsx +++ b/src/layouts/AnonymousLayout.tsx @@ -1,9 +1,30 @@ -import {Outlet} from "react-router-dom"; +import { Outlet } from "react-router-dom"; +import { Toaster } from "@/components/ui/toaster.tsx"; +import logo from "@/assets/images/logo.svg"; const AnonymousLayout = () => { return ( - <Outlet/> + <> + <Toaster /> + <div className="flex"> + <div className="flex flex-col justify-between bg-neutral-900 h-screen w-1/2"> + <div className="m-5"> + <img src={logo} alt="Tonality Logo"></img> + </div> + <div className="m-5"> + <p className="text-neutral-100"> + "Music is the universal language that transcends borders, + cultures, and time, speaking to the very core of our humanity, + where words alone often fall short." + </p> + </div> + </div> + <div className="h-screen w-1/2 bg-neutral-950 flex items-center justify-center"> + <Outlet /> + </div> + </div> + </> ); }; -export default AnonymousLayout; \ No newline at end of file +export default AnonymousLayout; diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index 8cbab696816078f7690dbfca12c427fffad2c612..f06545bdf397b739b4f9f804927c0ae384ffa5bb 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -1,15 +1,15 @@ -import Sidebar from "@/components/Sidebar.tsx"; +import Sidebar from "@/components/sidebar.tsx"; import { Outlet } from "react-router-dom"; const MainLayout = () => { return ( <div className="wrapper"> - <Sidebar/> - <div className='ml-20 pr-10'> + <Sidebar /> + <div className="ml-20 pr-10"> <Outlet /> </div> </div> ); }; -export default MainLayout; \ No newline at end of file +export default MainLayout; diff --git a/src/layouts/NotMatchLayout.tsx b/src/layouts/NotMatchLayout.tsx index 6c1d898ce62bd4f6260cb3e861eb5fde9ccf0dbb..1907ae0b0eb3460917e962d2e047a69a597d1d66 100644 --- a/src/layouts/NotMatchLayout.tsx +++ b/src/layouts/NotMatchLayout.tsx @@ -1,7 +1,7 @@ -import {Outlet} from "react-router-dom"; +import { Outlet } from "react-router-dom"; -export const NotMatchLayout = () => { - return ( - <Outlet/> - ); -}; \ No newline at end of file +const NotMatchLayout = () => { + return <Outlet />; +}; + +export default NotMatchLayout; diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 038e8ebe8f3077d19eab6609480a274c470f4815..6504d958d1297c79a1072fb52684785d8a323074 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -1,9 +1,141 @@ +"use client"; + +import * as z from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import axios from "axios"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form.tsx"; +import { Input } from "@/components/ui/input.tsx"; +import { Button } from "@/components/ui/button.tsx"; +import { Link } from "react-router-dom"; +import { storeAccessToken } from "@/utils/token.ts"; +import { StatusCodes } from "http-status-codes"; + +const restApiUrl: string = import.meta.env.VITE_REST_API_URL; + +const isUsernameInvalid = async (username: string): Promise<boolean> => { + const res = await axios.post(restApiUrl + "username-availability", { + username: username, + }); + + const responseBody: { usernameAvailable: string } = res.data; + + return responseBody.usernameAvailable === "false"; +}; + +const loginFormSchema = z.object({ + username: z + .string() + .min(2, { + message: "Username has a minimum length of 2.", + }) + .max(50, { + message: "Username has a maximum length of 50.", + }) + .refine(isUsernameInvalid, { + message: "User does not exist.", + }), + password: z + .string() + .min(8, { + message: "Password has a minimum length of 8.", + }) + .max(255, { + message: "Password has a maximum length of 255.", + }), +}); + const LoginPage = () => { + // Define form + const loginForm = useForm<z.infer<typeof loginFormSchema>>({ + resolver: zodResolver(loginFormSchema), + defaultValues: { + username: "", + password: "", + }, + }); + + // Define submit handler + async function onSubmit(values: z.infer<typeof loginFormSchema>) { + try { + const res = await axios.post( + restApiUrl + "login", + { + username: values.username, + password: values.password, + }, + { + withCredentials: true, + }, + ); + + storeAccessToken(res.data.accessToken); + } catch (err) { + if ( + axios.isAxiosError(err) && + err.response?.status === StatusCodes.UNAUTHORIZED + ) { + loginForm.setError("password", { + message: "Invalid password.", + }); + } else { + // Handle other errors, such as network issues or server errors + console.error("Error occurred during login:", err); + } + } + } + return ( - <div> - Login Page + <div className="flex flex-col"> + <Form {...loginForm}> + <form onSubmit={loginForm.handleSubmit(onSubmit)} className="space-y-8"> + <FormField + control={loginForm.control} + name="username" + render={({ field }) => ( + <FormItem> + <FormLabel>Username</FormLabel> + <FormControl> + <Input placeholder="" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={loginForm.control} + name="password" + render={({ field }) => ( + <FormItem> + <FormLabel>Password</FormLabel> + <FormControl> + <Input placeholder="" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <Button type="submit">Login</Button> + </form> + </Form> + + <div className="text-neutral-100 mt-6"> + Don't have an account?{" "} + <Link to="/signup" className="underline"> + Sign up for Tonality + </Link> + . + </div> </div> ); }; -export default LoginPage; \ No newline at end of file +export default LoginPage; diff --git a/src/pages/NotMatch.tsx b/src/pages/NotMatch.tsx index f7d2400a3a299d2a88a6de2147ca247a7cbca9e1..65355e317a3ea2147a606ac0e2852f8f7166a835 100644 --- a/src/pages/NotMatch.tsx +++ b/src/pages/NotMatch.tsx @@ -2,15 +2,11 @@ const NotMatch = () => { return ( <> <div className="h-screen flex flex-col items-center justify-center"> - <h1 className="font-bold text-3xl text-neutral-100"> - Oops! - </h1> + <h1 className="font-bold text-3xl text-neutral-100">Oops!</h1> <p className="my-5 text-neutral-100"> Sorry, an unexpected error has occurred. </p> - <p className="text-neutral-100"> - Not Found - </p> + <p className="text-neutral-100 italic">Not Found</p> </div> </> ); diff --git a/src/pages/SignUpPage.tsx b/src/pages/SignUpPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d5eb1f6ad0b629d26691ec41f7e0043858e1ba89 --- /dev/null +++ b/src/pages/SignUpPage.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form.tsx"; +import { Input } from "@/components/ui/input.tsx"; +import { Button } from "@/components/ui/button.tsx"; +import { Link, useNavigate } from "react-router-dom"; +import { StatusCodes } from "http-status-codes"; +import axios from "axios"; +import { useToast } from "@/components/ui/use-toast.ts"; + +const restApiUrl: string = import.meta.env.VITE_REST_API_URL; + +const isUsernameAvailable = async (username: string): Promise<boolean> => { + const res = await axios.post(restApiUrl + "username-availability", { + username: username, + }); + + const responseBody: { usernameAvailable: string } = res.data; + + return responseBody.usernameAvailable === "true"; +}; + +const signUpFormSchema = z.object({ + username: z + .string() + .min(2, { + message: "Username must have a minimum length of 2.", + }) + .max(50, { + message: "Username must have a maximum length of 50.", + }) + .refine(isUsernameAvailable, { + message: "Username already exists.", + }), + password: z + .string() + .min(8, { + message: "Password must have a minimum length of 8.", + }) + .max(255, { + message: "Password must have a maximum length of 255.", + }), +}); + +const SignUpPage = () => { + const { toast } = useToast(); + const navigate = useNavigate(); + + // Define form + const signUpForm = useForm<z.infer<typeof signUpFormSchema>>({ + resolver: zodResolver(signUpFormSchema), + defaultValues: { + username: "", + password: "", + }, + }); + + // Define submit handler + async function onSubmit(values: z.infer<typeof signUpFormSchema>) { + const res = await axios.post(restApiUrl + "signup", { + username: values.username, + password: values.password, + }); + + if (res.status === StatusCodes.OK) { + toast({ + description: "You have successfully signed up for Tonality!", + }); + navigate("/login"); + } + } + + return ( + <div className="flex flex-col"> + <Form {...signUpForm}> + <form + onSubmit={signUpForm.handleSubmit(onSubmit)} + className="space-y-8" + > + <FormField + control={signUpForm.control} + name="username" + render={({ field }) => ( + <FormItem> + <FormLabel>Username</FormLabel> + <FormControl> + <Input placeholder="" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={signUpForm.control} + name="password" + render={({ field }) => ( + <FormItem> + <FormLabel>Password</FormLabel> + <FormControl> + <Input placeholder="" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <Button type="submit">Sign Up</Button> + </form> + </Form> + + <div className="text-neutral-100 mt-6"> + Already have an account?{" "} + <Link to="/login" className="underline"> + Login here + </Link> + . + </div> + </div> + ); +}; + +export default SignUpPage; diff --git a/src/routes/ProtectedRoute.tsx b/src/routes/ProtectedRoute.tsx index c1e81a4272d89b5a0f905687e3e85c2996f99d8e..5b4eb2cbd57502cd11fc6ab2134da2c6ba42e7f7 100644 --- a/src/routes/ProtectedRoute.tsx +++ b/src/routes/ProtectedRoute.tsx @@ -1,9 +1,9 @@ -import {Navigate, Outlet,} from 'react-router-dom'; -import {useAuth} from "@/TonalityApp.tsx"; +import { Navigate, Outlet } from "react-router-dom"; +import { useAuth } from "@/TonalityApp.tsx"; -const ProtectedRoute = ({isPublic}) => { - const isValidUser : boolean = useAuth().token === null; - return (isValidUser || isPublic) ? <Outlet/> : <Navigate to='/login'/> -} +const ProtectedRoute = ({ isPublic }) => { + const isValidUser: boolean = useAuth().token === null; + return isValidUser || isPublic ? <Outlet /> : <Navigate to="/login" />; +}; -export default ProtectedRoute; \ No newline at end of file +export default ProtectedRoute; diff --git a/src/routes/RenderRoutes.tsx b/src/routes/RenderRoutes.tsx index 8a02c748042a38d332c979710361c6ce278096ce..8eb823c2674fbf748af552345889ac80bf9df37a 100644 --- a/src/routes/RenderRoutes.tsx +++ b/src/routes/RenderRoutes.tsx @@ -1,37 +1,45 @@ -import React from 'react'; -import {Route, Routes} from 'react-router-dom'; +import React from "react"; +import { Route, Routes } from "react-router-dom"; import ProtectedRoute from "@/routes/ProtectedRoute.tsx"; -import {generateFlattenRoutes} from "@/lib/utils.ts"; -export const RenderRoutes : React.FC = (mainRoutes) => { - return ({isAuthorized}) => { - const layouts = mainRoutes.map(({layout: Layout, routes}, index) => { +import { generateFlattenRoutes } from "@/lib/utils.ts"; + +export const RenderRoutes: React.FC = (mainRoutes) => { + return ({ isAuthorized }) => { + const layouts = mainRoutes.map(({ layout: Layout, routes }, index) => { const subRoutes = generateFlattenRoutes(routes); return ( - <Route key={index} element={<Layout/>}> - {subRoutes.map(({component: Component, path, name, isPublic}, index) => { - const isPublics : boolean = typeof isPublic === 'boolean' ? isPublic : false; - const componentFound = Component !== undefined; - if (!componentFound) return null; + <Route key={index} element={<Layout />}> + {subRoutes.map( + ({ component: Component, path, name, isPublic }, index) => { + const isPublics: boolean = + typeof isPublic === "boolean" ? isPublic : false; + const componentFound = Component !== undefined; + if (!componentFound) return null; - return ( - (<Route key={index} element={<ProtectedRoute isPublic={isPublics} isAuthorized={isAuthorized}/>}>) - && Component - && path - && (<Route key={name} element={<Component/>} path={path}/>) - && (</Route>) - ) - })} + return ( + <Route + key={index} + element={ + <ProtectedRoute + isPublic={isPublics} + isAuthorized={isAuthorized} + /> + } + > + ) && Component && path && ( + <Route key={name} element={<Component />} path={path} />) && ( + </Route> + ); + }, + )} </Route> - ) + ); }); - console.log('layouts', layouts) return ( <Routes> - <> - {layouts} - </> + <>{layouts}</> </Routes> ); }; -} +}; diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 46a6e9829c1e023e5907469d584e77e46c64fec6..c8704b223f5785291c9aa48968303bcfbee0cbd3 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -1,5 +1,6 @@ import AnonymousLayout from "@/layouts/AnonymousLayout.tsx"; import LoginPage from "@/pages/LoginPage.tsx"; +import SignUpPage from "@/pages/SignUpPage.tsx"; import MainLayout from "@/layouts/MainLayout.tsx"; import AlbumPage from "@/pages/AlbumPage.tsx"; import AddAlbumPage from "@/pages/AddAlbumPage.tsx"; @@ -7,7 +8,7 @@ import EditAlbumPage from "@/pages/EditAlbumPage.tsx"; import SongsPage from "@/pages/SongsPage.tsx"; import AddSongPage from "@/pages/AddSongPage.tsx"; import EditSongPage from "@/pages/EditSongPage.tsx"; -import {NotMatchLayout} from "@/layouts/NotMatchLayout.tsx"; +import NotMatchLayout from "@/layouts/NotMatchLayout.tsx"; import NotMatch from "@/pages/NotMatch.tsx"; import DeleteAlbumDialog from "@/components/delete-dialog-album"; import DeleteSongDialog from "@/components/delete-dialog-song"; @@ -17,90 +18,97 @@ export const routes = [ layout: AnonymousLayout, routes: [ { - name: 'login', - title: 'Login page', + name: "login", + title: "Login to Tonality", component: LoginPage, path: "/login", isPublic: true, - } - ] + }, + { + name: "signup", + title: "Sign Up for Tonality", + component: SignUpPage, + path: "/signup", + isPublic: true, + }, + ], }, { layout: MainLayout, routes: [ { - name: 'album', - title: 'Album', + name: "album", + title: "Album", hasSiderLink: true, routes: [ { - name: 'album', - title: 'Album page', + name: "album", + title: "Album page", component: AlbumPage, path: "/album", }, { - name: 'add-album', - title: 'Add Album page', + name: "add-album", + title: "Add Album page", component: AddAlbumPage, path: "/add-album", }, { - name: 'edit-album', - title: 'Edit Album page', + name: "edit-album", + title: "Edit Album page", component: EditAlbumPage, path: "/:albumId/edit-album", }, { - name: 'delete-album', - title: 'Delete Album page', + name: "delete-album", + title: "Delete Album page", component: DeleteAlbumDialog, path: "/:albumId/delete-album/", - } - ] + }, + ], }, { - name: 'song', - title: 'Song', + name: "song", + title: "Song", hasSiderLink: true, routes: [ { - name: 'song', - title: 'Song page', + name: "song", + title: "Song page", component: SongsPage, path: "/:albumId/songs", }, { - name: 'add-song', - title: 'Add Song page', + name: "add-song", + title: "Add Song page", component: AddSongPage, path: "/:albumId/add-song", }, { - name: 'edit-song', - title: 'Edit Song page', + name: "edit-song", + title: "Edit Song page", component: EditSongPage, path: "/:albumId/edit-song/:songId", }, { - name: 'delete-song', - title: 'Delete Song page', + name: "delete-song", + title: "Delete Song page", component: DeleteSongDialog, path: "/:albumId/delete-song/:songId", - } - ] - } - ] + }, + ], + }, + ], }, { layout: NotMatchLayout, routes: [ { - name: 'not-match', - title: 'Not Match', + name: "not-match", + title: "Not Match", component: NotMatch, path: "*", - } - ] - } -] + }, + ], + }, +]; diff --git a/src/utils/token.ts b/src/utils/token.ts new file mode 100644 index 0000000000000000000000000000000000000000..25ed7b4a2397b40b32a215d8da915c0539567018 --- /dev/null +++ b/src/utils/token.ts @@ -0,0 +1,11 @@ +// Stores access token in session storage +const storeAccessToken = (accessToken: string): void => { + sessionStorage.setItem("accessToken", accessToken); +}; + +// Removes access token from session storage +const removeAccessToken = (): void => { + sessionStorage.removeItem("accessToken"); +} + +export { storeAccessToken, removeAccessToken } \ No newline at end of file