Updated with LuPa2 changes

This commit is contained in:
Igor Barcik 2023-10-27 14:24:55 +02:00
parent d13e6bab44
commit 961bac2b0e
Signed by: biggy
GPG Key ID: EA4CE0D1E2A6DC98
46 changed files with 1726 additions and 0 deletions

2
.env.development Normal file
View File

@ -0,0 +1,2 @@
VITE_APP_NAME=App Development
VITE_API_URL=http://localhost:7082

2
.env.production Normal file
View File

@ -0,0 +1,2 @@
VITE_APP_NAME=App Production
VITE_API_URL=http://192.168.179.36:7082

16
.eslintrc.cjs Normal file
View File

@ -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",
},
};

12
.prettierrc.cjs Normal file
View File

@ -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,
};

24
Jenkinsfile vendored Normal file
View File

@ -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()
}
}
}

BIN
bun.lockb Executable file

Binary file not shown.

31
daisyui.config.js Normal file
View File

@ -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",
];

10
frontend.code-workspace Normal file
View File

@ -0,0 +1,10 @@
{
"folders": [
{
"path": "."
}
],
"settings": {
"typescript.tsdk": "node_modules/typescript/lib"
}
}

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/icon.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

46
package.json Normal file
View File

@ -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"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

BIN
public/icon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

98
src/api/AxiosService.ts Normal file
View File

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

View File

@ -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 (
<li key={to} className="text-neutral-content">
<Link to={to}>{routeName}</Link>
</li>
);
};
const renderTextBreadcrumb = (value: string) => {
if (value === pathParams.id) return null;
const routeName = capitalizeFirstLetter(findRouteNameByPath(value));
return (
<li key={value} className="text-neutral-content">
{routeName}
</li>
);
};
if (hideBreadcrumbBar(location.pathname)) {
return (
<div className={BREADCRUMB_BAR_CLASSES}>{pathnames[0].toUpperCase()}</div>
);
}
return (
<div className={BREADCRUMB_BAR_CLASSES}>
<ul>
<li className="text-neutral-content">
<Link to="/">Home</Link>
</li>
{pathnames.map((value, index) => {
const isLast = index === pathnames.length - 1;
return isLast
? renderTextBreadcrumb(value)
: renderLinkBreadcrumb(value, index, pathnames);
})}
</ul>
</div>
);
};
export default Breadcrumbs;

35
src/components/Card.tsx Normal file
View File

@ -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 (
<div className="card min-w-[230px] bg-neutral text-neutral-content">
{!!bgImage && (
<figure>
<img src={bgImage} alt={title + "_bgImage"} />
</figure>
)}
<div className="card-body items-center text-center">
<h2 className="card-title">{title}</h2>
<p>{description}</p>
<div className="card-actions justify-end">
<Link to={link || "/"} className="btn btn-primary">
Go
</Link>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,13 @@
import { ReactNode } from "react";
const CardGrid = ({ children }: { children: ReactNode }) => {
return (
<div className="flex justify-center items-center p-8">
<div className="grid gap-8 xl:grid-cols-4 lg:grid-cols-3 md:grid-cols-2 sm:grid-cols-1">
{children}
</div>
</div>
);
};
export default CardGrid;

View File

@ -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 && (
<Modal
modalId={"confirmation_dialog"}
customModalBoxStyles={customModalBoxStyles}
customModalStyles={customModalStyles}
>
<button className="btn btn-primary" onClick={handleConfirm}>
Yes
</button>
<button className="btn btn-secondary" onClick={handleCancel}>
No
</button>
</Modal>
)}
</>
);
};
export default ConfirmationDialog;

View File

@ -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 (
<ConfirmationDialogContext.Provider
value={{
isConfirmationDialogVisible,
showDialog,
hideDialog,
handleConfirm,
handleCancel,
customModalBoxStyles,
customModalStyles,
setCustomModalBoxStyles,
setCustomModalStyles,
}}
>
{children}
</ConfirmationDialogContext.Provider>
);
};
export default ConfirmationDialogProvider;

View File

@ -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;

17
src/components/Loader.tsx Normal file
View File

@ -0,0 +1,17 @@
function Loader() {
return (
<span
className="loading loading-ring loading-lg"
style={{
position: "absolute",
top: "300%",
left: "50%",
transform: "translate(-50%, -50%)",
height: "128px",
width: "128px",
}}
></span>
);
}
export default Loader;

109
src/components/Modal.tsx Normal file
View File

@ -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 (
<dialog id={modalId} className={composeModalStyles()}>
<div className={comopseModalBoxStyles()}>
<h3 className="font-bold text-lg">{title ?? "Hello!"}</h3>
<p className="py-4">
{subTitle ?? "Press ESC key or click the button below to close"}
</p>
<p>{content}</p>
<div className="modal-action">
<form method="dialog">
<button
className="btn btn-circle btn-sm absolute top-2 right-2"
onClick={handleClose}
>
<Cross1Icon />
</button>
{children}
</form>
</div>
</div>
</dialog>
);
};
export default Modal;

View File

@ -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
* <ul className="menu bg-base-200 w-56 h-full">
* <NavigationTree routes={navigation} />
* </ul>
* ```
*/
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 (
<ul key={route.path}>
<li key={route.path}>
<Link
to={combinedPath || "/"}
className={locationHook.pathname === combinedPath ? "active" : ""}
>
{route.additionalProps.name}
</Link>
{route.children ? (
<ul>{GenerateNavigationEntries(route.children, combinedPath)}</ul>
) : null}
</li>
</ul>
);
}
// 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 (
<li key={route.path}>
<Link
to={combinedPath || "/"}
className={locationHook.pathname === combinedPath ? "active" : ""}
>
{route.additionalProps.name}
</Link>
</li>
);
}
}) || <>empty navigation tree</>
);
};
return <div className="h-fit">{GenerateNavigationEntries(props.routes)}</div>;
}
export default NavigationTree;

View File

@ -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 (
<svg
xmlns="http://www.w3.org/2000/svg"
className="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
case "success":
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
break;
case "info":
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="stroke-current shrink-0 w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
);
break;
case "warning":
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
);
break;
default:
return null;
break;
}
};
interface NotificationProps {
notification: INotification;
removeNotification: (id: number) => void;
}
function Notification({ notification, removeNotification }: NotificationProps) {
const [counter, setCounter] = useState<number | undefined>(
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 (
<div
className={NotificationColorsClasses[notification.status]}
key={notification.id}
>
{selectIcon(notification.status)}
<span>{notification.message}</span>
{isCloseable ? (
<button
className="btn btn-sm"
onClick={() => removeNotification(notification.id)}
>
Close
</button>
) : null}
{isCloseable ? null : (
<div className="flex flex-col p-2 bg-neutral rounded-box text-neutral-content">
<span className="countdown">
<span style={{ "--value": counter } as React.CSSProperties}></span>
</span>
</div>
)}
</div>
);
}
export default Notification;

View File

@ -0,0 +1,37 @@
import React, { useState } from "react";
import { INotification } from "./NotificationType";
interface INotificationStore {
notifications: INotification[] | [];
setNotifications: React.Dispatch<React.SetStateAction<[] | INotification[]>>;
duration?: number;
}
// Store for notifications
export const NotificationContext = React.createContext<INotificationStore>({
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<Array<INotification> | []>(
[],
);
NotificationContext.displayName = "Notifications";
return (
<NotificationContext.Provider
value={{
notifications,
setNotifications,
}}
>
{children}
</NotificationContext.Provider>
);
};
export default NotificationProvider;

View File

@ -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 (
<Notification
key={notification.id}
notification={notification}
removeNotification={removeNotification}
/>
);
});
};
return <div className="toast toast-end">{renderNotifications()}</div>;
}
export default NotificationStack;

View File

@ -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;
}

View File

@ -0,0 +1,2 @@
# Notification
I wrote own notification module but its little bit laggy and buggy so I don't use it.

View File

@ -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,
};
}

191
src/configure.tsx Normal file
View File

@ -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: <App />,
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: (
<Suspense fallback={<Loader />}>
<HomePageLazy />
</Suspense>
),
additionalProps: {
name: "Home",
disableRedirect: false,
disableInNavbar: false,
disableBreadcrumbBar: false,
},
},
{
path: "maintenance",
element: (
<Suspense fallback={<Loader />}>
<MaintenanceLazy />
</Suspense>
),
additionalProps: {
name: "Maintenance",
disableRedirect: false,
disableInNavbar: false,
disableBreadcrumbBar: false,
},
},
{
path: "administration",
element: (
<Suspense fallback={<Loader />}>
<AdministrationLazy />
</Suspense>
),
additionalProps: {
name: "Administration",
disableRedirect: false,
disableInNavbar: false,
disableBreadcrumbBar: false,
},
children: [
{
path: "asset_management/:state?/:id?",
element: <AssetManagement />,
additionalProps: {
name: "Asset Management",
disableRedirect: false,
disableInNavbar: false,
disableBreadcrumbBar: false,
},
},
{
path: "person_management",
element: <PersonManager />,
additionalProps: {
name: "Person Management",
disableRedirect: false,
disableInNavbar: false,
disableBreadcrumbBar: false,
},
},
],
},
// about page
{
path: "about",
element: <AboutPage />,
additionalProps: {
name: "About",
disableRedirect: false,
disableInNavbar: false,
disableBreadcrumbBar: false,
},
children: [
{
path: "child",
element: <div>DUPA CHILD</div>,
additionalProps: {
name: "About Child",
disableRedirect: false,
disableInNavbar: false,
disableBreadcrumbBar: false,
},
},
],
},
// login page
{
path: "login",
element: <LoginPage />,
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: <div>Test</div>,
additionalProps: {
name: "Test",
disableRedirect: false,
disableInNavbar: false,
disableBreadcrumbBar: false,
},
});
// Add debugger page
navigation[0].children?.push({
path: "debug_variables",
element: <Debugger />,
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<RouteObject, "children"> {
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 <purpose>.`,
program_authors: [{ name: "Author Name", email: "email@example.com" }],
};
//----
export const flatRoutes = flatternRoutingTable(navigation);

View File

@ -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<React.SetStateAction<string>>;
setCustomModalStyles: React.Dispatch<React.SetStateAction<string>>;
};
// Create the context with default values
export const ConfirmationDialogContext = createContext<
ConfirmationDialogContextType | undefined
>(undefined);
ConfirmationDialogContext.displayName = "ConfirmationDialog";

View File

@ -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 (
<div className="hero ">
<div className="hero-content text-center">
<div className="max-w-md">
<h1 className="text-5xl font-bold">About</h1>
<p className="py-6">{about.program_description}</p>
<h2 className="text-2xl font-bold pt-10">Authors</h2>
<div className="py-6">
{about.program_authors.map((author) => (
<div key={author.name}>
{author.name} -{" "}
<a href={"mailto:" + author.email} className="link">
{author.email}
</a>
</div>
))}
</div>
</div>
</div>
</div>
);
}
export default AboutPage;

108
src/features/App/App.tsx Normal file
View File

@ -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<string>("adient");
const [openDrawer, setOpenDrawer] = useState<boolean>(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 (
<div>
<ConfirmationDialog />
{/* Root drawer container */}
{/* Drawer opening is controlled directly via css prop and next via local useState variable */}
<div className={"drawer " + (openDrawer ? "lg:drawer-open" : "")}>
{/* A hidden checkbox to toggle the visibility of the drawer */}
<input
id="my-drawer"
type="checkbox"
placeholder="drawer-checkbox"
className="drawer-toggle"
checked={openDrawer}
onChange={handleDrawerStatus}
/>
{/* The actual drawer content */}
<div className="drawer-content">
{/* Navbar */}
<div className="navbar bg-neutral text-neutral-content w-auto">
{/* Left side navbar */}
<div className="flex-1">
<label
htmlFor="my-drawer"
className="btn btn-circle btn-neutral drawer-button"
>
<HamburgerMenuIcon className="w-6 h-6" />
</label>
<p className="text-lg font-bold ml-8">{main.program_name}</p>
</div>
{/* Right side navbar */}
<div className="flex-none gap-2">
<div className="dropdown dropdown-bottom dropdown-end">
<button tabIndex={0} className="btn btn-circle m-1">
<SunIcon className="w-6 h-6" />
</button>
<ul
tabIndex={0}
className="menu dropdown-content z-[12] p-2 shadow bg-base-100 rounded-box w-fit mt-4 max-h-24 sm:max-h-96 flex flex-col items-center flex-nowrap overflow-y-auto"
>
{themes.map((theme) => (
<li key={theme}>
<a className="prose" onClick={() => handleThemeChange(theme)}>
{theme}
</a>
</li>
))}
</ul>
</div>
<button className="btn m-1">Log out</button>
</div>
</div>
{/* App/active_drawer content */}
<div className="drawer-content">
{/*Automatically generated breadcrumbs based on routing table from configuration file and active path*/}
{/*Get active route and find it in routing file*/}
<Breadcrumbs />
<Outlet />
</div>
</div>
{/* Drawer sidebar wrapper */}
<div className="drawer-side z-20">
{/* Dark overlay on mobile devices, clickable to close drawer */}
<label htmlFor="my-drawer" className="drawer-overlay"></label>
<ul className="menu bg-base-200 w-56 h-full overflow-auto">
<NavigationTree routes={navigation} />
</ul>
</div>
</div>
</div>
);
}
export default App;

31
src/features/Debugger.tsx Normal file
View File

@ -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 (
<div className="m-auto p-4">
<details className="collapse bg-base-200 mb-4">
<summary className="collapse-title text-xl font-medium">
Flat Routes
</summary>
<code className="collapse-content">{JSON.stringify(flatRoutes)}</code>
</details>
<details className="collapse bg-base-200 mb-4">
<summary className="collapse-title text-xl font-medium">Locations</summary>
<div className="collapse-content">
<code>Location hook: {JSON.stringify(locationHook)}</code>
<br />
<code>Location window: {JSON.stringify(windowLocation)}</code>
</div>
</details>
<details className="collapse bg-base-200 mb-4">
<summary className="collapse-title text-xl font-medium">Routes</summary>
<code className="collapse-content">{JSON.stringify(flatRoutes)}</code>
</details>
</div>
);
}
export default Debugger;

View File

@ -0,0 +1,5 @@
function HomePage() {
return <>HOME</>;
}
export default HomePage;

View File

@ -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<Input>({
resolver: zodResolver(InputSchema),
});
const navigate = useNavigate();
const onSubmit: SubmitHandler<Input> = (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<Input> = () => {
console.log("Errors in form fields");
};
return (
<div className="mt-4">
<form onSubmit={handleSubmit(onSubmit, onError)}>
<fieldset className="flex flex-col content-around justify-center items-center space-y-4">
<div className="form-control">
<label className="input-group">
<span className="w-32 flex-none">Username</span>
<input
type="text"
placeholder="username"
autoComplete="username"
className="input input-bordered w-full"
disabled={isSubmitting}
{...register("username")}
/>
</label>
{errors.username && (
<label className="label">
<span className="label-text-alt">{errors.username?.message}</span>
</label>
)}
</div>
<div className="form-control">
<label className="input-group">
<span className="w-32 flex-none">Password</span>
<input
type="password"
placeholder="password"
autoComplete="current-password"
className="input input-bordered w-full"
disabled={isSubmitting}
{...register("password")}
/>
</label>
{errors.password && (
<label className="label">
<span className="label-text-alt">
Password must have: {errors.password?.message}{" "}
</span>
</label>
)}
</div>
<button
className="btn btn-primary btn-wide"
type="submit"
disabled={isSubmitting}
>
{isSubmitting && (
<span className="loading loading-spinner loading-lg"></span>
)}{" "}
Login
</button>
</fieldset>
</form>
</div>
);
}
export default LoginPage;

3
src/main.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

21
src/main.tsx Normal file
View File

@ -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(
<React.StrictMode>
{/* fallbackElement - use when routing table is quite big to load, it will display desired element in convention of loading screen */}
<ConfirmationDialogProvider>
<RouterProvider
router={router}
fallbackElement={<p>Loading route table...</p>}
/>
</ConfirmationDialogProvider>
</React.StrictMode>,
);

69
src/routes.tsx Normal file
View File

@ -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: <div>Empty routes</div>,
});
}
//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: <Redirect to="/" />,
},
];
/** 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;

9
src/utils/ObjectUtils.ts Normal file
View File

@ -0,0 +1,9 @@
export const rollThroughObj = (
obj: Readonly<Partial<{ state: string; id: string }>>,
) => {
let result = "";
for (const [key, value] of Object.entries(obj)) {
result += ` -${key}: ${value}`;
}
return result.trim();
};

18
src/utils/Redirect.tsx Normal file
View File

@ -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;

View File

@ -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;
};

View File

@ -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, "");
};

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

11
tailwind.config.js Normal file
View File

@ -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")],
};

49
tsconfig.json Normal file
View File

@ -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"
// }
// ]
}

15
vite.config.ts Normal file
View File

@ -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"),
// },
// },
});
};