wip: refactor design
|
|
@ -0,0 +1 @@
|
||||||
|
VITE_API_URL="https://dev-nam.winteraccess.id"
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
# 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?
|
||||||
|
|
||||||
|
.env
|
||||||
|
package-lock.json
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
# 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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default tseslint.config({
|
||||||
|
extends: [
|
||||||
|
// Remove ...tseslint.configs.recommended and replace with this
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
...tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
// other options...
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default tseslint.config({
|
||||||
|
plugins: {
|
||||||
|
// Add the react-x and react-dom plugins
|
||||||
|
'react-x': reactX,
|
||||||
|
'react-dom': reactDom,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// other rules...
|
||||||
|
// Enable its recommended typescript rules
|
||||||
|
...reactX.configs['recommended-typescript'].rules,
|
||||||
|
...reactDom.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>NAM</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"name": "nam",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.0.14",
|
||||||
|
"@tanstack/react-query": "^5.69.0",
|
||||||
|
"@tanstack/react-table": "^8.21.2",
|
||||||
|
"axios": "^1.8.4",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"daisyui": "^5.0.6",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"lucide-react": "^0.483.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router": "^7.4.0",
|
||||||
|
"react-svg": "^16.3.0",
|
||||||
|
"tailwind-merge": "^3.0.2",
|
||||||
|
"tailwindcss": "^4.0.14",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.21.0",
|
||||||
|
"@types/react": "^19.0.10",
|
||||||
|
"@types/react-dom": "^19.0.4",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"eslint": "^9.21.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
|
"globals": "^15.15.0",
|
||||||
|
"typescript": "~5.7.2",
|
||||||
|
"typescript-eslint": "^8.24.1",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -0,0 +1,94 @@
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Public+Sans:ital,wght@0,100..900;1,100..900&display=swap');
|
||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "daisyui" {
|
||||||
|
themes: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: "Public Sans", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugin "daisyui/theme" {
|
||||||
|
name: "light";
|
||||||
|
default: true;
|
||||||
|
prefersdark: dark;
|
||||||
|
color-scheme: "light";
|
||||||
|
--color-base-100: oklch(100% 0 0);
|
||||||
|
--color-base-200: oklch(97% 0 0);
|
||||||
|
--color-base-300: oklch(92% 0 0);
|
||||||
|
--color-base-content: oklch(20% 0 0);
|
||||||
|
--color-primary: #b71c1c;
|
||||||
|
--color-primary-content: oklch(93% 0.034 272.788);
|
||||||
|
--color-secondary: #9aa2ba;
|
||||||
|
--color-secondary-content: oklch(94% 0.028 342.258);
|
||||||
|
--color-accent: oklch(77% 0.152 181.912);
|
||||||
|
--color-accent-content: oklch(38% 0.063 188.416);
|
||||||
|
--color-neutral: oklch(14% 0.005 285.823);
|
||||||
|
--color-neutral-content: oklch(92% 0.004 286.32);
|
||||||
|
--color-info: #16c0e8;
|
||||||
|
--color-info-content: oklch(29% 0.066 243.157);
|
||||||
|
--color-success: #4caf50;
|
||||||
|
--color-success-content: oklch(37% 0.077 168.94);
|
||||||
|
--color-warning: #fb8c00;
|
||||||
|
--color-warning-content: oklch(41% 0.112 45.904);
|
||||||
|
--color-error: #F44336;
|
||||||
|
--color-error-content: oklch(27% 0.105 12.094);
|
||||||
|
--radius-selector: .75rem;
|
||||||
|
--radius-field: .75rem;
|
||||||
|
--radius-box: .75rem;
|
||||||
|
--size-selector: 0.25rem;
|
||||||
|
--size-field: 0.25rem;
|
||||||
|
--border: 1px;
|
||||||
|
--depth: 1;
|
||||||
|
--noise: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugin "daisyui/theme" {
|
||||||
|
name: "dark";
|
||||||
|
default: false;
|
||||||
|
prefersdark: true;
|
||||||
|
color-scheme: "dark";
|
||||||
|
|
||||||
|
--color-base-100: oklch(25.33% 0.016 252.42);
|
||||||
|
--color-base-200: oklch(23.26% 0.014 253.1);
|
||||||
|
--color-base-300: oklch(21.15% 0.012 254.09);
|
||||||
|
--color-base-content: oklch(97.807% 0.029 256.847);
|
||||||
|
--color-primary: oklch(59.66% 0.1887 27.48);
|
||||||
|
--color-primary-content: oklch(93% 0.034 272.788);
|
||||||
|
--color-secondary: #9aa2ba;
|
||||||
|
--color-secondary-content: oklch(94% 0.028 342.258);
|
||||||
|
--color-accent: oklch(77% 0.152 181.912);
|
||||||
|
--color-accent-content: oklch(38% 0.063 188.416);
|
||||||
|
--color-neutral: oklch(14% 0.005 285.823);
|
||||||
|
--color-neutral-content: oklch(92% 0.004 286.32);
|
||||||
|
--color-info: #16c0e8;
|
||||||
|
--color-info-content: oklch(29% 0.066 243.157);
|
||||||
|
--color-success: #4caf50;
|
||||||
|
--color-success-content: oklch(37% 0.077 168.94);
|
||||||
|
--color-warning: #fb8c00;
|
||||||
|
--color-warning-content: oklch(41% 0.112 45.904);
|
||||||
|
--color-error: #F44336;
|
||||||
|
--color-error-content: oklch(27% 0.105 12.094);
|
||||||
|
--radius-selector: .75rem;
|
||||||
|
--radius-field: .75rem;
|
||||||
|
--radius-box: .75rem;
|
||||||
|
--size-selector: 0.25rem;
|
||||||
|
--size-field: 0.25rem;
|
||||||
|
--border: 1px;
|
||||||
|
--depth: 1;
|
||||||
|
--noise: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-active {
|
||||||
|
@apply bg-primary text-primary
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.menu>li {
|
||||||
|
@apply py-[0.1rem]
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.menu>li>a {
|
||||||
|
@apply px-4 py-3 text-sm
|
||||||
|
}
|
||||||
|
|
||||||
|
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import "./App.css";
|
||||||
|
import { RouterProvider } from "react-router";
|
||||||
|
import router from "./routes";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
||||||
|
<path d="m5 20l14-8L5 4z"/>
|
||||||
|
<path d="M19 20L5 12l14-8z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 255 B |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M9 21q-1.925 0-3.463-1.1T3.35 17h17.3q-.65 1.8-2.187 2.9T15 21zm-6-6V9q0-2.5 1.75-4.25T9 3h6q2.5 0 4.25 1.75T21 9v6z"/></svg>
|
||||||
|
After Width: | Height: | Size: 237 B |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 512 512"><path fill="currentColor" d="M190.406 51.97c-4.104 29.047-7.13 44.833-6.72 58.53c-8.64 1.623-17.247 4.38-25.75 8.375c-25.746-38.28-86.43-44.672-139.06-13.5c27.12 33.838 44.53 50.06 88.468 49.094c-23.64 40.44-16.737 66.468-3.094 106.405c50.213-29.74 75.816-83.433 61.906-125.22c6.763-3.156 13.588-5.373 20.47-6.75c3.344 11.76 9.795 25.62 20.75 48.032c5.394-22.616 8.44-37.168 9.03-48.906c10.954 1.452 21.88 4.78 32.688 9.657c-5.85 16.68-9.962 35.86-13.25 60.688c17.36-16.772 29.677-31.288 38.844-46.188c5.488 3.756 10.908 7.874 16.25 12.313c4.39 3.648 8.69 7.515 12.906 11.563c-17.963 19.995-33.863 41.93-46.688 65.218c24.165-10.115 46.634-25.15 67.156-43.28a318 318 0 0 1 23.344 31.5c-26.33 17.278-50.652 37.486-70.375 59.844c28.77-5.146 58.07-17.09 85.908-33.156c5.354 10.33 9.97 20.708 13.75 30.906c-30.085 15.35-57.335 37.104-81.75 65.28c47.72 50.605 103.617 99.44 170 110.25c2.28-5.74 4.45-11.502 6.5-17.312c-60.357-19.627-109.66-52.267-145.563-89.156l13.406-13.03c33.782 34.707 80.56 65.736 137.97 84.405c16.732-55.238 20.843-112.648-1.844-167.217c-29.13 2.03-56.363 8.342-81.72 18.906c-4.096-10.92-9.036-21.94-14.717-32.845c30.638-19.792 58.958-44.156 82.092-69.313c-31.87 8.903-65.846 23.524-97.718 42.47c-7.725-11.933-16.287-23.498-25.53-34.376c26.697-26.486 49.666-57.987 68.186-90.03c-31.647 16.955-62.495 40.003-89.563 67.343c-4.5-4.3-9.1-8.428-13.812-12.345a225 225 0 0 0-19.375-14.438c10.292-22.438 15.372-47.604 20.875-83.437c-22.557 26.39-37.655 45.848-48.28 68.344c-13.42-5.92-27.255-9.89-41.22-11.313c-3.22-14.055-11.237-28.646-24.47-57.31zM426.25 302.187c11.947-.273 23.58 6.173 29.375 17.53c8.063 15.802 1.77 35.125-14.03 43.188c-15.803 8.063-35.126 1.802-43.19-14c-8.062-15.8-1.8-35.156 14-43.22a32 32 0 0 1 13.845-3.498"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
|
|
@ -0,0 +1,16 @@
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<mask id="mask0_28_37448" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
|
||||||
|
<g clip-path="url(#clip0_28_37448)">
|
||||||
|
<path opacity="0.32" d="M21.1808 16.9703C20.8971 17.6255 20.2225 18 19.5086 18H14.8154C14.8462 17.9145 14.8735 17.8269 14.8971 17.7373C15.1709 16.6974 14.8825 15.639 14.2214 14.8963C14.4654 12.9091 14.6177 10.8733 14.7108 9.26516C14.7569 8.46731 13.7795 8.20081 13.4274 8.91526C12.7178 10.3553 11.8493 12.1958 11.0842 14.041C10.1467 14.3479 9.3768 15.1177 9.10295 16.1576C8.93642 16.7899 8.97782 17.4291 9.18451 18H4.49141C3.77747 18 3.10288 17.6255 2.81918 16.9703C2.29212 15.7533 2 14.4108 2 13C2 7.47715 6.47715 3 12 3C17.5229 3 22 7.47715 22 13C22 14.4108 21.7079 15.7533 21.1808 16.9703Z" fill="#1877F2"/>
|
||||||
|
<path d="M14.7108 9.26516C14.7569 8.46731 13.7795 8.20081 13.4274 8.91526C12.7178 10.3553 11.8493 12.1958 11.0842 14.041C10.1467 14.3479 9.3768 15.1177 9.10295 16.1576C8.6742 17.7856 9.62375 19.459 11.2238 19.8953C12.8238 20.3315 14.4684 19.3654 14.8971 17.7373C15.1709 16.6974 14.8825 15.639 14.2214 14.8963C14.4654 12.9091 14.6177 10.8733 14.7108 9.26516Z" fill="#1877F2"/>
|
||||||
|
</g>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_28_37448)">
|
||||||
|
<rect width="24" height="24" fill="currentColor"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_28_37448">
|
||||||
|
<rect width="24" height="24" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 2v20M2 5h20M3 3v2m4-2v2m10-2v2m4-2v2m-2 0l-7 7l-7-7"/></svg>
|
||||||
|
After Width: | Height: | Size: 254 B |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 640 512"><path fill="currentColor" d="M96 224c35.3 0 64-28.7 64-64s-28.7-64-64-64s-64 28.7-64 64s28.7 64 64 64m448 0c35.3 0 64-28.7 64-64s-28.7-64-64-64s-64 28.7-64 64s28.7 64 64 64m32 32h-64c-17.6 0-33.5 7.1-45.1 18.6c40.3 22.1 68.9 62 75.1 109.4h66c17.7 0 32-14.3 32-32v-32c0-35.3-28.7-64-64-64m-256 0c61.9 0 112-50.1 112-112S381.9 32 320 32S208 82.1 208 144s50.1 112 112 112m76.8 32h-8.3c-20.8 10-43.9 16-68.5 16s-47.6-6-68.5-16h-8.3C179.6 288 128 339.6 128 403.2V432c0 26.5 21.5 48 48 48h288c26.5 0 48-21.5 48-48v-28.8c0-63.6-51.6-115.2-115.2-115.2m-223.7-13.4C161.5 263.1 145.6 256 128 256H64c-35.3 0-64 28.7-64 64v32c0 17.7 14.3 32 32 32h65.9c6.3-47.4 34.9-87.3 75.2-109.4"/></svg>
|
||||||
|
After Width: | Height: | Size: 763 B |
|
|
@ -0,0 +1,183 @@
|
||||||
|
import React, { JSX, forwardRef, ReactNode, ElementType } from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
import Loading from '../feedback/Loading'
|
||||||
|
import {
|
||||||
|
IComponentBaseProps,
|
||||||
|
ComponentColor,
|
||||||
|
ComponentShape,
|
||||||
|
ComponentSize,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
type ITagProps = {
|
||||||
|
a: {
|
||||||
|
attr: React.AnchorHTMLAttributes<HTMLAnchorElement>
|
||||||
|
ele: HTMLAnchorElement
|
||||||
|
}
|
||||||
|
button: {
|
||||||
|
attr: React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||||
|
ele: HTMLButtonElement
|
||||||
|
}
|
||||||
|
div: {
|
||||||
|
attr: React.HTMLAttributes<HTMLDivElement>
|
||||||
|
ele: HTMLDivElement
|
||||||
|
}
|
||||||
|
img: {
|
||||||
|
attr: React.ImgHTMLAttributes<HTMLImageElement>
|
||||||
|
ele: HTMLImageElement
|
||||||
|
}
|
||||||
|
input: {
|
||||||
|
attr: React.InputHTMLAttributes<HTMLInputElement>
|
||||||
|
ele: HTMLInputElement
|
||||||
|
}
|
||||||
|
label: {
|
||||||
|
attr: React.LabelHTMLAttributes<HTMLLabelElement>
|
||||||
|
ele: HTMLLabelElement
|
||||||
|
}
|
||||||
|
span: {
|
||||||
|
attr: React.HTMLAttributes<HTMLSpanElement>
|
||||||
|
ele: HTMLSpanElement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetTagProps<T extends ElementType> = T extends keyof ITagProps
|
||||||
|
? ITagProps[T]
|
||||||
|
: ITagProps['button']
|
||||||
|
|
||||||
|
export type ButtonProps<
|
||||||
|
T extends ElementType = 'button',
|
||||||
|
A extends React.HTMLAttributes<HTMLElement> = GetTagProps<T>['attr']
|
||||||
|
> = Omit<A, 'color' | 'size'> &
|
||||||
|
IComponentBaseProps & {
|
||||||
|
shape?: ComponentShape
|
||||||
|
size?: ComponentSize
|
||||||
|
variant?: 'outline' | 'link'
|
||||||
|
color?: ComponentColor
|
||||||
|
glass?: boolean
|
||||||
|
wide?: boolean
|
||||||
|
fullWidth?: boolean
|
||||||
|
responsive?: boolean
|
||||||
|
animation?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
active?: boolean
|
||||||
|
startIcon?: ReactNode
|
||||||
|
endIcon?: ReactNode
|
||||||
|
disabled?: boolean
|
||||||
|
tag?: T
|
||||||
|
}
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Glossary/Void_element
|
||||||
|
const VoidElementList: ElementType[] = [
|
||||||
|
'area',
|
||||||
|
'base',
|
||||||
|
'br',
|
||||||
|
'col',
|
||||||
|
'embed',
|
||||||
|
'hr',
|
||||||
|
'img',
|
||||||
|
'input',
|
||||||
|
'link',
|
||||||
|
'keygen',
|
||||||
|
'meta',
|
||||||
|
'param',
|
||||||
|
'source',
|
||||||
|
'track',
|
||||||
|
'wbr',
|
||||||
|
]
|
||||||
|
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
children,
|
||||||
|
shape,
|
||||||
|
size,
|
||||||
|
variant,
|
||||||
|
color,
|
||||||
|
glass,
|
||||||
|
startIcon,
|
||||||
|
endIcon,
|
||||||
|
wide,
|
||||||
|
fullWidth,
|
||||||
|
responsive,
|
||||||
|
animation = true,
|
||||||
|
loading,
|
||||||
|
active,
|
||||||
|
disabled,
|
||||||
|
dataTheme,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
tag = 'button',
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
): JSX.Element => {
|
||||||
|
const Tag = tag
|
||||||
|
const classes = twMerge(
|
||||||
|
'btn',
|
||||||
|
className,
|
||||||
|
clsx(((startIcon && !loading) || endIcon) && 'gap-2', {
|
||||||
|
'btn-lg': size === 'lg',
|
||||||
|
'btn-md': size === 'md',
|
||||||
|
'btn-sm': size === 'sm',
|
||||||
|
'btn-xs': size === 'xs',
|
||||||
|
'btn-circle': shape === 'circle',
|
||||||
|
'btn-square': shape === 'square',
|
||||||
|
'btn-outline': variant === 'outline',
|
||||||
|
'btn-link': variant === 'link',
|
||||||
|
'btn-neutral': color === 'neutral',
|
||||||
|
'btn-primary': color === 'primary',
|
||||||
|
'btn-secondary': color === 'secondary',
|
||||||
|
'btn-accent': color === 'accent',
|
||||||
|
'btn-info': color === 'info',
|
||||||
|
'btn-success': color === 'success',
|
||||||
|
'btn-warning': color === 'warning',
|
||||||
|
'btn-error': color === 'error',
|
||||||
|
'btn-ghost': color === 'ghost',
|
||||||
|
glass: glass,
|
||||||
|
'btn-wide': wide,
|
||||||
|
'btn-block': fullWidth,
|
||||||
|
'btn-xs sm:btn-sm md:btn-md lg:btn-lg': responsive,
|
||||||
|
'no-animation': !animation,
|
||||||
|
'btn-active': active,
|
||||||
|
'btn-disabled': disabled,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
if (VoidElementList.includes(Tag)) {
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
data-theme={dataTheme}
|
||||||
|
className={classes}
|
||||||
|
style={style}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
data-theme={dataTheme}
|
||||||
|
className={classes}
|
||||||
|
style={style}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{loading && <Loading size={size} />}
|
||||||
|
{startIcon && !loading && startIcon}
|
||||||
|
{children}
|
||||||
|
{endIcon && endIcon}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Button.displayName = 'Button'
|
||||||
|
|
||||||
|
export default Button as <
|
||||||
|
T extends ElementType = 'button',
|
||||||
|
E extends HTMLElement = GetTagProps<T>['ele'],
|
||||||
|
A extends React.HTMLAttributes<HTMLElement> = GetTagProps<T>['attr']
|
||||||
|
>(
|
||||||
|
props: ButtonProps<T, A> & { ref?: React.Ref<E> }
|
||||||
|
) => JSX.Element
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
import React, { JSX, ReactNode } from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
import { IComponentBaseProps } from '@/components/types'
|
||||||
|
|
||||||
|
import DropdownDetails from './DropdownDetails'
|
||||||
|
import DropdownMenu from './DropdownMenu'
|
||||||
|
import DropdownItem from './DropdownItem'
|
||||||
|
import DropdownToggle from './DropdownToggle'
|
||||||
|
|
||||||
|
export type DropdownProps<T extends HTMLElement = HTMLDivElement> =
|
||||||
|
React.HTMLAttributes<T> &
|
||||||
|
IComponentBaseProps & {
|
||||||
|
item?: ReactNode
|
||||||
|
horizontal?: 'left' | 'right'
|
||||||
|
vertical?: 'top' | 'bottom'
|
||||||
|
end?: boolean
|
||||||
|
hover?: boolean
|
||||||
|
open?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const classesFn = ({
|
||||||
|
className,
|
||||||
|
horizontal,
|
||||||
|
vertical,
|
||||||
|
end,
|
||||||
|
hover,
|
||||||
|
open,
|
||||||
|
}: Pick<
|
||||||
|
DropdownProps,
|
||||||
|
'className' | 'horizontal' | 'vertical' | 'end' | 'hover' | 'open'
|
||||||
|
>) =>
|
||||||
|
twMerge(
|
||||||
|
'dropdown',
|
||||||
|
className,
|
||||||
|
clsx({
|
||||||
|
'dropdown-left': horizontal === 'left',
|
||||||
|
'dropdown-right': horizontal === 'right',
|
||||||
|
'dropdown-top': vertical === 'top',
|
||||||
|
'dropdown-bottom': vertical === 'bottom',
|
||||||
|
'dropdown-end': end,
|
||||||
|
'dropdown-hover': hover,
|
||||||
|
'dropdown-open': open,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const Dropdown = React.forwardRef<HTMLDivElement, DropdownProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
item,
|
||||||
|
horizontal,
|
||||||
|
vertical,
|
||||||
|
end,
|
||||||
|
hover,
|
||||||
|
open,
|
||||||
|
dataTheme,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
data-theme={dataTheme}
|
||||||
|
className={classesFn({
|
||||||
|
className,
|
||||||
|
horizontal,
|
||||||
|
vertical,
|
||||||
|
end,
|
||||||
|
hover,
|
||||||
|
open,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{item ? (
|
||||||
|
<>
|
||||||
|
<label tabIndex={0}>{children}</label>
|
||||||
|
<ul className="dropdown-content">{item}</ul>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>{children}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default Object.assign(Dropdown, {
|
||||||
|
Details: DropdownDetails,
|
||||||
|
Toggle: DropdownToggle,
|
||||||
|
Menu: DropdownMenu,
|
||||||
|
Item: DropdownItem,
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import React, { JSX } from 'react'
|
||||||
|
|
||||||
|
import { classesFn, DropdownProps } from './Dropdown'
|
||||||
|
import { Summary } from './DropdownToggle'
|
||||||
|
|
||||||
|
export type DetailsProps = Omit<
|
||||||
|
DropdownProps<HTMLDetailsElement>,
|
||||||
|
'item' | 'hover'
|
||||||
|
>
|
||||||
|
const DropdownDetails = React.forwardRef<HTMLDetailsElement, DetailsProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
horizontal,
|
||||||
|
vertical,
|
||||||
|
end,
|
||||||
|
dataTheme,
|
||||||
|
open,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
role="listbox"
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
data-theme={dataTheme}
|
||||||
|
className={classesFn({
|
||||||
|
className,
|
||||||
|
horizontal,
|
||||||
|
vertical,
|
||||||
|
open,
|
||||||
|
end,
|
||||||
|
})}
|
||||||
|
open={open}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
DropdownDetails.displayName = 'DropdownDetails'
|
||||||
|
export default Object.assign(DropdownDetails, {
|
||||||
|
Toggle: Summary,
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
type Anchor = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||||
|
anchor?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoAnchor = Pick<Anchor, 'children'> & { anchor?: false }
|
||||||
|
|
||||||
|
export type DropdownItemProps = Anchor | NoAnchor
|
||||||
|
|
||||||
|
const DropdownItem = React.forwardRef<HTMLAnchorElement, DropdownItemProps>(
|
||||||
|
({ anchor = true, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<li role="menuitem">
|
||||||
|
{anchor ? <a ref={ref} {...props}></a> : props.children}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default DropdownItem
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
import { IComponentBaseProps } from '@/components/types'
|
||||||
|
|
||||||
|
export type DropdownMenuProps = React.HTMLAttributes<HTMLUListElement> &
|
||||||
|
IComponentBaseProps
|
||||||
|
|
||||||
|
const DropdownMenu = ({
|
||||||
|
dataTheme,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DropdownMenuProps) => {
|
||||||
|
const classes = twMerge(
|
||||||
|
'dropdown-content menu p-2 shadow bg-base-100 rounded-box',
|
||||||
|
className
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
{...props}
|
||||||
|
tabIndex={0}
|
||||||
|
data-theme={dataTheme}
|
||||||
|
className={classes}
|
||||||
|
role="menu"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DropdownMenu
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import React, { JSX, forwardRef } from 'react'
|
||||||
|
|
||||||
|
import { ComponentColor, ComponentSize, IComponentBaseProps } from '@/components/types'
|
||||||
|
|
||||||
|
import Button, { ButtonProps } from '../Button'
|
||||||
|
|
||||||
|
export type DropdownToggleProps = Omit<
|
||||||
|
React.LabelHTMLAttributes<HTMLLabelElement>,
|
||||||
|
'color'
|
||||||
|
> &
|
||||||
|
IComponentBaseProps & {
|
||||||
|
color?: ComponentColor
|
||||||
|
size?: ComponentSize
|
||||||
|
button?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropdownToggle = ({
|
||||||
|
children,
|
||||||
|
color,
|
||||||
|
size,
|
||||||
|
button = true,
|
||||||
|
dataTheme,
|
||||||
|
className,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
}: DropdownToggleProps) => {
|
||||||
|
return (
|
||||||
|
<label tabIndex={0} className={className} {...props}>
|
||||||
|
{button ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
dataTheme={dataTheme}
|
||||||
|
color={color}
|
||||||
|
size={size}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SummaryProps = Omit<ButtonProps, 'tag'>
|
||||||
|
export const Summary = forwardRef<HTMLElement, SummaryProps>(
|
||||||
|
(props, ref): JSX.Element => {
|
||||||
|
return <Button {...props} ref={ref} tag="summary" />
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default DropdownToggle
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
export const componentPositions = ['top', 'bottom', 'left', 'right'] as const
|
||||||
|
export const componentShapes = ['circle', 'square'] as const
|
||||||
|
export const componentSizes = ['lg', 'md', 'sm', 'xs'] as const
|
||||||
|
export const componentStatuses = [
|
||||||
|
'info',
|
||||||
|
'success',
|
||||||
|
'warning',
|
||||||
|
'error',
|
||||||
|
] as const
|
||||||
|
export const brandColors = [
|
||||||
|
'neutral',
|
||||||
|
'primary',
|
||||||
|
'secondary',
|
||||||
|
'accent',
|
||||||
|
] as const
|
||||||
|
export const componentColors = [
|
||||||
|
...brandColors,
|
||||||
|
'ghost',
|
||||||
|
...componentStatuses,
|
||||||
|
] as const
|
||||||
|
export const bgColors = ['base-100', 'base-200', 'base-300', 'neutral'] as const
|
||||||
|
|
||||||
|
export const defaultTheme = 'light'
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import React, { JSX } from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
import { IComponentBaseProps, ComponentSize } from '@/components/types'
|
||||||
|
|
||||||
|
import CardActions, { CardActionsProps as ActionProps } from './CardActions'
|
||||||
|
import CardBody, { CardBodyProps as BodyProps } from './CardBody'
|
||||||
|
import CardTitle, { CardTitleProps as TitleProps } from './CardTitle'
|
||||||
|
import CardImage, { CardImageProps as ImageProps } from './CardImage'
|
||||||
|
|
||||||
|
export type CardActionsProps = ActionProps
|
||||||
|
export type CardBodyProps = BodyProps
|
||||||
|
export type CardTitleProps = TitleProps
|
||||||
|
export type CardImageProps = ImageProps
|
||||||
|
|
||||||
|
export type CardProps = React.HTMLAttributes<HTMLDivElement> &
|
||||||
|
IComponentBaseProps & {
|
||||||
|
bordered?: boolean
|
||||||
|
imageFull?: boolean
|
||||||
|
|
||||||
|
// responsive props
|
||||||
|
normal?: ComponentSize | boolean // Applies default paddings
|
||||||
|
compact?: ComponentSize | boolean // Applies smaller padding
|
||||||
|
side?: ComponentSize | boolean // The image in <figure> will be on to the side
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModifierMap {
|
||||||
|
[key: string]: {
|
||||||
|
[key: string]: string | undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DYNAMIC_MODIFIERS: ModifierMap = {
|
||||||
|
compact: {
|
||||||
|
true: 'card-compact',
|
||||||
|
xs: 'xs:card-compact',
|
||||||
|
sm: 'sm:card-compact',
|
||||||
|
md: 'md:card-compact',
|
||||||
|
lg: 'lg:card-compact',
|
||||||
|
},
|
||||||
|
normal: {
|
||||||
|
true: 'card-normal',
|
||||||
|
xs: 'xs:card-normal',
|
||||||
|
sm: 'sm:card-normal',
|
||||||
|
md: 'md:card-normal',
|
||||||
|
lg: 'lg:card-normal',
|
||||||
|
},
|
||||||
|
side: {
|
||||||
|
true: 'card-side',
|
||||||
|
xs: 'xs:card-side',
|
||||||
|
sm: 'sm:card-side',
|
||||||
|
md: 'md:card-side',
|
||||||
|
lg: 'lg:card-side',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||||
|
(
|
||||||
|
{ bordered = true, imageFull, normal, compact, side, className, ...props },
|
||||||
|
ref
|
||||||
|
): JSX.Element => {
|
||||||
|
const classes = twMerge(
|
||||||
|
'card',
|
||||||
|
className,
|
||||||
|
clsx({
|
||||||
|
'card-bordered': bordered,
|
||||||
|
'image-full': imageFull,
|
||||||
|
[(compact && DYNAMIC_MODIFIERS.compact[compact.toString()]) || '']:
|
||||||
|
compact,
|
||||||
|
[(normal && DYNAMIC_MODIFIERS.normal[normal.toString()]) || '']: normal,
|
||||||
|
[(side && DYNAMIC_MODIFIERS.side[side.toString()]) || '']: side,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return <div aria-label="Card" {...props} className={classes} ref={ref} />
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default Object.assign(Card, {
|
||||||
|
Actions: CardActions,
|
||||||
|
Body: CardBody,
|
||||||
|
Title: CardTitle,
|
||||||
|
Image: CardImage,
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
import { IComponentBaseProps } from '@/components/types'
|
||||||
|
|
||||||
|
export type CardActionsProps = React.HTMLAttributes<HTMLDivElement> &
|
||||||
|
IComponentBaseProps
|
||||||
|
|
||||||
|
const CardActions = React.forwardRef<HTMLDivElement, CardActionsProps>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div {...props} className={twMerge('card-actions', className)} ref={ref} />
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
export default CardActions
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
import { IComponentBaseProps } from '@/components/types'
|
||||||
|
|
||||||
|
export type CardBodyProps = React.HTMLAttributes<HTMLDivElement> &
|
||||||
|
IComponentBaseProps
|
||||||
|
|
||||||
|
const CardBody = React.forwardRef<HTMLDivElement, CardBodyProps>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div {...props} className={twMerge('card-body', className)} ref={ref} />
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
export default CardBody
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export type CardImageProps = React.ImgHTMLAttributes<HTMLImageElement>
|
||||||
|
|
||||||
|
const CardImage = React.forwardRef<HTMLElement, CardImageProps>(
|
||||||
|
({ ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<figure ref={ref}>
|
||||||
|
<img {...props} />
|
||||||
|
</figure>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default CardImage
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import React, { ElementType } from 'react'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
import { IComponentBaseProps } from '@/components/types'
|
||||||
|
|
||||||
|
export type CardTitleProps = React.HTMLAttributes<HTMLDivElement> &
|
||||||
|
IComponentBaseProps & {
|
||||||
|
tag?: ElementType
|
||||||
|
}
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLElement, CardTitleProps>(
|
||||||
|
({ className, tag = 'div', ...props }, ref) => {
|
||||||
|
const Tag = tag
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag {...props} className={twMerge('card-title', className)} ref={ref} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default CardTitle
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
export const DEFAULT_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',
|
||||||
|
'dim',
|
||||||
|
'nord',
|
||||||
|
'sunset',
|
||||||
|
] as const
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import React, { forwardRef, JSX, ReactNode } from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
import { IComponentBaseProps, ComponentStatus } from '../types'
|
||||||
|
|
||||||
|
export type AlertProps = React.HTMLAttributes<HTMLDivElement> &
|
||||||
|
IComponentBaseProps & {
|
||||||
|
icon?: ReactNode
|
||||||
|
status?: ComponentStatus
|
||||||
|
soft?: boolean
|
||||||
|
outline?: boolean
|
||||||
|
dash?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Alert = forwardRef<HTMLDivElement, AlertProps>(
|
||||||
|
(
|
||||||
|
{ children, icon, status, soft, outline, dash, dataTheme, className, ...props },
|
||||||
|
ref
|
||||||
|
): JSX.Element => {
|
||||||
|
const classes = twMerge(
|
||||||
|
'alert text-base-100',
|
||||||
|
className,
|
||||||
|
clsx({
|
||||||
|
'alert-info': status === 'info',
|
||||||
|
'alert-success': status === 'success',
|
||||||
|
'alert-warning': status === 'warning',
|
||||||
|
'alert-error': status === 'error',
|
||||||
|
'alert-soft': soft,
|
||||||
|
'alert-outline': outline,
|
||||||
|
'alert-dash': dash,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
data-theme={dataTheme}
|
||||||
|
className={classes}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Alert.displayName = 'Alert'
|
||||||
|
|
||||||
|
export default Alert
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import React, { forwardRef, JSX } from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
import {
|
||||||
|
IComponentBaseProps,
|
||||||
|
ComponentColor,
|
||||||
|
ComponentSize,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
export type LoadingProps =
|
||||||
|
React.HTMLAttributes<HTMLSpanElement>
|
||||||
|
& IComponentBaseProps
|
||||||
|
& {
|
||||||
|
size?: ComponentSize
|
||||||
|
color?: ComponentColor
|
||||||
|
variant?: 'spinner' | 'dots' | 'ring' | 'ball' | 'bars' | 'infinity'
|
||||||
|
}
|
||||||
|
|
||||||
|
const Loading = forwardRef<HTMLSpanElement, LoadingProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
size,
|
||||||
|
variant = 'spinner',
|
||||||
|
color,
|
||||||
|
dataTheme,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
): JSX.Element => {
|
||||||
|
const classes = twMerge(
|
||||||
|
'loading',
|
||||||
|
className,
|
||||||
|
clsx({
|
||||||
|
'loading-lg': size === 'lg',
|
||||||
|
'loading-md': size === 'md',
|
||||||
|
'loading-sm': size === 'sm',
|
||||||
|
'loading-xs': size === 'xs',
|
||||||
|
'loading-spinner': variant === 'spinner',
|
||||||
|
'loading-dots': variant === 'dots',
|
||||||
|
'loading-ring': variant === 'ring',
|
||||||
|
'loading-ball': variant === 'ball',
|
||||||
|
'loading-bars': variant === 'bars',
|
||||||
|
'loading-infinity': variant === 'infinity',
|
||||||
|
'text-primary': color === 'primary',
|
||||||
|
'text-secondary': color === 'secondary',
|
||||||
|
'text-accent': color === 'accent',
|
||||||
|
'text-info': color === 'info',
|
||||||
|
'text-success': color === 'success',
|
||||||
|
'text-warning': color === 'warning',
|
||||||
|
'text-error': color === 'error',
|
||||||
|
'text-ghost': color === 'ghost',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
data-theme={dataTheme}
|
||||||
|
className={classes}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Loading.displayName = 'Loading'
|
||||||
|
|
||||||
|
export default Loading
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import React, { forwardRef, JSX } from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
import { IComponentBaseProps, ComponentColor, ComponentSize } from '../types'
|
||||||
|
|
||||||
|
export type InputProps = Omit<
|
||||||
|
React.InputHTMLAttributes<HTMLInputElement>,
|
||||||
|
'size' | 'color'
|
||||||
|
> &
|
||||||
|
IComponentBaseProps & {
|
||||||
|
bordered?: boolean
|
||||||
|
borderOffset?: boolean
|
||||||
|
size?: ComponentSize
|
||||||
|
color?: ComponentColor
|
||||||
|
}
|
||||||
|
|
||||||
|
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
value,
|
||||||
|
placeholder,
|
||||||
|
bordered = true,
|
||||||
|
borderOffset,
|
||||||
|
size,
|
||||||
|
color,
|
||||||
|
dataTheme,
|
||||||
|
className,
|
||||||
|
type,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
): JSX.Element => {
|
||||||
|
const classes = twMerge(
|
||||||
|
'input',
|
||||||
|
className,
|
||||||
|
clsx({
|
||||||
|
'input-lg': size === 'lg',
|
||||||
|
'input-md': size === 'md',
|
||||||
|
'input-sm': size === 'sm',
|
||||||
|
'input-xs': size === 'xs',
|
||||||
|
'input-primary': color === 'primary',
|
||||||
|
'input-secondary': color === 'secondary',
|
||||||
|
'input-accent': color === 'accent',
|
||||||
|
'input-ghost': color === 'ghost',
|
||||||
|
'input-info': color === 'info',
|
||||||
|
'input-success': color === 'success',
|
||||||
|
'input-warning': color === 'warning',
|
||||||
|
'input-error': color === 'error',
|
||||||
|
'input-bordered': bordered,
|
||||||
|
'focus:outline-offset-0': !borderOffset,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
data-theme={dataTheme}
|
||||||
|
className={classes}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Input.displayName = 'Input'
|
||||||
|
|
||||||
|
export default Input
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
import React from "react";
|
||||||
|
import { NavLink } from "react-router";
|
||||||
|
import { User } from "@/features/auth/store";
|
||||||
|
import Menu from "@/components/navigation/Menu/Menu";
|
||||||
|
import Dropdown from "@/components/actions/Dropdown/Dropdown";
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
user: User | null;
|
||||||
|
handleLogout: () => void;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Header: React.FC<HeaderProps> = ({
|
||||||
|
user,
|
||||||
|
handleLogout,
|
||||||
|
toggleSidebar,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<header className="p-4 sticky top-0 z-[2]">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
{/* Mobile Sidebar Toggle */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<button
|
||||||
|
className="btn btn-square btn-ghost lg:hidden mr-2 text-primary-content"
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="w-6 h-6"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header Actions */}
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<label className="swap swap-rotate">
|
||||||
|
{/* this hidden checkbox controls the state */}
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="theme-controller"
|
||||||
|
value="light"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* sun icon */}
|
||||||
|
<svg
|
||||||
|
className="swap-off h-6 w-6 fill-current"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* moon icon */}
|
||||||
|
<svg
|
||||||
|
className="swap-on h-6 w-6 fill-current"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z" />
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* User Dropdown */}
|
||||||
|
<Dropdown end>
|
||||||
|
<Dropdown.Toggle button={false} className="btn btn-ghost btn-circle avatar">
|
||||||
|
<div className="w-10 rounded-full bg-base-100 text-primary">
|
||||||
|
<span className="text-xl flex items-center justify-center h-full">
|
||||||
|
{user?.name?.charAt(0) || "U"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
<Dropdown.Menu
|
||||||
|
tabIndex={0}
|
||||||
|
className="dropdown-content bg-base-100 rounded-box w-52"
|
||||||
|
>
|
||||||
|
<Menu.Title>Hi, {user?.name}</Menu.Title>
|
||||||
|
<Dropdown.Item>
|
||||||
|
<NavLink to="#" className="justify-between">
|
||||||
|
Profile
|
||||||
|
</NavLink>
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item>
|
||||||
|
<NavLink to="#">Settings</NavLink>
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item anchor={false}>
|
||||||
|
<button onClick={handleLogout}>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="w-4 h-4 mr-2 text-primary"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { NavLink } from 'react-router';
|
||||||
|
import { ReactSVG } from 'react-svg';
|
||||||
|
|
||||||
|
// Import your local SVG icons here
|
||||||
|
import DashboardIcon from '../../../assets/speedometer.svg';
|
||||||
|
import BackboneIcon from '../../../assets/backbone.svg';
|
||||||
|
import FishboneIcon from '../../../assets/fishbone.svg';
|
||||||
|
import DevicesIcon from '../../../assets/device.svg';
|
||||||
|
import TowerIcon from '../../../assets/tower.svg';
|
||||||
|
import UserIcon from '../../../assets/users.svg';
|
||||||
|
import Menu from '@/components/navigation/Menu/Menu';
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
sidebarOpen: boolean;
|
||||||
|
user?: {
|
||||||
|
name?: string;
|
||||||
|
role?: string;
|
||||||
|
} | null;
|
||||||
|
handleLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Sidebar: React.FC<SidebarProps> = ({ sidebarOpen, user, handleLogout }) => {
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={`
|
||||||
|
bg-base-100
|
||||||
|
w-64
|
||||||
|
fixed
|
||||||
|
inset-y-0
|
||||||
|
z-50
|
||||||
|
transition-transform
|
||||||
|
duration-300
|
||||||
|
shadow-lg
|
||||||
|
lg:translate-x-0
|
||||||
|
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||||
|
lg:relative
|
||||||
|
lg:z-0
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Logo / Header */}
|
||||||
|
<div className="py-2 px-4 border-b border-base-300 text-center">
|
||||||
|
<h1 className="text-lg font-bold text-primary">Network Asset Management</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Menu */}
|
||||||
|
<nav className="flex-1 p-4 overflow-y-auto">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="px-4 text-xs text-base-500 uppercase mb-2">Data Master</h3>
|
||||||
|
<Menu className="sidebar p-2 w-full [--handle-size:1.5rem]">
|
||||||
|
<Menu.Item>
|
||||||
|
<NavLink
|
||||||
|
to="/dashboard"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`${isActive ? 'bg-primary text-primary-content' : 'hover:bg-base-200'}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ReactSVG src={DashboardIcon} title="Dashboard" className="w-5 h-5 mr-2" />
|
||||||
|
Dashboard
|
||||||
|
</NavLink>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item>
|
||||||
|
<NavLink
|
||||||
|
to="/backbone"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`${isActive ? 'bg-primary text-primary-content' : 'hover:bg-base-200'}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ReactSVG src={BackboneIcon} title="Backbone" className="w-5 h-5 mr-2" />
|
||||||
|
Backbone
|
||||||
|
</NavLink>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item>
|
||||||
|
<NavLink
|
||||||
|
to="/fishbone"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`${isActive ? 'bg-primary text-primary-content' : 'hover:bg-base-200'}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ReactSVG src={FishboneIcon} title="Fishbone" className="w-5 h-5 mr-2" />
|
||||||
|
Fishbone
|
||||||
|
</NavLink>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item>
|
||||||
|
<NavLink
|
||||||
|
to="/devices"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`${isActive ? 'bg-primary text-primary-content' : 'hover:bg-base-200'}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ReactSVG src={DevicesIcon} title="Devices" className="w-5 h-5 mr-2" />
|
||||||
|
Devices
|
||||||
|
</NavLink>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item>
|
||||||
|
<NavLink
|
||||||
|
to="/tower"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`${isActive ? 'bg-primary text-primary-content' : 'hover:bg-base-200'}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ReactSVG src={TowerIcon} title="Tower" className="w-5 h-5 mr-2" />
|
||||||
|
Tower
|
||||||
|
</NavLink>
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<div className="divider my-2"></div>
|
||||||
|
|
||||||
|
<h3 className="px-4 text-xs text-base-500 uppercase mb-2">Management</h3>
|
||||||
|
<ul className="sidebar menu menu-md p-0 w-full">
|
||||||
|
{user?.role && (
|
||||||
|
<Menu.Item>
|
||||||
|
<NavLink
|
||||||
|
to="/user"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`${isActive ? 'bg-primary text-primary-content' : 'hover:bg-base-200'}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ReactSVG src={UserIcon} title="User" className="w-5 h-5 mr-2" />
|
||||||
|
User
|
||||||
|
</NavLink>
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { NavLink, Outlet, useNavigate } from 'react-router';
|
||||||
|
import { useAuthStore } from '../../features/auth/store';
|
||||||
|
import Sidebar from './Dashboard/Sidebar'; // Import the Sidebar component we created earlier
|
||||||
|
import Header from './Dashboard/Header';
|
||||||
|
|
||||||
|
const DashboardLayout: React.FC = () => {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
setSidebarOpen(!sidebarOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-base-200 flex">
|
||||||
|
{/* Overlay for mobile sidebar */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
lg:hidden
|
||||||
|
fixed
|
||||||
|
inset-0
|
||||||
|
z-40
|
||||||
|
bg-gray-900
|
||||||
|
bg-opacity-50
|
||||||
|
transition-opacity
|
||||||
|
duration-300
|
||||||
|
${sidebarOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}
|
||||||
|
`}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<Sidebar
|
||||||
|
sidebarOpen={sidebarOpen}
|
||||||
|
user={user}
|
||||||
|
handleLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 flex flex-col min-h-screen overflow-x-hidden relative">
|
||||||
|
{/* Header */}
|
||||||
|
<div className='bg-primary h-[105px] top-0 absolute left-0 w-full'></div>
|
||||||
|
<Header
|
||||||
|
user={user}
|
||||||
|
handleLogout={handleLogout}
|
||||||
|
toggleSidebar={toggleSidebar}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
|
<main className="flex-1 p-6 pt-0 z-[1]">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="py-4 px-6 border-t text-center text-sm">
|
||||||
|
<p>© {new Date().getFullYear()} Network Asset Management. All rights reserved.</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DashboardLayout;
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import React, { JSX, ReactElement } from 'react'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
import { IComponentBaseProps } from '@/components/types'
|
||||||
|
|
||||||
|
import BreadcrumbsItem, { BreadcrumbsItemProps } from './BreadcrumbsItem'
|
||||||
|
|
||||||
|
export type BreadcrumbsProps = React.HTMLAttributes<HTMLDivElement> &
|
||||||
|
IComponentBaseProps & {
|
||||||
|
children?:
|
||||||
|
| ReactElement<BreadcrumbsItemProps>
|
||||||
|
| ReactElement<BreadcrumbsItemProps>[]
|
||||||
|
innerRef?: React.Ref<HTMLUListElement>
|
||||||
|
innerProps?: React.HTMLAttributes<HTMLUListElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
const Breadcrumbs = React.forwardRef<HTMLDivElement, BreadcrumbsProps>(
|
||||||
|
(
|
||||||
|
{ children, dataTheme, className, innerProps, innerRef, ...props },
|
||||||
|
ref
|
||||||
|
): JSX.Element => {
|
||||||
|
const classes = twMerge('breadcrumbs', 'text-sm', className)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="navigation"
|
||||||
|
aria-label="Breadcrumbs"
|
||||||
|
{...props}
|
||||||
|
data-theme={dataTheme}
|
||||||
|
className={classes}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<ul {...innerProps} ref={innerRef}>
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default Object.assign(Breadcrumbs, { Item: BreadcrumbsItem })
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import React, { JSX } from 'react'
|
||||||
|
|
||||||
|
export type BreadcrumbsItemProps = React.LiHTMLAttributes<HTMLLIElement> & {
|
||||||
|
href?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BreadcrumbsItem = React.forwardRef<HTMLLIElement, BreadcrumbsItemProps>(
|
||||||
|
({ children, href, ...props }, ref): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<li role="link" {...props} ref={ref}>
|
||||||
|
{href ? <a href={href}>{children}</a> : <>{children}</>}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default BreadcrumbsItem
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import React from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
import { IComponentBaseProps, ComponentSize } from '@/components/types'
|
||||||
|
|
||||||
|
import MenuTitle, { MenuTitleProps as TitleProps } from './MenuTitle'
|
||||||
|
import MenuItem, { MenuItemProps as ItemProps } from './MenuItem'
|
||||||
|
import MenuDropdown, {
|
||||||
|
MenuDropdownProps as DropdownProps,
|
||||||
|
} from './MenuDropdown'
|
||||||
|
import MenuDetails, { MenuDetailsProps as DetailsProps } from './MenuDetails'
|
||||||
|
|
||||||
|
export type MenuTitleProps = TitleProps
|
||||||
|
export type MenuItemProps = ItemProps
|
||||||
|
export type MenuDropdownProps = DropdownProps
|
||||||
|
export type MenuDetailsProps = DetailsProps
|
||||||
|
|
||||||
|
export type MenuProps = React.HTMLAttributes<HTMLUListElement> &
|
||||||
|
IComponentBaseProps & {
|
||||||
|
vertical?: boolean // Vertical menu (default)
|
||||||
|
horizontal?: boolean // Horizontal menu
|
||||||
|
responsive?: boolean
|
||||||
|
size?: ComponentSize
|
||||||
|
}
|
||||||
|
|
||||||
|
const Menu = React.forwardRef<HTMLUListElement, MenuProps>(
|
||||||
|
(
|
||||||
|
{ responsive, horizontal, vertical, dataTheme, className, size, ...props },
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const classes = twMerge(
|
||||||
|
'menu',
|
||||||
|
className,
|
||||||
|
clsx({
|
||||||
|
'menu-vertical lg:menu-horizontal': responsive,
|
||||||
|
'menu-lg': size === 'lg',
|
||||||
|
'menu-md': size === 'md',
|
||||||
|
'menu-sm': size === 'sm',
|
||||||
|
'menu-xs': size === 'xs',
|
||||||
|
'menu-vertical': vertical,
|
||||||
|
'menu-horizontal': horizontal,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
role="menu"
|
||||||
|
data-theme={dataTheme}
|
||||||
|
className={classes}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default Object.assign(Menu, {
|
||||||
|
Title: MenuTitle,
|
||||||
|
Item: MenuItem,
|
||||||
|
Dropdown: MenuDropdown,
|
||||||
|
Details: MenuDetails,
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import React, { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { IComponentBaseProps } from '@/components/types'
|
||||||
|
|
||||||
|
export type MenuDetailsProps = React.DetailsHTMLAttributes<HTMLDetailsElement> &
|
||||||
|
IComponentBaseProps & {
|
||||||
|
label: ReactNode
|
||||||
|
open?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const MenuDetails = React.forwardRef<HTMLDetailsElement, MenuDetailsProps>(
|
||||||
|
({ className, label, open, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<details {...props} open={open} className={className} ref={ref}>
|
||||||
|
<summary>{label}</summary>
|
||||||
|
<ul>{children}</ul>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default MenuDetails
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import React, { ReactNode } from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
import { IComponentBaseProps } from '@/components/types'
|
||||||
|
|
||||||
|
export type MenuDropdownProps = React.HTMLAttributes<HTMLSpanElement> &
|
||||||
|
IComponentBaseProps & {
|
||||||
|
label: ReactNode
|
||||||
|
open?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const MenuDropdown = React.forwardRef<HTMLSpanElement, MenuDropdownProps>(
|
||||||
|
({ className, label, open, children, ...props }, ref) => {
|
||||||
|
const classes = twMerge(
|
||||||
|
'menu-dropdown-toggle',
|
||||||
|
className,
|
||||||
|
clsx({ 'menu-dropdown-show': open })
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span {...props} className={classes} ref={ref}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<ul className={clsx('menu-dropdown', { 'menu-dropdown-show': open })}>
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default MenuDropdown
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import React from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
import { IComponentBaseProps } from '@/components/types'
|
||||||
|
|
||||||
|
export type MenuItemProps = React.LiHTMLAttributes<HTMLLIElement> &
|
||||||
|
IComponentBaseProps & {
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const MenuItem = React.forwardRef<HTMLLIElement, MenuItemProps>(
|
||||||
|
({ className, disabled, ...props }, ref) => {
|
||||||
|
const classes = twMerge(
|
||||||
|
className,
|
||||||
|
clsx({
|
||||||
|
disabled: disabled,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return <li role="menuitem" className={classes} {...props} ref={ref} />
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default MenuItem
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
import { IComponentBaseProps } from '@/components/types'
|
||||||
|
|
||||||
|
export type MenuTitleProps = React.LiHTMLAttributes<HTMLLIElement> &
|
||||||
|
IComponentBaseProps
|
||||||
|
|
||||||
|
const MenuTitle = React.forwardRef<HTMLLIElement, MenuTitleProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
const classes = twMerge('menu-title', className)
|
||||||
|
|
||||||
|
return <li {...props} className={classes} ref={ref} />
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default MenuTitle
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import {
|
||||||
|
componentStatuses,
|
||||||
|
componentColors,
|
||||||
|
componentPositions,
|
||||||
|
componentSizes,
|
||||||
|
componentShapes,
|
||||||
|
bgColors,
|
||||||
|
brandColors,
|
||||||
|
} from './constants'
|
||||||
|
import { DEFAULT_THEMES } from './defaultThemes'
|
||||||
|
|
||||||
|
export type DataTheme = typeof DEFAULT_THEMES[number] | string
|
||||||
|
|
||||||
|
export interface IComponentBaseProps {
|
||||||
|
dataTheme?: DataTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ComponentColor = typeof componentColors[number]
|
||||||
|
|
||||||
|
export type ComponentPosition = typeof componentPositions[number]
|
||||||
|
export type ComponentShape = typeof componentShapes[number]
|
||||||
|
export type ComponentSize = typeof componentSizes[number]
|
||||||
|
export type ComponentStatus = typeof componentStatuses[number]
|
||||||
|
export type ComponentBrandColors = typeof brandColors[number]
|
||||||
|
export type ComponentBgColors = typeof bgColors[number]
|
||||||
|
|
||||||
|
export type ListOrItem<T> = T[] | T | Array<T | T[]>
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { apiClient, ApiResponse, extractApiErrors } from '../../../lib/api-client';
|
||||||
|
import { useAuthStore } from '../store';
|
||||||
|
// import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface LoginCredentials {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginResponse {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Callback {
|
||||||
|
onError?: (errors: Record<string, string>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
|
||||||
|
const response = await apiClient.post<ApiResponse<LoginResponse>>('/api/v1/users/login', credentials);
|
||||||
|
|
||||||
|
console.log(response.data);
|
||||||
|
|
||||||
|
if (!response.data.data) {
|
||||||
|
throw new Error('Authentication failed');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLogin = ({ onError }: Callback) => {
|
||||||
|
const { setUser, setToken } = useAuthStore();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: login,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setUser({
|
||||||
|
name: data.name,
|
||||||
|
role: data.role,
|
||||||
|
});
|
||||||
|
setToken(data.token);
|
||||||
|
// toast.success('Login berhasil');
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
const errors = extractApiErrors(error);
|
||||||
|
|
||||||
|
onError && onError(errors);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
setUser: (user: User) => void;
|
||||||
|
setToken: (token: string) => void;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
setUser: (user) => set({ user, isAuthenticated: true }),
|
||||||
|
setToken: (token) => set({ token }),
|
||||||
|
logout: () => set({ user: null, token: null, isAuthenticated: false }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "auth-storage",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
// src/features/devices/api/devices.ts
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { DeviceDataTable } from '../types';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { ApiResponse } from '@/lib/api-client'; // Pastikan Anda memiliki tipe ApiResponse
|
||||||
|
import axios, { AxiosError } from 'axios';
|
||||||
|
import { DataTableDummy } from '../dummy';
|
||||||
|
|
||||||
|
// Tipe untuk error response
|
||||||
|
interface ApiErrorResponse {
|
||||||
|
status?: {
|
||||||
|
code: number;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDevices = async (): Promise<DeviceDataTable[]> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<ApiResponse<DeviceDataTable[]>>('/api/v1/devices');
|
||||||
|
|
||||||
|
// Pastikan hanya response dengan status code 200 yang diterima
|
||||||
|
if (response.data.status.code === 200) {
|
||||||
|
return response.data.data as DeviceDataTable[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throw error untuk status code selain 200
|
||||||
|
throw new Error(response.data.status.description || 'Unknown error');
|
||||||
|
} catch (error) {
|
||||||
|
// Tangani berbagai jenis error
|
||||||
|
if (DataTableDummy.length > 0) {
|
||||||
|
return DataTableDummy as unknown as DeviceDataTable[];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
const axiosError = error as AxiosError<ApiErrorResponse>;
|
||||||
|
|
||||||
|
// Handle specific error scenarios
|
||||||
|
if (axiosError.response) {
|
||||||
|
// Server responded with an error status code
|
||||||
|
switch (axiosError.response.status) {
|
||||||
|
case 400:
|
||||||
|
throw new Error('Bad Request: Invalid data');
|
||||||
|
case 401:
|
||||||
|
throw new Error('Unauthorized: Please log in again');
|
||||||
|
case 403:
|
||||||
|
throw new Error('Forbidden: You do not have permission');
|
||||||
|
case 404:
|
||||||
|
throw new Error('Not Found: Devices endpoint does not exist');
|
||||||
|
case 500:
|
||||||
|
throw new Error('Server Error: Please try again later');
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
axiosError.response.data?.status?.description ||
|
||||||
|
axiosError.response.data?.message ||
|
||||||
|
'An unexpected error occurred'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (axiosError.request) {
|
||||||
|
// Request was made but no response received (e.g., network error)
|
||||||
|
if (axiosError.message === 'Network Error') {
|
||||||
|
throw new Error('Network Error: Please check your internet connection');
|
||||||
|
}
|
||||||
|
throw new Error('No response received from server');
|
||||||
|
} else {
|
||||||
|
// Something happened in setting up the request
|
||||||
|
throw new Error('Error setting up the request');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle non-axios errors
|
||||||
|
throw new Error('An unexpected error occurred');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDevices = () => {
|
||||||
|
return useQuery<DeviceDataTable[], Error>({
|
||||||
|
queryKey: ['devices'],
|
||||||
|
queryFn: getDevices,
|
||||||
|
retry: 1, // Mencoba ulang sekali jika error
|
||||||
|
retryDelay: 1000, // Delay 1 detik antara percobaan
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
export const DataTableDummy = [
|
||||||
|
{
|
||||||
|
"id": "99fa8f36-b483-4881-8547-1e799eb321b3",
|
||||||
|
"device_code": "YYYFEE",
|
||||||
|
"device_type": "ODP",
|
||||||
|
"longitude": -6.327583,
|
||||||
|
"latitude": 108.324936,
|
||||||
|
"port_amount": 2,
|
||||||
|
"status": "inactive",
|
||||||
|
"created_at": "2025-02-12T09:03:24.904402Z",
|
||||||
|
"updated_at": "2025-03-14T06:53:07Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d6f7806a-479d-4bfd-8efc-432839a47fd4",
|
||||||
|
"device_code": "DEVICE-01",
|
||||||
|
"device_type": "ODP",
|
||||||
|
"longitude": -6.737246,
|
||||||
|
"latitude": 108.550659,
|
||||||
|
"port_amount": 16,
|
||||||
|
"status": "active",
|
||||||
|
"created_at": "2025-03-13T07:00:13Z",
|
||||||
|
"updated_at": "2025-03-14T07:22:42Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "21d9be7a-9887-4bde-a324-d57b7a61d83c",
|
||||||
|
"device_code": "XXXFEE",
|
||||||
|
"device_type": "ODP",
|
||||||
|
"longitude": -6.737246,
|
||||||
|
"latitude": 108.550659,
|
||||||
|
"port_amount": 40,
|
||||||
|
"status": "active",
|
||||||
|
"created_at": "2025-02-12T09:01:10.023377Z",
|
||||||
|
"updated_at": "2025-03-19T07:17:45Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
// src/features/devices/types.ts
|
||||||
|
export interface DeviceDataTable {
|
||||||
|
id: number;
|
||||||
|
device_code: string;
|
||||||
|
device_type: string;
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
address: string;
|
||||||
|
port_amount: number;
|
||||||
|
total_used_port: number;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient, ApiResponse, extractApiErrors } from '../../../lib/api-client';
|
||||||
|
// import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
// Tipe untuk model User
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fungsi untuk mendapatkan daftar pengguna
|
||||||
|
export const getUsers = async (): Promise<User[]> => {
|
||||||
|
const response = await apiClient.get<ApiResponse<User[]>>('/users');
|
||||||
|
return response.data.data || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook untuk query daftar pengguna
|
||||||
|
export const useUsers = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['users'],
|
||||||
|
queryFn: getUsers,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fungsi untuk mendapatkan detail pengguna
|
||||||
|
export const getUser = async (id: string): Promise<User> => {
|
||||||
|
const response = await apiClient.get<ApiResponse<User>>(`/users/${id}`);
|
||||||
|
if (!response.data.data) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook untuk query detail pengguna
|
||||||
|
export const useUser = (id: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['users', id],
|
||||||
|
queryFn: () => getUser(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Interface untuk data pembuatan atau pembaruan pengguna
|
||||||
|
export interface UserFormData {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password?: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fungsi untuk membuat pengguna baru
|
||||||
|
export const createUser = async (userData: UserFormData): Promise<User> => {
|
||||||
|
const response = await apiClient.post<ApiResponse<User>>('/users', userData);
|
||||||
|
return response.data.data!;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook untuk mutasi pembuatan pengguna
|
||||||
|
export const useCreateUser = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createUser,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
// toast.success('Pengguna berhasil dibuat');
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
const errors = extractApiErrors(error);
|
||||||
|
Object.values(errors).forEach((message) => {
|
||||||
|
// toast.error(message);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fungsi untuk memperbarui pengguna
|
||||||
|
export const updateUser = async ({ id, data }: { id: string; data: UserFormData }): Promise<User> => {
|
||||||
|
const response = await apiClient.put<ApiResponse<User>>(`/users/${id}`, data);
|
||||||
|
return response.data.data!;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook untuk mutasi pembaruan pengguna
|
||||||
|
export const useUpdateUser = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: updateUser,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users', data.id] });
|
||||||
|
// toast.success('Pengguna berhasil diperbarui');
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
const errors = extractApiErrors(error);
|
||||||
|
Object.values(errors).forEach((message) => {
|
||||||
|
// toast.error(message);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fungsi untuk menghapus pengguna
|
||||||
|
export const deleteUser = async (id: string): Promise<void> => {
|
||||||
|
await apiClient.delete<ApiResponse<null>>(`/users/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook untuk mutasi penghapusan pengguna
|
||||||
|
export const useDeleteUser = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: deleteUser,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
// toast.success('Pengguna berhasil dihapus');
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
const errors = extractApiErrors(error);
|
||||||
|
Object.values(errors).forEach((message) => {
|
||||||
|
// toast.error(message);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
interface UsersState {
|
||||||
|
users: any[];
|
||||||
|
selectedUser: any | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
setUsers: (users: any[]) => void;
|
||||||
|
setSelectedUser: (user: any | null) => void;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUsersStore = create<UsersState>((set) => ({
|
||||||
|
users: [],
|
||||||
|
selectedUser: null,
|
||||||
|
isLoading: false,
|
||||||
|
setUsers: (users) => set({ users }),
|
||||||
|
setSelectedUser: (selectedUser) => set({ selectedUser }),
|
||||||
|
setLoading: (isLoading) => set({ isLoading }),
|
||||||
|
}));
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import axios, { AxiosError, AxiosResponse } from "axios";
|
||||||
|
import { useAuthStore } from "../features/auth/store";
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api";
|
||||||
|
|
||||||
|
// Buat instance axios
|
||||||
|
export const apiClient = axios.create({
|
||||||
|
baseURL: API_URL,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Interceptor untuk request
|
||||||
|
apiClient.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = useAuthStore.getState().token;
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}, (error: AxiosError) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Interceptor untuk response
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response: AxiosResponse) => {
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error: AxiosError) => {
|
||||||
|
const { response } = error;
|
||||||
|
|
||||||
|
// Handle expired token atau unauthorized
|
||||||
|
// if (response?.status === 401) {
|
||||||
|
// useAuthStore.getState().logout();
|
||||||
|
// window.location.href = "/login";
|
||||||
|
// }
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tipe untuk respon API berdasarkan format yang Anda berikan
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
status: {
|
||||||
|
errors?: Record<string, string>;
|
||||||
|
code: number;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
data: T | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper untuk ekstrak error dari respon API
|
||||||
|
export const extractApiErrors = (error: unknown): Record<string, string> => {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
if (error.response) {
|
||||||
|
|
||||||
|
const responseData = error.response?.data as ApiResponse<unknown>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
responseData?.status?.errors || {
|
||||||
|
general: responseData?.status?.description || "Terjadi kesalahan pada server",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return { general: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { general: "Terjadi kesalahan" };
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "./index.css";
|
||||||
|
import App from "./App.tsx";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
const NotFoundPage = () => {
|
||||||
|
return (
|
||||||
|
<div className="hero min-h-screen bg-base-200">
|
||||||
|
<div className="hero-content text-center">
|
||||||
|
<div className="max-w-md">
|
||||||
|
<h1 className="text-5xl font-bold">404</h1>
|
||||||
|
<p className="text-8xl font-bold text-primary my-4">Oops!</p>
|
||||||
|
<p className="py-6">Halaman yang Anda cari tidak ditemukan. Mungkin halaman telah dipindahkan atau dihapus.</p>
|
||||||
|
<Link to="/dashboard" className="btn btn-primary">
|
||||||
|
Kembali ke Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotFoundPage;
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { useLogin } from "../../features/auth/api/auth";
|
||||||
|
import axios from "axios";
|
||||||
|
import Input from "@/components/forms/Input";
|
||||||
|
import Alert from "@/components/feedback/Alert";
|
||||||
|
import Card from "@/components/data_display/Card/Card";
|
||||||
|
import Button from "@/components/actions/Button";
|
||||||
|
|
||||||
|
const LoginPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const { ...login } = useLogin({
|
||||||
|
onError: (error) => {
|
||||||
|
setErrors(error)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value,
|
||||||
|
}));
|
||||||
|
// Hapus error untuk field yang sedang diubah
|
||||||
|
if (errors[name]) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[name];
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login.mutateAsync(formData);
|
||||||
|
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 400) {
|
||||||
|
const apiErrors = error.response.data.status.errors;
|
||||||
|
setErrors(apiErrors || {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-screen flex items-center justify-center bg-base-200 mx-0">
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-base-200 mx-0">
|
||||||
|
<Card className="w-sm bg-base-100 shadow-xl">
|
||||||
|
<Card.Body>
|
||||||
|
<h2 className="card-title text-2xl mx-auto mb-2">Login</h2>
|
||||||
|
{errors.general && (
|
||||||
|
<Alert
|
||||||
|
status="error"
|
||||||
|
soft
|
||||||
|
icon={
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" 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>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
<span>{errors.general}</span>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col w-full component-preview gap-4 items-center justify-center font-sans">
|
||||||
|
<div className="form-control w-full">
|
||||||
|
<label className="label mb-1">
|
||||||
|
<span className="label-text font-semibold">Username</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Username"
|
||||||
|
name="username"
|
||||||
|
onChange={handleChange}
|
||||||
|
className={`w-full ${errors.username ? "input-error" : ""}`}
|
||||||
|
/>
|
||||||
|
{errors.username && (
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text-alt">{errors.username}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control w-full">
|
||||||
|
<label className="label mb-1">
|
||||||
|
<span className="label-text font-semibold">Password</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
onChange={handleChange}
|
||||||
|
|
||||||
|
className={`w-full ${errors.password ? "input-error" : ""}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errors.password && (
|
||||||
|
<label className="label">
|
||||||
|
|
||||||
|
<span className="label-text-alt">{errors.password}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-control mt-6">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
color="primary"
|
||||||
|
disabled={login.isPending}
|
||||||
|
>
|
||||||
|
{login.isPending ? (
|
||||||
|
<span className="loading loading-spinner loading-sm"></span>
|
||||||
|
) : (
|
||||||
|
"Login"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
};
|
||||||
|
export default LoginPage;
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
import { JSX, useEffect, useState } from 'react';
|
||||||
|
import { useAuthStore } from '../../features/auth/store';
|
||||||
|
|
||||||
|
interface StatCard {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
icon: JSX.Element;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardPage = () => {
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
const [stats, setStats] = useState<StatCard[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Simulasi loading data statistik dari API
|
||||||
|
const loadStats = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Di sini Anda bisa mengganti dengan panggilan API yang sebenarnya
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
const dummyStats: StatCard[] = [
|
||||||
|
{
|
||||||
|
title: 'Total Users',
|
||||||
|
value: 1200,
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
color: 'bg-blue-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Active Users',
|
||||||
|
value: 850,
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
color: 'bg-green-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'New Users',
|
||||||
|
value: 35,
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zM3 19.235v-.11a6.375 6.375 0 0112.75 0v.109A12.318 12.318 0 019.374 21c-2.331 0-4.512-.645-6.374-1.766z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
color: 'bg-indigo-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Admin Users',
|
||||||
|
value: 10,
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
color: 'bg-purple-500',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
setStats(dummyStats);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading stats:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col gap-4 z-[1]">
|
||||||
|
<div className="bg-base-100 shadow-md p-4 rounded-lg flex justify-between items-center">
|
||||||
|
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||||
|
<p className="text-base-content/70">
|
||||||
|
Selamat datang kembali, {user?.name || 'User'}!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Statistik Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{stats.map((stat, index) => (
|
||||||
|
<div key={index} className="card bg-base-100 shadow-md">
|
||||||
|
<div className="card-body p-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm opacity-70">{stat.title}</p>
|
||||||
|
<h2 className="card-title text-2xl">{stat.value.toLocaleString()}</h2>
|
||||||
|
</div>
|
||||||
|
<div className={`${stat.color} p-3 rounded-lg text-white`}>
|
||||||
|
{stat.icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
<div className="card bg-base-100 shadow-md">
|
||||||
|
<div className="card-body">
|
||||||
|
<h2 className="card-title text-xl mb-4">Aktivitas Terkini</h2>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Aktivitas</th>
|
||||||
|
<th>Waktu</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>admin@example.com</td>
|
||||||
|
<td>Login ke sistem</td>
|
||||||
|
<td>5 menit yang lalu</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>user1@example.com</td>
|
||||||
|
<td>Update profil</td>
|
||||||
|
<td>10 menit yang lalu</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>admin@example.com</td>
|
||||||
|
<td>Tambah user baru</td>
|
||||||
|
<td>15 menit yang lalu</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>user2@example.com</td>
|
||||||
|
<td>Login ke sistem</td>
|
||||||
|
<td>20 menit yang lalu</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>user3@example.com</td>
|
||||||
|
<td>Reset password</td>
|
||||||
|
<td>30 menit yang lalu</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System Info */}
|
||||||
|
<div className="card bg-base-100 shadow-md">
|
||||||
|
<div className="card-body">
|
||||||
|
<h2 className="card-title text-xl mb-4">Informasi Sistem</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-2">Teknologi</h3>
|
||||||
|
<ul className="list-disc list-inside space-y-1">
|
||||||
|
<li>React + TypeScript</li>
|
||||||
|
<li>Tailwind CSS + DaisyUI</li>
|
||||||
|
<li>React Router</li>
|
||||||
|
<li>TanStack Query</li>
|
||||||
|
<li>Zustand</li>
|
||||||
|
<li>Axios</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-2">Version</h3>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
<li>App Version: 1.0.0</li>
|
||||||
|
<li>React: 18.2.0</li>
|
||||||
|
<li>DaisyUI: 5.0.6</li>
|
||||||
|
<li>API: v1</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DashboardPage;
|
||||||
|
|
@ -0,0 +1,280 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
useReactTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
flexRender,
|
||||||
|
createColumnHelper,
|
||||||
|
SortingState,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import Button from "@/components/actions/Button";
|
||||||
|
import Card from "@/components/data_display/Card/Card";
|
||||||
|
import { useDevices } from "@/features/devices/api/devices";
|
||||||
|
import { DeviceDataTable } from "@/features/devices/types";
|
||||||
|
import Loading from "@/components/feedback/Loading";
|
||||||
|
import Alert from "@/components/feedback/Alert";
|
||||||
|
import { Pencil } from "lucide-react";
|
||||||
|
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import id from "dayjs/locale/id";
|
||||||
|
import Dropdown from "@/components/actions/Dropdown/Dropdown";
|
||||||
|
|
||||||
|
dayjs.locale(id);
|
||||||
|
|
||||||
|
export default function DevicePage() {
|
||||||
|
const { data: devices, isLoading, isError, error } = useDevices();
|
||||||
|
// State untuk sorting dan filtering
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
const [globalFilter, setGlobalFilter] = useState("");
|
||||||
|
const [pagination, setPagination] = useState({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 10, // Default 10 data per halaman
|
||||||
|
});
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<DeviceDataTable>();
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
columnHelper.display({
|
||||||
|
id: "actions",
|
||||||
|
header: "",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Dropdown hover horizontal="right">
|
||||||
|
<Dropdown.Toggle
|
||||||
|
button={false}
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="w-4 h-4"
|
||||||
|
>
|
||||||
|
<circle cx={12} cy={12} r={1} />
|
||||||
|
<circle cx={12} cy={5} r={1} />
|
||||||
|
<circle cx={12} cy={19} r={1} />
|
||||||
|
</svg>
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
<Dropdown.Menu className="shadow-2xl border p-1 bg-base-100 w-32">
|
||||||
|
<Dropdown.Item>
|
||||||
|
<Pencil className="w-4 h-4 mr-2 text-info" />
|
||||||
|
Edit
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
|
),
|
||||||
|
size: 10,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("device_code", {
|
||||||
|
header: "Device Code",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
size: 120,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("device_type", {
|
||||||
|
header: "Device Type",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
size: 100,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("longitude", {
|
||||||
|
header: "Longitude",
|
||||||
|
cell: (info) => info.getValue().toFixed(4),
|
||||||
|
size: 100,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("latitude", {
|
||||||
|
header: "Latitude",
|
||||||
|
cell: (info) => info.getValue().toFixed(4),
|
||||||
|
size: 100,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("address", {
|
||||||
|
header: "Address",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
size: 200,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("port_amount", {
|
||||||
|
header: "Total Ports",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
size: 100,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("total_used_port", {
|
||||||
|
header: "Used Ports",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
size: 100,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("status", {
|
||||||
|
header: "Status",
|
||||||
|
cell: (info) => (
|
||||||
|
<span
|
||||||
|
className={`badge ${
|
||||||
|
info.getValue() === "active" ? "badge-success" : "badge-error"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{info.getValue()}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
size: 100,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("created_at", {
|
||||||
|
header: "Created At",
|
||||||
|
cell: (info) => dayjs(info.getValue()).format("DD MMMM YYYY, HH:mm"),
|
||||||
|
size: 150,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: devices || [],
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
globalFilter,
|
||||||
|
pagination,
|
||||||
|
},
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="bg-base-100 shadow-md p-4 rounded-lg flex justify-between items-center">
|
||||||
|
<h1 className="text-2xl font-bold">Devices</h1>
|
||||||
|
<Button color="primary">Add Device</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="bg-base-100 shadow-lg mt-8">
|
||||||
|
<Card.Body>
|
||||||
|
{isError && (
|
||||||
|
<Alert
|
||||||
|
status="error"
|
||||||
|
icon={
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-6 w-6 shrink-0 stroke-current"
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{error?.message || "Failed to load devices"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
{/* Search Input */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search devices..."
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
className="input input-bordered w-full max-w-xs"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Page Size Dropdown */}
|
||||||
|
<select
|
||||||
|
value={pagination.pageSize}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPagination((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pageSize: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="select select-bordered"
|
||||||
|
>
|
||||||
|
{[5, 10, 25, 50].map((pageSize) => (
|
||||||
|
<option key={pageSize} value={pageSize}>
|
||||||
|
Show {pageSize}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table table-zebra w-full">
|
||||||
|
<thead>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th
|
||||||
|
key={header.id}
|
||||||
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{isLoading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length} className="text-center">
|
||||||
|
<Loading />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<tr key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td key={cell.id} className="w-fit">
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-end space-x-2 py-4">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
Page{" "}
|
||||||
|
<strong>
|
||||||
|
{table.getState().pagination.pageIndex + 1} of{" "}
|
||||||
|
{table.getPageCount()}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router';
|
||||||
|
import { useCreateUser, useUpdateUser, useUser, UserFormData } from '../../features/users/api/users';
|
||||||
|
import { extractApiErrors } from '../../lib/api-client';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const UserFormPage = () => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isEditing = !!id;
|
||||||
|
|
||||||
|
// Query dan mutations
|
||||||
|
const { data: user, isLoading: isLoadingUser } = useUser(id || '');
|
||||||
|
const createUser = useCreateUser();
|
||||||
|
const updateUser = useUpdateUser();
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [formData, setFormData] = useState<UserFormData>({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: 'user', // Default role
|
||||||
|
});
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Isi form dengan data user jika mode edit
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing && user) {
|
||||||
|
setFormData({
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
// Password kosong saat edit
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isEditing, user]);
|
||||||
|
|
||||||
|
const handleChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||||
|
) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Hapus error untuk field yang sedang diubah
|
||||||
|
if (errors[name]) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[name];
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEditing && id) {
|
||||||
|
// Jika password kosong, hapus dari data yang dikirim
|
||||||
|
const dataToSubmit = { ...formData };
|
||||||
|
if (!dataToSubmit.password) {
|
||||||
|
delete dataToSubmit.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateUser.mutateAsync({ id, data: dataToSubmit });
|
||||||
|
} else {
|
||||||
|
await createUser.mutateAsync(formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate('/users');
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 400) {
|
||||||
|
const apiErrors = error.response.data.status.errors;
|
||||||
|
setErrors(apiErrors || {});
|
||||||
|
} else {
|
||||||
|
const extractedErrors = extractApiErrors(error);
|
||||||
|
setErrors(extractedErrors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEditing && isLoadingUser) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-4">
|
||||||
|
<div className="card bg-base-100 shadow-xl">
|
||||||
|
<div className="card-body">
|
||||||
|
<h2 className="card-title text-2xl mb-6">
|
||||||
|
{isEditing ? 'Edit Pengguna' : 'Tambah Pengguna'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Nama</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={`input input-bordered ${errors.name ? 'input-error' : ''}`}
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text-alt text-error">{errors.name}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Email</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={`input input-bordered ${errors.email ? 'input-error' : ''}`}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text-alt text-error">{errors.email}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Password {isEditing && '(kosongkan jika tidak ingin mengubah)'}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
value={formData.password || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={`input input-bordered ${errors.password ? 'input-error' : ''}`}
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text-alt text-error">{errors.password}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Role</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="role"
|
||||||
|
value={formData.role}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={`select select-bordered ${errors.role ? 'select-error' : ''}`}
|
||||||
|
>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
{errors.role && (
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text-alt text-error">{errors.role}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-actions justify-end mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
onClick={() => navigate('/users')}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={createUser.isPending || updateUser.isPending}
|
||||||
|
>
|
||||||
|
{(createUser.isPending || updateUser.isPending) ? (
|
||||||
|
<span className="loading loading-spinner loading-sm"></span>
|
||||||
|
) : (
|
||||||
|
isEditing ? 'Update' : 'Simpan'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserFormPage;
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { useUsers, useDeleteUser } from '../../features/users/api/users';
|
||||||
|
|
||||||
|
const UsersListPage = () => {
|
||||||
|
const { data: users, isLoading, isError } = useUsers();
|
||||||
|
const deleteUser = useDeleteUser();
|
||||||
|
const [userToDelete, setUserToDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await deleteUser.mutateAsync(id);
|
||||||
|
setUserToDelete(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete user:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="alert alert-error">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" 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>
|
||||||
|
<span>Gagal memuat data pengguna.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-4">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Manajemen Pengguna</h1>
|
||||||
|
<Link to="/users/create" className="btn btn-primary">
|
||||||
|
Tambah Pengguna
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nama</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users?.map((user) => (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<td>{user.name}</td>
|
||||||
|
<td>{user.email}</td>
|
||||||
|
<td>{user.role}</td>
|
||||||
|
<td className="flex gap-2">
|
||||||
|
<Link to={`/users/edit/${user.id}`} className="btn btn-sm btn-info">
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-error"
|
||||||
|
onClick={() => setUserToDelete(user.id)}
|
||||||
|
>
|
||||||
|
Hapus
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal konfirmasi hapus */}
|
||||||
|
{userToDelete && (
|
||||||
|
<div className="modal modal-open">
|
||||||
|
<div className="modal-box">
|
||||||
|
<h3 className="font-bold text-lg">Konfirmasi Hapus</h3>
|
||||||
|
<p className="py-4">Apakah Anda yakin ingin menghapus pengguna ini?</p>
|
||||||
|
<div className="modal-action">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline"
|
||||||
|
onClick={() => setUserToDelete(null)}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-error"
|
||||||
|
onClick={() => handleDelete(userToDelete)}
|
||||||
|
disabled={deleteUser.isPending}
|
||||||
|
>
|
||||||
|
{deleteUser.isPending ? (
|
||||||
|
<span className="loading loading-spinner loading-sm"></span>
|
||||||
|
) : (
|
||||||
|
'Hapus'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UsersListPage;
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { Navigate, Outlet } from 'react-router';
|
||||||
|
import { useAuthStore } from '../features/auth/store';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
requiresAuth?: boolean;
|
||||||
|
requiresAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProtectedRoute = ({
|
||||||
|
requiresAuth = true,
|
||||||
|
requiresAdmin = false,
|
||||||
|
}: ProtectedRouteProps) => {
|
||||||
|
const { isAuthenticated, user } = useAuthStore();
|
||||||
|
|
||||||
|
// Jika memerlukan authentication tapi tidak authenticated, redirect ke login
|
||||||
|
if (requiresAuth && !isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jika sudah authenticated tapi mengakses halaman login, redirect ke dashboard
|
||||||
|
if (!requiresAuth && isAuthenticated) {
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jika memerlukan role admin tapi bukan admin, redirect ke dashboard
|
||||||
|
if (requiresAdmin && user?.role !== 'admin') {
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProtectedRoute;
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { createBrowserRouter } from 'react-router';
|
||||||
|
import ProtectedRoute from './ProtectedRoute';
|
||||||
|
import DashboardLayout from '@/components/layouts/DashboardLayout';
|
||||||
|
import LoginPage from '@/pages/auth/LoginPage';
|
||||||
|
import DashboardPage from '@/pages/dashboard/DashboardPage';
|
||||||
|
import UsersListPage from '@/pages/users/UsersListPage';
|
||||||
|
import UserFormPage from '@/pages/users/UserFormPage';
|
||||||
|
import NotFoundPage from '@/pages/NotFoundPage';
|
||||||
|
import DevicePage from '@/pages/device/DevicePage';
|
||||||
|
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
// Public routes
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute requiresAuth={false} />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
element: <LoginPage />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Protected routes
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute requiresAuth />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
element: <DashboardLayout />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
element: <DashboardPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/devices',
|
||||||
|
element: <DevicePage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/users',
|
||||||
|
element: <UsersListPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/users/create',
|
||||||
|
element: <UserFormPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/users/:id/edit',
|
||||||
|
element: <UserFormPage />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// Not found route
|
||||||
|
{
|
||||||
|
path: '*',
|
||||||
|
element: <NotFoundPage />,
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': '/src',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [tailwindcss(), react()],
|
||||||
|
server: {
|
||||||
|
allowedHosts: ['0ed6-103-14-20-142.ngrok-free.app'],
|
||||||
|
}
|
||||||
|
})
|
||||||