diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..bfa0621
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+
+.idea/*
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 8056623..6ec0d65 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -6,6 +6,7 @@
"": {
"name": "meiro-task-be",
"dependencies": {
+ "@fastify/cors": "8.5.0",
"fastify": "4.25.2"
},
"devDependencies": {
@@ -37,6 +38,15 @@
"fast-uri": "^2.0.0"
}
},
+ "node_modules/@fastify/cors": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz",
+ "integrity": "sha512-/oZ1QSb02XjP0IK1U0IXktEsw/dUBTxJOW7IpIeO8c/tNalw/KjoNSJv1Sf6eqoBPO+TDGkifq6ynFK3v68HFQ==",
+ "dependencies": {
+ "fastify-plugin": "^4.0.0",
+ "mnemonist": "0.39.6"
+ }
+ },
"node_modules/@fastify/deepmerge": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-1.3.0.tgz",
@@ -321,6 +331,11 @@
"toad-cache": "^3.3.0"
}
},
+ "node_modules/fastify-plugin": {
+ "version": "4.5.1",
+ "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz",
+ "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ=="
+ },
"node_modules/fastq": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.16.0.tgz",
@@ -430,11 +445,24 @@
"node": ">=10"
}
},
+ "node_modules/mnemonist": {
+ "version": "0.39.6",
+ "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz",
+ "integrity": "sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==",
+ "dependencies": {
+ "obliterator": "^2.0.1"
+ }
+ },
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
+ "node_modules/obliterator": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.4.tgz",
+ "integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ=="
+ },
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/frontend/.prettierignore b/frontend/.prettierignore
new file mode 100644
index 0000000..d155fdb
--- /dev/null
+++ b/frontend/.prettierignore
@@ -0,0 +1,4 @@
+# Add files here to ignore them from prettier formatting
+/dist
+/coverage
+/.nx/cache
\ No newline at end of file
diff --git a/frontend/.prettierrc b/frontend/.prettierrc
new file mode 100644
index 0000000..fc56e31
--- /dev/null
+++ b/frontend/.prettierrc
@@ -0,0 +1,7 @@
+{
+ "trailingComma": "es5",
+ "tabWidth": 2,
+ "semi": true,
+ "singleQuote": true,
+ "printWidth": 120
+}
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..9d0b4bc
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,30 @@
+# React + TypeScript + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
+
+## Expanding the ESLint configuration
+
+If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
+
+- Configure the top-level `parserOptions` property like this:
+
+```js
+export default {
+ // other rules...
+ parserOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ project: ['./tsconfig.json', './tsconfig.node.json'],
+ tsconfigRootDir: __dirname,
+ },
+};
+```
+
+- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
+- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
+- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
new file mode 100644
index 0000000..5339bc4
--- /dev/null
+++ b/frontend/eslint.config.js
@@ -0,0 +1,10 @@
+import eslint from '@eslint/js';
+import tslint from 'typescript-eslint';
+
+export default [
+ eslint.configs.recommended,
+ ...tslint.configs.recommended,
+ {
+ ignores: ['node_modules', 'dist'],
+ },
+];
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..9131e32
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ meiro - FE test scenario
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..2eb1ce1
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,47 @@
+{
+ "name": "frontend",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "lint": "eslint .",
+ "format:check": "prettier --check .",
+ "format:fix": "prettier --write ."
+ },
+ "dependencies": {
+ "@emotion/react": "^11.11.4",
+ "@emotion/styled": "^11.11.5",
+ "@fontsource/open-sans": "^5.0.28",
+ "@mui/icons-material": "^5.15.18",
+ "@mui/material": "^5.15.18",
+ "@tanstack/react-query": "^5.37.1",
+ "axios": "^1.7.2",
+ "dayjs": "^1.11.11",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.23.1",
+ "tslib": "^2.6.2"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.3.0",
+ "@types/react": "^18.2.66",
+ "@types/react-dom": "^18.2.22",
+ "@typescript-eslint/eslint-plugin": "^7.2.0",
+ "@typescript-eslint/parser": "^7.2.0",
+ "@vitejs/plugin-react": "^4.2.1",
+ "eslint": "^8.0.1",
+ "eslint-config-standard": "^17.1.0",
+ "eslint-plugin-import": "^2.25.2",
+ "eslint-plugin-n": "^15.0.0 || ^16.0.0 ",
+ "eslint-plugin-promise": "^6.0.0",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "eslint-plugin-react-refresh": "^0.4.6",
+ "prettier": "^3.2.5",
+ "typescript": "^5.2.2",
+ "typescript-eslint": "^7.10.0",
+ "vite": "^5.2.0"
+ }
+}
diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/frontend/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/App/App.tsx b/frontend/src/App/App.tsx
new file mode 100644
index 0000000..b1da9e9
--- /dev/null
+++ b/frontend/src/App/App.tsx
@@ -0,0 +1,22 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ThemeProvider, CssBaseline } from '@mui/material';
+import { theme } from '../styles';
+import AppRouter from './AppRouter';
+
+import '@fontsource/open-sans/300.css';
+import '@fontsource/open-sans/500.css';
+import '@fontsource/open-sans/700.css';
+import '@fontsource/open-sans/800.css';
+
+const queryClient = new QueryClient();
+
+const App = () => (
+
+
+
+
+
+
+);
+
+export default App;
diff --git a/frontend/src/App/AppRouter.tsx b/frontend/src/App/AppRouter.tsx
new file mode 100644
index 0000000..5f8a200
--- /dev/null
+++ b/frontend/src/App/AppRouter.tsx
@@ -0,0 +1,43 @@
+import { lazy } from 'react';
+import { RouterProvider, createBrowserRouter } from 'react-router-dom';
+import { ROUTES } from '../constants';
+import { PageLayout, PagePreloader } from '../components';
+
+const Error = lazy(() => import('../modules/Error/Error'));
+const Home = lazy(() => import('../modules/Home/Home'));
+const Attributes = lazy(() => import('../modules/Attributes/Attributes'));
+
+const AppRouter = () => {
+ const router = createBrowserRouter(
+ [
+ {
+ path: '*',
+ element: ,
+ },
+ {
+ element: ,
+ children: [
+ {
+ path: ROUTES.home.path,
+ element: ,
+ },
+ {
+ path: ROUTES.attributes.path,
+ element: ,
+ children: [
+ {
+ path: `${ROUTES.attributes.path}/:id`,
+ element: ,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ { basename: '' }
+ );
+
+ return } />;
+};
+
+export default AppRouter;
diff --git a/frontend/src/App/index.ts b/frontend/src/App/index.ts
new file mode 100644
index 0000000..c866729
--- /dev/null
+++ b/frontend/src/App/index.ts
@@ -0,0 +1 @@
+export { default as App } from './App';
diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg
new file mode 100644
index 0000000..6c87de9
--- /dev/null
+++ b/frontend/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/components/ConfirmDialog/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog/ConfirmDialog.tsx
new file mode 100644
index 0000000..f9b8fb0
--- /dev/null
+++ b/frontend/src/components/ConfirmDialog/ConfirmDialog.tsx
@@ -0,0 +1,45 @@
+import { ReactNode } from 'react';
+import { Dialog, DialogTitle, DialogContent, DialogActions, IconButton, Button } from '@mui/material';
+import CloseIcon from '@mui/icons-material/Close';
+import { WithChildren } from '../../types';
+
+export interface ConfirmDialogProps extends WithChildren {
+ title?: ReactNode;
+ open: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+}
+
+const ConfirmDialog = ({ children, title, open, onClose, onConfirm }: ConfirmDialogProps) => {
+ const confirmHandler = () => {
+ onClose();
+ onConfirm();
+ };
+
+ return (
+
+ );
+};
+
+export default ConfirmDialog;
diff --git a/frontend/src/components/ConfirmDialog/index.ts b/frontend/src/components/ConfirmDialog/index.ts
new file mode 100644
index 0000000..0f29ed1
--- /dev/null
+++ b/frontend/src/components/ConfirmDialog/index.ts
@@ -0,0 +1,2 @@
+export { default as ConfirmDialog } from './ConfirmDialog';
+export type { ConfirmDialogProps } from './ConfirmDialog';
diff --git a/frontend/src/components/Footer/Footer.tsx b/frontend/src/components/Footer/Footer.tsx
new file mode 100644
index 0000000..dcd42ae
--- /dev/null
+++ b/frontend/src/components/Footer/Footer.tsx
@@ -0,0 +1,27 @@
+import { styled, Box, Container, Typography, Stack, BoxProps } from '@mui/material';
+import { PROJECT } from '../../config';
+import { CONTAINER_MAX_WIDTH_DEFAULT } from '../../constants';
+
+const Wrapper = styled(Box)(({ theme: { spacing } }) => ({
+ padding: spacing(2),
+}));
+
+interface FooterProps {
+ wrapperProps?: Partial>;
+}
+
+const Footer = ({ wrapperProps }: FooterProps) => {
+ return (
+
+
+
+
+ {PROJECT.meta.name} - {PROJECT.meta.description} ({PROJECT.meta.year})
+
+
+
+
+ );
+};
+
+export default Footer;
diff --git a/frontend/src/components/Footer/index.ts b/frontend/src/components/Footer/index.ts
new file mode 100644
index 0000000..da94c29
--- /dev/null
+++ b/frontend/src/components/Footer/index.ts
@@ -0,0 +1 @@
+export { default as Footer } from './Footer';
diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx
new file mode 100644
index 0000000..b5d8af1
--- /dev/null
+++ b/frontend/src/components/Header/Header.tsx
@@ -0,0 +1,104 @@
+import { useState, MouseEvent } from 'react';
+import { Link } from 'react-router-dom';
+import { Box, Toolbar, AppBar, IconButton, Typography, Menu, Container, Button, MenuItem } from '@mui/material';
+import MenuIcon from '@mui/icons-material/Menu';
+import { PROJECT } from '../../config';
+import { CONTAINER_MAX_WIDTH_DEFAULT, HEADER_DESKTOP_HEIGHT, MAIN_MENU } from '../../constants';
+
+const Header = () => {
+ const [anchorElNav, setAnchorElNav] = useState(null);
+
+ const openNavMenuHandler = (event: MouseEvent) => setAnchorElNav(event.currentTarget);
+
+ const closeNavMenuHandler = () => setAnchorElNav(null);
+
+ return (
+
+
+
+
+ {PROJECT.meta.name}
+
+
+
+
+
+
+
+
+
+ {PROJECT.meta.name}
+
+
+
+ {MAIN_MENU.map((item) => (
+
+ ))}
+
+
+
+
+ );
+};
+
+export default Header;
diff --git a/frontend/src/components/Header/index.ts b/frontend/src/components/Header/index.ts
new file mode 100644
index 0000000..5653319
--- /dev/null
+++ b/frontend/src/components/Header/index.ts
@@ -0,0 +1 @@
+export { default as Header } from './Header';
diff --git a/frontend/src/components/ViewHeading/ViewHeading.tsx b/frontend/src/components/ViewHeading/ViewHeading.tsx
new file mode 100644
index 0000000..07c8595
--- /dev/null
+++ b/frontend/src/components/ViewHeading/ViewHeading.tsx
@@ -0,0 +1,17 @@
+import { Typography } from '@mui/material';
+
+export interface ViewHeadingProps {
+ title?: string;
+ description?: string;
+}
+
+const ViewHeading = ({ title, description }: ViewHeadingProps) => {
+ return (
+
+ {title && {title}}
+ {description && {description}}
+
+ );
+};
+
+export default ViewHeading;
diff --git a/frontend/src/components/ViewHeading/index.ts b/frontend/src/components/ViewHeading/index.ts
new file mode 100644
index 0000000..5eee009
--- /dev/null
+++ b/frontend/src/components/ViewHeading/index.ts
@@ -0,0 +1,2 @@
+export { default as ViewHeading } from './ViewHeading';
+export type { ViewHeadingProps } from './ViewHeading';
diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts
new file mode 100644
index 0000000..91188fd
--- /dev/null
+++ b/frontend/src/components/index.ts
@@ -0,0 +1,6 @@
+export * from './ConfirmDialog';
+export * from './Footer';
+export * from './Header';
+export * from './layout';
+export * from './ViewHeading';
+export * from './preloader';
diff --git a/frontend/src/components/layout/PageLayout.tsx b/frontend/src/components/layout/PageLayout.tsx
new file mode 100644
index 0000000..b5042b1
--- /dev/null
+++ b/frontend/src/components/layout/PageLayout.tsx
@@ -0,0 +1,35 @@
+import { Suspense } from 'react';
+import { Outlet } from 'react-router-dom';
+import { styled, Box } from '@mui/material';
+import { HEADER_DESKTOP_HEIGHT } from '../../constants';
+import { LayoutPreloader } from '../preloader';
+import { Header } from '../Header';
+import { Footer } from '../Footer';
+
+const Wrapper = styled(Box)({
+ width: '100%',
+ height: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+});
+
+const Main = styled(Box)({
+ paddingTop: HEADER_DESKTOP_HEIGHT,
+ flex: 1,
+});
+
+const PageLayout = () => {
+ return (
+
+
+
+ }>
+
+
+
+
+
+ );
+};
+
+export default PageLayout;
diff --git a/frontend/src/components/layout/ViewLayout.tsx b/frontend/src/components/layout/ViewLayout.tsx
new file mode 100644
index 0000000..fd6983a
--- /dev/null
+++ b/frontend/src/components/layout/ViewLayout.tsx
@@ -0,0 +1,42 @@
+import { styled, Box, Container, BoxProps, ContainerProps } from '@mui/material';
+import { WithChildren } from '../../types';
+import { CONTAINER_MAX_WIDTH_DEFAULT } from '../../constants';
+import { ViewHeading, ViewHeadingProps } from '../ViewHeading';
+
+const Wrapper = styled(Box, {
+ shouldForwardProp: (propName) => propName !== 'isCentered',
+})<{ readonly isCentered?: boolean }>(({ isCentered }) => ({
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: isCentered ? 'center' : 'start',
+ flex: 1,
+}));
+
+interface ViewLayoutProps extends WithChildren {
+ maxWidth?: ContainerProps['maxWidth'];
+ wrapperProps?: Partial;
+ containerProps?: Partial>;
+ heading?: ViewHeadingProps;
+ isCentered?: boolean;
+}
+
+const ViewLayout = ({
+ maxWidth = CONTAINER_MAX_WIDTH_DEFAULT,
+ children,
+ wrapperProps,
+ containerProps,
+ heading,
+ isCentered,
+}: ViewLayoutProps) => {
+ return (
+
+
+ {heading && }
+ {children}
+
+
+ );
+};
+
+export default ViewLayout;
diff --git a/frontend/src/components/layout/index.ts b/frontend/src/components/layout/index.ts
new file mode 100644
index 0000000..40543b8
--- /dev/null
+++ b/frontend/src/components/layout/index.ts
@@ -0,0 +1,2 @@
+export { default as PageLayout } from './PageLayout';
+export { default as ViewLayout } from './ViewLayout';
diff --git a/frontend/src/components/preloader/LayoutPreloader.tsx b/frontend/src/components/preloader/LayoutPreloader.tsx
new file mode 100644
index 0000000..b5744ab
--- /dev/null
+++ b/frontend/src/components/preloader/LayoutPreloader.tsx
@@ -0,0 +1,5 @@
+const LayoutPreloader = () => {
+ return ...LayoutPreloader...
;
+};
+
+export default LayoutPreloader;
diff --git a/frontend/src/components/preloader/PagePreloader.tsx b/frontend/src/components/preloader/PagePreloader.tsx
new file mode 100644
index 0000000..6f875ec
--- /dev/null
+++ b/frontend/src/components/preloader/PagePreloader.tsx
@@ -0,0 +1,5 @@
+const PagePreloader = () => {
+ return ...PagePreloader...
;
+};
+
+export default PagePreloader;
diff --git a/frontend/src/components/preloader/index.ts b/frontend/src/components/preloader/index.ts
new file mode 100644
index 0000000..f7702a7
--- /dev/null
+++ b/frontend/src/components/preloader/index.ts
@@ -0,0 +1,2 @@
+export { default as LayoutPreloader } from './LayoutPreloader';
+export { default as PagePreloader } from './PagePreloader';
diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts
new file mode 100644
index 0000000..e984fc5
--- /dev/null
+++ b/frontend/src/config/index.ts
@@ -0,0 +1 @@
+export * from './project';
diff --git a/frontend/src/config/project.ts b/frontend/src/config/project.ts
new file mode 100644
index 0000000..1004c4a
--- /dev/null
+++ b/frontend/src/config/project.ts
@@ -0,0 +1,7 @@
+export const PROJECT = {
+ meta: {
+ name: 'meiro',
+ description: 'Frontend test scenario by sychrat@gmail.com',
+ year: 2024,
+ },
+};
diff --git a/frontend/src/constants/api.ts b/frontend/src/constants/api.ts
new file mode 100644
index 0000000..c6c79bb
--- /dev/null
+++ b/frontend/src/constants/api.ts
@@ -0,0 +1,8 @@
+export const API_BASE = 'http://127.0.0.1:3000/';
+
+export const API_EP = {
+ getAttributes: '/attributes',
+ getAttributesDetail: (id?: string) => `/attributes/${id}`,
+ deleteAttributesDetail: (id?: string) => `/attributes/${id}`,
+ getLabels: '/labels',
+};
diff --git a/frontend/src/constants/attributes.ts b/frontend/src/constants/attributes.ts
new file mode 100644
index 0000000..ebdd78b
--- /dev/null
+++ b/frontend/src/constants/attributes.ts
@@ -0,0 +1,2 @@
+export const ATTRIBUTES_FILTER_LIMIT_DEFAULT = 10;
+export const ATTRIBUTES_SEARCH_MIN_LENGTH = 3;
diff --git a/frontend/src/constants/index.ts b/frontend/src/constants/index.ts
new file mode 100644
index 0000000..c5ec998
--- /dev/null
+++ b/frontend/src/constants/index.ts
@@ -0,0 +1,7 @@
+export * from './api';
+export * from './attributes';
+export * from './labels';
+export * from './layout';
+export * from './nav';
+export * from './options';
+export * from './routes';
diff --git a/frontend/src/constants/labels.ts b/frontend/src/constants/labels.ts
new file mode 100644
index 0000000..c9a0820
--- /dev/null
+++ b/frontend/src/constants/labels.ts
@@ -0,0 +1 @@
+export const LABELS_FILTER_LIMIT_DEFAULT = 10;
diff --git a/frontend/src/constants/layout.ts b/frontend/src/constants/layout.ts
new file mode 100644
index 0000000..51f177b
--- /dev/null
+++ b/frontend/src/constants/layout.ts
@@ -0,0 +1,5 @@
+export const CONTAINER_MAX_WIDTH_DEFAULT = 'lg';
+
+export const DIALOG_CLOSE_DELAY = 250;
+
+export const HEADER_DESKTOP_HEIGHT = '60px';
diff --git a/frontend/src/constants/nav.ts b/frontend/src/constants/nav.ts
new file mode 100644
index 0000000..b80225b
--- /dev/null
+++ b/frontend/src/constants/nav.ts
@@ -0,0 +1,12 @@
+export const MAIN_MENU = [
+ {
+ key: 1,
+ label: 'Home',
+ path: '/',
+ },
+ {
+ key: 2,
+ label: 'Attributes',
+ path: '/attributes',
+ },
+];
diff --git a/frontend/src/constants/options.ts b/frontend/src/constants/options.ts
new file mode 100644
index 0000000..b240613
--- /dev/null
+++ b/frontend/src/constants/options.ts
@@ -0,0 +1,21 @@
+export const ATTRIBUTES_SORT_BY_OPTIONS = [
+ {
+ value: 'name',
+ label: 'Name',
+ },
+ {
+ value: 'createdAt',
+ label: 'Created At',
+ },
+];
+
+export const ATTRIBUTES_SORT_DIR_OPTIONS = [
+ {
+ value: 'asc',
+ label: 'Ascend',
+ },
+ {
+ value: 'desc',
+ label: 'Descend',
+ },
+];
diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts
new file mode 100644
index 0000000..e7bb34c
--- /dev/null
+++ b/frontend/src/constants/routes.ts
@@ -0,0 +1,8 @@
+export const ROUTES = {
+ home: {
+ path: '/',
+ },
+ attributes: {
+ path: '/attributes',
+ },
+};
diff --git a/frontend/src/enums/attributes.ts b/frontend/src/enums/attributes.ts
new file mode 100644
index 0000000..272d296
--- /dev/null
+++ b/frontend/src/enums/attributes.ts
@@ -0,0 +1,15 @@
+export const attributesListSortByKeys = {
+ name: 'name',
+ createdAt: 'createdAt',
+} as const;
+
+export const attributesListSortDirKeys = {
+ asc: 'asc',
+ desc: 'desc',
+} as const;
+
+export const attributesResponseStatusKeys = {
+ success: 'success',
+ error: 'error',
+ pending: 'pending',
+} as const;
diff --git a/frontend/src/enums/index.ts b/frontend/src/enums/index.ts
new file mode 100644
index 0000000..b17fcda
--- /dev/null
+++ b/frontend/src/enums/index.ts
@@ -0,0 +1 @@
+export * from './attributes';
diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts
new file mode 100644
index 0000000..a310d11
--- /dev/null
+++ b/frontend/src/hooks/index.ts
@@ -0,0 +1,3 @@
+export * from './useAttributes';
+export * from './useAxiosInstance';
+export * from './useLabels';
diff --git a/frontend/src/hooks/useAttributes.ts b/frontend/src/hooks/useAttributes.ts
new file mode 100644
index 0000000..d530551
--- /dev/null
+++ b/frontend/src/hooks/useAttributes.ts
@@ -0,0 +1,52 @@
+import { useQuery, useMutation, useInfiniteQuery } from '@tanstack/react-query';
+import { AxiosInstance } from 'axios';
+import { useAxiosInstance } from './useAxiosInstance';
+import { Attribute, AttributesFilter, AttributesResponse } from '../types';
+import { API_EP } from '../constants';
+
+const fetchAttributes = async (instance: AxiosInstance, filter: AttributesFilter): Promise => {
+ const response = await instance.get(API_EP.getAttributes, { params: { ...filter } });
+
+ return {
+ data: response.data?.data ?? [],
+ meta: response?.data?.meta,
+ };
+};
+
+export const useInfinityAttributes = ({ offset, limit, sortBy, sortDir, searchText }: AttributesFilter) => {
+ const { apiBase } = useAxiosInstance();
+
+ return useInfiniteQuery({
+ initialData: undefined,
+ initialPageParam: undefined,
+ queryKey: ['attributes', Object.values({ offset, limit, sortBy, sortDir, searchText })],
+ queryFn: ({ pageParam }) =>
+ fetchAttributes(apiBase, { offset: (pageParam as number) ?? 0, limit, sortBy, sortDir, searchText }),
+ getNextPageParam: (lastPage, pages) => (lastPage.meta.hasNextPage ? pages.length * limit : undefined),
+ });
+};
+
+export const useAttributesDetail = (id?: string) => {
+ const { apiBase } = useAxiosInstance();
+
+ const { data, ...query } = useQuery({
+ enabled: !!id,
+ queryKey: [`attributes`, id],
+ queryFn: () => apiBase.get(API_EP.getAttributesDetail(id), {}),
+ });
+
+ return {
+ data: data?.data?.data as Attribute,
+ ...query,
+ };
+};
+
+export const useDeleteAttributeMutation = (instance: AxiosInstance) =>
+ useMutation({
+ mutationFn: (id: string) => {
+ return instance({
+ url: API_EP.deleteAttributesDetail(id),
+ method: 'DELETE',
+ });
+ },
+ });
diff --git a/frontend/src/hooks/useAxiosInstance.ts b/frontend/src/hooks/useAxiosInstance.ts
new file mode 100644
index 0000000..290e3e0
--- /dev/null
+++ b/frontend/src/hooks/useAxiosInstance.ts
@@ -0,0 +1,17 @@
+import axios, { CreateAxiosDefaults } from 'axios';
+import { API_BASE } from '../constants';
+
+export const useAxiosInstance = (config?: CreateAxiosDefaults) => {
+ const apiBase = axios.create({
+ baseURL: API_BASE,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...config?.headers,
+ },
+ ...config,
+ });
+
+ return {
+ apiBase,
+ };
+};
diff --git a/frontend/src/hooks/useLabels.ts b/frontend/src/hooks/useLabels.ts
new file mode 100644
index 0000000..d423e39
--- /dev/null
+++ b/frontend/src/hooks/useLabels.ts
@@ -0,0 +1,43 @@
+import { useEffect, useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useAxiosInstance } from './useAxiosInstance';
+import { LabelList, LabelsFilter, LabelsMeta } from '../types';
+import { API_EP, LABELS_FILTER_LIMIT_DEFAULT } from '../constants';
+
+export const useLabels = (filter: LabelsFilter, enabled: boolean = true) => {
+ const { apiBase } = useAxiosInstance();
+
+ const { data, ...query } = useQuery({
+ queryKey: ['labels', Object.values(filter)],
+ queryFn: () => apiBase.get(API_EP.getLabels, { params: { ...filter } }),
+ enabled,
+ });
+
+ return {
+ data: (data?.data?.data ?? []) as LabelList,
+ meta: data?.data?.meta as LabelsMeta,
+ ...query,
+ };
+};
+
+export const useLabelsItems = () => {
+ const [labels, setLabels] = useState([]);
+ const [offset, setOffset] = useState(0);
+ const [limit] = useState(LABELS_FILTER_LIMIT_DEFAULT);
+ const [hasNextPage, setHasNextPage] = useState(true);
+
+ const { data, meta } = useLabels({ offset, limit }, hasNextPage);
+
+ useEffect(() => {
+ if (data)
+ setLabels(Array.from([...labels, ...data].reduce((map, obj) => map.set(obj.id, obj), new Map()).values()));
+ if (meta) {
+ setHasNextPage(meta.hasNextPage);
+ if (meta.hasNextPage) setOffset(offset + limit);
+ }
+ }, [data, meta]);
+
+ return {
+ labels,
+ };
+};
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..e2394c7
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { App } from './App';
+
+import './styles/index.css';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+);
diff --git a/frontend/src/modules/Attributes/Attributes.tsx b/frontend/src/modules/Attributes/Attributes.tsx
new file mode 100644
index 0000000..e8b08ae
--- /dev/null
+++ b/frontend/src/modules/Attributes/Attributes.tsx
@@ -0,0 +1,74 @@
+import { useEffect, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import { useLabelsItems, useDeleteAttributeMutation, useAxiosInstance, useInfinityAttributes } from '../../hooks';
+import { ROUTES, DIALOG_CLOSE_DELAY } from '../../constants';
+import { ViewLayout, ConfirmDialog } from '../../components';
+import { AttributesContextProvider } from './contexts';
+import { useAttributesContextControl } from './hooks';
+import { FeedbackSnack } from './components';
+import { AttributesList } from './AttributesList';
+import { AttributesDetail } from './AttributesDetail';
+
+const Attributes = () => {
+ const [snackSuccessOpen, setSnackSuccessOpen] = useState(false);
+
+ const providerValue = useAttributesContextControl();
+ const { offset, limit, searchText, sortBy, sortDir, confirmId, setConfirmId, setConfirmOpen, setLabels } =
+ providerValue;
+ const navigate = useNavigate();
+ const { id } = useParams();
+ const { labels } = useLabelsItems();
+ const { refetch } = useInfinityAttributes({ offset, limit, searchText, sortBy, sortDir });
+ const { apiBase } = useAxiosInstance();
+ const deleteMutation = useDeleteAttributeMutation(apiBase);
+
+ const deleteHandler = (id: string) => {
+ setConfirmId(id);
+ setConfirmOpen(true);
+ };
+
+ const closeHandler = () => {
+ setConfirmOpen(false);
+ setTimeout(() => {
+ setConfirmId(null);
+ }, DIALOG_CLOSE_DELAY);
+ };
+
+ const confirmHandler = () => {
+ if (confirmId) {
+ if (id) navigate(ROUTES.attributes.path);
+ deleteMutation.mutate(confirmId, {
+ onSuccess: () => {
+ closeHandler();
+ refetch().then(() => {
+ setSnackSuccessOpen(true);
+ });
+ },
+ });
+ }
+ };
+
+ useEffect(() => setLabels(labels), [labels]);
+
+ return (
+
+
+
+
+
+
+ Are you sure you want delete #{providerValue.confirmId} item?
+
+ setSnackSuccessOpen(false)}>
+ Item was successfully deleted
+
+
+ );
+};
+
+export default Attributes;
diff --git a/frontend/src/modules/Attributes/AttributesDetail/AttributesDetail.tsx b/frontend/src/modules/Attributes/AttributesDetail/AttributesDetail.tsx
new file mode 100644
index 0000000..dd9155d
--- /dev/null
+++ b/frontend/src/modules/Attributes/AttributesDetail/AttributesDetail.tsx
@@ -0,0 +1,55 @@
+import { useState, useEffect } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { Drawer, Typography, Button, Stack } from '@mui/material';
+import { ROUTES } from '../../../constants';
+import { useAttributesDetail } from '../../../hooks';
+import { LabelsList } from '../components';
+
+interface AttributesDetailProps {
+ onDelete: (id: string) => void;
+}
+
+const AttributesDetail = ({ onDelete }: AttributesDetailProps) => {
+ const [open, setOpen] = useState(false);
+
+ const { id } = useParams();
+ const navigate = useNavigate();
+ const { data } = useAttributesDetail(id);
+
+ const closeHandler = () => {
+ navigate(ROUTES.attributes.path);
+ setOpen(false);
+ };
+
+ const deleteHandler = (id: string) => {
+ onDelete(id);
+ };
+
+ useEffect(() => setOpen(!!id), [id]);
+
+ return (
+
+ spacing(2) }}>
+ {data?.name}
+
+
+
+
+
+
+
+ );
+};
+
+export default AttributesDetail;
diff --git a/frontend/src/modules/Attributes/AttributesDetail/index.ts b/frontend/src/modules/Attributes/AttributesDetail/index.ts
new file mode 100644
index 0000000..bef1d56
--- /dev/null
+++ b/frontend/src/modules/Attributes/AttributesDetail/index.ts
@@ -0,0 +1 @@
+export { default as AttributesDetail } from './AttributesDetail';
diff --git a/frontend/src/modules/Attributes/AttributesList/AttributesList.tsx b/frontend/src/modules/Attributes/AttributesList/AttributesList.tsx
new file mode 100644
index 0000000..86ed7fe
--- /dev/null
+++ b/frontend/src/modules/Attributes/AttributesList/AttributesList.tsx
@@ -0,0 +1,45 @@
+import { useEffect } from 'react';
+import { Stack } from '@mui/material';
+import { AttributeInfinityListResponse } from '../../../types';
+import { useInfinityAttributes } from '../../../hooks';
+import { useAttributesContext } from '../contexts';
+import AttributesListFilter from './AttributesListFilter';
+import AttributesListTable from './AttributesListTable';
+
+interface AttributesListProps {
+ onDelete: (id: string) => void;
+}
+
+const AttributesList = ({ onDelete }: AttributesListProps) => {
+ const { offset, limit, searchText, sortBy, sortDir, setOffset } = useAttributesContext();
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status, isLoading } = useInfinityAttributes({
+ offset,
+ limit,
+ sortBy,
+ sortDir,
+ searchText,
+ });
+
+ const deleteHandler = (id: string) => onDelete(id);
+
+ useEffect(() => {
+ if (data?.pageParams[1]) setOffset(data?.pageParams[1] as number);
+ }, [data]);
+
+ return (
+
+
+
+
+ );
+};
+
+export default AttributesList;
diff --git a/frontend/src/modules/Attributes/AttributesList/AttributesListFilter.tsx b/frontend/src/modules/Attributes/AttributesList/AttributesListFilter.tsx
new file mode 100644
index 0000000..300c8b6
--- /dev/null
+++ b/frontend/src/modules/Attributes/AttributesList/AttributesListFilter.tsx
@@ -0,0 +1,114 @@
+import { useState, ChangeEvent } from 'react';
+import {
+ Stack,
+ OutlinedInput,
+ Select,
+ MenuItem,
+ Paper,
+ Toolbar,
+ InputAdornment,
+ IconButton,
+ SelectChangeEvent,
+} from '@mui/material';
+import CloseIcon from '@mui/icons-material/Close';
+import SearchIcon from '@mui/icons-material/Search';
+import { AttributeListSortBy, AttributeListSortDir } from '../../../types';
+import {
+ ATTRIBUTES_SEARCH_MIN_LENGTH,
+ ATTRIBUTES_SORT_BY_OPTIONS,
+ ATTRIBUTES_SORT_DIR_OPTIONS,
+} from '../../../constants';
+import { useAttributesContext } from '../contexts';
+
+const AttributesListFilter = () => {
+ const [searchStringTmp, setSearchStringTmp] = useState('');
+
+ const { setSearchText, sortBy, setSortBy, sortDir, setSortDir, setOffset, offset } = useAttributesContext();
+
+ const searchTextHandler = (event: ChangeEvent) => {
+ const value = event.target.value;
+
+ if (value.length > ATTRIBUTES_SEARCH_MIN_LENGTH) {
+ setSearchText(value);
+ setSearchStringTmp(value);
+ setOffset(0);
+ } else {
+ setSearchText('');
+ setSearchStringTmp(value);
+ setOffset(offset);
+ }
+ };
+
+ const sortByHandler = (event: SelectChangeEvent) => {
+ setSortBy(event.target.value as AttributeListSortBy);
+ setOffset(0);
+ };
+
+ const sortDirectionHandler = (event: SelectChangeEvent) => {
+ setSortDir(event.target.value as AttributeListSortDir);
+ setOffset(0);
+ };
+
+ const clearSearchHandler = () => {
+ setSearchText('');
+ setSearchStringTmp('');
+ setOffset(0);
+ };
+
+ return (
+
+
+
+
+
+
+
+ }
+ endAdornment={
+
+ 1 ? 1 : 0 }}
+ onClick={clearSearchHandler}
+ >
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default AttributesListFilter;
diff --git a/frontend/src/modules/Attributes/AttributesList/AttributesListTable.tsx b/frontend/src/modules/Attributes/AttributesList/AttributesListTable.tsx
new file mode 100644
index 0000000..e32fbf4
--- /dev/null
+++ b/frontend/src/modules/Attributes/AttributesList/AttributesListTable.tsx
@@ -0,0 +1,140 @@
+import { useRef, useCallback } from 'react';
+import { Link } from 'react-router-dom';
+import dayjs from 'dayjs';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Paper,
+ Button,
+ Typography,
+ Box,
+ Stack,
+} from '@mui/material';
+import { AttributeInfinityListResponse, AttributeResponseStatus } from '../../../types';
+import { ATTRIBUTES_SEARCH_MIN_LENGTH, ROUTES } from '../../../constants';
+import { attributesResponseStatusKeys } from '../../../enums';
+import { useAttributesContext } from '../contexts';
+import { LabelsList } from '../components';
+
+interface AttributesListTableProps {
+ onRowDelete: (id: string) => void;
+ fetchNextPage: () => void;
+ hasNextPage: boolean;
+ isFetchingNextPage: boolean;
+ status: AttributeResponseStatus;
+ isLoading: boolean;
+ data: AttributeInfinityListResponse;
+}
+
+const AttributesListTable = ({
+ onRowDelete,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ status,
+ isLoading,
+ data,
+}: AttributesListTableProps) => {
+ const { searchText } = useAttributesContext();
+ const observer = useRef(null);
+ const lastElementRef = useCallback(
+ (node: HTMLDivElement | null) => {
+ if (isFetchingNextPage) return;
+ if (observer.current) observer.current.disconnect();
+ observer.current = new IntersectionObserver((entries) => {
+ if (entries[0].isIntersecting && hasNextPage) {
+ fetchNextPage?.();
+ }
+ });
+ if (node) observer.current.observe(node);
+ },
+ [isFetchingNextPage, fetchNextPage, hasNextPage]
+ );
+
+ return (
+
+
+
+
+
+ Name
+ Labels
+ Created at
+ Actions
+
+
+
+ {isLoading && (
+
+ Loading
+
+ )}
+ {status === attributesResponseStatusKeys.error && (
+
+ Error while loading data
+
+ )}
+ {data?.pages
+ .flatMap((page) => page.data)
+ .map((row) => (
+
+
+
+ {row.name}
+
+
+
+
+
+ {`${dayjs(row.createdAt).format('YYYY-MM-DD')}`}
+
+
+
+
+
+
+
+ ))}
+ {searchText.length > ATTRIBUTES_SEARCH_MIN_LENGTH && !isLoading && data?.pages[0].data.length === 0 && (
+
+ For "{searchText}" nothing was found
+
+ )}
+ {isFetchingNextPage && (
+
+ Loading more ...
+
+ )}
+
+ ...
+
+
+
+
+
+ );
+};
+
+export default AttributesListTable;
diff --git a/frontend/src/modules/Attributes/AttributesList/index.ts b/frontend/src/modules/Attributes/AttributesList/index.ts
new file mode 100644
index 0000000..cd719c3
--- /dev/null
+++ b/frontend/src/modules/Attributes/AttributesList/index.ts
@@ -0,0 +1 @@
+export { default as AttributesList } from './AttributesList';
diff --git a/frontend/src/modules/Attributes/components/FeedbackSnack.tsx b/frontend/src/modules/Attributes/components/FeedbackSnack.tsx
new file mode 100644
index 0000000..cc158a3
--- /dev/null
+++ b/frontend/src/modules/Attributes/components/FeedbackSnack.tsx
@@ -0,0 +1,28 @@
+import { Snackbar, Alert, AlertProps } from '@mui/material';
+import { WithChildren } from '../../../types';
+
+export interface FeedbackSnackProps extends Partial {
+ open: boolean;
+ onClose: () => void;
+ autoHideDuration?: number;
+ alertProps?: Partial>;
+ title?: string;
+}
+
+const FeedbackSnack = ({ children, title, open, onClose, autoHideDuration = 6000, alertProps }: FeedbackSnackProps) => {
+ return (
+
+
+
+ );
+};
+
+export default FeedbackSnack;
diff --git a/frontend/src/modules/Attributes/components/LabelsList.tsx b/frontend/src/modules/Attributes/components/LabelsList.tsx
new file mode 100644
index 0000000..7cc73c8
--- /dev/null
+++ b/frontend/src/modules/Attributes/components/LabelsList.tsx
@@ -0,0 +1,26 @@
+import { useMemo } from 'react';
+import { Stack, Chip, StackProps, ChipProps } from '@mui/material';
+import { Label } from '../../../types';
+import { useAttributesContext } from '../contexts';
+
+export interface LabelsListProps {
+ labelIds: Label['id'][];
+ stackProps?: Partial;
+ chipProps?: Partial;
+}
+
+const LabelsList = ({ labelIds = [], stackProps, chipProps }: LabelsListProps) => {
+ const { labels: labelsList } = useAttributesContext();
+
+ const labels = useMemo(() => [...labelsList.filter((label) => labelIds.includes(label.id))], [labelIds, labelsList]);
+
+ return (
+
+ {labels.map((label) => (
+
+ ))}
+
+ );
+};
+
+export default LabelsList;
diff --git a/frontend/src/modules/Attributes/components/index.ts b/frontend/src/modules/Attributes/components/index.ts
new file mode 100644
index 0000000..ee23ce1
--- /dev/null
+++ b/frontend/src/modules/Attributes/components/index.ts
@@ -0,0 +1,4 @@
+export { default as LabelsList } from './LabelsList';
+export { default as FeedbackSnack } from './FeedbackSnack';
+export type { LabelsListProps } from './LabelsList';
+export type { FeedbackSnackProps } from './FeedbackSnack';
diff --git a/frontend/src/modules/Attributes/contexts/AttributesContext.ts b/frontend/src/modules/Attributes/contexts/AttributesContext.ts
new file mode 100644
index 0000000..c7b7df5
--- /dev/null
+++ b/frontend/src/modules/Attributes/contexts/AttributesContext.ts
@@ -0,0 +1,45 @@
+import { createContext, useContext } from 'react';
+import { AttributeListSortBy, AttributeListSortDir, AttributesFilter, LabelList } from '../../../types';
+import { attributesListSortByKeys, attributesListSortDirKeys } from '../../../enums';
+
+interface AttributesContext extends AttributesFilter {
+ setOffset: (offset: number) => void;
+ setLimit: (limit: number) => void;
+ setSearchText: (searchText: string) => void;
+ setSortBy: (sortBy: AttributeListSortBy) => void;
+ setSortDir: (sortDir: AttributeListSortDir) => void;
+ labels: LabelList;
+ setLabels: (labels: LabelList) => void;
+ confirmOpen: boolean;
+ setConfirmOpen: (open: boolean) => void;
+ confirmId: string | null;
+ setConfirmId: (id: string | null) => void;
+}
+
+const defaultContext: AttributesContext = {
+ offset: 0,
+ limit: 10,
+ searchText: '',
+ sortBy: attributesListSortByKeys.name,
+ sortDir: attributesListSortDirKeys.asc,
+ setOffset: () => {},
+ setLimit: () => {},
+ setSearchText: () => {},
+ setSortBy: () => {},
+ setSortDir: () => {},
+ labels: [],
+ setLabels: () => {},
+ confirmOpen: false,
+ setConfirmOpen: () => {},
+ confirmId: null,
+ setConfirmId: () => {},
+};
+
+const AttributesContext = createContext(defaultContext);
+
+const AttributesContextProvider = AttributesContext.Provider;
+const AttributesContextConsumer = AttributesContext.Consumer;
+
+const useAttributesContext = () => useContext(AttributesContext);
+
+export { AttributesContext, AttributesContextProvider, AttributesContextConsumer, useAttributesContext };
diff --git a/frontend/src/modules/Attributes/contexts/index.ts b/frontend/src/modules/Attributes/contexts/index.ts
new file mode 100644
index 0000000..fe5f32c
--- /dev/null
+++ b/frontend/src/modules/Attributes/contexts/index.ts
@@ -0,0 +1 @@
+export * from './AttributesContext';
diff --git a/frontend/src/modules/Attributes/hooks/index.ts b/frontend/src/modules/Attributes/hooks/index.ts
new file mode 100644
index 0000000..e05c353
--- /dev/null
+++ b/frontend/src/modules/Attributes/hooks/index.ts
@@ -0,0 +1 @@
+export * from './useAttributesContextControl';
diff --git a/frontend/src/modules/Attributes/hooks/useAttributesContextControl.ts b/frontend/src/modules/Attributes/hooks/useAttributesContextControl.ts
new file mode 100644
index 0000000..f370311
--- /dev/null
+++ b/frontend/src/modules/Attributes/hooks/useAttributesContextControl.ts
@@ -0,0 +1,34 @@
+import { useState } from 'react';
+import { AttributeListSortBy, AttributeListSortDir, LabelList } from '../../../types';
+import { attributesListSortByKeys, attributesListSortDirKeys } from '../../../enums';
+import { ATTRIBUTES_FILTER_LIMIT_DEFAULT } from '../../../constants';
+
+export const useAttributesContextControl = () => {
+ const [offset, setOffset] = useState(0);
+ const [limit, setLimit] = useState(ATTRIBUTES_FILTER_LIMIT_DEFAULT);
+ const [searchText, setSearchText] = useState('');
+ const [sortBy, setSortBy] = useState(attributesListSortByKeys.name);
+ const [sortDir, setSortDir] = useState(attributesListSortDirKeys.asc);
+ const [labels, setLabels] = useState([]);
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const [confirmId, setConfirmId] = useState(null);
+
+ return {
+ offset,
+ setOffset,
+ limit,
+ setLimit,
+ searchText,
+ setSearchText,
+ sortBy,
+ setSortBy,
+ sortDir,
+ setSortDir,
+ labels,
+ setLabels,
+ confirmOpen,
+ setConfirmOpen,
+ confirmId,
+ setConfirmId,
+ };
+};
diff --git a/frontend/src/modules/Error/Error.tsx b/frontend/src/modules/Error/Error.tsx
new file mode 100644
index 0000000..4de9138
--- /dev/null
+++ b/frontend/src/modules/Error/Error.tsx
@@ -0,0 +1,11 @@
+import { ViewLayout } from '../../components';
+
+const Error = () => {
+ return (
+
+ ...Error view...
+
+ );
+};
+
+export default Error;
diff --git a/frontend/src/modules/Home/Home.tsx b/frontend/src/modules/Home/Home.tsx
new file mode 100644
index 0000000..8df95d3
--- /dev/null
+++ b/frontend/src/modules/Home/Home.tsx
@@ -0,0 +1,7 @@
+import { ViewLayout } from '../../components';
+
+const Home = () => {
+ return ...Home module view...;
+};
+
+export default Home;
diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css
new file mode 100644
index 0000000..71c6030
--- /dev/null
+++ b/frontend/src/styles/index.css
@@ -0,0 +1,34 @@
+:root {
+ font-family: 'Open Sans', system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 500;
+
+ color-scheme: light dark;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+html,
+body {
+ width: 100%;
+ height: 100%;
+}
+
+html {
+ font-size: 16px;
+}
+
+body {
+ margin: 0;
+ font-size: 1rem;
+}
+
+#root {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
diff --git a/frontend/src/styles/index.ts b/frontend/src/styles/index.ts
new file mode 100644
index 0000000..7b1f54e
--- /dev/null
+++ b/frontend/src/styles/index.ts
@@ -0,0 +1 @@
+export * from './theme';
diff --git a/frontend/src/styles/theme.ts b/frontend/src/styles/theme.ts
new file mode 100644
index 0000000..69e5bb1
--- /dev/null
+++ b/frontend/src/styles/theme.ts
@@ -0,0 +1,30 @@
+import { createTheme } from '@mui/material/styles';
+
+export const theme = createTheme({
+ palette: {
+ mode: 'dark',
+ },
+ typography: {
+ fontFamily: '"Open Sans", system-ui, Avenir, Helvetica, Arial, sans-serif',
+ fontWeightRegular: 500,
+ fontWeightMedium: 700,
+ fontWeightBold: 800,
+ button: {
+ fontWeight: 600,
+ },
+ },
+ components: {
+ MuiButton: {
+ styleOverrides: {
+ root: {
+ textTransform: 'none',
+ },
+ },
+ },
+ MuiOutlinedInput: {
+ defaultProps: {
+ size: 'small',
+ },
+ },
+ },
+});
diff --git a/frontend/src/types/attributes.ts b/frontend/src/types/attributes.ts
new file mode 100644
index 0000000..bc5f5a1
--- /dev/null
+++ b/frontend/src/types/attributes.ts
@@ -0,0 +1,38 @@
+import { attributesListSortByKeys, attributesListSortDirKeys, attributesResponseStatusKeys } from '../enums';
+import { Label } from './labels';
+
+export type Attribute = {
+ id: string;
+ name: string;
+ createdAt: string;
+ labelIds: Array