.
This commit is contained in:
parent
8e13fa18ae
commit
6cb7b831e8
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@ -9,5 +9,8 @@
|
||||
"prettier.useEditorConfig": false,
|
||||
"prettier.useTabs": false,
|
||||
"prettier.configPath": ".prettierrc",
|
||||
"asciidoc.antora.enableAntoraSupport": true
|
||||
"asciidoc.antora.enableAntoraSupport": true,
|
||||
|
||||
// other vscode settings
|
||||
"tailwindCSS.rootFontSize": 16 // <- your root font size here
|
||||
}
|
||||
|
||||
2
client/.env.development
Normal file
2
client/.env.development
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_API_URL=http://127.0.0.1:4001/api/v1
|
||||
VITE_API_KEY=e175f809ba71fb2765ad5e60f9d77596-es19
|
||||
0
client/.env.production
Normal file
0
client/.env.production
Normal file
16
client/.eslintrc.cjs
Normal file
16
client/.eslintrc.cjs
Normal file
@ -0,0 +1,16 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
],
|
||||
ignorePatterns: ["dist", ".eslintrc.cjs"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["react-refresh"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
},
|
||||
};
|
||||
24
client/.gitignore
vendored
Normal file
24
client/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
17
client/components.json
Normal file
17
client/components.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "src/",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
18
client/index.html
Normal file
18
client/index.html
Normal file
@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Uecko</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="uecko"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
80
client/package.json
Normal file
80
client/package.json
Normal file
@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "@uecko-presupuestador/client",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"author": "Rodax Software <dev@rodax-software.com>",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.5.0",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-aspect-ratio": "^1.0.3",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-context-menu": "^2.1.5",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-hover-card": "^1.0.7",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-menubar": "^1.0.4",
|
||||
"@radix-ui/react-navigation-menu": "^1.1.4",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-progress": "^1.0.3",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-toggle": "^1.0.3",
|
||||
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/react-query": "^5.39.0",
|
||||
"axios": "^1.7.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"joi": "^17.13.1",
|
||||
"lucide-react": "^0.379.0",
|
||||
"react": "^18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-resizable-panels": "^2.0.19",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"react-secure-storage": "^1.3.2",
|
||||
"react-toastify": "^10.0.5",
|
||||
"react-wrap-balancer": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/react-query-devtools": "^5.39.0",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||
"@typescript-eslint/parser": "^7.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"postcss": "^8.4.38",
|
||||
"postcss-import": "^16.1.0",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.2.12"
|
||||
}
|
||||
}
|
||||
7
client/postcss.config.js
Normal file
7
client/postcss.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"postcss-import": {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
1
client/public/vite.svg
Normal file
1
client/public/vite.svg
Normal file
@ -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 |
42
client/src/App.css
Normal file
42
client/src/App.css
Normal file
@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
43
client/src/App.tsx
Normal file
43
client/src/App.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { AuthProvider, ThemeProvider } from "@/lib/hooks";
|
||||
import { TooltipProvider } from "@/ui";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { Suspense } from "react";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { Routes } from "./Routes";
|
||||
import { LoadingOverlay, TailwindIndicator } from "./components";
|
||||
import { createAxiosDataProvider } from "./lib/axios";
|
||||
import { createAxiosAuthActions } from "./lib/axios/createAxiosAuthActions";
|
||||
import { DataSourceProvider } from "./lib/hooks/useDataSource/DataSourceContext";
|
||||
|
||||
function App() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: 10000, // Specify a staleTime to only fetch when the data is older than a certain amount of time
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DataSourceProvider dataSource={createAxiosDataProvider(import.meta.env.VITE_API_URL)}>
|
||||
<AuthProvider authActions={createAxiosAuthActions(import.meta.env.VITE_API_URL)}>
|
||||
<ThemeProvider defaultTheme='light' storageKey='vite-ui-theme'>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Suspense fallback={<LoadingOverlay />}>
|
||||
<Routes />
|
||||
<ToastContainer />
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
<TailwindIndicator />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</DataSourceProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
64
client/src/Routes.tsx
Normal file
64
client/src/Routes.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { RouterProvider, createBrowserRouter } from "react-router-dom";
|
||||
import { ProtectedRoute } from "./components";
|
||||
import { AuthContextState, useAuth } from "./lib/hooks";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
|
||||
export const Routes = () => {
|
||||
const { isLoggedIn } = useAuth() as AuthContextState;
|
||||
|
||||
// Define public routes accessible to all users
|
||||
const routesForPublic = [
|
||||
{
|
||||
path: "/service",
|
||||
element: <div>Service Page</div>,
|
||||
},
|
||||
{
|
||||
path: "/about-us",
|
||||
element: <div>About Us</div>,
|
||||
},
|
||||
];
|
||||
|
||||
// Define routes accessible only to authenticated users
|
||||
const routesForAuthenticatedOnly = [
|
||||
{
|
||||
path: "/admin",
|
||||
element: <ProtectedRoute />, // Wrap the component in ProtectedRoute
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
element: <div>Dashboard</div>,
|
||||
},
|
||||
{
|
||||
path: "profile",
|
||||
element: <div>User Profile</div>,
|
||||
},
|
||||
{
|
||||
path: "logout",
|
||||
element: <div>Logout</div>,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Define routes accessible only to non-authenticated users
|
||||
const routesForNotAuthenticatedOnly = [
|
||||
{
|
||||
path: "/",
|
||||
element: <div>Home Page</div>,
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
Component: LoginPage,
|
||||
},
|
||||
];
|
||||
|
||||
// Combine and conditionally include routes based on authentication status
|
||||
const router = createBrowserRouter([
|
||||
...routesForPublic,
|
||||
...(!isLoggedIn ? routesForNotAuthenticatedOnly : []),
|
||||
...routesForAuthenticatedOnly,
|
||||
]);
|
||||
|
||||
// Provide the router configuration using RouterProvider
|
||||
return <RouterProvider router={router} />;
|
||||
};
|
||||
130
client/src/Typography.tsx
Normal file
130
client/src/Typography.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { Container } from "./components/Container";
|
||||
|
||||
export function TypographyDemo() {
|
||||
return (
|
||||
<Container className="mx-auto mt-8 prose prose-slate lg:prose-lg">
|
||||
<h1>The Joke Tax Chronicles</h1>
|
||||
<p className="leading-7">
|
||||
Once upon a time, in a far-off land, there was a very lazy king who
|
||||
spent all day lounging on his throne. One day, his advisors came to him
|
||||
with a problem: the kingdom was running out of money.
|
||||
</p>
|
||||
<pre>
|
||||
<code className="language-html">
|
||||
<article class="prose"> <h1>Garlic bread with cheese: What
|
||||
the science tells us</h1> <p> For years parents have
|
||||
espoused the health benefits of eating garlic bread with cheese to
|
||||
their children, with the food earning such an iconic status in our
|
||||
culture that kids will often dress up as warm, cheesy loaf for
|
||||
Halloween. </p> <p> But a recent study shows that the
|
||||
celebrated appetizer may be linked to a series of rabies cases
|
||||
springing up around the country. </p> <!-- ... -->
|
||||
</article>
|
||||
</code>
|
||||
</pre>
|
||||
<h2>The King's Plan</h2>
|
||||
<p>
|
||||
The king thought long and hard, and finally came up with{" "}
|
||||
<a
|
||||
href="#"
|
||||
className="font-medium underline text-primary underline-offset-4"
|
||||
>
|
||||
a brilliant plan
|
||||
</a>
|
||||
: he would tax the jokes in the kingdom.
|
||||
</p>
|
||||
<blockquote className="pl-6 mt-6 italic border-l-2">
|
||||
"After all," he said, "everyone enjoys a good joke, so it's only fair
|
||||
that they should pay for the privilege."
|
||||
</blockquote>
|
||||
<h3 className="mt-8 text-2xl font-semibold tracking-tight scroll-m-20">
|
||||
The Joke Tax
|
||||
</h3>
|
||||
<p className="leading-7 [&:not(:first-child)]:mt-6">
|
||||
The king's subjects were not amused. They grumbled and complained, but
|
||||
the king was firm:
|
||||
</p>
|
||||
<ul className="my-6 ml-6 list-disc [&>li]:mt-2">
|
||||
<li>1st level of puns: 5 gold coins</li>
|
||||
<li>2nd level of jokes: 10 gold coins</li>
|
||||
<li>3rd level of one-liners : 20 gold coins</li>
|
||||
</ul>
|
||||
<p className="leading-7 [&:not(:first-child)]:mt-6">
|
||||
As a result, people stopped telling jokes, and the kingdom fell into a
|
||||
gloom. But there was one person who refused to let the king's
|
||||
foolishness get him down: a court jester named Jokester.
|
||||
</p>
|
||||
<h3 className="mt-8 text-2xl font-semibold tracking-tight scroll-m-20">
|
||||
Jokester's Revolt
|
||||
</h3>
|
||||
<p className="leading-7 [&:not(:first-child)]:mt-6">
|
||||
Jokester began sneaking into the castle in the middle of the night and
|
||||
leaving jokes all over the place: under the king's pillow, in his soup,
|
||||
even in the royal toilet. The king was furious, but he couldn't seem to
|
||||
stop Jokester.
|
||||
</p>
|
||||
<p className="leading-7 [&:not(:first-child)]:mt-6">
|
||||
And then, one day, the people of the kingdom discovered that the jokes
|
||||
left by Jokester were so funny that they couldn't help but laugh. And
|
||||
once they started laughing, they couldn't stop.
|
||||
</p>
|
||||
<h3 className="mt-8 text-2xl font-semibold tracking-tight scroll-m-20">
|
||||
The People's Rebellion
|
||||
</h3>
|
||||
<p className="leading-7 [&:not(:first-child)]:mt-6">
|
||||
The people of the kingdom, feeling uplifted by the laughter, started to
|
||||
tell jokes and puns again, and soon the entire kingdom was in on the
|
||||
joke.
|
||||
</p>
|
||||
<div className="w-full my-6 overflow-y-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="p-0 m-0 border-t even:bg-muted">
|
||||
<th className="border px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right">
|
||||
King's Treasury
|
||||
</th>
|
||||
<th className="border px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right">
|
||||
People's happiness
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="p-0 m-0 border-t even:bg-muted">
|
||||
<td className="border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right">
|
||||
Empty
|
||||
</td>
|
||||
<td className="border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right">
|
||||
Overflowing
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="p-0 m-0 border-t even:bg-muted">
|
||||
<td className="border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right">
|
||||
Modest
|
||||
</td>
|
||||
<td className="border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right">
|
||||
Satisfied
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="p-0 m-0 border-t even:bg-muted">
|
||||
<td className="border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right">
|
||||
Full
|
||||
</td>
|
||||
<td className="border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right">
|
||||
Ecstatic
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="leading-7 [&:not(:first-child)]:mt-6">
|
||||
The king, seeing how much happier his subjects were, realized the error
|
||||
of his ways and repealed the joke tax. Jokester was declared a hero, and
|
||||
the kingdom lived happily ever after.
|
||||
</p>
|
||||
<p className="leading-7 [&:not(:first-child)]:mt-6">
|
||||
The moral of the story is: never underestimate the power of a good laugh
|
||||
and always be careful of bad ideas.
|
||||
</p>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
11
client/src/app/auth/AuthLayout.tsx
Normal file
11
client/src/app/auth/AuthLayout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { createAxiosDataProvider } from "@/lib/axios";
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
export const AuthLayout = (): JSX.Element => {
|
||||
const authDataProvider = createAxiosDataProvider(import.meta.env.VITE_API_URL);
|
||||
return (
|
||||
<AuthProvider dataProvider={authDataProvider}>
|
||||
<Outlet />
|
||||
</AuthProvider>
|
||||
);
|
||||
};
|
||||
1
client/src/app/auth/index.ts
Normal file
1
client/src/app/auth/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./AuthLayout";
|
||||
1
client/src/assets/react.svg
Normal file
1
client/src/assets/react.svg
Normal file
@ -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 |
31
client/src/components/Container/Container.tsx
Normal file
31
client/src/components/Container/Container.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const containerVariants = cva("p-6", {
|
||||
variants: {
|
||||
variant: {
|
||||
full: "w-full",
|
||||
boxed: "container max-w-3xl lg:max-w-5xl mx-auto",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "full",
|
||||
},
|
||||
});
|
||||
|
||||
export interface ContainerProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof containerVariants> {
|
||||
as?: React.ElementType;
|
||||
}
|
||||
|
||||
export const Container = ({
|
||||
className,
|
||||
as: Comp = "article",
|
||||
variant,
|
||||
...props
|
||||
}: ContainerProps) => <Comp className={cn(containerVariants({ variant, className }))} {...props} />;
|
||||
|
||||
Container.displayName = "Container";
|
||||
1
client/src/components/Container/index.ts
Normal file
1
client/src/components/Container/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./Container";
|
||||
67
client/src/components/Forms/FormGroup.tsx
Normal file
67
client/src/components/Forms/FormGroup.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/ui";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
interface FormGroupProps
|
||||
extends React.HTMLAttributes<HTMLHeadingElement>,
|
||||
React.HTMLAttributes<HTMLDivElement> {
|
||||
description?: string;
|
||||
actions?: JSX.Element;
|
||||
footerActions?: JSX.Element;
|
||||
}
|
||||
|
||||
export const FormGroup = React.forwardRef<HTMLDivElement, FormGroupProps>(
|
||||
(
|
||||
{ className, title, description, actions, footerActions, children },
|
||||
ref
|
||||
) => {
|
||||
const id = React.useId();
|
||||
const hasHeader = useMemo(
|
||||
() => title || description || actions,
|
||||
[title, description, actions]
|
||||
);
|
||||
return (
|
||||
<Card
|
||||
id={id}
|
||||
className={cn(!hasHeader ? "pt-6" : "", className)}
|
||||
ref={ref}
|
||||
>
|
||||
{hasHeader && (
|
||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between sm:flex-nowrap">
|
||||
<div>
|
||||
{title && (
|
||||
<CardTitle className="text-lg leading-normal">
|
||||
{title}
|
||||
</CardTitle>
|
||||
)}
|
||||
{description && (
|
||||
<CardDescription className="leading-loose">
|
||||
{description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
{actions && <div className="flex-shrink-0">{actions}</div>}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className="grid items-start gap-6">{children}</CardContent>
|
||||
{footerActions && (
|
||||
<CardFooter
|
||||
className="px-6 py-4 border-t"
|
||||
style={{ borderStyle: "inherit" }}
|
||||
>
|
||||
{footerActions}
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FormGroup.displayName = "FormGroup";
|
||||
26
client/src/components/Forms/FormLabel.tsx
Normal file
26
client/src/components/Forms/FormLabel.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import * as UI from "@/ui";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import React from "react";
|
||||
import { FormInputProps } from "./FormProps";
|
||||
|
||||
export interface FormLabelProps {
|
||||
label: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
FormLabelProps &
|
||||
Pick<FormInputProps, "required">
|
||||
>(({ label, hint, required, ...props }, ref) => {
|
||||
const _hint = hint ? hint : required ? "obligatorio" : undefined;
|
||||
return (
|
||||
<UI.FormLabel ref={ref} className="flex justify-between" {...props}>
|
||||
<span className="block font-semibold">{label}</span>
|
||||
{_hint && <span className="font-normal">{_hint}</span>}
|
||||
</UI.FormLabel>
|
||||
);
|
||||
});
|
||||
|
||||
FormLabel.displayName = "FormLabel";
|
||||
137
client/src/components/Forms/FormMoneyField.tsx
Normal file
137
client/src/components/Forms/FormMoneyField.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from "@/ui";
|
||||
|
||||
import { createElement } from "react";
|
||||
import { FormTextFieldProps } from "./FormTextField";
|
||||
|
||||
type FormMoneyFieldProps = Omit<FormTextFieldProps, "type">;
|
||||
|
||||
// Spanish currency config
|
||||
const moneyFormatter = Intl.NumberFormat("es-ES", {
|
||||
currency: "EUR",
|
||||
currencyDisplay: "symbol",
|
||||
currencySign: "standard",
|
||||
style: "currency",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export function FormMoneyField({
|
||||
label,
|
||||
placeholder,
|
||||
hint,
|
||||
description,
|
||||
required,
|
||||
className,
|
||||
leadIcon,
|
||||
trailIcon,
|
||||
button,
|
||||
disabled,
|
||||
errors,
|
||||
name,
|
||||
control,
|
||||
}: FormMoneyFieldProps) {
|
||||
/*const initialValue = props.form.getValues()[props.name]
|
||||
? moneyFormatter.format(props.form.getValues()[props.name])
|
||||
: "";
|
||||
|
||||
const [value, setValue] = useReducer((_: any, next: string) => {
|
||||
const digits = next.replace(/\D/g, "");
|
||||
return moneyFormatter.format(Number(digits) / 100);
|
||||
}, initialValue);
|
||||
|
||||
function handleChange(realChangeFn: Function, formattedValue: string) {
|
||||
const digits = formattedValue.replace(/\D/g, "");
|
||||
const realValue = Number(digits) / 100;
|
||||
realChangeFn(realValue);
|
||||
}*/
|
||||
|
||||
//const error = Boolean(errors && errors[name]);
|
||||
|
||||
const transform = {
|
||||
input: (value: any) =>
|
||||
isNaN(value) || value === 0 ? "" : moneyFormatter.format(value),
|
||||
output: (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const output = parseInt(event.target.value, 10);
|
||||
return isNaN(output) ? 0 : output;
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
rules={{ required }}
|
||||
disabled={disabled}
|
||||
render={({ field, fieldState, formState }) => {
|
||||
return (
|
||||
<FormItem className={cn(className, "space-y-3")}>
|
||||
{label && <FormLabel label={label} hint={hint} />}
|
||||
<div className={cn(button ? "flex" : null)}>
|
||||
<div
|
||||
className={cn(
|
||||
leadIcon
|
||||
? "relative flex items-stretch flex-grow focus-within:z-10"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{leadIcon && (
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
{createElement(
|
||||
leadIcon,
|
||||
{
|
||||
className: "h-5 w-5 text-muted-foreground",
|
||||
"aria-hidden": true,
|
||||
},
|
||||
null,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormControl
|
||||
className={cn(
|
||||
"block",
|
||||
leadIcon ? "pl-10" : "",
|
||||
trailIcon ? "pr-10" : "",
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(transform.output(e))}
|
||||
value={transform.input(field.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
{trailIcon && (
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pl-3 pointer-events-none">
|
||||
{createElement(
|
||||
trailIcon,
|
||||
{
|
||||
className: "h-5 w-5 text-muted-foreground",
|
||||
"aria-hidden": true,
|
||||
},
|
||||
null,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{button && <>{createElement(button)}</>}
|
||||
</div>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
27
client/src/components/Forms/FormProps.tsx
Normal file
27
client/src/components/Forms/FormProps.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface FormInputProps {
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface FormInputWithIconProps {
|
||||
leadIcon?: React.ElementType;
|
||||
trailIcon?: React.ElementType;
|
||||
}
|
||||
|
||||
export interface FormButtonInputProps {
|
||||
onClick?: (event: React.SyntheticEvent) => void;
|
||||
}
|
||||
|
||||
export interface FormInputWithButtonProps {
|
||||
withButton: boolean;
|
||||
onButtonClick: (event: React.SyntheticEvent) => void;
|
||||
}
|
||||
|
||||
export interface FormDialogInputProps {
|
||||
content: ReactNode;
|
||||
onClose?: (event: React.SyntheticEvent, payload: unknown) => void;
|
||||
}
|
||||
70
client/src/components/Forms/FormTextAreaField.tsx
Normal file
70
client/src/components/Forms/FormTextAreaField.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
AutosizeTextarea,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
Textarea,
|
||||
} from "@/ui";
|
||||
|
||||
import {
|
||||
FieldErrors,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
UseControllerProps,
|
||||
} from "react-hook-form";
|
||||
import { FormLabel, FormLabelProps } from "./FormLabel";
|
||||
|
||||
export const FormTextAreaField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
label,
|
||||
hint,
|
||||
placeholder,
|
||||
description,
|
||||
autoSize,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
autoSize?: boolean;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
} & Partial<FormLabelProps> &
|
||||
UseControllerProps<TFieldValues, TName> & {
|
||||
errors?: FieldErrors<TFieldValues>;
|
||||
}) => {
|
||||
return (
|
||||
<FormField
|
||||
control={props.control}
|
||||
name={props.name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={cn(className, "flex flex-col")}>
|
||||
{label && <FormLabel label={label} hint={hint} />}
|
||||
<FormControl>
|
||||
{autoSize ? (
|
||||
<AutosizeTextarea
|
||||
disabled={props.disabled}
|
||||
placeholder={placeholder}
|
||||
className="resize-y"
|
||||
{...field}
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
disabled={props.disabled}
|
||||
placeholder={placeholder}
|
||||
className="resize-y"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
120
client/src/components/Forms/FormTextField.tsx
Normal file
120
client/src/components/Forms/FormTextField.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
Input,
|
||||
InputProps,
|
||||
} from "@/ui";
|
||||
|
||||
import * as React from "react";
|
||||
import { createElement } from "react";
|
||||
import {
|
||||
FieldErrors,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
UseControllerProps,
|
||||
} from "react-hook-form";
|
||||
import { FormLabel, FormLabelProps } from "./FormLabel";
|
||||
import { FormInputProps, FormInputWithIconProps } from "./FormProps";
|
||||
|
||||
export type FormTextFieldProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
button?: (props?) => React.ReactNode;
|
||||
} & InputProps &
|
||||
FormInputProps &
|
||||
Partial<FormLabelProps> &
|
||||
FormInputWithIconProps &
|
||||
UseControllerProps<TFieldValues, TName> & {
|
||||
errors?: FieldErrors<TFieldValues>;
|
||||
};
|
||||
|
||||
export function FormTextField({
|
||||
label,
|
||||
placeholder,
|
||||
hint,
|
||||
description,
|
||||
required,
|
||||
className,
|
||||
leadIcon,
|
||||
trailIcon,
|
||||
button,
|
||||
disabled,
|
||||
errors,
|
||||
name,
|
||||
control,
|
||||
type,
|
||||
}: FormTextFieldProps) {
|
||||
//const error = Boolean(errors && errors[name]);
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
rules={{ required }}
|
||||
disabled={disabled}
|
||||
render={({ field, fieldState, formState }) => (
|
||||
<FormItem className={cn(className, "space-y-3")}>
|
||||
{label && <FormLabel label={label} hint={hint} />}
|
||||
<div className={cn(button ? "flex" : null)}>
|
||||
<div
|
||||
className={cn(
|
||||
leadIcon
|
||||
? "relative flex items-stretch flex-grow focus-within:z-10"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{leadIcon && (
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
{React.createElement(
|
||||
leadIcon,
|
||||
{
|
||||
className: "h-5 w-5 text-muted-foreground",
|
||||
"aria-hidden": true,
|
||||
},
|
||||
null,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormControl
|
||||
className={cn(
|
||||
"block",
|
||||
leadIcon ? "pl-10" : "",
|
||||
trailIcon ? "pr-10" : "",
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{trailIcon && (
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pl-3 pointer-events-none">
|
||||
{createElement(
|
||||
trailIcon,
|
||||
{
|
||||
className: "h-5 w-5 text-muted-foreground",
|
||||
"aria-hidden": true,
|
||||
},
|
||||
null,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{button && <>{createElement(button)}</>}
|
||||
</div>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
5
client/src/components/Forms/index.ts
Normal file
5
client/src/components/Forms/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "./FormGroup";
|
||||
export * from "./FormLabel";
|
||||
export * from "./FormMoneyField";
|
||||
export * from "./FormTextAreaField";
|
||||
export * from "./FormTextField";
|
||||
@ -0,0 +1,24 @@
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.LoadingIndicator {
|
||||
@apply flex flex-col items-center justify-center max-w-xs;
|
||||
@apply justify-center w-full h-full mx-auto;
|
||||
}
|
||||
|
||||
.LoadingIndicator__title {
|
||||
@apply mt-6 text-xl font-semibold text-center text-white;
|
||||
}
|
||||
|
||||
.LoadingIndicator__subtitle {
|
||||
@apply text-center text-white;
|
||||
}
|
||||
|
||||
.LoadingIndicator__lighttext {
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
.LoadingIndicator__darktext {
|
||||
@apply text-slate-600;
|
||||
}
|
||||
}
|
||||
55
client/src/components/LoadingIndicator/LoadingIndicator.tsx
Normal file
55
client/src/components/LoadingIndicator/LoadingIndicator.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import styles from "./LoadingIndicator.module.css";
|
||||
import { LoadingSpinIcon } from "./LoadingSpinIcon";
|
||||
|
||||
export type LoadingIndicatorProps = {
|
||||
look?: "dark" | string;
|
||||
size?: number;
|
||||
active?: boolean;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
export const LoadingIndicator = ({
|
||||
active = true,
|
||||
look = "dark",
|
||||
title = "Cargando...",
|
||||
subtitle = "",
|
||||
}: LoadingIndicatorProps) => {
|
||||
const isDark = look === "dark";
|
||||
const loadingSpinClassName = isDark ? "text-brand" : "text-white";
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.LoadingIndicator}>
|
||||
<LoadingSpinIcon size={12} className={loadingSpinClassName} />
|
||||
{/*<Spinner {...spinnerProps} />*/}
|
||||
{title ? (
|
||||
<h2
|
||||
className={cn(
|
||||
styles.LoadingIndicator__title,
|
||||
isDark ? styles.LoadingIndicator__darktext : styles.LoadingIndicator__lighttext
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
) : null}
|
||||
{subtitle ? (
|
||||
<p
|
||||
className={cn(
|
||||
styles.LoadingIndicator__subtitle,
|
||||
isDark ? styles.LoadingIndicator__darktext : styles.LoadingIndicator__lighttext
|
||||
)}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
LoadingIndicator.displayName = "LoadingIndicator";
|
||||
@ -0,0 +1,31 @@
|
||||
export const LoadingSpinIcon = ({
|
||||
size = 5,
|
||||
color = 'brand',
|
||||
className,
|
||||
}: {
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}): JSX.Element => (
|
||||
<svg
|
||||
className={`animate-spin text-${color} w-${size} h-${size} ${className}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
@ -0,0 +1 @@
|
||||
export * from './LoadingSpinIcon';
|
||||
1
client/src/components/LoadingIndicator/index.ts
Normal file
1
client/src/components/LoadingIndicator/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './LoadingIndicator';
|
||||
@ -0,0 +1,8 @@
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.LoadingOverlay {
|
||||
@apply fixed top-0 bottom-0 left-0 right-0 z-50 w-full h-screen overflow-hidden;
|
||||
@apply flex justify-center bg-red-700 opacity-75;
|
||||
}
|
||||
}
|
||||
21
client/src/components/LoadingOverlay/LoadingOverlay.tsx
Normal file
21
client/src/components/LoadingOverlay/LoadingOverlay.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { LoadingIndicator } from "../LoadingIndicator";
|
||||
import styles from "./LoadingOverlay.module.css";
|
||||
|
||||
export type LoadingOverlayProps = {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
export const LoadingOverlay = ({
|
||||
title = "Cargando",
|
||||
subtitle = "Esto puede tardar unos segundos. Por favor, no cierre esta página.",
|
||||
...props
|
||||
}: LoadingOverlayProps) => {
|
||||
return (
|
||||
<div className={styles.LoadingOverlay} {...props}>
|
||||
<LoadingIndicator look='light' title={title} subtitle={subtitle} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
LoadingOverlay.displayName = "LoadingOverlay";
|
||||
1
client/src/components/LoadingOverlay/index.ts
Normal file
1
client/src/components/LoadingOverlay/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './LoadingOverlay';
|
||||
28
client/src/components/ProtectedRoute/ProtectedRoute.tsx
Normal file
28
client/src/components/ProtectedRoute/ProtectedRoute.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { useIsLoggedIn } from "@/lib/hooks";
|
||||
import React from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
type ProctectRouteProps = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ProtectedRoute = ({ children }: ProctectRouteProps) => {
|
||||
const { isSuccess, data } = useIsLoggedIn();
|
||||
|
||||
if (isSuccess && data) {
|
||||
const { authenticated, redirectTo } = data;
|
||||
if (authenticated) {
|
||||
return (
|
||||
<Navigate
|
||||
to={redirectTo ?? "/login"}
|
||||
replace
|
||||
state={{
|
||||
error: "No authentication, please complete the login process.",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
1
client/src/components/ProtectedRoute/index.ts
Normal file
1
client/src/components/ProtectedRoute/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./ProtectedRoute";
|
||||
@ -0,0 +1,16 @@
|
||||
export function TailwindIndicator() {
|
||||
if (import.meta.env.PROD) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed z-50 flex items-center justify-center p-3 font-mono text-xs text-white bg-gray-800 rounded-full bottom-1 left-1 size-6">
|
||||
<div className="block sm:hidden">xs</div>
|
||||
<div className="hidden sm:block md:hidden lg:hidden xl:hidden 2xl:hidden">
|
||||
sm
|
||||
</div>
|
||||
<div className="hidden md:block lg:hidden xl:hidden 2xl:hidden">md</div>
|
||||
<div className="hidden lg:block xl:hidden 2xl:hidden">lg</div>
|
||||
<div className="hidden xl:block 2xl:hidden">xl</div>
|
||||
<div className="hidden 2xl:block">2xl</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
client/src/components/TailwindIndicator/index.ts
Normal file
1
client/src/components/TailwindIndicator/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./TailwindIndicator";
|
||||
9
client/src/components/UeckoLogo/UeckoLogo.tsx
Normal file
9
client/src/components/UeckoLogo/UeckoLogo.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
export const UeckoLogo = ({ className }: { className: string }) => (
|
||||
<svg viewBox='0 0 336 133' fill='none' xmlns='http://www.w3.org/2000/svg' className={className}>
|
||||
<path
|
||||
d='M49.7002 83.0001H66.9002V22.5001H49.7002V56.2001C49.7002 64.3001 45.5002 68.5001 39.0002 68.5001C32.5002 68.5001 28.6002 64.3001 28.6002 56.2001V22.5001H0.700195V33.2001H11.4002V61.6001C11.4002 75.5001 19.0002 84.1001 31.9002 84.1001C40.6002 84.1001 45.7002 79.5001 49.6002 74.4001V83.0001H49.7002ZM120.6 48.0001H94.8002C96.2002 40.2001 100.8 35.1001 107.9 35.1001C115.1 35.2001 119.6 40.3001 120.6 48.0001ZM137.1 58.7001C137.2 57.1001 137.3 56.1001 137.3 54.4001V54.2001C137.3 37.0001 128 21.4001 107.8 21.4001C90.2002 21.4001 77.9002 35.6001 77.9002 52.9001V53.1001C77.9002 71.6001 91.3002 84.4001 109.5 84.4001C120.4 84.4001 128.6 80.1001 134.2 73.1001L124.4 64.4001C119.7 68.8001 115.5 70.6001 109.7 70.6001C102 70.6001 96.6002 66.5001 94.9002 58.7001H137.1ZM162.2 52.9001V52.7001C162.2 43.8001 168.3 36.2001 176.9 36.2001C183 36.2001 186.8 38.8001 190.7 42.9001L201.2 31.6001C195.6 25.3001 188.4 21.4001 177 21.4001C158.5 21.4001 145.3 35.6001 145.3 52.9001V53.1001C145.3 70.4001 158.6 84.4001 176.8 84.4001C188.9 84.4001 195.6 79.8001 201.5 73.3001L191.5 63.1001C187.3 67.1001 183.4 69.5001 177.6 69.5001C168.2 69.6001 162.2 62.1001 162.2 52.9001ZM269.1 83.0001L245.3 46.3001L268.3 22.5001H247.8L227.7 44.5001V0.600098H210.5V83.0001H227.7V64.6001L233.7 58.3001L249.5 83.0001H269.1ZM318.5 53.1001C318.5 62.0001 312.6 69.6001 302.8 69.6001C293.3 69.6001 286.9 61.8001 286.9 52.9001V52.7001C286.9 43.8001 292.8 36.2001 302.6 36.2001C312.1 36.2001 318.5 44.0001 318.5 52.9001V53.1001ZM335.4 52.9001V52.7001C335.4 35.3001 321.5 21.4001 302.8 21.4001C284 21.4001 270 35.5001 270 52.9001V53.1001C270 70.5001 283.9 84.4001 302.6 84.4001C321.4 84.4001 335.4 70.3001 335.4 52.9001Z'
|
||||
fill='black'
|
||||
className={className}
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
0
client/src/components/UeckoLogo/index.ts
Normal file
0
client/src/components/UeckoLogo/index.ts
Normal file
6
client/src/components/index.ts
Normal file
6
client/src/components/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from "./Container";
|
||||
export * from "./Forms";
|
||||
export * from "./LoadingIndicator";
|
||||
export * from "./LoadingOverlay";
|
||||
export * from "./ProtectedRoute";
|
||||
export * from "./TailwindIndicator";
|
||||
64
client/src/index.css
Normal file
64
client/src/index.css
Normal file
@ -0,0 +1,64 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* https://ui.jln.dev/ Light Blue */
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 210 40% 96.08%;
|
||||
--foreground: 334 55% 1%;
|
||||
--muted: 214.29 31.82% 91.37%;
|
||||
--muted-foreground: 334 9% 37%;
|
||||
--popover: 334 62% 100%;
|
||||
--popover-foreground: 334 55% 1%;
|
||||
--card: 334 62% 100%;
|
||||
--card-foreground: 334 55% 1%;
|
||||
--border: 334 5% 95%;
|
||||
--input: 334 5% 95%;
|
||||
--primary: 209.23 58.21% 39.41%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 213.75 20.25% 69.02%;
|
||||
--secondary-foreground: 334 0% 100%;
|
||||
--accent: 214.29 31.82% 91.37%;
|
||||
--accent-foreground: 334 20% 22%;
|
||||
--destructive: 348.37 78.4% 49.02%;
|
||||
--destructive-foreground: 18 0% 100%;
|
||||
--ring: 209.23 58.21% 39.41%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.22 47.37% 11.18%;
|
||||
--foreground: 334 34% 98%;
|
||||
--muted: 215.38 16.32% 46.86%;
|
||||
--muted-foreground: 334 0% 87.69%;
|
||||
--popover: 217.24 32.58% 17.45%;
|
||||
--popover-foreground: 334 34% 98%;
|
||||
--card: 217.24 32.58% 17.45%;
|
||||
--card-foreground: 334 34% 98%;
|
||||
--border: 334 0% 32.31%;
|
||||
--input: 215.29 25% 26.67%;
|
||||
--primary: 227.56 53.78% 49.22%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 214.29 5.04% 27.25%;
|
||||
--secondary-foreground: 334 0% 100%;
|
||||
--accent: 222.22 47.37% 11.18%;
|
||||
--accent-foreground: 226.73 0% 100%;
|
||||
--destructive: 358.82 84.44% 64.71%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--ring: 227.56 53.78% 49.22%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
tab-size: 4;
|
||||
font-feature-settings: normal;
|
||||
font-variation-settings: normal;
|
||||
@apply h-full text-foreground bg-background;
|
||||
}
|
||||
|
||||
#uecko {
|
||||
@apply flex min-h-screen min-w-[320px] flex-col;
|
||||
}
|
||||
}
|
||||
207
client/src/lib/axios/HttpError.ts
Normal file
207
client/src/lib/axios/HttpError.ts
Normal file
@ -0,0 +1,207 @@
|
||||
// https://dev.to/mperon/axios-error-handling-like-a-boss-333d
|
||||
|
||||
import axios, { AxiosError } from "axios";
|
||||
|
||||
export interface ValidationErrors {
|
||||
[field: string]:
|
||||
| string
|
||||
| string[]
|
||||
| boolean
|
||||
| { key: string; message: string };
|
||||
}
|
||||
|
||||
export interface IHttpError extends Record<string, any> {
|
||||
message: string;
|
||||
statusCode: number;
|
||||
errors?: ValidationErrors;
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message); // 'Error' breaks prototype chain here
|
||||
this.name = "HttpError";
|
||||
Object.setPrototypeOf(this, new.target.prototype); // restore prototype chain
|
||||
}
|
||||
}
|
||||
|
||||
export const httpErrorHandler = (error: any) => {
|
||||
if (error === null) throw new Error("Unrecoverable error!! Error is null!");
|
||||
if (axios.isAxiosError(error)) {
|
||||
//here we have a type guard check, error inside this if will be treated as AxiosError
|
||||
const response = error?.response;
|
||||
const request = error?.request;
|
||||
const config = error?.config; //here we have access the config used to make the api call (we can make a retry using this conf)
|
||||
|
||||
if (error.code === "ERR_NETWORK") {
|
||||
console.log("connection problems..");
|
||||
} else if (error.code === "ERR_CANCELED") {
|
||||
console.log("connection canceled..");
|
||||
}
|
||||
if (response) {
|
||||
//The request was made and the server responded with a status code that falls out of the range of 2xx the http status code mentioned above
|
||||
const statusCode = response?.status;
|
||||
if (statusCode === 404) {
|
||||
console.log(
|
||||
"The requested resource does not exist or has been deleted",
|
||||
);
|
||||
} else if (statusCode === 401) {
|
||||
console.log("Please login to access this resource");
|
||||
//redirect user to login
|
||||
}
|
||||
} else if (request) {
|
||||
//The request was made but no response was received, `error.request` is an instance of XMLHttpRequest in the browser and an instance of http.ClientRequest in Node.js
|
||||
}
|
||||
}
|
||||
//Something happened in setting up the request and triggered an Error
|
||||
console.log(error.message);
|
||||
};
|
||||
|
||||
interface HttpData {
|
||||
code: string;
|
||||
description?: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
// this is all errrors allowed to receive
|
||||
type THttpError = Error | AxiosError | null;
|
||||
|
||||
// object that can be passed to our registy
|
||||
interface ErrorHandlerObject {
|
||||
after?(error?: THttpError, options?: ErrorHandlerObject): void;
|
||||
before?(error?: THttpError, options?: ErrorHandlerObject): void;
|
||||
message?: string;
|
||||
notify?: any; //QNotifyOptions;
|
||||
}
|
||||
|
||||
//signature of error function that can be passed to ours registry
|
||||
type ErrorHandlerFunction = (
|
||||
error?: THttpError,
|
||||
) => ErrorHandlerObject | boolean | undefined;
|
||||
|
||||
//type that our registry accepts
|
||||
type ErrorHandler = ErrorHandlerFunction | ErrorHandlerObject | string;
|
||||
|
||||
//interface for register many handlers once (object where key will be presented as search key for error handling
|
||||
interface ErrorHandlerMany {
|
||||
[key: string]: ErrorHandler;
|
||||
}
|
||||
|
||||
// type guard to identify that is an ErrorHandlerObject
|
||||
function isErrorHandlerObject(value: any): value is ErrorHandlerObject {
|
||||
if (typeof value === "object") {
|
||||
return ["message", "after", "before", "notify"].some((k) => k in value);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
class ErrorHandlerRegistry {
|
||||
private handlers = new Map<string, ErrorHandler>();
|
||||
|
||||
private parent: ErrorHandlerRegistry | null = null;
|
||||
|
||||
constructor(
|
||||
parent: ErrorHandlerRegistry = undefined,
|
||||
input?: ErrorHandlerMany,
|
||||
) {
|
||||
if (typeof parent !== "undefined") this.parent = parent;
|
||||
if (typeof input !== "undefined") this.registerMany(input);
|
||||
}
|
||||
|
||||
// allow to register an handler
|
||||
register(key: string, handler: ErrorHandler) {
|
||||
this.handlers.set(key, handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
// unregister a handler
|
||||
unregister(key: string) {
|
||||
this.handlers.delete(key);
|
||||
return this;
|
||||
}
|
||||
|
||||
// search a valid handler by key
|
||||
find(seek: string): ErrorHandler | undefined {
|
||||
const handler = this.handlers.get(seek);
|
||||
if (handler) return handler;
|
||||
return this.parent?.find(seek);
|
||||
}
|
||||
|
||||
// pass an object and register all keys/value pairs as handler.
|
||||
registerMany(input: ErrorHandlerMany) {
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
this.register(key, value);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
// handle error seeking for key
|
||||
handleError(
|
||||
this: ErrorHandlerRegistry,
|
||||
seek: (string | undefined)[] | string,
|
||||
error: THttpError,
|
||||
): boolean {
|
||||
if (Array.isArray(seek)) {
|
||||
return seek.some((key) => {
|
||||
if (key !== undefined) return this.handleError(String(key), error);
|
||||
});
|
||||
}
|
||||
const handler = this.find(String(seek));
|
||||
if (!handler) {
|
||||
return false;
|
||||
} else if (typeof handler === "string") {
|
||||
return this.handleErrorObject(error, { message: handler });
|
||||
} else if (typeof handler === "function") {
|
||||
const result = handler(error);
|
||||
if (isErrorHandlerObject(result))
|
||||
return this.handleErrorObject(error, result);
|
||||
return !!result;
|
||||
} else if (isErrorHandlerObject(handler)) {
|
||||
return this.handleErrorObject(error, handler);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// if the error is an ErrorHandlerObject, handle here
|
||||
handleErrorObject(error: THttpError, options: ErrorHandlerObject = {}) {
|
||||
options?.before?.(error, options);
|
||||
showToastError(options.message ?? "Unknown Error!!", options, "error");
|
||||
return true;
|
||||
}
|
||||
|
||||
// this is the function that will be registered in interceptor.
|
||||
resposeErrorHandler(
|
||||
this: ErrorHandlerRegistry,
|
||||
error: THttpError,
|
||||
direct?: boolean,
|
||||
) {
|
||||
if (error === null)
|
||||
throw new Error("Unrecoverrable error!! Error is null!");
|
||||
if (axios.isAxiosError(error)) {
|
||||
const response = error?.response;
|
||||
const config = error?.config;
|
||||
const data = response?.data as HttpData;
|
||||
if (!direct && config?.raw) throw error;
|
||||
const seekers = [
|
||||
data?.code,
|
||||
error.code,
|
||||
error?.name,
|
||||
String(data?.status),
|
||||
String(response?.status),
|
||||
];
|
||||
const result = this.handleError(seekers, error);
|
||||
if (!result) {
|
||||
if (data?.code && data?.description) {
|
||||
return this.handleErrorObject(error, {
|
||||
message: data?.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
return this.handleError(error.name, error);
|
||||
}
|
||||
//if nothings works, throw away
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// create ours globalHandlers object
|
||||
const globalHandlers = new ErrorHandlerRegistry();
|
||||
120
client/src/lib/axios/axiosInstance.ts
Normal file
120
client/src/lib/axios/axiosInstance.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import axiosClient from "axios";
|
||||
import { setupInterceptorsTo } from "./setupInterceptors";
|
||||
|
||||
// extend the AxiosRequestConfig interface and add two optional options raw and silent. I
|
||||
// https://dev.to/mperon/axios-error-handling-like-a-boss-333d
|
||||
declare module "axios" {
|
||||
export interface AxiosRequestConfig {
|
||||
raw?: boolean;
|
||||
silent?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export const defaultAxiosRequestConfig = {
|
||||
mode: "cors",
|
||||
cache: "no-cache",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-cache",
|
||||
//'api-key': SERVER_API_KEY,
|
||||
},
|
||||
//timeout: 300,
|
||||
|
||||
// `onUploadProgress` allows handling of progress events for uploads
|
||||
// browser only
|
||||
//onUploadProgress: function (progressEvent) {
|
||||
// Do whatever you want with the native progress event
|
||||
//},
|
||||
|
||||
// `onDownloadProgress` allows handling of progress events for downloads
|
||||
// browser only
|
||||
//onDownloadProgress: function (progressEvent) {
|
||||
// Do whatever you want with the native progress event
|
||||
//},
|
||||
|
||||
// `cancelToken` specifies a cancel token that can be used to cancel the request
|
||||
/*cancelToken: new CancelToken(function (cancel) {
|
||||
}),*/
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an initial 'axios' instance with custom settings.
|
||||
*/
|
||||
const axiosInstance = setupInterceptorsTo(axiosClient.create(defaultAxiosRequestConfig));
|
||||
|
||||
/**
|
||||
* Handle all responses.
|
||||
*/
|
||||
/*axiosInstance.interceptors.response.use(
|
||||
(response) => {
|
||||
if (!response) {
|
||||
return Promise.resolve({
|
||||
statusCode: 500,
|
||||
body: null,
|
||||
});
|
||||
}
|
||||
|
||||
const { data, headers } = response;
|
||||
const DTOBody = !isArray(data)
|
||||
? data
|
||||
: {
|
||||
items: data,
|
||||
totalCount: headers["x-total-count"]
|
||||
? parseInt(headers["x-total-count"])
|
||||
: data.length,
|
||||
};
|
||||
|
||||
const result = {
|
||||
statusCode: response.status,
|
||||
body: DTOBody,
|
||||
};
|
||||
|
||||
//console.log('Axios OK => ', result);
|
||||
|
||||
return Promise.resolve(result);
|
||||
},
|
||||
(error) => {
|
||||
console.group("Axios error:");
|
||||
if (error.response) {
|
||||
// La respuesta fue hecha y el servidor respondió con un código de estado
|
||||
// que esta fuera del rango de 2xx
|
||||
console.log("1 => El servidor respondió con un código de estado > 200");
|
||||
console.log(error.response.data);
|
||||
console.log(error.response.status);
|
||||
} else if (error.request) {
|
||||
// La petición fue hecha pero no se recibió respuesta
|
||||
console.log("2 => El servidor no respondió");
|
||||
console.log(error.request);
|
||||
} else {
|
||||
// Algo paso al preparar la petición que lanzo un Error
|
||||
console.log("3 => Error desconocido");
|
||||
console.log("Error", error.message);
|
||||
}
|
||||
|
||||
const customError = {
|
||||
message: error.response?.data?.message,
|
||||
statusCode: error.response?.status,
|
||||
};
|
||||
|
||||
console.log("Axios BAD => ", error, customError);
|
||||
console.groupEnd();
|
||||
|
||||
return Promise.reject(customError);
|
||||
}
|
||||
);*/
|
||||
|
||||
/**
|
||||
* Replaces main `axios` instance with the custom-one.
|
||||
*
|
||||
* @param config - Axios configuration object.
|
||||
* @returns A promise object of a response of the HTTP request with the 'data' object already
|
||||
* destructured.
|
||||
*/
|
||||
/*const axios = <T>(config: AxiosRequestConfig) =>
|
||||
axiosInstance.request<any, T>(config);
|
||||
|
||||
export default axios;*/
|
||||
|
||||
export const createAxiosInstance = () => axiosInstance;
|
||||
66
client/src/lib/axios/createAxiosAuthActions.ts
Normal file
66
client/src/lib/axios/createAxiosAuthActions.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { ILogin_DTO, ILogin_Response_DTO } from "@shared/contexts";
|
||||
import secureLocalStorage from "react-secure-storage";
|
||||
import { IAuthActions } from "../hooks";
|
||||
import { createAxiosInstance } from "./axiosInstance";
|
||||
|
||||
export const createAxiosAuthActions = (
|
||||
apiUrl: string,
|
||||
httpClient = createAxiosInstance()
|
||||
): IAuthActions => ({
|
||||
login: async ({ email, password }: ILogin_DTO) => {
|
||||
// eslint-disable-next-line no-useless-catch
|
||||
try {
|
||||
const { data } = await httpClient.request<ILogin_Response_DTO>({
|
||||
url: `${apiUrl}/auth/login`,
|
||||
method: "POST",
|
||||
data: {
|
||||
email,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
secureLocalStorage.setItem("uecko", JSON.stringify(data));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
redirectTo: "/",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message: "Login failed",
|
||||
name: "Invalid email or password",
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
logout: () => {
|
||||
secureLocalStorage.removeItem("uecko");
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
redirectTo: "/login",
|
||||
});
|
||||
},
|
||||
check: () => {
|
||||
const data: ILogin_Response_DTO = JSON.parse(
|
||||
secureLocalStorage.getItem("uecko")?.toString() || ""
|
||||
);
|
||||
return Promise.resolve(
|
||||
data.token
|
||||
? {
|
||||
authenticated: true,
|
||||
}
|
||||
: { authenticated: false, redirectTo: "/login" }
|
||||
);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error(error);
|
||||
secureLocalStorage.removeItem("uecko");
|
||||
return Promise.resolve({
|
||||
error,
|
||||
logout: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
309
client/src/lib/axios/createAxiosDataProvider.ts
Normal file
309
client/src/lib/axios/createAxiosDataProvider.ts
Normal file
@ -0,0 +1,309 @@
|
||||
import { IListResponse_DTO } from "@shared/contexts";
|
||||
import {
|
||||
ICreateOneDataProviderParams,
|
||||
IDataSource,
|
||||
IFilterItemDataProviderParam,
|
||||
IGetListDataProviderParams,
|
||||
IGetOneDataProviderParams,
|
||||
IPaginationDataProviderParam,
|
||||
IRemoveOneDataProviderParams,
|
||||
ISortItemDataProviderParam,
|
||||
IUpdateOneDataProviderParams,
|
||||
} from "../hooks/useDataSource/DataSource";
|
||||
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "../hooks/usePagination";
|
||||
import { createAxiosInstance } from "./axiosInstance";
|
||||
|
||||
export const createAxiosDataProvider = (
|
||||
apiUrl: string,
|
||||
httpClient = createAxiosInstance()
|
||||
): IDataSource => ({
|
||||
name: () => "AxiosDataProvider",
|
||||
|
||||
getList: async <R>(params: IGetListDataProviderParams): Promise<IListResponse_DTO<R>> => {
|
||||
const { resource, quickSearchTerm, pagination, filters, sort } = params;
|
||||
|
||||
const url = `${apiUrl}/${resource}`;
|
||||
const urlParams = new URLSearchParams();
|
||||
|
||||
const queryPagination = extractPaginationParams(pagination);
|
||||
urlParams.append("page", String(queryPagination.page));
|
||||
urlParams.append("limit", String(queryPagination.limit));
|
||||
|
||||
const generatedSort = extractSortParams(sort);
|
||||
if (generatedSort && generatedSort.length > 0) {
|
||||
urlParams.append("$sort_by", generatedSort.join(","));
|
||||
}
|
||||
|
||||
const queryQuickSearch = quickSearchTerm || generateQuickSearch(filters);
|
||||
if (queryQuickSearch) {
|
||||
urlParams.append("q", queryQuickSearch);
|
||||
}
|
||||
|
||||
const queryFilters = extractFilterParams(filters);
|
||||
if (queryFilters && queryFilters.length > 0) {
|
||||
urlParams.append("$filters", queryFilters.join(","));
|
||||
}
|
||||
|
||||
const response = await httpClient.request<IListResponse_DTO<R>>({
|
||||
url: `${url}?${urlParams.toString()}`,
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getOne: async <R>(params: IGetOneDataProviderParams): Promise<R> => {
|
||||
const { resource, id } = params;
|
||||
|
||||
const response = await httpClient.request<R>({
|
||||
url: `${apiUrl}/${resource}/${id}`,
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/*saveOne: async <P, R>(params: ISaveOneDataProviderParams<P>): Promise<R> => {
|
||||
const { resource, data, id } = params;
|
||||
|
||||
console.log(params);
|
||||
|
||||
const result = await httpClient.request<R>({
|
||||
url: `${apiUrl}/${resource}/${id}`,
|
||||
method: "PUT",
|
||||
data,
|
||||
});
|
||||
|
||||
return result.data;
|
||||
},*/
|
||||
|
||||
createOne: async <P, R>(params: ICreateOneDataProviderParams<P>): Promise<R> => {
|
||||
const { resource, data } = params;
|
||||
|
||||
const response = await httpClient.request<R>({
|
||||
url: `${apiUrl}/${resource}`,
|
||||
method: "POST",
|
||||
data,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateOne: async <P, R>(params: IUpdateOneDataProviderParams<P>): Promise<R> => {
|
||||
const { resource, data, id } = params;
|
||||
|
||||
const response = await httpClient.request<R>({
|
||||
url: `${apiUrl}/${resource}/${id}`,
|
||||
method: "PUT",
|
||||
data,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
removeOne: async <R>(params: IRemoveOneDataProviderParams) => {
|
||||
const { resource, id } = params;
|
||||
|
||||
await httpClient.request<R>({
|
||||
url: `${apiUrl}/${resource}/${id}`,
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
return;
|
||||
},
|
||||
|
||||
/*getMany: async ({ resource }) => {
|
||||
const { body } = await httpClient.request({
|
||||
url: `${apiUrl}/${resource}`,
|
||||
method: "GET",
|
||||
//...defaultRequestConfig,
|
||||
});
|
||||
|
||||
return body;
|
||||
},*/
|
||||
|
||||
/*create: async ({ resource, values }) => {
|
||||
const url = `${apiUrl}/${resource}`;
|
||||
|
||||
const { body } = await httpClient.post(url, values, defaultRequestConfig);
|
||||
|
||||
return body;
|
||||
},*/
|
||||
|
||||
/*createMany: async ({ resource, values }) => {
|
||||
const response = await Promise.all(
|
||||
values.map(async (param) => {
|
||||
const { body } = await httpClient.post(
|
||||
`${apiUrl}/${resource}`,
|
||||
param
|
||||
//defaultRequestConfig,
|
||||
);
|
||||
return body;
|
||||
})
|
||||
);
|
||||
|
||||
return response;
|
||||
},*/
|
||||
|
||||
/*update: async ({ resource, id, values }) => {
|
||||
const url = `${apiUrl}/${resource}/${id}`;
|
||||
const { body } = await httpClient.patch(url, values, defaultRequestConfig);
|
||||
return body;
|
||||
},*/
|
||||
|
||||
/*updateMany: async ({ resource, ids, values }) => {
|
||||
const response = await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const { body } = await httpClient.patch(
|
||||
`${apiUrl}/${resource}/${id}`,
|
||||
values
|
||||
//defaultRequestConfig,
|
||||
);
|
||||
return body;
|
||||
})
|
||||
);
|
||||
|
||||
return response;
|
||||
},*/
|
||||
|
||||
// removeMany: async ({ resource, ids }) => {
|
||||
// const url = `${apiUrl}/${resource}/bulk-delete`;
|
||||
|
||||
// const { body } = await httpClient.request({
|
||||
// url,
|
||||
// method: "PATCH",
|
||||
// data: {
|
||||
// ids,
|
||||
// },
|
||||
// //defaultRequestConfig,
|
||||
// });
|
||||
|
||||
// return body;
|
||||
// },
|
||||
|
||||
// upload: async ({ resource, file, onUploadProgress }) => {
|
||||
// const url = `${apiUrl}/${resource}`;
|
||||
// const options = {
|
||||
// //...defaultRequestConfig,
|
||||
// onUploadProgress,
|
||||
// headers: {
|
||||
// //...defaultRequestConfig.headers,
|
||||
// "Content-Type": "multipart/form-data",
|
||||
// },
|
||||
// };
|
||||
|
||||
// const formData = new FormData();
|
||||
// formData.append("file", file);
|
||||
|
||||
// const { body } = await httpClient.post(url, formData, options);
|
||||
// return body;
|
||||
// },
|
||||
|
||||
/*uploadMany: async ({ resource, values }) => {
|
||||
const url = `${apiUrl}/${resource}`;
|
||||
const options = {
|
||||
//...defaultRequestConfig,
|
||||
headers: {
|
||||
...defaultRequestConfig.headers,
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
};
|
||||
|
||||
const response = await Promise.all(
|
||||
values.map(async (value) => {
|
||||
const { body } = await httpClient.post(
|
||||
url,
|
||||
value,
|
||||
options
|
||||
);
|
||||
return body;
|
||||
}),
|
||||
);
|
||||
|
||||
return response;
|
||||
},*/
|
||||
|
||||
/*custom: async ({ url, method, filters, sort, payload, query, headers }) => {
|
||||
let requestUrl = `${url}?`;
|
||||
|
||||
if (sort) {
|
||||
const generatedSort = extractSortParams(sort);
|
||||
if (generatedSort) {
|
||||
const { _sort, _order } = generatedSort;
|
||||
const sortQuery = {
|
||||
_sort: _sort.join(","),
|
||||
_order: _order.join(","),
|
||||
};
|
||||
requestUrl = `${requestUrl}&${queryString.stringify(sortQuery)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (filters) {
|
||||
const filterQuery = extractFilterParams(filters);
|
||||
requestUrl = `${requestUrl}&${queryString.stringify(filterQuery)}`;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
requestUrl = `${requestUrl}&${queryString.stringify(query)}`;
|
||||
}
|
||||
|
||||
if (headers) {
|
||||
httpClient.defaults.headers = {
|
||||
...httpClient.defaults.headers,
|
||||
...headers,
|
||||
};
|
||||
}
|
||||
|
||||
let axiosResponse;
|
||||
switch (method) {
|
||||
case "put":
|
||||
case "post":
|
||||
case "patch":
|
||||
axiosResponse = await httpClient[method](url, payload);
|
||||
break;
|
||||
case "remove":
|
||||
axiosResponse = await httpClient.delete(url);
|
||||
break;
|
||||
default:
|
||||
axiosResponse = await httpClient.get(requestUrl);
|
||||
break;
|
||||
}
|
||||
|
||||
const { data } = axiosResponse;
|
||||
|
||||
return Promise.resolve({ data });
|
||||
},*/
|
||||
});
|
||||
|
||||
const extractSortParams = (sort: ISortItemDataProviderParam[] = []) =>
|
||||
sort.map((item) => `${item.order === "DESC" ? "-" : "+"}${item.field}`);
|
||||
|
||||
const extractFilterParams = (filters?: IFilterItemDataProviderParam[]): string[] => {
|
||||
let queryFilters: string[] = [];
|
||||
if (filters) {
|
||||
queryFilters = filters
|
||||
.filter((item) => item.field !== "q")
|
||||
.map(({ field, operator, value }) => `${field}[${operator}]${value}`);
|
||||
}
|
||||
return queryFilters;
|
||||
};
|
||||
|
||||
const generateQuickSearch = (filters?: IFilterItemDataProviderParam[]): string | undefined => {
|
||||
let quickSearch: string | undefined = undefined;
|
||||
if (filters) {
|
||||
const qsArray = filters.filter((item) => item.field === "q");
|
||||
if (qsArray.length > 0) {
|
||||
quickSearch = qsArray[0].value;
|
||||
}
|
||||
}
|
||||
return quickSearch;
|
||||
};
|
||||
|
||||
const extractPaginationParams = (pagination?: IPaginationDataProviderParam) => {
|
||||
const { pageIndex = INITIAL_PAGE_INDEX, pageSize = INITIAL_PAGE_SIZE } = pagination || {};
|
||||
|
||||
return {
|
||||
page: pageIndex,
|
||||
limit: pageSize,
|
||||
};
|
||||
};
|
||||
65
client/src/lib/axios/createJSONDataProvider.ts
Normal file
65
client/src/lib/axios/createJSONDataProvider.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { IListResponse_DTO } from "@shared/contexts";
|
||||
import {
|
||||
IDataSource,
|
||||
IGetListDataProviderParams,
|
||||
IGetOneDataProviderParams,
|
||||
IPaginationDataProviderParam,
|
||||
} from "../hooks/useDataSource/DataSource";
|
||||
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "../hooks/usePagination";
|
||||
|
||||
export const createJSONDataProvider = (jsonData: unknown[]): IDataSource => ({
|
||||
name: () => "JSONDataProvider",
|
||||
|
||||
getList: <R>(params: IGetListDataProviderParams): Promise<IListResponse_DTO<R>> => {
|
||||
const { resource, quickSearchTerm, pagination, filters, sort } = params;
|
||||
const queryPagination = extractPaginationParams(pagination);
|
||||
|
||||
const items = jsonData.slice(
|
||||
queryPagination.page * queryPagination.limit,
|
||||
queryPagination.page * queryPagination.limit + queryPagination.limit
|
||||
);
|
||||
|
||||
const totalItems = jsonData.length;
|
||||
const totalPages = Math.ceil(totalItems / queryPagination.limit);
|
||||
|
||||
const response: IListResponse_DTO<R> = {
|
||||
page: queryPagination.page,
|
||||
per_page: queryPagination.limit,
|
||||
total_pages: totalPages,
|
||||
total_items: totalItems,
|
||||
items,
|
||||
};
|
||||
|
||||
return Promise.resolve(response);
|
||||
},
|
||||
|
||||
getOne: async <R>(params: IGetOneDataProviderParams): Promise<R> => {
|
||||
/*const { resource, id } = params;
|
||||
|
||||
const response = await httpClient.request<R>({
|
||||
url: `${apiUrl}/${resource}/${id}`,
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
return response.data;*/
|
||||
},
|
||||
|
||||
createOne: <P>(params: ICreateOneDataProviderParams<P>) => {
|
||||
throw Error;
|
||||
},
|
||||
updateOne: <P>(params: IUpdateOneDataProviderParams<P>) => {
|
||||
throw Error;
|
||||
},
|
||||
removeOne: (params: IRemoveOneDataProviderParams) => {
|
||||
throw Error;
|
||||
},
|
||||
});
|
||||
|
||||
const extractPaginationParams = (pagination?: IPaginationDataProviderParam) => {
|
||||
const { pageIndex = INITIAL_PAGE_INDEX, pageSize = INITIAL_PAGE_SIZE } = pagination || {};
|
||||
|
||||
return {
|
||||
page: pageIndex,
|
||||
limit: pageSize,
|
||||
};
|
||||
};
|
||||
2
client/src/lib/axios/index.ts
Normal file
2
client/src/lib/axios/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./HttpError";
|
||||
export * from "./createAxiosDataProvider";
|
||||
104
client/src/lib/axios/setupInterceptors.ts
Normal file
104
client/src/lib/axios/setupInterceptors.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import {
|
||||
AxiosError,
|
||||
AxiosInstance,
|
||||
AxiosResponse,
|
||||
InternalAxiosRequestConfig,
|
||||
} from "axios";
|
||||
|
||||
//use(onFulfilled?: ((value: V) => V | Promise<V>) | null,
|
||||
//onRejected?: ((error: any) => any) | null, options?: AxiosInterceptorOptions): number;
|
||||
|
||||
const onRequest = (
|
||||
request: InternalAxiosRequestConfig
|
||||
): InternalAxiosRequestConfig => {
|
||||
/*console.group("[request]");
|
||||
console.dir(request);
|
||||
console.groupEnd();*/
|
||||
|
||||
return request;
|
||||
};
|
||||
|
||||
const onRequestError = (error: AxiosError): Promise<AxiosError> => {
|
||||
/*console.group("[request error]");
|
||||
console.dir(error);
|
||||
console.groupEnd();*/
|
||||
|
||||
return Promise.reject(error);
|
||||
};
|
||||
|
||||
const onResponse = (response: AxiosResponse): AxiosResponse => {
|
||||
console.group("[response]");
|
||||
console.dir(response);
|
||||
console.groupEnd();
|
||||
|
||||
const config = response?.config;
|
||||
if (config.raw) {
|
||||
return response;
|
||||
}
|
||||
|
||||
/*if (response.status === 200) {
|
||||
const data = response?.data;
|
||||
if (!data) {
|
||||
throw new HttpError("API Error. No data!");
|
||||
}
|
||||
return data;
|
||||
}*/
|
||||
//throw new HttpError("API Error! Invalid status code!");
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
const onResponseError = (error: AxiosError): Promise<AxiosError> => {
|
||||
console.group("[response error]");
|
||||
console.log(error);
|
||||
|
||||
if (error.response) {
|
||||
// La respuesta fue hecha y el servidor respondió con un código de estado
|
||||
// que esta fuera del rango de 2xx
|
||||
console.log("1 => El servidor respondió con un código de estado > 200");
|
||||
console.log(error.response.data);
|
||||
console.log(error.response.status);
|
||||
|
||||
switch (error.response.status) {
|
||||
case 400:
|
||||
console.error("Bad Request");
|
||||
break;
|
||||
case 401:
|
||||
console.error("UnAuthorized");
|
||||
break;
|
||||
case 403:
|
||||
console.error("Forbidden");
|
||||
break;
|
||||
/*AppEvents.publish(Events.N_Error, {
|
||||
message: "Forbidden",
|
||||
description: "Operation ",
|
||||
});*/
|
||||
case 404:
|
||||
console.error("Not found");
|
||||
break;
|
||||
|
||||
case 422:
|
||||
console.error("Unprocessable Content");
|
||||
throw error.response.data;
|
||||
}
|
||||
console.error(error.response.status);
|
||||
} else if (error.request) {
|
||||
// La petición fue hecha pero no se recibió respuesta
|
||||
console.log("2 => El servidor no respondió");
|
||||
console.error(error);
|
||||
} else {
|
||||
// Algo paso al preparar la petición que lanzo un Error
|
||||
console.log("3 => Error desconocido");
|
||||
console.error(error);
|
||||
}
|
||||
console.groupEnd();
|
||||
throw error;
|
||||
};
|
||||
|
||||
export function setupInterceptorsTo(
|
||||
axiosInstance: AxiosInstance
|
||||
): AxiosInstance {
|
||||
axiosInstance.interceptors.request.use(onRequest, onRequestError);
|
||||
axiosInstance.interceptors.response.use(onResponse, onResponseError);
|
||||
return axiosInstance;
|
||||
}
|
||||
0
client/src/lib/helpers/index.ts
Normal file
0
client/src/lib/helpers/index.ts
Normal file
15
client/src/lib/hooks/index.ts
Normal file
15
client/src/lib/hooks/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/*export * from "./useBreadcrumbs";
|
||||
export * from "./useDataSource";
|
||||
export * from "./useDataTable";
|
||||
export * from "./useForm";
|
||||
export * from "./useLoadingOvertime";
|
||||
export * from "./useMounted";
|
||||
export * from "./usePagination";
|
||||
export * from "./useRemoveAlertDialog";
|
||||
export * from "./useResizeObserver";
|
||||
|
||||
export * from "./useUrlId";
|
||||
*/
|
||||
|
||||
export * from "./useAuth";
|
||||
export * from "./useTheme";
|
||||
41
client/src/lib/hooks/useAuth/AuthActions.ts
Normal file
41
client/src/lib/hooks/useAuth/AuthActions.ts
Normal file
@ -0,0 +1,41 @@
|
||||
export type SuccessNotificationResponse = {
|
||||
message: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type PermissionResponse = unknown;
|
||||
|
||||
export type IdentityResponse = unknown;
|
||||
|
||||
export type AuthActionCheckResponse = {
|
||||
authenticated: boolean;
|
||||
redirectTo?: string;
|
||||
logout?: boolean;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
export type AuthActionOnErrorResponse = {
|
||||
redirectTo?: string;
|
||||
logout?: boolean;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
export type AuthActionResponse = {
|
||||
success: boolean;
|
||||
redirectTo?: string;
|
||||
error?: Error;
|
||||
[key: string]: unknown;
|
||||
successNotification?: SuccessNotificationResponse;
|
||||
};
|
||||
|
||||
export interface IAuthActions {
|
||||
login: (params: any) => Promise<AuthActionResponse>;
|
||||
logout: (params: any) => Promise<AuthActionResponse>;
|
||||
check: (params?: any) => Promise<AuthActionCheckResponse>;
|
||||
onError?: (error: any) => Promise<AuthActionOnErrorResponse>;
|
||||
register?: (params: unknown) => Promise<AuthActionResponse>;
|
||||
forgotPassword?: (params: unknown) => Promise<AuthActionResponse>;
|
||||
updatePassword?: (params: unknown) => Promise<AuthActionResponse>;
|
||||
getPermissions?: (params?: Record<string, unknown>) => Promise<PermissionResponse>;
|
||||
getIdentity?: (params?: unknown) => Promise<IdentityResponse>;
|
||||
}
|
||||
51
client/src/lib/hooks/useAuth/AuthContext.tsx
Normal file
51
client/src/lib/hooks/useAuth/AuthContext.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { PropsWithChildren, createContext } from "react";
|
||||
import { IAuthActions } from "./AuthActions";
|
||||
|
||||
export interface IAuthContextState extends IAuthActions {}
|
||||
|
||||
export const AuthContext = createContext<IAuthContextState | null>(null);
|
||||
|
||||
export const AuthProvider = ({
|
||||
children,
|
||||
authActions,
|
||||
}: PropsWithChildren<{ authActions: Partial<IAuthActions> }>) => {
|
||||
const handleLogin = (params: unknown) => {
|
||||
try {
|
||||
return Promise.resolve(authActions.login?.(params));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = (params: unknown) => {
|
||||
try {
|
||||
return Promise.resolve(authActions.logout?.(params));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheck = async (params: unknown) => {
|
||||
try {
|
||||
return Promise.resolve(authActions.check?.(params));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
...authActions,
|
||||
login: handleLogin as IAuthActions["login"],
|
||||
logout: handleLogout as IAuthActions["logout"],
|
||||
check: handleCheck as IAuthActions["check"],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
5
client/src/lib/hooks/useAuth/index.ts
Normal file
5
client/src/lib/hooks/useAuth/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "./AuthActions";
|
||||
export * from "./AuthContext";
|
||||
export * from "./useAuth";
|
||||
export * from "./useIsLoggedIn";
|
||||
export * from "./useLogin";
|
||||
8
client/src/lib/hooks/useAuth/useAuth.tsx
Normal file
8
client/src/lib/hooks/useAuth/useAuth.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import { useContext } from "react";
|
||||
import { AuthContext } from "./AuthContext";
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === null) throw new Error("useAuth must be used within a AuthProvider");
|
||||
return context;
|
||||
};
|
||||
16
client/src/lib/hooks/useAuth/useIsLoggedIn.tsx
Normal file
16
client/src/lib/hooks/useAuth/useIsLoggedIn.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { AuthActionCheckResponse, useAuth } from "@/lib/hooks";
|
||||
import { UseMutationOptions, useMutation } from "@tanstack/react-query";
|
||||
import { useQueryKey } from "../useQueryKey";
|
||||
|
||||
export const useIsLoggedIn = (
|
||||
params?: UseMutationOptions<AuthActionCheckResponse, Error, unknown>
|
||||
) => {
|
||||
const keys = useQueryKey();
|
||||
const { check } = useAuth();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: keys().auth().action("check").get(),
|
||||
mutationFn: check,
|
||||
...params,
|
||||
});
|
||||
};
|
||||
36
client/src/lib/hooks/useAuth/useLogin.tsx
Normal file
36
client/src/lib/hooks/useAuth/useLogin.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { AuthActionResponse, useAuth } from "@/lib/hooks";
|
||||
import { ILogin_DTO } from "@shared/contexts";
|
||||
import { UseMutationOptions, useMutation } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { useQueryKey } from "../useQueryKey";
|
||||
|
||||
export const useLogin = (params?: UseMutationOptions<AuthActionResponse, Error, ILogin_DTO>) => {
|
||||
const { onSuccess, onError, ...restParams } = params || {};
|
||||
const keys = useQueryKey();
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: keys().auth().action("login").get(),
|
||||
mutationFn: login,
|
||||
onSuccess: async (data, variables, context) => {
|
||||
const { success, redirectTo } = data;
|
||||
if (success && redirectTo) {
|
||||
navigate(redirectTo);
|
||||
}
|
||||
if (onSuccess) {
|
||||
onSuccess(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
const { message } = error;
|
||||
toast.error(message);
|
||||
|
||||
if (onError) {
|
||||
onError(error, variables, context);
|
||||
}
|
||||
},
|
||||
...restParams,
|
||||
});
|
||||
};
|
||||
19
client/src/lib/hooks/useBreadcrumbs.tsx
Normal file
19
client/src/lib/hooks/useBreadcrumbs.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMatches } from "react-router";
|
||||
|
||||
export const useBreadcrumbs = (): any[] => {
|
||||
const [crumbs, setCrumbs] = useState<any[]>([]);
|
||||
let matches = useMatches();
|
||||
|
||||
useEffect(() => {
|
||||
const _crumbs = matches
|
||||
// @ts-ignore
|
||||
.filter((match) => Boolean(match.handle?.crumb))
|
||||
// @ts-ignore
|
||||
.map((match) => match.handle?.crumb(match.data));
|
||||
|
||||
setCrumbs(_crumbs);
|
||||
}, matches);
|
||||
|
||||
return crumbs;
|
||||
};
|
||||
82
client/src/lib/hooks/useDataSource/DataSource.ts
Normal file
82
client/src/lib/hooks/useDataSource/DataSource.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { IListResponse_DTO } from "@shared/contexts";
|
||||
|
||||
export interface IPaginationDataProviderParam {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface ISortItemDataProviderParam {
|
||||
order: string;
|
||||
field: string;
|
||||
}
|
||||
|
||||
export interface IFilterItemDataProviderParam {
|
||||
field: string;
|
||||
operator?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface IGetListDataProviderParams {
|
||||
resource: string;
|
||||
quickSearchTerm?: string;
|
||||
pagination?: IPaginationDataProviderParam;
|
||||
sort?: ISortItemDataProviderParam[];
|
||||
filters?: IFilterItemDataProviderParam[];
|
||||
}
|
||||
|
||||
export interface IGetOneDataProviderParams {
|
||||
resource: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ISaveOneDataProviderParams<T> {
|
||||
resource: string;
|
||||
data: T;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ICreateOneDataProviderParams<T> {
|
||||
resource: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface IUpdateOneDataProviderParams<T> {
|
||||
resource: string;
|
||||
data: T;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface IRemoveOneDataProviderParams {
|
||||
resource: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
/*export interface ICustomDataProviderParam {
|
||||
resource: string;
|
||||
method: string;
|
||||
params: any;
|
||||
}*/
|
||||
|
||||
export interface IDataSource {
|
||||
name: () => string;
|
||||
getList: <R>(params: IGetListDataProviderParams) => Promise<IListResponse_DTO<R>>;
|
||||
getOne: <R>(params: IGetOneDataProviderParams) => Promise<R>;
|
||||
//saveOne: <P, R>(params: ISaveOneDataProviderParams<P>) => Promise<R>;
|
||||
createOne: <P, R>(params: ICreateOneDataProviderParams<P>) => Promise<R>;
|
||||
updateOne: <P, R>(params: IUpdateOneDataProviderParams<P>) => Promise<R>;
|
||||
removeOne: (params: IRemoveOneDataProviderParams) => Promise<void>;
|
||||
|
||||
//custom: <R>(params: ICustomDataProviderParam) => Promise<R>;
|
||||
|
||||
//getApiUrl: () => string;
|
||||
|
||||
//create: () => any;
|
||||
//createMany: () => any;
|
||||
//removeMany: () => any;
|
||||
//getMany: () => any;
|
||||
//update: () => any;
|
||||
//updateMany: () => any;
|
||||
//upload: () => any;
|
||||
//custom: () => any;
|
||||
//getApiUrl: () => string;
|
||||
}
|
||||
11
client/src/lib/hooks/useDataSource/DataSourceContext.tsx
Normal file
11
client/src/lib/hooks/useDataSource/DataSourceContext.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { PropsWithChildren, createContext } from "react";
|
||||
import { IDataSource } from "./DataSource";
|
||||
|
||||
export const DataSourceContext = createContext<IDataSource | null>(null);
|
||||
|
||||
export const DataSourceProvider = ({
|
||||
dataSource,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
dataSource: IDataSource;
|
||||
}>) => <DataSourceContext.Provider value={dataSource}>{children}</DataSourceContext.Provider>;
|
||||
10
client/src/lib/hooks/useDataSource/index.ts
Normal file
10
client/src/lib/hooks/useDataSource/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
// export * from './useApiUrl';
|
||||
// export * from './useCreateMany';
|
||||
export * from "./useList";
|
||||
export * from "./useMany";
|
||||
export * from "./useOne";
|
||||
export * from "./useRemove";
|
||||
export * from "./useRemoveMany";
|
||||
export * from "./useSave";
|
||||
// export * from './useUpdateMany';
|
||||
// export * from './useUpload';
|
||||
10
client/src/lib/hooks/useDataSource/useDataSource.tsx
Normal file
10
client/src/lib/hooks/useDataSource/useDataSource.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
import { DataSourceContext } from "./DataSourceContext";
|
||||
|
||||
export const useDataSource = () => {
|
||||
const context = useContext(DataSourceContext);
|
||||
if (context === undefined)
|
||||
throw new Error("useDataSource must be used within a DataSourceProvider");
|
||||
|
||||
return context;
|
||||
};
|
||||
78
client/src/lib/hooks/useDataSource/useList.tsx
Normal file
78
client/src/lib/hooks/useDataSource/useList.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { IsResponseAListDTO } from "@shared/contexts";
|
||||
import {
|
||||
QueryFunctionContext,
|
||||
QueryKey,
|
||||
UseQueryResult,
|
||||
keepPreviousData,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
UseLoadingOvertimeOptionsProps,
|
||||
UseLoadingOvertimeReturnType,
|
||||
useLoadingOvertime,
|
||||
} from "../useLoadingOvertime/useLoadingOvertime";
|
||||
|
||||
const DEFAULT_REFETCH_INTERVAL = 2 * 60 * 1000; // 2 minutes
|
||||
const DEFAULT_STALE_TIME = 60 * 1000; // 1 minute
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export type UseListQueryOptions<TUseListQueryData, TUseListQueryError> = {
|
||||
queryKey: QueryKey;
|
||||
queryFn: (context: QueryFunctionContext) => Promise<TUseListQueryData>;
|
||||
enabled?: boolean;
|
||||
select?: (data: TUseListQueryData) => TUseListQueryData;
|
||||
queryOptions?: Record<string, unknown>;
|
||||
} & UseLoadingOvertimeOptionsProps;
|
||||
|
||||
export type UseListQueryResult<TUseListQueryData, TUseListQueryError> =
|
||||
UseQueryResult<TUseListQueryData, TUseListQueryError> & {
|
||||
isEmpty: boolean;
|
||||
} & UseLoadingOvertimeReturnType;
|
||||
|
||||
export const useList = <TUseListQueryData, TUseListQueryError>({
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled,
|
||||
select,
|
||||
queryOptions = {},
|
||||
overtimeOptions,
|
||||
}: UseListQueryOptions<
|
||||
TUseListQueryData,
|
||||
TUseListQueryError
|
||||
>): UseListQueryResult<TUseListQueryData, TUseListQueryError> => {
|
||||
const [isEmpty, setIsEmpty] = useState<boolean>(false);
|
||||
|
||||
const queryResponse = useQuery<TUseListQueryData, TUseListQueryError>({
|
||||
queryKey,
|
||||
queryFn,
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: DEFAULT_STALE_TIME,
|
||||
refetchInterval: DEFAULT_REFETCH_INTERVAL,
|
||||
refetchOnWindowFocus: true,
|
||||
enabled: enabled && !!queryFn,
|
||||
select,
|
||||
...queryOptions,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (queryResponse.isSuccess && IsResponseAListDTO(queryResponse.data)) {
|
||||
setIsEmpty(queryResponse.data.total_items === 0);
|
||||
}
|
||||
}, [queryResponse]);
|
||||
|
||||
const { elapsedTime } = useLoadingOvertime({
|
||||
isPending: queryResponse.isFetching,
|
||||
interval: overtimeOptions?.interval,
|
||||
onInterval: overtimeOptions?.onInterval,
|
||||
});
|
||||
|
||||
const result = {
|
||||
...queryResponse,
|
||||
overtime: { elapsedTime },
|
||||
isEmpty,
|
||||
};
|
||||
return result;
|
||||
};
|
||||
37
client/src/lib/hooks/useDataSource/useMany.tsx
Normal file
37
client/src/lib/hooks/useDataSource/useMany.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import {
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseQueryResult,
|
||||
useQuery,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
|
||||
export interface IUseManyQueryOptions<
|
||||
TUseManyQueryData = unknown,
|
||||
TUseManyQueryError = unknown
|
||||
> {
|
||||
queryKey: QueryKey;
|
||||
queryFn: QueryFunction<TUseManyQueryData, QueryKey>;
|
||||
enabled?: boolean;
|
||||
select?: (data: TUseManyQueryData) => TUseManyQueryData;
|
||||
queryOptions?: any;
|
||||
}
|
||||
|
||||
export function useMany<TUseManyQueryData, TUseManyQueryError>(
|
||||
options: IUseManyQueryOptions<TUseManyQueryData, TUseManyQueryError>
|
||||
): UseQueryResult<TUseManyQueryData, TUseManyQueryError> {
|
||||
const { queryKey, queryFn, enabled, select, queryOptions } = options;
|
||||
|
||||
const queryResponse = useQuery<TUseManyQueryData, TUseManyQueryError>({
|
||||
queryKey,
|
||||
queryFn,
|
||||
keepPreviousData: true,
|
||||
...queryOptions,
|
||||
enabled,
|
||||
select,
|
||||
|
||||
});
|
||||
|
||||
return queryResponse;
|
||||
}
|
||||
|
||||
47
client/src/lib/hooks/useDataSource/useOne.tsx
Normal file
47
client/src/lib/hooks/useDataSource/useOne.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import {
|
||||
QueryFunctionContext,
|
||||
QueryKey,
|
||||
UseQueryResult,
|
||||
keepPreviousData,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
UseLoadingOvertimeOptionsProps,
|
||||
UseLoadingOvertimeReturnType,
|
||||
} from "../useLoadingOvertime/useLoadingOvertime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export type UseOneQueryOptions<TUseOneQueryData, TUseOneQueryError> = {
|
||||
queryKey: QueryKey;
|
||||
queryFn: (context: QueryFunctionContext) => Promise<TUseOneQueryData>;
|
||||
enabled?: boolean;
|
||||
autoRefresh?: boolean;
|
||||
select?: (data: TUseOneQueryData) => TUseOneQueryData;
|
||||
queryOptions?: Record<string, unknown>;
|
||||
} & UseLoadingOvertimeOptionsProps;
|
||||
|
||||
export type UseOneQueryResult<TUseOneQueryData, TUseOneQueryError> =
|
||||
UseQueryResult<TUseOneQueryData, TUseOneQueryError> & {
|
||||
isEmpty: boolean;
|
||||
} & UseLoadingOvertimeReturnType;
|
||||
|
||||
export function useOne<TUseOneQueryData, TUseOneQueryError>({
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled,
|
||||
select,
|
||||
queryOptions = {},
|
||||
}: UseOneQueryOptions<TUseOneQueryData, TUseOneQueryError>): UseQueryResult<
|
||||
TUseOneQueryData,
|
||||
TUseOneQueryError
|
||||
> {
|
||||
return useQuery<TUseOneQueryData, TUseOneQueryError>({
|
||||
queryKey,
|
||||
queryFn,
|
||||
placeholderData: keepPreviousData,
|
||||
enabled,
|
||||
select,
|
||||
...queryOptions,
|
||||
});
|
||||
}
|
||||
30
client/src/lib/hooks/useDataSource/useRemove.tsx
Normal file
30
client/src/lib/hooks/useDataSource/useRemove.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
export interface IUseRemoveMutationOptions<
|
||||
TUseRemoveMutationData,
|
||||
TUseRemoveMutationError,
|
||||
TUseRemoveMutationVariables
|
||||
> {
|
||||
mutationFn: (
|
||||
variables: TUseRemoveMutationVariables,
|
||||
) => Promise<TUseRemoveMutationData>;
|
||||
}
|
||||
|
||||
export function useRemove<
|
||||
TUseRemoveMutationData,
|
||||
TUseRemoveMutationError,
|
||||
TUseRemoveMutationVariables>(options: IUseRemoveMutationOptions<
|
||||
TUseRemoveMutationData,
|
||||
TUseRemoveMutationError,
|
||||
TUseRemoveMutationVariables
|
||||
>) {
|
||||
const { mutationFn, ...params } = options;
|
||||
|
||||
return useMutation<
|
||||
TUseRemoveMutationData,
|
||||
TUseRemoveMutationError,
|
||||
TUseRemoveMutationVariables>({
|
||||
mutationFn,
|
||||
...params
|
||||
});
|
||||
}
|
||||
13
client/src/lib/hooks/useDataSource/useRemoveMany.tsx
Normal file
13
client/src/lib/hooks/useDataSource/useRemoveMany.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
export interface IUseRemoveManyMutationOptions {
|
||||
mutationFn: any;
|
||||
}
|
||||
|
||||
export function useRemoveMany(options: IUseRemoveManyMutationOptions) {
|
||||
const { mutationFn } = options;
|
||||
|
||||
return useMutation({
|
||||
mutationFn,
|
||||
});
|
||||
}
|
||||
43
client/src/lib/hooks/useDataSource/useSave.ts
Normal file
43
client/src/lib/hooks/useDataSource/useSave.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import {
|
||||
DefaultError,
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
export interface IUseSaveMutationOptions<
|
||||
TUseSaveMutationData = unknown,
|
||||
TUseSaveMutationError = DefaultError,
|
||||
TUseSaveMutationVariables = unknown,
|
||||
TUseSaveMutationContext = unknown,
|
||||
> extends UseMutationOptions<
|
||||
TUseSaveMutationData,
|
||||
TUseSaveMutationError,
|
||||
TUseSaveMutationVariables,
|
||||
TUseSaveMutationContext
|
||||
> {}
|
||||
|
||||
export function useSave<
|
||||
TUseSaveMutationData = unknown,
|
||||
TUseSaveMutationError = DefaultError,
|
||||
TUseSaveMutationVariables = unknown,
|
||||
TUseSaveMutationContext = unknown,
|
||||
>(
|
||||
options: IUseSaveMutationOptions<
|
||||
TUseSaveMutationData,
|
||||
TUseSaveMutationError,
|
||||
TUseSaveMutationVariables,
|
||||
TUseSaveMutationContext
|
||||
>,
|
||||
) {
|
||||
const { mutationFn, ...params } = options;
|
||||
|
||||
return useMutation<
|
||||
TUseSaveMutationData,
|
||||
TUseSaveMutationError,
|
||||
TUseSaveMutationVariables,
|
||||
TUseSaveMutationContext
|
||||
>({
|
||||
mutationFn,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
192
client/src/lib/hooks/useDataTable/helper.ts
Normal file
192
client/src/lib/hooks/useDataTable/helper.ts
Normal file
@ -0,0 +1,192 @@
|
||||
import { differenceWith, isEmpty, isEqual, reverse, unionWith } from 'lodash';
|
||||
|
||||
export const parseQueryString = (queryString: Record<string, string>) => {
|
||||
// pagination
|
||||
const pageIndex = parseInt(queryString.page ?? '0', 0) - 1;
|
||||
const pageSize = Math.min(parseInt(queryString.limit ?? '10', 10), 100);
|
||||
const pagination =
|
||||
pageIndex >= 0 && pageSize > 0 ? { pageIndex, pageSize } : undefined;
|
||||
|
||||
// pagination
|
||||
/*let pagination = undefined;
|
||||
if (page !== undefined && limit !== undefined) {
|
||||
let parsedPage = toSafeInteger(queryString['page']) - 1;
|
||||
if (parsedPage < 0) parsedPage = 0;
|
||||
|
||||
let parsedPageSize = toSafeInteger(queryString['limit']);
|
||||
if (parsedPageSize > 100) parsedPageSize = 100;
|
||||
if (parsedPageSize < 5) parsedPageSize = 5;
|
||||
|
||||
pagination = {
|
||||
pageIndex: parsedPage,
|
||||
pageSize: parsedPageSize,
|
||||
};
|
||||
}*/
|
||||
|
||||
// sorter
|
||||
// sorter
|
||||
const sorter = (queryString.sort ?? '')
|
||||
.split(',')
|
||||
.map((token) => token.match(/([+-]?)([\w_]+)/i))
|
||||
.slice(0, 3)
|
||||
.map((item) => (item ? { id: item[2], desc: item[1] === '-' } : null))
|
||||
.filter(Boolean);
|
||||
|
||||
/*let sorter = [];
|
||||
if (sort !== undefined) {
|
||||
sorter = sort
|
||||
.split(',')
|
||||
.map((token) => token.match(/([+-]?)([\w_]+)/i))
|
||||
.slice(0, 3)
|
||||
.map((item) =>
|
||||
item ? { id: item[2], desc: item[1] === '-' } : null
|
||||
);
|
||||
}*/
|
||||
|
||||
// filters
|
||||
const filters = Object.entries(queryString)
|
||||
.filter(([key]) => key !== 'page' && key !== 'limit' && key !== 'sort')
|
||||
.map(([key, value]) => {
|
||||
const [, field, , , operator] =
|
||||
key.match(/([\w]+)(([\[])([\w]+)([\]]))*/i) ?? [];
|
||||
const sanitizedOperator = _sanitizeOperator(operator ?? '');
|
||||
return !isEmpty(value)
|
||||
? { field, operator: sanitizedOperator, value }
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
/*let filters = [];
|
||||
if (filterCandidates !== undefined) {
|
||||
Object.keys(filterCandidates).map((token) => {
|
||||
const [, field, , , operator] = token.match( */
|
||||
// /([\w]+)(([\[])([\w]+)([\]]))*/i
|
||||
/* );
|
||||
const value = filterCandidates[token];
|
||||
|
||||
if (!isEmpty(value)) {
|
||||
filters.push({
|
||||
field,
|
||||
operator: _sanitizeOperator(operator),
|
||||
value,
|
||||
});
|
||||
}
|
||||
});
|
||||
}*/
|
||||
|
||||
return {
|
||||
pagination,
|
||||
sorter,
|
||||
filters,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildQueryString = ({ pagination, sorter, filters }) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (
|
||||
pagination &&
|
||||
pagination.pageIndex !== undefined &&
|
||||
pagination.pageSize !== undefined
|
||||
) {
|
||||
params.append('page', String(pagination.pageIndex + 1));
|
||||
params.append('limit', String(pagination.pageSize));
|
||||
}
|
||||
|
||||
if (sorter && Array.isArray(sorter) && sorter.length > 0) {
|
||||
params.append(
|
||||
'sort',
|
||||
sorter.map(({ id, desc }) => `${desc ? '-' : ''}${id}`).toString()
|
||||
);
|
||||
}
|
||||
|
||||
if (filters && Array.isArray(filters) && filters.length > 0) {
|
||||
filters.forEach((filterItem) => {
|
||||
if (filterItem.value !== undefined) {
|
||||
let operator = _mapFilterOperator(filterItem.operator);
|
||||
if (operator === 'eq') {
|
||||
params.append(`${filterItem.field}`, filterItem.value);
|
||||
} else {
|
||||
params.append(
|
||||
`${filterItem.field}[${_mapFilterOperator(
|
||||
filterItem.operator
|
||||
)}]`,
|
||||
filterItem.value
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
export const combineFilters = (requiredFilters = [], otherFilters = []) => [
|
||||
...differenceWith(otherFilters, requiredFilters, isEqual),
|
||||
...requiredFilters,
|
||||
];
|
||||
|
||||
export const unionFilters = (
|
||||
permanentFilter = [],
|
||||
newFilters = [],
|
||||
prevFilters = []
|
||||
) =>
|
||||
reverse(
|
||||
unionWith(
|
||||
permanentFilter,
|
||||
newFilters,
|
||||
prevFilters,
|
||||
(left, right) =>
|
||||
left.field == right.field && left.operator == right.operator
|
||||
)
|
||||
).filter(
|
||||
(crudFilter) =>
|
||||
crudFilter.value !== undefined && crudFilter.value !== null
|
||||
);
|
||||
|
||||
export const extractTableSortPropertiesFromColumn = (columns) => {
|
||||
const _extractColumnSortProperies = (column) => {
|
||||
const { canSort, isSorted, sortedIndex, isSortedDesc } = column;
|
||||
if (!isSorted || !canSort) {
|
||||
return undefined;
|
||||
} else {
|
||||
return {
|
||||
index: sortedIndex,
|
||||
field: column.id,
|
||||
order: isSortedDesc ? 'DESC' : 'ASC',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return columns
|
||||
.map((column) => _extractColumnSortProperies(column))
|
||||
.filter((item) => item)
|
||||
.sort((a, b) => a.index - b.index);
|
||||
};
|
||||
|
||||
export const extractTableSortProperties = (sorter) => {
|
||||
return sorter.map((sortItem, index) => ({
|
||||
index,
|
||||
field: sortItem.id,
|
||||
order: sortItem.desc ? 'DESC' : 'ASC',
|
||||
}));
|
||||
};
|
||||
|
||||
export const extractTableFilterProperties = (filters) =>
|
||||
filters.filter((item) => !isEmpty(item.value));
|
||||
|
||||
const _sanitizeOperator = (operator) =>
|
||||
['eq', 'ne', 'gte', 'lte', 'like'].includes(operator) ? operator : 'eq';
|
||||
|
||||
const _mapFilterOperator = (operator) => {
|
||||
switch (operator) {
|
||||
case 'ne':
|
||||
case 'gte':
|
||||
case 'lte':
|
||||
return `[${operator}]`;
|
||||
case 'contains':
|
||||
return '[like]';
|
||||
default:
|
||||
return 'eq';
|
||||
}
|
||||
};
|
||||
3
client/src/lib/hooks/useDataTable/index.ts
Normal file
3
client/src/lib/hooks/useDataTable/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./useDataTable";
|
||||
export * from "./useDataTableColumns";
|
||||
export * from "./useQueryDataTable";
|
||||
321
client/src/lib/hooks/useDataTable/useDataTable.tsx
Normal file
321
client/src/lib/hooks/useDataTable/useDataTable.tsx
Normal file
@ -0,0 +1,321 @@
|
||||
import {
|
||||
OnChangeFn,
|
||||
PaginationState,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { getDataTableSelectionColumn } from "@/components";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { DataTableFilterField } from "../../types";
|
||||
import { usePaginationParams } from "../usePagination";
|
||||
|
||||
//import { useDebounce } from "@/hooks/use-debounce";
|
||||
|
||||
interface UseDataTableProps<TData, TValue> {
|
||||
/**
|
||||
* The data for the table.
|
||||
* @default []
|
||||
* @type TData[]
|
||||
*/
|
||||
data: TData[];
|
||||
|
||||
/**
|
||||
* The columns of the table.
|
||||
* @default []
|
||||
* @type ColumnDef<TData, TValue>[]
|
||||
*/
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
|
||||
/**
|
||||
* The number of pages in the table.
|
||||
* @type number
|
||||
*/
|
||||
pageCount: number;
|
||||
|
||||
/**
|
||||
* Enable sorting columns.
|
||||
* @default false
|
||||
* @type boolean
|
||||
*/
|
||||
enableSorting?: boolean;
|
||||
|
||||
/**
|
||||
* Enable hiding columns.
|
||||
* @default false
|
||||
* @type boolean
|
||||
*/
|
||||
enableHiding?: boolean;
|
||||
|
||||
/**
|
||||
* Enable selection rows.
|
||||
* @default false
|
||||
* @type boolean
|
||||
*/
|
||||
enableRowSelection?: boolean;
|
||||
|
||||
/**
|
||||
* Defines filter fields for the table. Supports both dynamic faceted filters and search filters.
|
||||
* - Faceted filters are rendered when `options` are provided for a filter field.
|
||||
* - Otherwise, search filters are rendered.
|
||||
*
|
||||
* The indie filter field `value` represents the corresponding column name in the database table.
|
||||
* @default []
|
||||
* @type { label: string, value: keyof TData, placeholder?: string, options?: { label: string, value: string, icon?: React.ComponentType<{ className?: string }> }[] }[]
|
||||
* @example
|
||||
* ```ts
|
||||
* // Render a search filter
|
||||
* const filterFields = [
|
||||
* { label: "Title", value: "title", placeholder: "Search titles" }
|
||||
* ];
|
||||
* // Render a faceted filter
|
||||
* const filterFields = [
|
||||
* {
|
||||
* label: "Status",
|
||||
* value: "status",
|
||||
* options: [
|
||||
* { label: "Todo", value: "todo" },
|
||||
* { label: "In Progress", value: "in-progress" },
|
||||
* { label: "Done", value: "done" },
|
||||
* { label: "Canceled", value: "canceled" }
|
||||
* ]
|
||||
* }
|
||||
* ];
|
||||
* ```
|
||||
*/
|
||||
filterFields?: DataTableFilterField<TData>[];
|
||||
|
||||
/**
|
||||
* Enable notion like column filters.
|
||||
* Advanced filters and column filters cannot be used at the same time.
|
||||
* @default false
|
||||
* @type boolean
|
||||
*/
|
||||
enableAdvancedFilter?: boolean;
|
||||
}
|
||||
|
||||
export function useDataTable<TData, TValue>({
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
enableSorting = false,
|
||||
enableHiding = false,
|
||||
enableRowSelection = false,
|
||||
filterFields = [],
|
||||
enableAdvancedFilter = false,
|
||||
}: UseDataTableProps<TData, TValue>) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [pagination, setPagination] = usePaginationParams();
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
// Memoize computation of searchableColumns and filterableColumns
|
||||
const { searchableColumns, filterableColumns } = useMemo(() => {
|
||||
return {
|
||||
searchableColumns: filterFields.filter((field) => !field.options),
|
||||
filterableColumns: filterFields.filter((field) => field.options),
|
||||
};
|
||||
}, [filterFields]);
|
||||
|
||||
// Create query string
|
||||
/*const createQueryString = useCallback(
|
||||
(params: Record<string, string | number | null>) => {
|
||||
const newSearchParams = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === null) {
|
||||
newSearchParams.delete(key);
|
||||
} else {
|
||||
newSearchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return newSearchParams.toString();
|
||||
},
|
||||
[searchParams]
|
||||
);*/
|
||||
|
||||
// Initial column filters
|
||||
const initialColumnFilters: ColumnFiltersState = useMemo(() => {
|
||||
return Array.from(searchParams.entries()).reduce<ColumnFiltersState>(
|
||||
(filters, [key, value]) => {
|
||||
const filterableColumn = filterableColumns.find(
|
||||
(column) => column.value === key
|
||||
);
|
||||
const searchableColumn = searchableColumns.find(
|
||||
(column) => column.value === key
|
||||
);
|
||||
|
||||
if (filterableColumn) {
|
||||
filters.push({
|
||||
id: key,
|
||||
value: value.split("."),
|
||||
});
|
||||
} else if (searchableColumn) {
|
||||
filters.push({
|
||||
id: key,
|
||||
value: [value],
|
||||
});
|
||||
}
|
||||
|
||||
return filters;
|
||||
},
|
||||
[]
|
||||
);
|
||||
}, [filterableColumns, searchableColumns, searchParams]);
|
||||
|
||||
// Table states
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({});
|
||||
|
||||
const [columnFilters, setColumnFilters] =
|
||||
React.useState<ColumnFiltersState>(initialColumnFilters);
|
||||
|
||||
const paginationUpdater: OnChangeFn<PaginationState> = (updater) => {
|
||||
if (typeof updater === "function") {
|
||||
const newPagination = updater(pagination);
|
||||
console.log(newPagination);
|
||||
setPagination(newPagination);
|
||||
}
|
||||
};
|
||||
|
||||
const sortingUpdater: OnChangeFn<SortingState> = (updater) => {
|
||||
if (typeof updater === "function") {
|
||||
setSorting(updater(sorting));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle server-side filtering
|
||||
/*const debouncedSearchableColumnFilters = JSON.parse(
|
||||
useDebounce(
|
||||
JSON.stringify(
|
||||
columnFilters.filter((filter) => {
|
||||
return searchableColumns.find((column) => column.value === filter.id);
|
||||
})
|
||||
),
|
||||
500
|
||||
)
|
||||
) as ColumnFiltersState;*/
|
||||
|
||||
/*const filterableColumnFilters = columnFilters.filter((filter) => {
|
||||
return filterableColumns.find((column) => column.value === filter.id);
|
||||
});
|
||||
|
||||
const [mounted, setMounted] = useState(false);*/
|
||||
|
||||
/*useEffect(() => {
|
||||
// Opt out when advanced filter is enabled, because it contains additional params
|
||||
if (enableAdvancedFilter) return;
|
||||
|
||||
// Prevent resetting the page on initial render
|
||||
if (!mounted) {
|
||||
setMounted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize new params
|
||||
const newParamsObject = {
|
||||
page: 1,
|
||||
};
|
||||
|
||||
// Handle debounced searchable column filters
|
||||
for (const column of debouncedSearchableColumnFilters) {
|
||||
if (typeof column.value === "string") {
|
||||
Object.assign(newParamsObject, {
|
||||
[column.id]: typeof column.value === "string" ? column.value : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle filterable column filters
|
||||
for (const column of filterableColumnFilters) {
|
||||
if (typeof column.value === "object" && Array.isArray(column.value)) {
|
||||
Object.assign(newParamsObject, { [column.id]: column.value.join(".") });
|
||||
}
|
||||
}
|
||||
|
||||
// Remove deleted values
|
||||
for (const key of searchParams.keys()) {
|
||||
if (
|
||||
(searchableColumns.find((column) => column.value === key) &&
|
||||
!debouncedSearchableColumnFilters.find(
|
||||
(column) => column.id === key
|
||||
)) ||
|
||||
(filterableColumns.find((column) => column.value === key) &&
|
||||
!filterableColumnFilters.find((column) => column.id === key))
|
||||
) {
|
||||
Object.assign(newParamsObject, { [key]: null });
|
||||
}
|
||||
}
|
||||
|
||||
// After cumulating all the changes, push new params
|
||||
navigate(`${location.pathname}?${createQueryString(newParamsObject)}`);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
//JSON.stringify(debouncedSearchableColumnFilters),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
JSON.stringify(filterableColumnFilters),
|
||||
]);*/
|
||||
|
||||
const getTableColumns = useCallback(() => {
|
||||
const _columns = columns;
|
||||
if (enableRowSelection) {
|
||||
_columns.unshift(getDataTableSelectionColumn());
|
||||
}
|
||||
return _columns;
|
||||
}, [columns, enableRowSelection]);
|
||||
|
||||
const table = useReactTable({
|
||||
columns: getTableColumns(),
|
||||
data,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
//getPaginationRowModel: getPaginationRowModel(),
|
||||
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
},
|
||||
|
||||
enableRowSelection,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
|
||||
manualSorting: true,
|
||||
enableSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onSortingChange: sortingUpdater,
|
||||
|
||||
enableHiding,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
|
||||
manualPagination: true,
|
||||
pageCount: pageCount ?? -1,
|
||||
onPaginationChange: paginationUpdater,
|
||||
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
|
||||
manualFiltering: true,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
});
|
||||
|
||||
return { table };
|
||||
}
|
||||
42
client/src/lib/hooks/useDataTable/useDataTableColumns.tsx
Normal file
42
client/src/lib/hooks/useDataTable/useDataTableColumns.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Función para calcular las columnas
|
||||
export function useDataTableColumns<T extends object>(
|
||||
data: T[],
|
||||
extraColumns: ColumnDef<T>[] = []
|
||||
): ColumnDef<T>[] {
|
||||
const [columns, setColumns] = useState<ColumnDef<T>[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || data.length === 0) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Obtener las claves de todas las propiedades de todos los elementos
|
||||
const keys = data.reduce((acc: string[], item: T) => {
|
||||
return acc.concat(Object.keys(item));
|
||||
}, []);
|
||||
|
||||
// Eliminar claves duplicadas y ordenarlas alfabéticamente
|
||||
const uniqueKeys = Array.from(new Set(keys)).sort();
|
||||
|
||||
// Crear una columna para cada clave
|
||||
const calculatedColumns: ColumnDef<T>[] = uniqueKeys.map((key) => {
|
||||
return {
|
||||
id: key,
|
||||
header: key,
|
||||
accessorKey: key.toLowerCase().replace(/\s+/g, "_"),
|
||||
};
|
||||
});
|
||||
|
||||
const finalColumns = extraColumns
|
||||
? calculatedColumns.concat(extraColumns)
|
||||
: calculatedColumns;
|
||||
|
||||
setColumns(finalColumns);
|
||||
}, [data, extraColumns]);
|
||||
|
||||
return columns;
|
||||
}
|
||||
127
client/src/lib/hooks/useDataTable/useQueryDataTable.tsx
Normal file
127
client/src/lib/hooks/useDataTable/useQueryDataTable.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import {
|
||||
DataTableColumnProps,
|
||||
getDataTableSelectionColumn,
|
||||
} from "@/components";
|
||||
import { IListResponse_DTO } from "@shared/contexts";
|
||||
import {
|
||||
OnChangeFn,
|
||||
PaginationState,
|
||||
SortingState,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { UseListQueryResult } from "../useDataSource";
|
||||
import { usePaginationParams } from "../usePagination";
|
||||
|
||||
type TUseDataTableQueryResult<TData, TError> = UseListQueryResult<
|
||||
IListResponse_DTO<TData>,
|
||||
TError
|
||||
>;
|
||||
|
||||
type TUseDataTableQuery<TData, TError> = (params: {
|
||||
pagination: {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
};
|
||||
enabled?: boolean;
|
||||
}) => TUseDataTableQueryResult<TData, TError>;
|
||||
|
||||
type UseDataTableProps<TData, TValue, TError> = {
|
||||
fetchQuery: TUseDataTableQuery<TData, TError>;
|
||||
enabled?: boolean;
|
||||
|
||||
columnOptions: DataTableColumnsOptionsProps<TData, TValue>;
|
||||
enableSorting?: boolean;
|
||||
enableHiding?: boolean;
|
||||
|
||||
//initialPage?: number;
|
||||
//initialPageSize?: number;
|
||||
};
|
||||
|
||||
type DataTableColumnsOptionsProps<TData, TValue> = {
|
||||
enableSelectionColumn?: boolean;
|
||||
columns: DataTableColumnsProps<TData, TValue>;
|
||||
};
|
||||
|
||||
type DataTableColumnsProps<TData, TValue> = DataTableColumnProps<
|
||||
TData,
|
||||
TValue
|
||||
>[];
|
||||
|
||||
export const useQueryDataTable = <
|
||||
TData = unknown,
|
||||
TValue = unknown,
|
||||
TError = Error
|
||||
>({
|
||||
fetchQuery,
|
||||
enabled = true,
|
||||
|
||||
columnOptions,
|
||||
enableSorting = false,
|
||||
enableHiding = false,
|
||||
}: UseDataTableProps<TData, TValue, TError>) => {
|
||||
const defaultData = useMemo(() => [], []);
|
||||
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [pagination, setPagination] = usePaginationParams();
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const paginationUpdater: OnChangeFn<PaginationState> = (updater) => {
|
||||
if (typeof updater === "function") {
|
||||
setPagination(updater(pagination));
|
||||
}
|
||||
};
|
||||
|
||||
const sortingUpdater: OnChangeFn<SortingState> = (updater) => {
|
||||
if (typeof updater === "function") {
|
||||
setSorting(updater(sorting));
|
||||
}
|
||||
};
|
||||
|
||||
const getTableColumns = useCallback(() => {
|
||||
const _columns = columnOptions.columns;
|
||||
if (columnOptions.enableSelectionColumn) {
|
||||
_columns.unshift(getDataTableSelectionColumn());
|
||||
}
|
||||
return _columns;
|
||||
}, [columnOptions]);
|
||||
|
||||
const queryResult: TUseDataTableQueryResult<TData, TError> = fetchQuery({
|
||||
pagination,
|
||||
enabled,
|
||||
});
|
||||
|
||||
const table = useReactTable<TData>({
|
||||
columns: getTableColumns(),
|
||||
data: queryResult.data?.items ?? defaultData,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
//getPaginationRowModel: getPaginationRowModel(),
|
||||
|
||||
enableRowSelection: columnOptions.enableSelectionColumn,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
|
||||
enableSorting,
|
||||
onSortingChange: sortingUpdater,
|
||||
|
||||
enableHiding,
|
||||
//onColumnVisibilityChange: columnVisibilityUpdater,
|
||||
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
manualPagination: true,
|
||||
//autoResetPageIndex: true,
|
||||
pageCount: queryResult?.data?.total_pages ?? -1,
|
||||
onPaginationChange: paginationUpdater,
|
||||
|
||||
debugTable: true,
|
||||
});
|
||||
|
||||
return {
|
||||
table,
|
||||
queryResult,
|
||||
};
|
||||
};
|
||||
1
client/src/lib/hooks/useLoadingOvertime/index.ts
Normal file
1
client/src/lib/hooks/useLoadingOvertime/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./useLoadingOvertime";
|
||||
104
client/src/lib/hooks/useLoadingOvertime/useLoadingOvertime.ts
Normal file
104
client/src/lib/hooks/useLoadingOvertime/useLoadingOvertime.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type UseLoadingOvertimeRefineContext = Omit<
|
||||
UseLoadingOvertimeCoreProps,
|
||||
"isPending" | "interval"
|
||||
> &
|
||||
Required<Pick<UseLoadingOvertimeCoreProps, "interval">>;
|
||||
|
||||
export type UseLoadingOvertimeOptionsProps = {
|
||||
overtimeOptions?: UseLoadingOvertimeCoreOptions;
|
||||
};
|
||||
|
||||
export type UseLoadingOvertimeReturnType = {
|
||||
overtime: {
|
||||
elapsedTime?: number;
|
||||
};
|
||||
};
|
||||
|
||||
type UseLoadingOvertimeCoreOptions = Omit<
|
||||
UseLoadingOvertimeCoreProps,
|
||||
"isPending"
|
||||
>;
|
||||
|
||||
type UseLoadingOvertimeCoreReturnType = {
|
||||
elapsedTime?: number;
|
||||
};
|
||||
|
||||
export type UseLoadingOvertimeCoreProps = {
|
||||
/**
|
||||
* The pengind state. If true, the elapsed time will be calculated.
|
||||
*/
|
||||
isPending: boolean;
|
||||
|
||||
/**
|
||||
* The interval in milliseconds. If the pending time exceeds this time, the `onInterval` callback will be called.
|
||||
* If not specified, the `interval` value from the `overtime` option of the `RefineProvider` will be used.
|
||||
*
|
||||
* @default: 1000 (1 second)
|
||||
*/
|
||||
interval?: number;
|
||||
|
||||
/**
|
||||
* The callback function that will be called when the pending time exceeds the specified time.
|
||||
* If not specified, the `onInterval` value from the `overtime` option of the `RefineProvider` will be used.
|
||||
*
|
||||
* @param elapsedInterval The elapsed time in milliseconds.
|
||||
*/
|
||||
onInterval?: (elapsedInterval: number) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* if you need to do something when the loading time exceeds the specified time, refine provides the `useLoadingOvertime` hook.
|
||||
* It returns the elapsed time in milliseconds.
|
||||
*
|
||||
* @example
|
||||
* const { elapsedTime } = useLoadingOvertime({
|
||||
* isLoading,
|
||||
* interval: 1000,
|
||||
* onInterval(elapsedInterval) {
|
||||
* console.log("loading overtime", elapsedInterval);
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export const useLoadingOvertime = ({
|
||||
isPending,
|
||||
interval = 1000,
|
||||
onInterval,
|
||||
}: UseLoadingOvertimeCoreProps): UseLoadingOvertimeCoreReturnType => {
|
||||
const [elapsedTime, setElapsedTime] = useState<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
let intervalFn: ReturnType<typeof setInterval>;
|
||||
|
||||
if (isPending) {
|
||||
intervalFn = setInterval(() => {
|
||||
// increase elapsed time
|
||||
setElapsedTime((prevElapsedTime) => {
|
||||
if (prevElapsedTime === undefined) {
|
||||
return interval;
|
||||
}
|
||||
|
||||
return prevElapsedTime + interval;
|
||||
});
|
||||
}, interval);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalFn);
|
||||
// reset elapsed time
|
||||
setElapsedTime(undefined);
|
||||
};
|
||||
}, [isPending, interval]);
|
||||
|
||||
useEffect(() => {
|
||||
// call onInterval callback
|
||||
if (onInterval && elapsedTime) {
|
||||
onInterval(elapsedTime);
|
||||
}
|
||||
}, [elapsedTime]);
|
||||
|
||||
return {
|
||||
elapsedTime,
|
||||
};
|
||||
};
|
||||
1
client/src/lib/hooks/useMounted/index.ts
Normal file
1
client/src/lib/hooks/useMounted/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./useMounted";
|
||||
13
client/src/lib/hooks/useMounted/useMounted.ts
Normal file
13
client/src/lib/hooks/useMounted/useMounted.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import * as React from "react";
|
||||
|
||||
export const useMounted = () => {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
return mounted;
|
||||
};
|
||||
2
client/src/lib/hooks/usePagination/index.ts
Normal file
2
client/src/lib/hooks/usePagination/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./usePagination";
|
||||
export * from "./usePaginationParams";
|
||||
50
client/src/lib/hooks/usePagination/usePagination.tsx
Normal file
50
client/src/lib/hooks/usePagination/usePagination.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export const INITIAL_PAGE_INDEX = 0;
|
||||
export const INITIAL_PAGE_SIZE = 10;
|
||||
|
||||
export const MIN_PAGE_INDEX = 0;
|
||||
export const MIN_PAGE_SIZE = 1;
|
||||
|
||||
export const MAX_PAGE_SIZE = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
export const DEFAULT_PAGE_SIZES = [10, 25, 50, 100];
|
||||
|
||||
export interface PaginationState {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export const defaultPaginationState = {
|
||||
pageIndex: INITIAL_PAGE_INDEX,
|
||||
pageSize: INITIAL_PAGE_SIZE,
|
||||
};
|
||||
|
||||
export const usePagination = (
|
||||
initialPageIndex: number = INITIAL_PAGE_INDEX,
|
||||
initialPageSize: number = INITIAL_PAGE_SIZE
|
||||
) => {
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: initialPageIndex,
|
||||
pageSize: initialPageSize,
|
||||
});
|
||||
|
||||
const updatePagination = (newPagination: PaginationState) => {
|
||||
// Realiza comprobaciones antes de actualizar el estado
|
||||
|
||||
if (newPagination.pageIndex < INITIAL_PAGE_INDEX) {
|
||||
newPagination.pageIndex = INITIAL_PAGE_INDEX;
|
||||
}
|
||||
|
||||
if (
|
||||
newPagination.pageSize < MIN_PAGE_SIZE ||
|
||||
newPagination.pageSize > MAX_PAGE_SIZE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPagination(newPagination);
|
||||
};
|
||||
|
||||
return [pagination, updatePagination] as const;
|
||||
};
|
||||
52
client/src/lib/hooks/usePagination/usePaginationParams.tsx
Normal file
52
client/src/lib/hooks/usePagination/usePaginationParams.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
INITIAL_PAGE_INDEX,
|
||||
INITIAL_PAGE_SIZE,
|
||||
usePagination,
|
||||
} from "./usePagination";
|
||||
|
||||
export const usePaginationParams = (
|
||||
initialPageIndex: number = INITIAL_PAGE_INDEX,
|
||||
initialPageSize: number = INITIAL_PAGE_SIZE
|
||||
) => {
|
||||
const [urlSearchParams, setUrlSearchParams] = useSearchParams();
|
||||
|
||||
const urlParamPageIndex: string | null = urlSearchParams.get("page_index");
|
||||
const urlParamPageSize: string | null = urlSearchParams.get("page_size");
|
||||
|
||||
const calculatedPageIndex = useMemo(() => {
|
||||
const parsedPageIndex = parseInt(urlParamPageIndex ?? "", 10);
|
||||
return !isNaN(parsedPageIndex) ? parsedPageIndex : initialPageIndex;
|
||||
}, [urlParamPageIndex, initialPageIndex]);
|
||||
|
||||
const calculatedPageSize = useMemo(() => {
|
||||
const parsedPageSize = parseInt(urlParamPageSize ?? "", 10);
|
||||
return !isNaN(parsedPageSize) ? parsedPageSize : initialPageSize;
|
||||
}, [urlParamPageSize, initialPageSize]);
|
||||
|
||||
const [pagination, setPagination] = usePagination(
|
||||
calculatedPageIndex,
|
||||
calculatedPageSize
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Actualizar la URL cuando cambia la paginación
|
||||
const actualSearchParam = Object.fromEntries(
|
||||
new URLSearchParams(urlSearchParams)
|
||||
);
|
||||
|
||||
if (
|
||||
String(pagination.pageIndex) !== actualSearchParam.page_index ||
|
||||
String(pagination.pageSize) !== actualSearchParam.page_size
|
||||
) {
|
||||
setUrlSearchParams({
|
||||
...actualSearchParam,
|
||||
page_index: String(pagination.pageIndex),
|
||||
page_size: String(pagination.pageSize),
|
||||
});
|
||||
}
|
||||
}, [pagination]);
|
||||
|
||||
return [pagination, setPagination] as const;
|
||||
};
|
||||
181
client/src/lib/hooks/useQueryKey/KeyBuilder.ts
Normal file
181
client/src/lib/hooks/useQueryKey/KeyBuilder.ts
Normal file
@ -0,0 +1,181 @@
|
||||
type BaseKey = string | number;
|
||||
|
||||
type ParametrizedDataActions = "list" | "infinite";
|
||||
type IdRequiredDataActions = "one";
|
||||
type IdsRequiredDataActions = "many";
|
||||
type DataMutationActions =
|
||||
| "custom"
|
||||
| "customMutation"
|
||||
| "create"
|
||||
| "createMany"
|
||||
| "update"
|
||||
| "updateMany"
|
||||
| "delete"
|
||||
| "deleteMany";
|
||||
|
||||
type AuthActionType =
|
||||
| "login"
|
||||
| "logout"
|
||||
| "identity"
|
||||
| "register"
|
||||
| "forgotPassword"
|
||||
| "check"
|
||||
| "onError"
|
||||
| "permissions"
|
||||
| "updatePassword";
|
||||
|
||||
type AuditActionType = "list" | "log" | "rename";
|
||||
|
||||
type IdType = BaseKey;
|
||||
type IdsType = IdType[];
|
||||
|
||||
type ParamsType = any;
|
||||
|
||||
type KeySegment = string | IdType | IdsType | ParamsType;
|
||||
|
||||
export function arrayFindIndex<T>(array: T[], slice: T[]): number {
|
||||
return array.findIndex(
|
||||
(item, index) =>
|
||||
index <= array.length - slice.length &&
|
||||
slice.every((sliceItem, sliceIndex) => array[index + sliceIndex] === sliceItem)
|
||||
);
|
||||
}
|
||||
|
||||
export function arrayReplace<T>(array: T[], partToBeReplaced: T[], newPart: T[]): T[] {
|
||||
const newArray: T[] = [...array];
|
||||
const startIndex = arrayFindIndex(array, partToBeReplaced);
|
||||
|
||||
if (startIndex !== -1) {
|
||||
newArray.splice(startIndex, partToBeReplaced.length, ...newPart);
|
||||
}
|
||||
|
||||
return newArray;
|
||||
}
|
||||
|
||||
export function stripUndefined(segments: KeySegment[]) {
|
||||
return segments.filter((segment) => segment !== undefined);
|
||||
}
|
||||
|
||||
class BaseKeyBuilder {
|
||||
segments: KeySegment[] = [];
|
||||
|
||||
constructor(segments: KeySegment[] = []) {
|
||||
this.segments = segments;
|
||||
}
|
||||
|
||||
key() {
|
||||
return this.segments;
|
||||
}
|
||||
|
||||
get() {
|
||||
return this.segments;
|
||||
}
|
||||
}
|
||||
|
||||
class ParamsKeyBuilder extends BaseKeyBuilder {
|
||||
params(paramsValue?: ParamsType) {
|
||||
return new BaseKeyBuilder([...this.segments, paramsValue]);
|
||||
}
|
||||
}
|
||||
|
||||
class DataIdRequiringKeyBuilder extends BaseKeyBuilder {
|
||||
id(idValue?: IdType) {
|
||||
return new ParamsKeyBuilder([...this.segments, idValue ? String(idValue) : undefined]);
|
||||
}
|
||||
}
|
||||
|
||||
class DataIdsRequiringKeyBuilder extends BaseKeyBuilder {
|
||||
ids(...idsValue: IdsType) {
|
||||
return new ParamsKeyBuilder([
|
||||
...this.segments,
|
||||
...(idsValue.length ? [idsValue.map((el) => String(el))] : []),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class DataResourceKeyBuilder extends BaseKeyBuilder {
|
||||
action(actionType: ParametrizedDataActions): ParamsKeyBuilder;
|
||||
action(actionType: IdRequiredDataActions): DataIdRequiringKeyBuilder;
|
||||
action(actionType: IdsRequiredDataActions): DataIdsRequiringKeyBuilder;
|
||||
action(
|
||||
actionType: ParametrizedDataActions | IdRequiredDataActions | IdsRequiredDataActions
|
||||
): ParamsKeyBuilder | DataIdRequiringKeyBuilder | DataIdsRequiringKeyBuilder {
|
||||
if (actionType === "one") {
|
||||
return new DataIdRequiringKeyBuilder([...this.segments, actionType]);
|
||||
}
|
||||
if (actionType === "many") {
|
||||
return new DataIdsRequiringKeyBuilder([...this.segments, actionType]);
|
||||
}
|
||||
if (["list", "infinite"].includes(actionType)) {
|
||||
return new ParamsKeyBuilder([...this.segments, actionType]);
|
||||
}
|
||||
throw new Error("Invalid action type");
|
||||
}
|
||||
}
|
||||
|
||||
class DataKeyBuilder extends BaseKeyBuilder {
|
||||
resource(resourceName?: string) {
|
||||
return new DataResourceKeyBuilder([...this.segments, resourceName]);
|
||||
}
|
||||
|
||||
mutation(mutationName: DataMutationActions) {
|
||||
return new ParamsKeyBuilder([
|
||||
...(mutationName === "custom" ? this.segments : [this.segments[0]]),
|
||||
mutationName,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class AuthKeyBuilder extends BaseKeyBuilder {
|
||||
action(actionType: AuthActionType) {
|
||||
return new ParamsKeyBuilder([...this.segments, actionType]);
|
||||
}
|
||||
}
|
||||
|
||||
class AccessResourceKeyBuilder extends BaseKeyBuilder {
|
||||
action(resourceName: string) {
|
||||
return new ParamsKeyBuilder([...this.segments, resourceName]);
|
||||
}
|
||||
}
|
||||
|
||||
class AccessKeyBuilder extends BaseKeyBuilder {
|
||||
resource(resourceName?: string) {
|
||||
return new AccessResourceKeyBuilder([...this.segments, resourceName]);
|
||||
}
|
||||
}
|
||||
|
||||
class AuditActionKeyBuilder extends BaseKeyBuilder {
|
||||
action(actionType: Extract<AuditActionType, "list">) {
|
||||
return new ParamsKeyBuilder([...this.segments, actionType]);
|
||||
}
|
||||
}
|
||||
|
||||
class AuditKeyBuilder extends BaseKeyBuilder {
|
||||
resource(resourceName?: string) {
|
||||
return new AuditActionKeyBuilder([...this.segments, resourceName]);
|
||||
}
|
||||
|
||||
action(actionType: Extract<AuditActionType, "rename" | "log">) {
|
||||
return new ParamsKeyBuilder([...this.segments, actionType]);
|
||||
}
|
||||
}
|
||||
|
||||
export class KeyBuilder extends BaseKeyBuilder {
|
||||
data(name?: string) {
|
||||
return new DataKeyBuilder(["data", name || "default"]);
|
||||
}
|
||||
|
||||
auth() {
|
||||
return new AuthKeyBuilder(["auth"]);
|
||||
}
|
||||
|
||||
access() {
|
||||
return new AccessKeyBuilder(["access"]);
|
||||
}
|
||||
|
||||
audit() {
|
||||
return new AuditKeyBuilder(["audit"]);
|
||||
}
|
||||
}
|
||||
|
||||
export const keys = () => new KeyBuilder([]);
|
||||
5
client/src/lib/hooks/useQueryKey/index.ts
Normal file
5
client/src/lib/hooks/useQueryKey/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { keys } from "./KeyBuilder";
|
||||
|
||||
export const useQueryKey = () => {
|
||||
return keys;
|
||||
};
|
||||
1
client/src/lib/hooks/useRemoveAlertDialog/index.ts
Normal file
1
client/src/lib/hooks/useRemoveAlertDialog/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./useRemoveAlertDialog";
|
||||
@ -0,0 +1,107 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/ui";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/ui/alert-dialog";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
interface UseRemoveAlertDialogProps {
|
||||
isOpen?: boolean;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
|
||||
onCancel?: (event: React.SyntheticEvent) => void;
|
||||
onRemove?: (payload: any, event: React.SyntheticEvent) => void;
|
||||
}
|
||||
|
||||
interface UseRemoveAlertDialogReturnType {
|
||||
RemoveAlertDialog: () => JSX.Element;
|
||||
open: (title: string, description: string, payload?: any) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const useRemoveAlertDialog = ({
|
||||
isOpen = false,
|
||||
onRemove,
|
||||
onCancel,
|
||||
}: UseRemoveAlertDialogProps): UseRemoveAlertDialogReturnType => {
|
||||
const [visible, setVisible] = useState<boolean>(isOpen);
|
||||
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const [description, setDescription] = useState<string>("");
|
||||
const [payload, setPayload] = useState<any>({});
|
||||
|
||||
/*const cancelButtonProps = {
|
||||
cancelButtonText: "Cancelar",
|
||||
cancelButtonProps: {},
|
||||
onCancel,
|
||||
};
|
||||
|
||||
const submitButtonProps = {
|
||||
submitButtonText: "Eliminar",
|
||||
submitButtonProps: {},
|
||||
onSubmit: (event: React.SyntheticEvent) => {
|
||||
if (onRemove) {
|
||||
onRemove(payload, event);
|
||||
}
|
||||
},
|
||||
};*/
|
||||
|
||||
const open = useCallback(
|
||||
(title: string, description: string, payload: any = {}) => {
|
||||
setTitle(title);
|
||||
setDescription(description);
|
||||
setPayload(payload);
|
||||
setVisible(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const close = () => setVisible(false);
|
||||
|
||||
const RemoveAlertDialog = (): JSX.Element => (
|
||||
<AlertDialog open={visible} onOpenChange={setVisible}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onCancel}>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={cn(
|
||||
buttonVariants({ variant: "destructive" }),
|
||||
"mt-2 sm:mt-0"
|
||||
)}
|
||||
onClick={(event) => (onRemove ? onRemove(payload, event) : null)}
|
||||
>
|
||||
Eliminar
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
|
||||
return { RemoveAlertDialog, open, close };
|
||||
};
|
||||
|
||||
/*
|
||||
<ConfirmDialog
|
||||
dialogType="danger"
|
||||
title={title}
|
||||
description={description}
|
||||
{...cancelButton}
|
||||
{...continueButton}
|
||||
{...modalProps}
|
||||
>
|
||||
{children}
|
||||
</ConfirmDialog>
|
||||
*/
|
||||
32
client/src/lib/hooks/useResizeObserver.tsx
Normal file
32
client/src/lib/hooks/useResizeObserver.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
// https://github.com/wojtekmaj/react-hooks/blob/main/src/useResizeObserver.ts
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Observes a given element using ResizeObserver.
|
||||
*
|
||||
* @param {Element} [element] Element to attach ResizeObserver to
|
||||
* @param {ResizeObserverOptions} [options] ResizeObserver options. WARNING! If you define the
|
||||
* object in component body, make sure to memoize it.
|
||||
* @param {ResizeObserverCallback} observerCallback ResizeObserver callback. WARNING! If you define
|
||||
* the function in component body, make sure to memoize it.
|
||||
* @returns {void}
|
||||
*/
|
||||
export default function useResizeObserver(
|
||||
element: Element | null,
|
||||
options: ResizeObserverOptions | undefined,
|
||||
observerCallback: ResizeObserverCallback,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!element || !('ResizeObserver' in window)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(observerCallback);
|
||||
|
||||
observer.observe(element, options);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [element, options, observerCallback]);
|
||||
}
|
||||
1
client/src/lib/hooks/useTheme/index.ts
Normal file
1
client/src/lib/hooks/useTheme/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./useTheme";
|
||||
72
client/src/lib/hooks/useTheme/useTheme.tsx
Normal file
72
client/src/lib/hooks/useTheme/useTheme.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "dark" | "light" | "system";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return context;
|
||||
};
|
||||
1
client/src/lib/hooks/useUrlId/index.ts
Normal file
1
client/src/lib/hooks/useUrlId/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './useUrlId';
|
||||
6
client/src/lib/hooks/useUrlId/useUrlId.tsx
Normal file
6
client/src/lib/hooks/useUrlId/useUrlId.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
export const useUrlId = (): string | undefined => {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
return id;
|
||||
};
|
||||
17
client/src/lib/hooks/useWindowResize.tsx
Normal file
17
client/src/lib/hooks/useWindowResize.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useWindowResize = (onWindowResize: (resized: boolean) => any) => {
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
onWindowResize && onWindowResize(true);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
6
client/src/lib/utils.ts
Normal file
6
client/src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
10
client/src/main.tsx
Normal file
10
client/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("uecko")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
51
client/src/pages/ErrorPage.tsx
Normal file
51
client/src/pages/ErrorPage.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
// - - - - - ErrorPage.tsx - - - - -
|
||||
import { Button } from "@/ui";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
// import Button
|
||||
|
||||
const isDev = process.env.REACT_APP_NODE_ENV === "development";
|
||||
const hostname = `${
|
||||
isDev ? process.env.REACT_APP_DEV_API_URL : process.env.REACT_APP_PROD_API_URL
|
||||
}`;
|
||||
|
||||
type ErrorPageProps = {
|
||||
error?: string;
|
||||
};
|
||||
export const ErrorPage = (props: ErrorPageProps) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const navMsg = location.state?.error;
|
||||
const msg = navMsg ? (
|
||||
<p className='text-lg'>{navMsg}</p>
|
||||
) : props.error ? (
|
||||
<p className='text-lg'>{props.error}</p>
|
||||
) : (
|
||||
<p className='text-lg'>
|
||||
The targeted page <b>"{location.pathname}"</b> was not found, please confirm the spelling and
|
||||
try again.
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<section id='Error' className='flex flex-col items-center w-full h-full'>
|
||||
{msg}
|
||||
<span className='flex flex-row items-center justify-center my-10 space-x-8 '>
|
||||
<Button id='backButton' onClick={() => navigate(-1)}>
|
||||
Return to Previous Page
|
||||
</Button>
|
||||
<Button id='homeButton' onClick={() => navigate("/home")}>
|
||||
Return to Home Page
|
||||
</Button>
|
||||
<Button
|
||||
id='logout'
|
||||
onClick={() => {
|
||||
const endpoint = `${hostname}/logout`;
|
||||
window.open(endpoint, "_blank");
|
||||
}}
|
||||
>
|
||||
Reset Authentication
|
||||
</Button>
|
||||
</span>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
155
client/src/pages/LoginPage.tsx
Normal file
155
client/src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import { Container, FormTextField } from "@/components";
|
||||
import { UeckoLogo } from "@/components/UeckoLogo/UeckoLogo";
|
||||
import { useLogin } from "@/lib/hooks";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
} from "@/ui";
|
||||
import { joiResolver } from "@hookform/resolvers/joi";
|
||||
import Joi from "joi";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import { Link } from "react-router-dom";
|
||||
import SpanishJoiMessages from "../spanish-joi-messages.json";
|
||||
|
||||
type LoginDataForm = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export const LoginPage = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { mutate: login } = useLogin({
|
||||
onSuccess: (data) => {
|
||||
const { success, error } = data;
|
||||
if (!success && error) {
|
||||
form.setError("root", error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<LoginDataForm>({
|
||||
mode: "onBlur",
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
resolver: joiResolver(
|
||||
Joi.object({
|
||||
email: Joi.string()
|
||||
.email({ tlds: { allow: false } })
|
||||
.required(),
|
||||
password: Joi.string().min(4).alphanum().required(),
|
||||
}),
|
||||
{
|
||||
messages: SpanishJoiMessages,
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<LoginDataForm> = async (data) => {
|
||||
console.log(data);
|
||||
try {
|
||||
setLoading(true);
|
||||
login({ email: data.email, password: data.password });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container
|
||||
variant={"full"}
|
||||
className='p-0 lg:grid lg:min-h-[600px] lg:grid-cols-2 xl:min-h-[800px] h-screen'
|
||||
>
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='mx-auto grid w-[450px] gap-6'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<UeckoLogo className='inline-block m-auto mb-6 align-middle max-w-32' />
|
||||
<CardTitle>Presupuestador para distribuidores</CardTitle>
|
||||
<CardDescription>Enter your email below to login to your account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className='grid items-start gap-6'>
|
||||
<div className='grid gap-6'>
|
||||
<FormTextField
|
||||
disabled={loading}
|
||||
label='Email'
|
||||
type='email'
|
||||
placeholder='micorreo@ejemplo.com'
|
||||
{...form.register("email", {
|
||||
required: true,
|
||||
})}
|
||||
errors={form.formState.errors}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-6'>
|
||||
<FormTextField
|
||||
disabled={loading}
|
||||
label='Contraseña'
|
||||
type='password'
|
||||
{...form.register("password", {
|
||||
required: true,
|
||||
})}
|
||||
errors={form.formState.errors}
|
||||
/>
|
||||
<div className='mb-4 -mt-2 text-sm'>
|
||||
¿Has olvidado tu contraseña?
|
||||
<br />
|
||||
<Link to='https://uecko.com/distribuidores' className='underline'>
|
||||
Contacta con nosotros
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.formState.errors.root?.message && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircleIcon className='w-4 h-4' />
|
||||
<AlertTitle>Heads up!</AlertTitle>
|
||||
<AlertDescription>{form.formState.errors.root?.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button disabled={loading} type='submit' className='w-full'>
|
||||
Entrar
|
||||
</Button>
|
||||
|
||||
<div className='mt-4 text-sm text-center'>
|
||||
¿Quieres ser distribuidor de Uecko?
|
||||
<br />
|
||||
<Link to='https://uecko.com/distribuidores' className='underline'>
|
||||
Contacta con nosotros
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='hidden bg-muted lg:block'>
|
||||
<img
|
||||
src='https://uecko.com/assets/img/021/nara2.jpg'
|
||||
alt='Image'
|
||||
width='1920'
|
||||
height='1080'
|
||||
className='h-full w-full object-cover dark:brightness-[0.2] dark:grayscale'
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
93
client/src/spanish-joi-messages.json
Normal file
93
client/src/spanish-joi-messages.json
Normal file
@ -0,0 +1,93 @@
|
||||
{
|
||||
"any.unknown": "{{#label}}: no está permitido",
|
||||
"any.invalid": "{{#label}}: contiene un valor invalido",
|
||||
"any.empty": "{{#label}}: no está permitido que sea vacío",
|
||||
"any.required": "{{#label}}: es un campo requerido",
|
||||
"any.allowOnly": "{{#label}}: debería ser uno de las siguientes variantes: {{valids}}",
|
||||
"any.default": "emitió un error cuando se ejecutó el metodo default",
|
||||
"alternatives.base": "{{#label}}: no coincide con ninguna de las alternativas permitidas",
|
||||
"array.base": "{{#label}}: debe ser un array",
|
||||
"array.includes": "{{#label}}: en la posición {{pos}} no coincide con ninguno de los tipos permitidos",
|
||||
"array.includesSingle": "{{#label}}: el valor de \"{{!key}}\" no coincide con ninguno de los tipos permitidos",
|
||||
"array.includesOne": "{{#label}}: en la posición {{pos}} falló porque {{reason}}",
|
||||
"array.includesOneSingle": "{{#label}}: el valor \"{{!key}}\" falló porque {{reason}}",
|
||||
"array.includesRequiredUnknowns": "{{#label}}: no contiene valor/es requerido/s: {{unknownMisses}} ",
|
||||
"array.includesRequiredKnowns": "{{#label}}: no contiene: {{knownMisses}}",
|
||||
"array.includesRequiredBoth": "{{#label}}: no contiene {{knownMisses}} y {{unknownMisses}} otros valores requeridos",
|
||||
"array.excludes": "{{#label}}: en la posición {{pos}} contiene un valor excluído",
|
||||
"array.excludesSingle": "{{#label}}: el valor \"{{!key}}\" contiene un valor excluído",
|
||||
"array.min": "{{#label}}: debe contener al menos {{limit}} items",
|
||||
"array.max": "{{#label}}: debe contener máximo {{limit}} items",
|
||||
"array.length": "{{#label}}: debe contener exactamente {{limit}} items",
|
||||
"array.ordered": "{{#label}}: en la posición {{pos}} falló porque {{reason}}",
|
||||
"array.orderedLength": "{{#label}}: en la posición {{pos}} falló porque el array debre contener como máximo {{limit}} items",
|
||||
"array.sparse": "{{#label}}: no debe ser un array esparcido",
|
||||
"array.unique": "{{#label}}: posición {{pos}} contiene un valor duplicado",
|
||||
"boolean.base": "{{#label}}: debe ser un valor verdadero/falso o si/no",
|
||||
"binary.base": "{{#label}}: debe ser un buffer o un string",
|
||||
"binary.min": "{{#label}}: debe ser como mínimo de {{limit}} bytes",
|
||||
"binary.max": "{{#label}}: debe ser como máximo de {{limit}} bytes",
|
||||
"binary.length": "{{#label}}: debe tener exactamente {{limit}} bytes",
|
||||
"date.base": "{{#label}}: debe ser una cantidad de milisegundos o una fecha en cadena de texto válida",
|
||||
"date.min": "{{#label}}: debe ser mayor o igual a \"{{limit}}\"",
|
||||
"date.max": "{{#label}}: debe ser menor o igual que \"{{limit}}\"",
|
||||
"date.isoDate": "{{#label}}: debe ser una fecha en formato ISO 8601",
|
||||
"date.ref": "referencia a \"{{ref}}\", que no es una fecha válida",
|
||||
"function.base": "{{#label}}: debe ser una función",
|
||||
"object.base": "{{#label}}: debe ser un objeto",
|
||||
"object.child": "hijo \"{{!key}}\" falló porque {{reason}}",
|
||||
"object.min": "{{#label}}: debe tener como mínimo {{limit}} hijo",
|
||||
"object.max": "{{#label}}: debe tener menos o a lo sumo {{limit}} hijo",
|
||||
"object.length": "{{#label}}: debe tener máximo {{limit}} hijo/s",
|
||||
"object.allowUnknown": "no está permitido",
|
||||
"object.with": "peer faltante: \"{{peer}}\"",
|
||||
"object.without": "conflicto con peer prohibido: \"{{peer}}\"",
|
||||
"object.missing": "{{#label}}: debe contener al menos uno de: {{peers}}",
|
||||
"object.xor": "{{#label}}: contiene un conflicto con alguno de: {{peers}}",
|
||||
"object.or": "{{#label}}: debe contener al menos uno de: {{peers}}",
|
||||
"object.and": "contiene {{present}} sin el requerido: {{missing}}",
|
||||
"object.nand": "!!\"{{main}}\" no debe existir simultáneamente con {{peers}}",
|
||||
"object.assert": "!!\"{{ref}}\" falló validacion porque \"{{ref}}\" falló a {{message}}",
|
||||
"object.rename.multiple": "{{#label}}: no se puede renombrar el hijo \"{{from}}\" porque múltiples re-nombramientos estan deshabilitados y otra clave fue renombrada a \"{{to}}\"",
|
||||
"object.rename.override": "{{#label}}: no se puede renombrar el hijo \"{{from}}\" porque la sobre escritura esta deshabilitada y el target \"{{to}}\" existe",
|
||||
"object.type": "{{#label}}: debe ser una instancia de \"{{type}}\"",
|
||||
"number.base": "{{#label}}: debe ser un número",
|
||||
"number.min": "{{#label}}: debe ser mayor o igual que {{limit}}",
|
||||
"number.max": "{{#label}}: debe ser menor o igual que {{limit}}",
|
||||
"number.less": "{{#label}}: debe ser menor a {{limit}}",
|
||||
"number.greater": "{{#label}}: debe ser mayor a {{limit}}",
|
||||
"number.float": "{{#label}}: debe ser un numero flotante",
|
||||
"number.integer": "{{#label}}: debe ser un número entero",
|
||||
"number.negative": "{{#label}}: debe ser un número negativo",
|
||||
"number.positive": "{{#label}}: debe ser un número positivo",
|
||||
"number.precision": "{{#label}}: no debe tener mas de {{limit}} decimales",
|
||||
"number.ref": "{{#label}}: referencia a \"{{ref}}\" que no es un número",
|
||||
"number.multiple": "{{#label}}: debe ser un múltiplo de {{multiple}}",
|
||||
"string.base": "{{#label}}: debe ser una cadena de texto",
|
||||
"string.min": "{{#label}}: debe ser mínimo de {{limit}} caracteres de largo",
|
||||
"string.max": "{{#label}}: debe ser de máximo {{limit}} caracteres de largo",
|
||||
"string.length": "{{#label}}: debe ser exactamente de {{limit}} caracteres de largo",
|
||||
"string.alphanum": "{{#label}}: debe contener solo letras y números",
|
||||
"string.token": "{{#label}}: debe contener solo letras, números y guines bajos",
|
||||
"string.regex.base": "{{#label}}: el valor \"{{!value}}\" no coincide con el pattern requerido: {{pattern}}",
|
||||
"string.regex.name": "{{#label}}: el valor \"{{!value}}\" no coincide con el nombre de pattern {{name}}",
|
||||
"string.email": "{{#label}}: debe ser un email válido",
|
||||
"string.uri": "{{#label}}: debe sre una uri válida",
|
||||
"string.uriCustomScheme": "{{#label}}: debe ser una uri válida con el esquema concidiente con el patrón {{scheme}}",
|
||||
"string.isoDate": "{{#label}}: debe ser una fecha en formato ISO 8601 válida",
|
||||
"string.guid": "{{#label}}: debe ser un GUID valido",
|
||||
"string.hex": "{{#label}}: debe contener solo caracteres hexadecimales",
|
||||
"string.hostname": "{{#label}}: deber ser un hostname válido",
|
||||
"string.lowercase": "{{#label}}: solo debe contener minúsculas",
|
||||
"string.uppercase": "{{#label}}: solo debe contener mayúsculas",
|
||||
"string.trim": "{{#label}}: no debe tener espacios en blanco delante o atrás",
|
||||
"string.creditCard": "{{#label}}: debe ser una tarjeta de crédito",
|
||||
"string.ref": "Referencia \"{{ref}}\" que no es un número",
|
||||
"string.ip": "{{#label}}: debe ser una dirección ip válida con un CDIR {{cidr}}",
|
||||
"string.ipVersion": "{{#label}}: debe ser una dirección ip válida de una de las siguientes versiones {{version}} con un CDIR {{cidr}}",
|
||||
"object.unknown": "{{#label}}: es un campo no es permitido",
|
||||
"luxon.lt": "{{#label}}: must be before {{#date}}",
|
||||
"luxon.gt": "{{#label}}: must be after {{#date}}",
|
||||
"luxon.lte": "{{#label}}: must be same as or before {{#date}}",
|
||||
"luxon.gte": "{{#label}}: must be same as or after {{#date}}"
|
||||
}
|
||||
56
client/src/ui/accordion.tsx
Normal file
56
client/src/ui/accordion.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
139
client/src/ui/alert-dialog.tsx
Normal file
139
client/src/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "./button";
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader";
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter";
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
};
|
||||
59
client/src/ui/alert.tsx
Normal file
59
client/src/ui/alert.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
5
client/src/ui/aspect-ratio.tsx
Normal file
5
client/src/ui/aspect-ratio.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root
|
||||
|
||||
export { AspectRatio }
|
||||
73
client/src/ui/autosize-textarea.tsx
Normal file
73
client/src/ui/autosize-textarea.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as React from "react";
|
||||
import { useImperativeHandle } from "react";
|
||||
import { useAutosizeTextArea } from "./use-autosize-textarea";
|
||||
|
||||
export type AutosizeTextAreaRef = {
|
||||
textArea: HTMLTextAreaElement;
|
||||
maxHeight: number;
|
||||
minHeight: number;
|
||||
};
|
||||
|
||||
type AutosizeTextAreaProps = {
|
||||
maxHeight?: number;
|
||||
minHeight?: number;
|
||||
} & React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
||||
export const AutosizeTextarea = React.forwardRef<
|
||||
AutosizeTextAreaRef,
|
||||
AutosizeTextAreaProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
maxHeight = Number.MAX_SAFE_INTEGER,
|
||||
minHeight = 52,
|
||||
className,
|
||||
onChange,
|
||||
value,
|
||||
...props
|
||||
}: AutosizeTextAreaProps,
|
||||
ref: React.Ref<AutosizeTextAreaRef>,
|
||||
) => {
|
||||
const textAreaRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||
const [triggerAutoSize, setTriggerAutoSize] = React.useState("");
|
||||
|
||||
useAutosizeTextArea({
|
||||
textAreaRef: textAreaRef.current,
|
||||
triggerAutoSize: triggerAutoSize,
|
||||
maxHeight,
|
||||
minHeight,
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
textArea: textAreaRef.current as HTMLTextAreaElement,
|
||||
focus: () => textAreaRef.current?.focus(),
|
||||
maxHeight,
|
||||
minHeight,
|
||||
}));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value || props?.defaultValue) {
|
||||
setTriggerAutoSize(value as string);
|
||||
}
|
||||
}, [value || props?.defaultValue]);
|
||||
|
||||
return (
|
||||
<textarea
|
||||
{...props}
|
||||
value={value}
|
||||
ref={textAreaRef}
|
||||
className={cn(
|
||||
"flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
onChange={(e) => {
|
||||
setTriggerAutoSize(e.target.value);
|
||||
onChange?.(e);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
AutosizeTextarea.displayName = "AutosizeTextarea";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user