diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..55315b6 --- /dev/null +++ b/.env.development @@ -0,0 +1,2 @@ +VITE_APP_NAME=App Development +VITE_API_URL=http://localhost:7082 \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..b61c784 --- /dev/null +++ b/.env.production @@ -0,0 +1,2 @@ +VITE_APP_NAME=App Production +VITE_API_URL=http://192.168.179.36:7082 \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..d29b926 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,16 @@ +module.exports = { + env: { browser: true, es2020: true, node: true }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react-hooks/recommended", + "prettier", + ], + parser: "@typescript-eslint/parser", + parserOptions: { ecmaVersion: "latest", sourceType: "module" }, + plugins: ["react-refresh", "prettier"], + rules: { + "react-refresh/only-export-components": "warn", + "prettier/prettier": "error", + }, +}; diff --git a/.prettierrc.cjs b/.prettierrc.cjs new file mode 100644 index 0000000..eaf4b83 --- /dev/null +++ b/.prettierrc.cjs @@ -0,0 +1,12 @@ +module.exports = { + printWidth: 80, + trailingComma: "all", + singleQuote: false, + semi: true, + arrowParens: "always", + jsxSingleQuote: false, + bracketSameLine: false, + endOfLine: "lf", + useTabs: true, + tabWidth: 1, +}; diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..a954736 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,24 @@ +pipeline { + agent any + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + stage('Hello World') { + steps { + dir('frontend') { + sh 'echo "Hello World!"' + } + } + } + } + post { + always { + // Cleanup the workspace + cleanWs() + } + } +} \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..0718093 Binary files /dev/null and b/bun.lockb differ diff --git a/daisyui.config.js b/daisyui.config.js new file mode 100644 index 0000000..eb83cfb --- /dev/null +++ b/daisyui.config.js @@ -0,0 +1,31 @@ +export const themes = [ + "light", + "dark", + "cupcake", + "bumblebee", + "emerald", + "corporate", + "synthwave", + "retro", + "cyberpunk", + "valentine", + "halloween", + "garden", + "forest", + "aqua", + "lofi", + "pastel", + "fantasy", + "wireframe", + "black", + "luxury", + "dracula", + "cmyk", + "autumn", + "business", + "acid", + "lemonade", + "night", + "coffee", + "winter", +]; \ No newline at end of file diff --git a/frontend.code-workspace b/frontend.code-workspace new file mode 100644 index 0000000..7ee208b --- /dev/null +++ b/frontend.code-workspace @@ -0,0 +1,10 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": { + "typescript.tsdk": "node_modules/typescript/lib" + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..3a49d4f --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..a46f3fe --- /dev/null +++ b/package.json @@ -0,0 +1,46 @@ +{ + "name": "app", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite --host", + "build": "tsc && vite build", + "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview --host" + }, + "dependencies": { + "@hookform/resolvers": "^3.3.2", + "@radix-ui/react-icons": "^1.3.0", + "@types/react-router-dom": "^5.3.3", + "axios": "^1.5.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.47.0", + "react-router-dom": "^6.17.0", + "uuid": "^9.0.1", + "zod": "^3.22.4" + }, + "devDependencies": { + "@tailwindcss/typography": "^0.5.10", + "@types/node": "^20.8.6", + "@types/react": "^18.2.28", + "@types/react-dom": "^18.2.13", + "@types/uuid": "^9.0.5", + "@typescript-eslint/eslint-plugin": "^6.8.0", + "@typescript-eslint/parser": "^6.8.0", + "@vitejs/plugin-react": "^4.1.0", + "autoprefixer": "^10.4.16", + "daisyui": "^3.9.3", + "eslint": "^8.51.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.1", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.3", + "postcss": "^8.4.31", + "prettier": "^3.0.3", + "tailwindcss": "^3.3.3", + "typescript": "^5.2.2", + "vite": "^4.4.11" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..7b75c83 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/public/icon.png b/public/icon.png new file mode 100755 index 0000000..dcac0cc Binary files /dev/null and b/public/icon.png differ diff --git a/src/api/AxiosService.ts b/src/api/AxiosService.ts new file mode 100644 index 0000000..4833ea4 --- /dev/null +++ b/src/api/AxiosService.ts @@ -0,0 +1,98 @@ +import axios from "axios"; + +// TODO: Add calls for notifications on varius states of the request + +export const setupAxiosInterceptors = () => { + axios.interceptors.request.use( + (config) => { + // Do something before request is sent + return Promise.resolve(config); + }, + (error) => { + // Do something with request error + return Promise.reject(error); + }, + ); + axios.interceptors.response.use( + (response) => { + // Do something with response data + const status = response.status; + if (status === 200 && response.data) { + console.error(response.data); + } + // Check if the status code is 401 (requires user authentication) + if (status === 401) { + console.error(response.data); + } + + return Promise.resolve(response); + }, + (error) => { + // Do something with response error + return Promise.reject(error); + }, + ); +}; + +const handleAxiosError = (error: any, callbackFunction = null) => { + if (error.message == "canceled") { + callbackFunction && console.debug(callbackFunction); + console.debug("Request Aborted"); + } else if (error.response) { + // Request made and server responded + console.log(error.response.data); + console.log(error.response.status); + console.log(error.response.headers); + } else if (error.request) { + // The request was made but no response was received + console.log(error.request); + } else { + // Something happened in setting up the request that triggered an Error + console.log("Error", error.message); + } + console.log(error.config); +}; + +export const axiosGet = async (url: string) => { + return axios + .get(url) + .then((response) => { + return response.data; + }) + .catch((error) => { + handleAxiosError(error); + }); +}; + +export const axiosPost = async (url: string, data: any) => { + return axios + .post(url, data) + .then((response) => { + return response.data; + }) + .catch((error) => { + handleAxiosError(error); + }); +}; + +export const axiosPut = async (url: string, data: any) => { + return axios + .put(url, data) + .then((response) => { + return response.data; + }) + .catch((error) => { + handleAxiosError(error); + }); +}; + +export const axiosDelete = async (url: string) => { + return axios + .delete(url) + .then((response) => { + return response.data; + }) + .catch((error) => { + handleAxiosError(error); + }); +}; diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx new file mode 100644 index 0000000..29a993f --- /dev/null +++ b/src/components/Breadcrumbs.tsx @@ -0,0 +1,72 @@ +import { Link, useLocation, useParams } from "react-router-dom"; +import { findElementInFlatRoutes } from "../utils/RoutingTableUtils"; +import { capitalizeFirstLetter } from "../utils/StringTransformationUtils"; + +const BREADCRUMB_BAR_CLASSES = + "text-sm breadcrumbs max-w pl-6 shadow-neutral-focus shadow-sm sticky top-2 z-10 bg-neutral transition-all rounded-md m-2 w-auto"; + +const Breadcrumbs = () => { + const location = useLocation(); + const pathnames = location.pathname.split("/").filter((x) => x); + const pathParams = useParams(); + + const hideBreadcrumbBar = (path: string) => { + // if (Object.keys(pathParams).length !== 0) return true; + const _route = findElementInFlatRoutes(path); + return _route?.disableBreadcrumbBar || false; + }; + + const findRouteNameByPath = (path: string) => { + const _route = findElementInFlatRoutes(path); + return _route?.name || path; + }; + + const renderLinkBreadcrumb = ( + value: string, + index: number, + pathnames: string[], + ) => { + if (value === pathParams.id) return null; + const to = `/${pathnames.slice(0, index + 1).join("/")}`; + const routeName = capitalizeFirstLetter(findRouteNameByPath(value)); + return ( +
  • + {routeName} +
  • + ); + }; + + const renderTextBreadcrumb = (value: string) => { + if (value === pathParams.id) return null; + const routeName = capitalizeFirstLetter(findRouteNameByPath(value)); + return ( +
  • + {routeName} +
  • + ); + }; + + if (hideBreadcrumbBar(location.pathname)) { + return ( +
    {pathnames[0].toUpperCase()}
    + ); + } + + return ( +
    + +
    + ); +}; + +export default Breadcrumbs; diff --git a/src/components/Card.tsx b/src/components/Card.tsx new file mode 100644 index 0000000..8eb8223 --- /dev/null +++ b/src/components/Card.tsx @@ -0,0 +1,35 @@ +import { Link } from "react-router-dom"; + +interface CardProps { + title: string; + description: string; + bgImage?: string; + link: string; +} +/** + * Card component + * @param {string} title - Card title + * @param {string} description - Card description + * @param {string} bgImage - Background image + * @param {string} link - Link to open on button click + */ +export function Card({ title, description, bgImage, link }: CardProps) { + return ( +
    + {!!bgImage && ( +
    + {title +
    + )} +
    +

    {title}

    +

    {description}

    +
    + + Go + +
    +
    +
    + ); +} diff --git a/src/components/CardGrid.tsx b/src/components/CardGrid.tsx new file mode 100644 index 0000000..9956d7a --- /dev/null +++ b/src/components/CardGrid.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from "react"; + +const CardGrid = ({ children }: { children: ReactNode }) => { + return ( +
    +
    + {children} +
    +
    + ); +}; + +export default CardGrid; diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.tsx b/src/components/ConfirmationDialog/ConfirmationDialog.tsx new file mode 100644 index 0000000..2afad34 --- /dev/null +++ b/src/components/ConfirmationDialog/ConfirmationDialog.tsx @@ -0,0 +1,39 @@ +import Modal from "../Modal"; +import useConfirmationDialog from "./useConfirmationDialog"; + +/** + * @description ConfirmationDialog component displays a confirmation dialog/modal with a message and two buttons: Yes and No. + * Actions of buttons are definded via hook. + * State of dialog is based on react context. + * @returns ConfirmationDialog component + */ +const ConfirmationDialog = () => { + const { + isConfirmationDialogVisible, + handleConfirm, + handleCancel, + customModalBoxStyles, + customModalStyles, + } = useConfirmationDialog(); + + return ( + <> + {isConfirmationDialogVisible && ( + + + + + )} + + ); +}; + +export default ConfirmationDialog; diff --git a/src/components/ConfirmationDialog/ConfirmationDialogProvider.tsx b/src/components/ConfirmationDialog/ConfirmationDialogProvider.tsx new file mode 100644 index 0000000..7b76f23 --- /dev/null +++ b/src/components/ConfirmationDialog/ConfirmationDialogProvider.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import { ConfirmationDialogContext } from "../../contexts/ConfirmationDialogContext"; + +export const ConfirmationDialogProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [isConfirmationDialogVisible, setIsConfirmationDialogVisible] = + useState(false); + const [customModalBoxStyles, setCustomModalBoxStyles] = useState(""); + const [customModalStyles, setCustomModalStyles] = useState(""); + + const showDialog = () => { + setIsConfirmationDialogVisible(true); + }; + + const hideDialog = () => { + setIsConfirmationDialogVisible(false); + }; + + const handleConfirm = () => { + // Logic for when the user confirms + // You can call external functions or dispatch actions here + hideDialog(); + }; + + const handleCancel = () => { + // Logic for when the user cancels + hideDialog(); + }; + + return ( + + {children} + + ); +}; + +export default ConfirmationDialogProvider; diff --git a/src/components/ConfirmationDialog/useConfirmationDialog.ts b/src/components/ConfirmationDialog/useConfirmationDialog.ts new file mode 100644 index 0000000..4363baf --- /dev/null +++ b/src/components/ConfirmationDialog/useConfirmationDialog.ts @@ -0,0 +1,23 @@ +import { useContext } from "react"; +import { + ConfirmationDialogContext, + ConfirmationDialogContextType, +} from "../../contexts/ConfirmationDialogContext"; + +/** + * @description Hook to manage the confirmation dialog, only passes the controls to specyfic dialog component + * @param onConfirm Function to be called when the user confirms the action + * @param onCancel Function to be called when the user cancels the action + * @returns Hook controls + */ +export const useConfirmationDialog = (): ConfirmationDialogContextType => { + const context = useContext(ConfirmationDialogContext); + if (!context) { + throw new Error( + "useConfirmationDialog must be used within a ConfirmationDialogProvider", + ); + } + return context; +}; + +export default useConfirmationDialog; diff --git a/src/components/Loader.tsx b/src/components/Loader.tsx new file mode 100644 index 0000000..aed735a --- /dev/null +++ b/src/components/Loader.tsx @@ -0,0 +1,17 @@ +function Loader() { + return ( + + ); +} + +export default Loader; diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx new file mode 100644 index 0000000..7802a2a --- /dev/null +++ b/src/components/Modal.tsx @@ -0,0 +1,109 @@ +import { useCallback, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import useConfirmationDialog from "./ConfirmationDialog/useConfirmationDialog"; +import { Cross1Icon } from "@radix-ui/react-icons"; + +interface ModalProps { + modalId: string; + title?: string; + subTitle?: string; + content?: string; + navigatePathOnClose?: string; + children?: React.ReactNode; + customModalStyles?: string; + customModalBoxStyles?: string; +} +/** + * @description A modal component that can be used to display a fairly custom modal/dialog + * @param modalId The id of the modal + * @param title The title of the modal + * @param subTitle The subtitle of the modal + * @param content The content of the modal + * @param navigatePathOnClose The path to navigate to when the modal is closed + * @param children The children of the modal + * @param customModalStyles Custom styles for the modal + * @param customModalBoxStyles Custom styles for the modal box + * @returns A modal component + */ +export const Modal = ({ + modalId, + navigatePathOnClose, + title, + subTitle, + content, + children, + customModalStyles, + customModalBoxStyles, +}: ModalProps) => { + const navigate = useNavigate(); + const { isConfirmationDialogVisible, hideDialog } = useConfirmationDialog(); + + const handleClose = useCallback(() => { + if (navigatePathOnClose) navigate(navigatePathOnClose); + if (isConfirmationDialogVisible) + (document.getElementById(modalId) as HTMLDialogElement).close(); + hideDialog(); + }, [ + hideDialog, + isConfirmationDialogVisible, + modalId, + navigate, + navigatePathOnClose, + ]); + + const handleKeyPress = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Escape") { + handleClose(); + } + }, + [handleClose], + ); + // Handle modal open + useEffect(() => { + if (!modalId) return; + (document.getElementById(modalId) as HTMLDialogElement).showModal(); + document.addEventListener("keydown", handleKeyPress); + // Handle modal close + return () => { + document.removeEventListener("keydown", handleKeyPress); + handleClose; + }; + }, [handleClose, handleKeyPress, modalId]); + + const composeModalStyles = useCallback(() => { + const _baseStyles = "modal modal-bottom sm:modal-middle w-screen z-50"; + if (customModalStyles) return _baseStyles + " " + customModalStyles; + return _baseStyles; + }, [customModalStyles]); + const comopseModalBoxStyles = useCallback(() => { + const _baseStyles = "modal-box"; + if (customModalBoxStyles) return _baseStyles + " " + customModalBoxStyles; + return _baseStyles; + }, [customModalBoxStyles]); + + return ( + +
    +

    {title ?? "Hello!"}

    +

    + {subTitle ?? "Press ESC key or click the button below to close"} +

    +

    {content}

    +
    +
    + + {children} +
    +
    +
    +
    + ); +}; + +export default Modal; diff --git a/src/components/NavigationTree.tsx b/src/components/NavigationTree.tsx new file mode 100644 index 0000000..89d45ce --- /dev/null +++ b/src/components/NavigationTree.tsx @@ -0,0 +1,79 @@ +import { CustomRouteObject } from "../configure"; +import { Link, useLocation } from "react-router-dom"; +import { + clearMultiplePathSlashes, + trimPathOfParameters, +} from "../utils/StringTransformationUtils"; + +/** + * @returns Navigation tree elements, require to be used like in example below + * @example + * ```tsx + * + * ``` + */ +function NavigationTree(props: { + routes: CustomRouteObject[]; +}): React.JSX.Element { + const locationHook = useLocation(); // Used to highlight active link in navigation tree + + const GenerateNavigationEntries = ( + routes: CustomRouteObject[], + parentPath?: string, + ): React.ReactNode => { + return ( + routes.map((route) => { + // Prepare path for links + let combinedPath = undefined; + if (parentPath !== undefined && route.path !== undefined) + combinedPath = trimPathOfParameters( + clearMultiplePathSlashes(`/${parentPath}/${route.path}`), + ); + else combinedPath = route.path; + // Does it have children and enabled? Make entry with `/{parent.path}/{route.path}` + if (route.children && !route.additionalProps.disableInNavbar) { + return ( + + ); + } + // Does it have children and not visible? Skip this entry and call this function for children passing path. + else if (route.children && route.additionalProps.disableInNavbar) { + return GenerateNavigationEntries(route.children, combinedPath); + } else if (route.additionalProps.disableInNavbar) { + return null; + } + // Make entry with `/{route.path}` + else { + return ( +
  • + + {route.additionalProps.name} + +
  • + ); + } + }) || <>empty navigation tree + ); + }; + + return
    {GenerateNavigationEntries(props.routes)}
    ; +} + +export default NavigationTree; diff --git a/src/components/Notification/Notification.tsx b/src/components/Notification/Notification.tsx new file mode 100644 index 0000000..d5048e5 --- /dev/null +++ b/src/components/Notification/Notification.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from "react"; +import { INotification, NotificationStatus } from "./NotificationType"; + +enum NotificationColorsClasses { + "info" = "alert alert-info", + "success" = "alert alert-success", + "warning" = "alert alert-warning", + "error" = "alert alert-error", +} + +const selectIcon = (status: NotificationStatus) => { + switch (status) { + case "error": + return ( + + + + ); + case "success": + return ( + + + + ); + break; + case "info": + return ( + + + + ); + break; + case "warning": + return ( + + + + ); + break; + default: + return null; + break; + } +}; + +interface NotificationProps { + notification: INotification; + removeNotification: (id: number) => void; +} + +function Notification({ notification, removeNotification }: NotificationProps) { + const [counter, setCounter] = useState( + notification.duration, + ); + const isCloseable = !!!notification.duration; + + useEffect(() => { + // Skip if counter is undefined + if (counter === undefined) return; + const intervalId = setInterval(() => { + setCounter(counter - 1); + if (counter === 0) { + removeNotification(notification.id); + } + }, 1000); + return () => { + clearInterval(intervalId); + }; + }, [counter]); + return ( +
    + {selectIcon(notification.status)} + {notification.message} + {isCloseable ? ( + + ) : null} + {isCloseable ? null : ( +
    + + + +
    + )} +
    + ); +} + +export default Notification; diff --git a/src/components/Notification/NotificationProvider.tsx b/src/components/Notification/NotificationProvider.tsx new file mode 100644 index 0000000..c48ee17 --- /dev/null +++ b/src/components/Notification/NotificationProvider.tsx @@ -0,0 +1,37 @@ +import React, { useState } from "react"; +import { INotification } from "./NotificationType"; + +interface INotificationStore { + notifications: INotification[] | []; + setNotifications: React.Dispatch>; + duration?: number; +} +// Store for notifications +export const NotificationContext = React.createContext({ + notifications: [], + setNotifications: () => {}, + duration: undefined, +}); + +// Just store for notifications, logic is in useNotification hook +export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + // Queue for notifications + const [notifications, setNotifications] = useState | []>( + [], + ); + NotificationContext.displayName = "Notifications"; + return ( + + {children} + + ); +}; + +export default NotificationProvider; diff --git a/src/components/Notification/NotificationStack.tsx b/src/components/Notification/NotificationStack.tsx new file mode 100644 index 0000000..cf0e9c6 --- /dev/null +++ b/src/components/Notification/NotificationStack.tsx @@ -0,0 +1,25 @@ +import Notification from "./Notification"; +import { useNotification } from "./useNotification"; +// ----------------------------------------------------------- +// Component that renders notification stack +// ----------------------------------------------------------- +function NotificationStack() { + const { notifications, removeNotification } = useNotification(); + + // It takes notification array from hook and renders it + const renderNotifications = () => { + return notifications.map((notification) => { + return ( + + ); + }); + }; + + return
    {renderNotifications()}
    ; +} + +export default NotificationStack; diff --git a/src/components/Notification/NotificationType.ts b/src/components/Notification/NotificationType.ts new file mode 100644 index 0000000..4064dcf --- /dev/null +++ b/src/components/Notification/NotificationType.ts @@ -0,0 +1,9 @@ +export type NotificationStatus = "success" | "error" | "warning" | "info"; +export interface INotificationBase { + message: string; + status: NotificationStatus; + duration?: number; +} +export interface INotification extends INotificationBase { + id: number; +} diff --git a/src/components/Notification/README.md b/src/components/Notification/README.md new file mode 100644 index 0000000..5fefc38 --- /dev/null +++ b/src/components/Notification/README.md @@ -0,0 +1,2 @@ + # Notification + I wrote own notification module but its little bit laggy and buggy so I don't use it. diff --git a/src/components/Notification/useNotification.ts b/src/components/Notification/useNotification.ts new file mode 100644 index 0000000..8d1ee63 --- /dev/null +++ b/src/components/Notification/useNotification.ts @@ -0,0 +1,34 @@ +import { useContext } from "react"; +import { NotificationContext } from "./NotificationProvider"; +import { INotification, NotificationStatus } from "./NotificationType"; +// ----------------------------------------------------------- +// Hook for adding notifications to the queue in local storage +// ----------------------------------------------------------- +export function useNotification() { + const { notifications, setNotifications } = useContext(NotificationContext); + + const addNotificationToQueue = ( + message: string, + status: NotificationStatus, + duration?: number, + ) => { + const id = notifications.length + 1; + const notification: INotification = { id, message, status, duration }; + setNotifications([...notifications, notification]); + }; + const removeNotificationFromQueue = (id: number) => { + setNotifications( + notifications.filter((notification) => notification.id !== id), + ); + }; + const clearNotificationsQueue = () => { + setNotifications([]); + }; + + return { + notifications: notifications, + addNotification: addNotificationToQueue, + removeNotification: removeNotificationFromQueue, + clearNotifications: clearNotificationsQueue, + }; +} diff --git a/src/configure.tsx b/src/configure.tsx new file mode 100644 index 0000000..ac03081 --- /dev/null +++ b/src/configure.tsx @@ -0,0 +1,191 @@ +/* eslint-disable react-refresh/only-export-components */ +import { Suspense, lazy } from "react"; +import { RouteObject } from "react-router-dom"; +import Loader from "./components/Loader"; +import AboutPage from "./features/About/AboutPage"; +import PersonManager from "./features/Administration/PersonManager"; +import App from "./features/App/App"; +import LoginPage from "./features/Login/LoginPage"; +import { flatternRoutingTable } from "./utils/RoutingTableUtils"; +import AssetManagement from "./features/Administration/AssetManagement"; +import Debugger from "./features/Debugger"; +//---- +const AdministrationLazy = lazy( + () => import("./features/Administration/Administration"), +); +const HomePageLazy = lazy(() => import("./features/Home/HomePage")); +const MaintenanceLazy = lazy( + () => import("./features/Maintenance/Maintenance"), +); + +export const viteEnv = import.meta.env; + +// Based on https://reactrouter.com/en/main/start/overview#nested-routes with additional props +export const navigation: CustomRouteObject[] = [ + // root page, mainframe for all pages, skipped in navigation tree generation (inly childs are used) + { + path: "/", + element: , + additionalProps: { + name: "Root", // used for breadcrumbs and navigation tree + disableRedirect: false, //entry will not be included in the react-router redirect list, will cut out childs + disableInNavbar: true, //entry will not be rendered in the navbar + disableBreadcrumbBar: false, //entry will be directly under root in routing table + }, + children: [ + // home page + { + path: "/", + index: true, //if true will be used as a default route for parent, dont declare path if use this + element: ( + }> + + + ), + additionalProps: { + name: "Home", + disableRedirect: false, + disableInNavbar: false, + disableBreadcrumbBar: false, + }, + }, + { + path: "maintenance", + element: ( + }> + + + ), + additionalProps: { + name: "Maintenance", + disableRedirect: false, + disableInNavbar: false, + disableBreadcrumbBar: false, + }, + }, + { + path: "administration", + element: ( + }> + + + ), + additionalProps: { + name: "Administration", + disableRedirect: false, + disableInNavbar: false, + disableBreadcrumbBar: false, + }, + children: [ + { + path: "asset_management/:state?/:id?", + element: , + additionalProps: { + name: "Asset Management", + disableRedirect: false, + disableInNavbar: false, + disableBreadcrumbBar: false, + }, + }, + { + path: "person_management", + element: , + additionalProps: { + name: "Person Management", + disableRedirect: false, + disableInNavbar: false, + disableBreadcrumbBar: false, + }, + }, + ], + }, + // about page + { + path: "about", + element: , + additionalProps: { + name: "About", + disableRedirect: false, + disableInNavbar: false, + disableBreadcrumbBar: false, + }, + children: [ + { + path: "child", + element:
    DUPA CHILD
    , + additionalProps: { + name: "About Child", + disableRedirect: false, + disableInNavbar: false, + disableBreadcrumbBar: false, + }, + }, + ], + }, + // login page + { + path: "login", + element: , + additionalProps: { + name: "Login", + disableRedirect: false, + disableInNavbar: false, + disableBreadcrumbBar: false, + }, + }, + ], + }, +]; +// --- + +if (viteEnv.DEV) { + // Add Test page + // Test page is outside of mainframe, will be rendered in root (withius App where is entire navigation frame) + navigation.push({ + path: "/Test", + element:
    Test
    , + additionalProps: { + name: "Test", + disableRedirect: false, + disableInNavbar: false, + disableBreadcrumbBar: false, + }, + }); + // Add debugger page + navigation[0].children?.push({ + path: "debug_variables", + element: , + additionalProps: { + name: "Debug Variables", + disableRedirect: false, + disableInNavbar: false, + disableBreadcrumbBar: false, + }, + }); +} + +// --- +//Custom Route Object for handling custom behaviours on app navigation +export interface RouteObjectAdditionalProps { + name: string; + disableRedirect: boolean; + disableInNavbar: boolean; + disableBreadcrumbBar: boolean; +} +export interface CustomRouteObject extends Omit { + additionalProps: RouteObjectAdditionalProps; + children?: CustomRouteObject[]; +} +//---- +//Main configuration, static data, mostly used here +export const main = { + program_name: viteEnv.VITE_APP_NAME, + program_version: "1.0.0", +}; +//About page configuration +export const about = { + program_description: `This is a ${main.program_name} for .`, + program_authors: [{ name: "Author Name", email: "email@example.com" }], +}; +//---- +export const flatRoutes = flatternRoutingTable(navigation); diff --git a/src/contexts/ConfirmationDialogContext.tsx b/src/contexts/ConfirmationDialogContext.tsx new file mode 100644 index 0000000..5bdbb68 --- /dev/null +++ b/src/contexts/ConfirmationDialogContext.tsx @@ -0,0 +1,19 @@ +import { createContext } from "react"; + +// Define the shape of the context +export type ConfirmationDialogContextType = { + isConfirmationDialogVisible: boolean; + showDialog: () => void; + hideDialog: () => void; + handleConfirm: () => void; + handleCancel: () => void; + customModalStyles: string; + customModalBoxStyles: string; + setCustomModalBoxStyles: React.Dispatch>; + setCustomModalStyles: React.Dispatch>; +}; +// Create the context with default values +export const ConfirmationDialogContext = createContext< + ConfirmationDialogContextType | undefined +>(undefined); +ConfirmationDialogContext.displayName = "ConfirmationDialog"; diff --git a/src/features/About/AboutPage.tsx b/src/features/About/AboutPage.tsx new file mode 100644 index 0000000..5ecd399 --- /dev/null +++ b/src/features/About/AboutPage.tsx @@ -0,0 +1,32 @@ +import { about } from "../../configure"; + +/** Here is located about page where you can find information about this application: + * - short description of this application + * - how to use it + * - information about persons responsible for maintaining this application + */ +function AboutPage() { + return ( +
    +
    +
    +

    About

    +

    {about.program_description}

    +

    Authors

    +
    + {about.program_authors.map((author) => ( +
    + {author.name} -{" "} + + {author.email} + +
    + ))} +
    +
    +
    +
    + ); +} + +export default AboutPage; diff --git a/src/features/App/App.tsx b/src/features/App/App.tsx new file mode 100644 index 0000000..7c37e90 --- /dev/null +++ b/src/features/App/App.tsx @@ -0,0 +1,108 @@ +import { HamburgerMenuIcon, SunIcon } from "@radix-ui/react-icons"; +import { useEffect, useState } from "react"; +import { Outlet } from "react-router-dom"; +import { themes } from "../../../daisyui.config.js"; +import { main, navigation } from "../../configure.tsx"; +import NavigationTree from "../../components/NavigationTree.tsx"; +import Breadcrumbs from "../../components/Breadcrumbs.tsx"; +import ConfirmationDialog from "../../components/ConfirmationDialog/ConfirmationDialog.tsx"; + +/** Here is located global wrapper for entire application, here you canfind: + * - Drawer - contains navigation buttons + * - Navbar - contains hamburger menu and theme selector + * - Outlet - contains active page content + * - App theme controll + */ +function App() { + const [theme, setTheme] = useState("adient"); + const [openDrawer, setOpenDrawer] = useState(false); + + useEffect(() => { + document.querySelector("html")?.setAttribute("data-theme", theme); + // To resolve this issue, you can use the import.meta.env object instead of process.env. The import.meta.env object is provided by Vite.js and allows you to access environment variables in your code. + document.title = import.meta.env.VITE_APP_NAME; + }, [theme, openDrawer]); + + // Function on click drawer hamburger button + const handleDrawerStatus = () => { + setOpenDrawer(!openDrawer); + }; + // Function on click theme on themes list + const handleThemeChange = (theme: string) => { + setTheme(theme); + console.log("Theme changed to " + theme, "info", 10); + }; + + return ( +
    + + {/* Root drawer container */} + {/* Drawer opening is controlled directly via css prop and next via local useState variable */} +
    + {/* A hidden checkbox to toggle the visibility of the drawer */} + + {/* The actual drawer content */} +
    + {/* Navbar */} +
    + {/* Left side navbar */} +
    + +

    {main.program_name}

    +
    + {/* Right side navbar */} +
    +
    + + +
    + +
    +
    + {/* App/active_drawer content */} +
    + {/*Automatically generated breadcrumbs based on routing table from configuration file and active path*/} + {/*Get active route and find it in routing file*/} + + +
    +
    + {/* Drawer sidebar wrapper */} +
    + {/* Dark overlay on mobile devices, clickable to close drawer */} + +
      + +
    +
    +
    +
    + ); +} + +export default App; diff --git a/src/features/Debugger.tsx b/src/features/Debugger.tsx new file mode 100644 index 0000000..3388869 --- /dev/null +++ b/src/features/Debugger.tsx @@ -0,0 +1,31 @@ +import { useLocation } from "react-router-dom"; +import { flatRoutes } from "../configure"; + +function Debugger() { + const windowLocation = window.location.pathname; + const locationHook = useLocation(); + return ( +
    +
    + + Flat Routes + + {JSON.stringify(flatRoutes)} +
    +
    + Locations +
    + Location hook: {JSON.stringify(locationHook)} +
    + Location window: {JSON.stringify(windowLocation)} +
    +
    +
    + Routes + {JSON.stringify(flatRoutes)} +
    +
    + ); +} + +export default Debugger; diff --git a/src/features/Home/HomePage.tsx b/src/features/Home/HomePage.tsx new file mode 100644 index 0000000..2d4d6bf --- /dev/null +++ b/src/features/Home/HomePage.tsx @@ -0,0 +1,5 @@ +function HomePage() { + return <>HOME; +} + +export default HomePage; diff --git a/src/features/Login/LoginPage.tsx b/src/features/Login/LoginPage.tsx new file mode 100644 index 0000000..770d52b --- /dev/null +++ b/src/features/Login/LoginPage.tsx @@ -0,0 +1,102 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; +import { z } from "zod"; + +type Input = { + username: string; + password: string; +}; +const InputSchema = z.object({ + username: z.string().min(4, "Username must be at least 4 characters"), + password: z + .string() + .regex(/[A-Za-z\d@$!%*#?&]{4,}/, "Minimum four characters") + .regex(/(?=.*[A-Z])/, "At least one big letter") + .regex(/(?=.*\d)/, "At least one number") + .regex(/(?=.*[@$!%*#?&])/, "At least one special character"), +}); + +function LoginPage() { + const [isSubmitting, setIsSubmitting] = useState(false); + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(InputSchema), + }); + const navigate = useNavigate(); + const onSubmit: SubmitHandler = (data) => { + if (data.username === "admin" && data.password === "A0m!n") { + console.log("Login successful!", 3); + navigate("/products"); + } else { + console.log("Wrong username or password"); + } + setIsSubmitting(false); + }; + const onError: SubmitErrorHandler = () => { + console.log("Errors in form fields"); + }; + + return ( +
    +
    +
    +
    + + {errors.username && ( + + )} +
    +
    + + {errors.password && ( + + )} +
    + +
    +
    +
    + ); +} + +export default LoginPage; diff --git a/src/main.css b/src/main.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/src/main.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..702fa7b --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { RouterProvider } from "react-router-dom"; +import { setupAxiosInterceptors } from "./api/AxiosService"; +import "./main.css"; +import router from "./routes"; +import ConfirmationDialogProvider from "./components/ConfirmationDialog/ConfirmationDialogProvider"; + +setupAxiosInterceptors(); + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + {/* fallbackElement - use when routing table is quite big to load, it will display desired element in convention of loading screen */} + + Loading route table...

    } + /> +
    +
    , +); diff --git a/src/routes.tsx b/src/routes.tsx new file mode 100644 index 0000000..642a91a --- /dev/null +++ b/src/routes.tsx @@ -0,0 +1,69 @@ +/** + * This file is used to configure react-router-dom. + * Do not change anything here, unless you know what you are doing. + * Every path and route is configured in `configure.tsx` file. + */ + +import { createBrowserRouter, RouteObject } from "react-router-dom"; +import { CustomRouteObject, navigation } from "./configure.tsx"; +import Redirect from "./utils/Redirect.tsx"; + +/** + * This function is used to convert `CustomRouteObject` to `RouteObject` and process all additionalProps + */ +const createRoutingFromCustomRouteObject = ( + routes: CustomRouteObject[], +): RouteObject[] => { + const navigationRoutes: RouteObject[] = []; //Root routes that will be used for navigation + routes.forEach((route) => { + // Extract everything except additionalProps and children + const { additionalProps, children, ...routeNative } = route; + //Filter routes that are disabled for navigation + const isRouteDisabled = additionalProps.disableRedirect; + if (isRouteDisabled) { + return; + } + //Add route to navigationRoutes, array that will be returned + const isRouteWithChildren = children !== undefined && children.length > 0; + if (!isRouteWithChildren) { + navigationRoutes.push({ + ...routeNative, + }); + } else { + navigationRoutes.push({ + path: route.path, + element: route.element, + children: createRoutingFromCustomRouteObject(children), + }); + } + }); + //In case of empty routes, add a default route with message + if (navigationRoutes.length === 0) { + navigationRoutes.push({ + path: "/", + element:
    Empty routes
    , + }); + } + //Always add redirect unmached route to root at the end + return navigationRoutes; +}; + +/** It is main variable that handle all navigation rules and paths */ +export const routes: RouteObject[] = [ + ...createRoutingFromCustomRouteObject(navigation), + { + path: "*", + element: , + }, +]; + +/** It is variable that have routes for react-router-dom, final form */ +export const router = createBrowserRouter(routes, { + basename: "/", + future: { + // Normalize `useNavigation()`/`useFetcher()` `formMethod` to uppercase + v7_normalizeFormMethod: true, + }, +}); + +export default router; diff --git a/src/utils/ObjectUtils.ts b/src/utils/ObjectUtils.ts new file mode 100644 index 0000000..47b3177 --- /dev/null +++ b/src/utils/ObjectUtils.ts @@ -0,0 +1,9 @@ +export const rollThroughObj = ( + obj: Readonly>, +) => { + let result = ""; + for (const [key, value] of Object.entries(obj)) { + result += ` -${key}: ${value}`; + } + return result.trim(); +}; diff --git a/src/utils/Redirect.tsx b/src/utils/Redirect.tsx new file mode 100644 index 0000000..64a35d7 --- /dev/null +++ b/src/utils/Redirect.tsx @@ -0,0 +1,18 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; + +/** + * @description Redirects to the given page + * @param {string} to - The page to redirect to + */ +function Redirect({ to }: { to: string }) { + // The navigate function from useNavigate is used to navigate to the given page + const navigate = useNavigate(); + // useEffect is used to navigate to the given page when the component mounts + useEffect(() => { + navigate(to); + }); + // A null element is returned because the component does not need to render anything + return null; +} +export default Redirect; diff --git a/src/utils/RoutingTableUtils.ts b/src/utils/RoutingTableUtils.ts new file mode 100644 index 0000000..c778233 --- /dev/null +++ b/src/utils/RoutingTableUtils.ts @@ -0,0 +1,79 @@ +import { + CustomRouteObject, + RouteObjectAdditionalProps, + flatRoutes, +} from "../configure"; +import { + clearMultiplePathSlashes, + trimPathOfParameters, +} from "./StringTransformationUtils"; + +interface FlatternRoutingTableElement extends RouteObjectAdditionalProps { + path: string; + name: string; +} +//! WARNING: This function will generate error if paths aren't unique, disableInNavbar or disableRedirect to prevent this. It's a useful feature to prevent duplicate path in navbar +/** + * @description Convert a existing routing table as a flat array + */ +export const flatternRoutingTable = ( + routes: CustomRouteObject[], + previousPath = "undefined", +): FlatternRoutingTableElement[] => { + const result: FlatternRoutingTableElement[] = []; + routes.forEach((route: CustomRouteObject) => { + if (route.additionalProps.disableRedirect) return; // Skip if disable redirect, children are skipped too + if ( + typeof route.path !== "undefined" && + typeof previousPath === "undefined" && + !route.additionalProps.disableInNavbar + ) { + result.push({ + path: trimPathOfParameters(route.path), + name: route.additionalProps.name, + disableBreadcrumbBar: route.additionalProps.disableBreadcrumbBar, + disableInNavbar: route.additionalProps.disableInNavbar, + disableRedirect: route.additionalProps.disableRedirect, + }); + } + if ( + typeof route.path !== "undefined" && + typeof previousPath !== "undefined" && + !route.additionalProps.disableInNavbar + ) { + result.push({ + path: trimPathOfParameters( + clearMultiplePathSlashes(`/${previousPath}/${route.path}`), + ), + name: route.additionalProps.name, + disableBreadcrumbBar: route.additionalProps.disableBreadcrumbBar, + disableInNavbar: route.additionalProps.disableInNavbar, + disableRedirect: route.additionalProps.disableRedirect, + }); + } + if (route.children && typeof previousPath === "undefined") { + result.push(...flatternRoutingTable(route.children)); + } + if (route.children && typeof previousPath !== "undefined") { + result.push(...flatternRoutingTable(route.children, route.path)); + } + // Errors handling + if (typeof route.path === "undefined") + console.error(`Route ${route.additionalProps.name} is missing path`); + }); + return result; +}; +/** + * @description Function to find element in flattern routes array by LAST path ex: /admin/MAINTENANCE + */ +export const findElementInFlatRoutes = ( + path: string, +): FlatternRoutingTableElement | undefined => { + // Split path string into array, split by '/', then get the last element + const _route = flatRoutes.find((route) => { + const pathArray = route.path.split("/"); + if (pathArray[pathArray.length - 1] === path) return true; + else return false; + }); + return _route; +}; diff --git a/src/utils/StringTransformationUtils.ts b/src/utils/StringTransformationUtils.ts new file mode 100644 index 0000000..b96728d --- /dev/null +++ b/src/utils/StringTransformationUtils.ts @@ -0,0 +1,22 @@ +export function capitalizeFirstLetter(string: string): string { + return string.charAt(0).toUpperCase() + string.slice(1); +} +/** + * @description Function to clear multiple path slashes + * @example clearMultiplePathSlashes("/admin//MAINTENANCE") => "/admin/MAINTENANCE" + */ +export const clearMultiplePathSlashes = (path: string): string => { + return path.replace(/\/{2,}/g, "/"); +}; +/** + * Trims parameters and queries from a URL path that start with ':' or '?'. + * + * @param {string} path - The URL path to trim. + * @returns {string} The URL path without parameters and queries. + * @example + * // returns "/path" + * trimPathOfParameters("/path/:param1/:param2:param3/?param4") + */ +export const trimPathOfParameters = (path: string) => { + return path.replace(/\/:[^/]*|\?[^/]*/g, ""); +}; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..a22a179 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +import { themes } from "./daisyui.config.js"; +export default { + content: ["./src/**/*.{js,ts,jsx,tsx}"], + // safelist is used to allow classes to not be purged by tailwind + safelist: ["alert-info", "alert-success", "alert-warning", "alert-error"], + daisyui: { + themes: themes, + }, + plugins: [require("daisyui"), require("@tailwindcss/typography")], +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f468454 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,49 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext" + ], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "allowImportingTsExtensions": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noErrorTruncation": true, + /* "react-jsx" and "react-jsxdev": These are new options available from TypeScript 4.1 onwards. "react-jsx" transforms JSX into calls to a function that will be imported from react/jsx-runtime. "react-jsxdev" does the same, but for development builds. These options are useful if you're using React 17 or later, which introduced a new JSX Transform. + Remember that to use these options, you also need to set the module compiler option to esnext or commonjs, because the output will contain import statements.*/ + "jsx": "react-jsx", + /* Vite handle emiting */ + "noEmit": true, + "useDefineForClassFields": true, + /* + "strict": true in TypeScript's tsconfig.json is an overarching setting that turns on a number of strict type-checking options. Some of these could overlap with ESLint rules related to good practices in JavaScript and TypeScript coding. However, since ESLint and TypeScript serve different purposes (ESLint for style and syntax, TypeScript for type checking), there usually isn't a direct conflict. + "noUnusedLocals": true and "noUnusedParameters": true in TypeScript could overlap with ESLint's no-unused-vars rule, which flags declared variables or arguments that are not used anywhere in the code. + "noFallthroughCasesInSwitch": true could overlap with ESLint's no-fallthrough rule, which disallows fallthrough behavior in switch statements, a common error in JavaScript. */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": [ + "@types/node" + ] + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules" + ], + // "references": [ + // { + // "path": "./tsconfig.node.json" + // } + // ] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..f5de01d --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig, loadEnv } from "vite"; +import react from "@vitejs/plugin-react"; + +// https://vitejs.dev/config/ +export default ({ mode }) => { + process.env = { ...process.env, ...loadEnv(mode, process.cwd()) }; + return defineConfig({ + plugins: [react()], + // resolve: { + // alias: { + // "tailwind.config.js": path.resolve(__dirname, "tailwind.config.js"), + // }, + // }, + }); +};