first commit
This commit is contained in:
2
frontend/.dockerignore
Normal file
2
frontend/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
1
frontend/.env
Normal file
1
frontend/.env
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost
|
29
frontend/.gitignore
vendored
Normal file
29
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
openapi.json
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
1
frontend/.nvmrc
Normal file
1
frontend/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
20
|
23
frontend/Dockerfile
Normal file
23
frontend/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
# Stage 0, "build-stage", based on Node.js, to build and compile the frontend
|
||||
FROM node:20 AS build-stage
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json /app/
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY ./ /app/
|
||||
|
||||
ARG VITE_API_URL=${VITE_API_URL}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# Stage 1, based on Nginx, to have only the compiled app, ready for production with Nginx
|
||||
FROM nginx:1
|
||||
|
||||
COPY --from=build-stage /app/dist/ /usr/share/nginx/html
|
||||
|
||||
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY ./nginx-backend-not-found.conf /etc/nginx/extra-conf.d/backend-not-found.conf
|
147
frontend/README.md
Normal file
147
frontend/README.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# FastAPI Project - Frontend
|
||||
|
||||
The frontend is built with [Vite](https://vitejs.dev/), [React](https://reactjs.org/), [TypeScript](https://www.typescriptlang.org/), [TanStack Query](https://tanstack.com/query), [TanStack Router](https://tanstack.com/router) and [Chakra UI](https://chakra-ui.com/).
|
||||
|
||||
## Frontend development
|
||||
|
||||
Before you begin, ensure that you have either the Node Version Manager (nvm) or Fast Node Manager (fnm) installed on your system.
|
||||
|
||||
* To install fnm follow the [official fnm guide](https://github.com/Schniz/fnm#installation). If you prefer nvm, you can install it using the [official nvm guide](https://github.com/nvm-sh/nvm#installing-and-updating).
|
||||
|
||||
* After installing either nvm or fnm, proceed to the `frontend` directory:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
```
|
||||
* If the Node.js version specified in the `.nvmrc` file isn't installed on your system, you can install it using the appropriate command:
|
||||
|
||||
```bash
|
||||
# If using fnm
|
||||
fnm install
|
||||
|
||||
# If using nvm
|
||||
nvm install
|
||||
```
|
||||
|
||||
* Once the installation is complete, switch to the installed version:
|
||||
|
||||
```bash
|
||||
# If using fnm
|
||||
fnm use
|
||||
|
||||
# If using nvm
|
||||
nvm use
|
||||
```
|
||||
|
||||
* Within the `frontend` directory, install the necessary NPM packages:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
* And start the live server with the following `npm` script:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
* Then open your browser at http://localhost:5173/.
|
||||
|
||||
Notice that this live server is not running inside Docker, it's for local development, and that is the recommended workflow. Once you are happy with your frontend, you can build the frontend Docker image and start it, to test it in a production-like environment. But building the image at every change will not be as productive as running the local development server with live reload.
|
||||
|
||||
Check the file `package.json` to see other available options.
|
||||
|
||||
### Removing the frontend
|
||||
|
||||
If you are developing an API-only app and want to remove the frontend, you can do it easily:
|
||||
|
||||
* Remove the `./frontend` directory.
|
||||
|
||||
* In the `docker-compose.yml` file, remove the whole service / section `frontend`.
|
||||
|
||||
* In the `docker-compose.override.yml` file, remove the whole service / section `frontend`.
|
||||
|
||||
Done, you have a frontend-less (api-only) app. 🤓
|
||||
|
||||
---
|
||||
|
||||
If you want, you can also remove the `FRONTEND` environment variables from:
|
||||
|
||||
* `.env`
|
||||
* `./scripts/*.sh`
|
||||
|
||||
But it would be only to clean them up, leaving them won't really have any effect either way.
|
||||
|
||||
## Generate Client
|
||||
|
||||
* Start the Docker Compose stack.
|
||||
|
||||
* Download the OpenAPI JSON file from `http://localhost/api/v1/openapi.json` and copy it to a new file `openapi.json` at the root of the `frontend` directory.
|
||||
|
||||
* To simplify the names in the generated frontend client code, modify the `openapi.json` file by running the following script:
|
||||
|
||||
```bash
|
||||
node modify-openapi-operationids.js
|
||||
```
|
||||
|
||||
* To generate the frontend client, run:
|
||||
|
||||
```bash
|
||||
npm run generate-client
|
||||
```
|
||||
|
||||
* Commit the changes.
|
||||
|
||||
Notice that everytime the backend changes (changing the OpenAPI schema), you should follow these steps again to update the frontend client.
|
||||
|
||||
## Using a Remote API
|
||||
|
||||
If you want to use a remote API, you can set the environment variable `VITE_API_URL` to the URL of the remote API. For example, you can set it in the `frontend/.env` file:
|
||||
|
||||
```env
|
||||
VITE_API_URL=https://my-remote-api.example.com
|
||||
```
|
||||
|
||||
Then, when you run the frontend, it will use that URL as the base URL for the API.
|
||||
|
||||
## Code Structure
|
||||
|
||||
The frontend code is structured as follows:
|
||||
|
||||
* `frontend/src` - The main frontend code.
|
||||
* `frontend/src/assets` - Static assets.
|
||||
* `frontend/src/client` - The generated OpenAPI client.
|
||||
* `frontend/src/components` - The different components of the frontend.
|
||||
* `frontend/src/hooks` - Custom hooks.
|
||||
* `frontend/src/routes` - The different routes of the frontend which include the pages.
|
||||
* `theme.tsx` - The Chakra UI custom theme.
|
||||
|
||||
## End-to-End Testing with Playwright
|
||||
|
||||
The frontend includes initial end-to-end tests using Playwright. To run the tests, you need to have the Docker Compose stack running. Start the stack with the following command:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Then, you can run the tests with the following command:
|
||||
|
||||
```bash
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
You can also run your tests in UI mode to see the browser and interact with it running:
|
||||
|
||||
```bash
|
||||
npx playwright test --ui
|
||||
```
|
||||
|
||||
To stop and remove the Docker Compose stack and clean the data created in tests, use the following command:
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
To update the tests, navigate to the tests directory and modify the existing test files or add new ones as needed.
|
||||
|
||||
For more information on writing and running Playwright tests, refer to the official [Playwright documentation](https://playwright.dev/docs/intro).
|
37
frontend/biome.json
Normal file
37
frontend/biome.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.6.1/schema.json",
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"files": {
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
"src/client/",
|
||||
"src/routeTree.gen.ts",
|
||||
"playwright.config.ts",
|
||||
"playwright-report"
|
||||
]
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noArrayIndexKey": "off"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"semicolons": "asNeeded"
|
||||
}
|
||||
}
|
||||
}
|
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Full Stack FastAPI Project</title>
|
||||
<link rel="icon" type="image/x-icon" href="/assets/images/favicon.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
<script> const global = globalThis; </script>
|
36
frontend/modify-openapi-operationids.js
Normal file
36
frontend/modify-openapi-operationids.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as fs from "node:fs"
|
||||
|
||||
async function modifyOpenAPIFile(filePath) {
|
||||
try {
|
||||
const data = await fs.promises.readFile(filePath)
|
||||
const openapiContent = JSON.parse(data)
|
||||
|
||||
const paths = openapiContent.paths
|
||||
for (const pathKey of Object.keys(paths)) {
|
||||
const pathData = paths[pathKey]
|
||||
for (const method of Object.keys(pathData)) {
|
||||
const operation = pathData[method]
|
||||
if (operation.tags && operation.tags.length > 0) {
|
||||
const tag = operation.tags[0]
|
||||
const operationId = operation.operationId
|
||||
const toRemove = `${tag}-`
|
||||
if (operationId.startsWith(toRemove)) {
|
||||
const newOperationId = operationId.substring(toRemove.length)
|
||||
operation.operationId = newOperationId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(
|
||||
filePath,
|
||||
JSON.stringify(openapiContent, null, 2),
|
||||
)
|
||||
console.log("File successfully modified")
|
||||
} catch (err) {
|
||||
console.error("Error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
const filePath = "./openapi.json"
|
||||
modifyOpenAPIFile(filePath)
|
9
frontend/nginx-backend-not-found.conf
Normal file
9
frontend/nginx-backend-not-found.conf
Normal file
@@ -0,0 +1,9 @@
|
||||
location /api {
|
||||
return 404;
|
||||
}
|
||||
location /docs {
|
||||
return 404;
|
||||
}
|
||||
location /redoc {
|
||||
return 404;
|
||||
}
|
11
frontend/nginx.conf
Normal file
11
frontend/nginx.conf
Normal file
@@ -0,0 +1,11 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
|
||||
include /etc/nginx/extra-conf.d/*.conf;
|
||||
}
|
4747
frontend/package-lock.json
generated
Normal file
4747
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
frontend/package.json
Normal file
57
frontend/package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "biome check --apply-unsafe --no-errors-on-unmatched --files-ignore-unknown=true ./",
|
||||
"preview": "vite preview",
|
||||
"generate-client": "openapi-ts --input ./openapi.json --output ./src/client --client axios --exportSchemas true && biome format --write ./src/client"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/icons": "2.1.1",
|
||||
"@chakra-ui/react": "2.8.2",
|
||||
"@emotion/react": "11.11.3",
|
||||
"@emotion/styled": "11.11.0",
|
||||
"@tanstack/react-query": "^5.28.14",
|
||||
"@tanstack/react-query-devtools": "^5.28.14",
|
||||
"@tanstack/react-router": "1.19.1",
|
||||
"axios": "1.7.4",
|
||||
"draft-js": "^0.11.7",
|
||||
"draftjs-to-html": "^0.9.1",
|
||||
"form-data": "4.0.0",
|
||||
"framer-motion": "10.16.16",
|
||||
"html-react-parser": "^5.1.15",
|
||||
"html-to-draftjs": "^1.5.0",
|
||||
"moment": "^2.30.1",
|
||||
"react": "^18.2.0",
|
||||
"react-datetime-picker": "^6.0.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-draft-wysiwyg": "^1.15.0",
|
||||
"react-easy-sort": "^1.6.0",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"react-hook-form": "7.49.3",
|
||||
"react-icons": "5.0.1",
|
||||
"slate": "^0.103.0",
|
||||
"slate-react": "^0.110.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.6.1",
|
||||
"@hey-api/openapi-ts": "^0.34.1",
|
||||
"@playwright/test": "^1.45.2",
|
||||
"@tanstack/router-devtools": "1.19.1",
|
||||
"@tanstack/router-vite-plugin": "1.19.0",
|
||||
"@types/draftjs-to-html": "^0.8.4",
|
||||
"@types/html-to-draftjs": "^1.4.3",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@types/react-draft-wysiwyg": "^1.13.8",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.13"
|
||||
}
|
||||
}
|
92
frontend/playwright.config.ts
Normal file
92
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: 'http://localhost:5173',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
||||
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'playwright/.auth/user.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
|
||||
// {
|
||||
// name: 'firefox',
|
||||
// use: {
|
||||
// ...devices['Desktop Firefox'],
|
||||
// storageState: 'playwright/.auth/user.json',
|
||||
// },
|
||||
// dependencies: ['setup'],
|
||||
// },
|
||||
|
||||
// {
|
||||
// name: 'webkit',
|
||||
// use: {
|
||||
// ...devices['Desktop Safari'],
|
||||
// storageState: 'playwright/.auth/user.json',
|
||||
// },
|
||||
// dependencies: ['setup'],
|
||||
// },
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
51
frontend/public/assets/images/fastapi-logo.svg
Normal file
51
frontend/public/assets/images/fastapi-logo.svg
Normal file
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
id="svg8"
|
||||
version="1.1"
|
||||
viewBox="0 0 346.52395 63.977134"
|
||||
height="63.977139mm"
|
||||
width="346.52396mm"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
id="g2149">
|
||||
<g
|
||||
id="g2141">
|
||||
<g
|
||||
id="g2106"
|
||||
transform="matrix(0.96564264,0,0,0.96251987,-899.3295,194.86874)">
|
||||
<circle
|
||||
style="fill:#009688;fill-opacity:0.980392;stroke:none;stroke-width:0.141404;stop-color:#000000"
|
||||
id="path875-5-9-7-3-2-3-9-9-8-0-0-5-87-7"
|
||||
cx="964.56165"
|
||||
cy="-169.22266"
|
||||
r="33.234192" />
|
||||
<path
|
||||
id="rect1249-6-3-4-4-3-6-6-1-2"
|
||||
style="fill:#ffffff;fill-opacity:0.980392;stroke:none;stroke-width:0.146895;stop-color:#000000"
|
||||
d="m 962.2685,-187.40837 -6.64403,14.80375 -3.03599,6.76393 -6.64456,14.80375 30.59142,-21.56768 h -14.35312 l 20.99715,-14.80375 z" />
|
||||
</g>
|
||||
<path
|
||||
style="font-size:79.7151px;line-height:1.25;font-family:Ubuntu;-inkscape-font-specification:Ubuntu;letter-spacing:0px;word-spacing:0px;fill:#009688;stroke-width:1.99288"
|
||||
d="M 89.523017,59.410606 V 4.1680399 H 122.84393 V 10.784393 H 97.255382 V 27.44485 h 22.718808 v 6.536638 H 97.255382 v 25.429118 z m 52.292963,-5.340912 q 2.6306,0 4.62348,-0.07972 2.07259,-0.15943 3.42774,-0.47829 V 41.155848 q -0.79715,-0.398576 -2.63059,-0.637721 -1.75374,-0.31886 -4.30462,-0.31886 -1.67402,0 -3.58718,0.239145 -1.83345,0.239145 -3.42775,1.036296 -1.51459,0.717436 -2.55088,2.072593 -1.0363,1.275442 -1.0363,3.427749 0,3.985755 2.55089,5.580058 2.55088,1.514586 6.93521,1.514586 z m -0.63772,-37.147238 q 4.46404,0 7.49322,1.195727 3.10889,1.116011 4.94233,3.268319 1.91317,2.072593 2.71032,5.022052 0.79715,2.869743 0.79715,6.377208 V 58.69317 q -0.95658,0.159431 -2.71031,0.478291 -1.67402,0.239145 -3.82633,0.478291 -2.15231,0.239145 -4.70319,0.398575 -2.47117,0.239146 -4.94234,0.239146 -3.50746,0 -6.45692,-0.717436 -2.94946,-0.717436 -5.10177,-2.232023 -2.1523,-1.594302 -3.34803,-4.145186 -1.19573,-2.550883 -1.19573,-6.138063 0,-3.427749 1.35516,-5.898917 1.43487,-2.471168 3.82632,-3.985755 2.39146,-1.514587 5.58006,-2.232023 3.18861,-0.717436 6.69607,-0.717436 1.11601,0 2.31174,0.15943 1.19572,0.07972 2.23202,0.31886 1.11601,0.159431 1.91316,0.318861 0.79715,0.15943 1.11601,0.239145 v -2.072593 q 0,-1.833447 -0.39857,-3.587179 -0.39858,-1.833448 -1.43487,-3.188604 -1.0363,-1.434872 -2.86975,-2.232023 -1.75373,-0.876866 -4.62347,-0.876866 -3.6669,0 -6.45693,0.558005 -2.71031,0.478291 -4.06547,1.036297 l -0.87686,-6.138063 q 1.43487,-0.637721 4.7829,-1.195727 3.34804,-0.637721 7.25408,-0.637721 z m 37.86462,37.147238 q 4.54377,0 6.69607,-1.195726 2.23203,-1.195727 2.23203,-3.826325 0,-2.710314 -2.15231,-4.304616 -2.15231,-1.594302 -7.09465,-3.587179 -2.39145,-0.956581 -4.62347,-1.913163 -2.15231,-1.036296 -3.74661,-2.391453 -1.5943,-1.355157 -2.55088,-3.268319 -0.95659,-1.913163 -0.95659,-4.703191 0,-5.500342 4.06547,-8.688946 4.06547,-3.26832 11.0804,-3.26832 1.75374,0 3.50747,0.239146 1.75373,0.15943 3.26832,0.47829 1.51458,0.239146 2.6306,0.558006 1.19572,0.31886 1.83344,0.558006 l -1.35515,6.377208 q -1.19573,-0.637721 -3.74661,-1.275442 -2.55089,-0.717436 -6.13807,-0.717436 -3.10889,0 -5.42062,1.275442 -2.31174,1.195727 -2.31174,3.826325 0,1.355157 0.47829,2.391453 0.55801,1.036296 1.5943,1.913163 1.11601,0.797151 2.71031,1.514587 1.59431,0.717436 3.82633,1.514587 2.94946,1.116011 5.2612,2.232022 2.31173,1.036297 3.90604,2.471169 1.67401,1.434871 2.55088,3.507464 0.87687,1.992878 0.87687,4.942337 0,5.739487 -4.30462,8.688946 -4.2249,2.949459 -12.1167,2.949459 -5.50034,0 -8.60923,-0.956582 -3.10889,-0.876866 -4.2249,-1.355156 l 1.35516,-6.377209 q 1.27544,0.478291 4.06547,1.434872 2.79003,0.956581 7.4135,0.956581 z m 32.84256,-36.110941 h 15.70387 v 6.217778 h -15.70387 v 19.131625 q 0,3.108889 0.47829,5.181481 0.47829,1.992878 1.43487,3.188604 0.95658,1.116012 2.39145,1.594302 1.43487,0.478291 3.34804,0.478291 3.34803,0 5.34091,-0.717436 2.07259,-0.797151 2.86974,-1.116011 l 1.43487,6.138063 q -1.11601,0.558005 -3.90604,1.355156 -2.79003,0.876867 -6.37721,0.876867 -4.2249,0 -7.01492,-1.036297 -2.71032,-1.116011 -4.38434,-3.268319 -1.67401,-2.152308 -2.39145,-5.261197 -0.63772,-3.188604 -0.63772,-7.333789 V 6.4000628 l 7.41351,-1.2754417 z m 62.49652,41.451853 q -1.35516,-3.587179 -2.55088,-7.014929 -1.19573,-3.507464 -2.47117,-7.094644 h -25.03054 l -5.02205,14.109573 h -8.05123 q 3.18861,-8.768661 5.97863,-16.182166 2.79003,-7.493219 5.42063,-14.189288 2.71031,-6.696069 5.34091,-12.754416 2.6306,-6.138063 5.50034,-12.1166961 h 7.09465 q 2.86974,5.9786331 5.50034,12.1166961 2.6306,6.058347 5.2612,12.754416 2.71031,6.696069 5.50034,14.189288 2.79003,7.413505 5.97863,16.182166 z m -7.25407,-20.486781 q -2.55089,-6.935214 -5.10177,-13.392137 -2.47117,-6.536639 -5.18148,-12.515272 -2.79003,5.978633 -5.34091,12.515272 -2.47117,6.456923 -4.94234,13.392137 z M 304.99242,3.6100342 q 11.6384,0 17.85618,4.4640458 6.29749,4.384331 6.29749,13.152992 0,4.782906 -1.75373,8.210656 -1.67402,3.348034 -4.94234,5.500342 -3.1886,2.072592 -7.81208,3.029174 -4.62347,0.956581 -10.44268,0.956581 h -6.13806 v 20.486781 h -7.73236 V 4.9651909 q 3.26832,-0.797151 7.25407,-1.0362963 4.06547,-0.3188604 7.41351,-0.3188604 z m 0.63772,6.7757838 q -4.94234,0 -7.57294,0.239145 v 21.682508 h 5.8192 q 3.98576,0 7.17436,-0.47829 3.18861,-0.558006 5.34092,-1.753733 2.23202,-1.275441 3.42774,-3.427749 1.19573,-2.152308 1.19573,-5.500342 0,-3.188604 -1.27544,-5.261197 -1.19573,-2.072593 -3.34803,-3.268319 -2.0726,-1.275442 -4.86263,-1.753732 -2.79002,-0.478291 -5.89891,-0.478291 z M 338.7916,4.1680399 h 7.73237 V 59.410606 h -7.73237 z"
|
||||
id="text979"
|
||||
aria-label="FastAPI" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 6.2 KiB |
BIN
frontend/public/assets/images/favicon.png
Normal file
BIN
frontend/public/assets/images/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.1 KiB |
BIN
frontend/public/assets/images/logo-15.png
Normal file
BIN
frontend/public/assets/images/logo-15.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 81 KiB |
BIN
frontend/public/assets/images/logo.png
Normal file
BIN
frontend/public/assets/images/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
25
frontend/src/client/core/ApiError.ts
Normal file
25
frontend/src/client/core/ApiError.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ApiRequestOptions } from "./ApiRequestOptions"
|
||||
import type { ApiResult } from "./ApiResult"
|
||||
|
||||
export class ApiError extends Error {
|
||||
public readonly url: string
|
||||
public readonly status: number
|
||||
public readonly statusText: string
|
||||
public readonly body: unknown
|
||||
public readonly request: ApiRequestOptions
|
||||
|
||||
constructor(
|
||||
request: ApiRequestOptions,
|
||||
response: ApiResult,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
|
||||
this.name = "ApiError"
|
||||
this.url = response.url
|
||||
this.status = response.status
|
||||
this.statusText = response.statusText
|
||||
this.body = response.body
|
||||
this.request = request
|
||||
}
|
||||
}
|
20
frontend/src/client/core/ApiRequestOptions.ts
Normal file
20
frontend/src/client/core/ApiRequestOptions.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type ApiRequestOptions = {
|
||||
readonly method:
|
||||
| "GET"
|
||||
| "PUT"
|
||||
| "POST"
|
||||
| "DELETE"
|
||||
| "OPTIONS"
|
||||
| "HEAD"
|
||||
| "PATCH"
|
||||
readonly url: string
|
||||
readonly path?: Record<string, unknown>
|
||||
readonly cookies?: Record<string, unknown>
|
||||
readonly headers?: Record<string, unknown>
|
||||
readonly query?: Record<string, unknown>
|
||||
readonly formData?: Record<string, unknown>
|
||||
readonly body?: any
|
||||
readonly mediaType?: string
|
||||
readonly responseHeader?: string
|
||||
readonly errors?: Record<number, string>
|
||||
}
|
7
frontend/src/client/core/ApiResult.ts
Normal file
7
frontend/src/client/core/ApiResult.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type ApiResult<TData = any> = {
|
||||
readonly body: TData
|
||||
readonly ok: boolean
|
||||
readonly status: number
|
||||
readonly statusText: string
|
||||
readonly url: string
|
||||
}
|
126
frontend/src/client/core/CancelablePromise.ts
Normal file
126
frontend/src/client/core/CancelablePromise.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
export class CancelError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = "CancelError"
|
||||
}
|
||||
|
||||
public get isCancelled(): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export interface OnCancel {
|
||||
readonly isResolved: boolean
|
||||
readonly isRejected: boolean
|
||||
readonly isCancelled: boolean
|
||||
|
||||
(cancelHandler: () => void): void
|
||||
}
|
||||
|
||||
export class CancelablePromise<T> implements Promise<T> {
|
||||
private _isResolved: boolean
|
||||
private _isRejected: boolean
|
||||
private _isCancelled: boolean
|
||||
readonly cancelHandlers: (() => void)[]
|
||||
readonly promise: Promise<T>
|
||||
private _resolve?: (value: T | PromiseLike<T>) => void
|
||||
private _reject?: (reason?: unknown) => void
|
||||
|
||||
constructor(
|
||||
executor: (
|
||||
resolve: (value: T | PromiseLike<T>) => void,
|
||||
reject: (reason?: unknown) => void,
|
||||
onCancel: OnCancel,
|
||||
) => void,
|
||||
) {
|
||||
this._isResolved = false
|
||||
this._isRejected = false
|
||||
this._isCancelled = false
|
||||
this.cancelHandlers = []
|
||||
this.promise = new Promise<T>((resolve, reject) => {
|
||||
this._resolve = resolve
|
||||
this._reject = reject
|
||||
|
||||
const onResolve = (value: T | PromiseLike<T>): void => {
|
||||
if (this._isResolved || this._isRejected || this._isCancelled) {
|
||||
return
|
||||
}
|
||||
this._isResolved = true
|
||||
if (this._resolve) this._resolve(value)
|
||||
}
|
||||
|
||||
const onReject = (reason?: unknown): void => {
|
||||
if (this._isResolved || this._isRejected || this._isCancelled) {
|
||||
return
|
||||
}
|
||||
this._isRejected = true
|
||||
if (this._reject) this._reject(reason)
|
||||
}
|
||||
|
||||
const onCancel = (cancelHandler: () => void): void => {
|
||||
if (this._isResolved || this._isRejected || this._isCancelled) {
|
||||
return
|
||||
}
|
||||
this.cancelHandlers.push(cancelHandler)
|
||||
}
|
||||
|
||||
Object.defineProperty(onCancel, "isResolved", {
|
||||
get: (): boolean => this._isResolved,
|
||||
})
|
||||
|
||||
Object.defineProperty(onCancel, "isRejected", {
|
||||
get: (): boolean => this._isRejected,
|
||||
})
|
||||
|
||||
Object.defineProperty(onCancel, "isCancelled", {
|
||||
get: (): boolean => this._isCancelled,
|
||||
})
|
||||
|
||||
return executor(onResolve, onReject, onCancel as OnCancel)
|
||||
})
|
||||
}
|
||||
|
||||
get [Symbol.toStringTag]() {
|
||||
return "Cancellable Promise"
|
||||
}
|
||||
|
||||
public then<TResult1 = T, TResult2 = never>(
|
||||
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
|
||||
onRejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
||||
): Promise<TResult1 | TResult2> {
|
||||
return this.promise.then(onFulfilled, onRejected)
|
||||
}
|
||||
|
||||
public catch<TResult = never>(
|
||||
onRejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null,
|
||||
): Promise<T | TResult> {
|
||||
return this.promise.catch(onRejected)
|
||||
}
|
||||
|
||||
public finally(onFinally?: (() => void) | null): Promise<T> {
|
||||
return this.promise.finally(onFinally)
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
if (this._isResolved || this._isRejected || this._isCancelled) {
|
||||
return
|
||||
}
|
||||
this._isCancelled = true
|
||||
if (this.cancelHandlers.length) {
|
||||
try {
|
||||
for (const cancelHandler of this.cancelHandlers) {
|
||||
cancelHandler()
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Cancellation threw an error", error)
|
||||
return
|
||||
}
|
||||
}
|
||||
this.cancelHandlers.length = 0
|
||||
if (this._reject) this._reject(new CancelError("Request aborted"))
|
||||
}
|
||||
|
||||
public get isCancelled(): boolean {
|
||||
return this._isCancelled
|
||||
}
|
||||
}
|
57
frontend/src/client/core/OpenAPI.ts
Normal file
57
frontend/src/client/core/OpenAPI.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { AxiosRequestConfig, AxiosResponse } from "axios"
|
||||
import type { ApiRequestOptions } from "./ApiRequestOptions"
|
||||
import type { TResult } from "./types"
|
||||
|
||||
type Headers = Record<string, string>
|
||||
type Middleware<T> = (value: T) => T | Promise<T>
|
||||
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>
|
||||
|
||||
export class Interceptors<T> {
|
||||
_fns: Middleware<T>[]
|
||||
|
||||
constructor() {
|
||||
this._fns = []
|
||||
}
|
||||
|
||||
eject(fn: Middleware<T>) {
|
||||
const index = this._fns.indexOf(fn)
|
||||
if (index !== -1) {
|
||||
this._fns = [...this._fns.slice(0, index), ...this._fns.slice(index + 1)]
|
||||
}
|
||||
}
|
||||
|
||||
use(fn: Middleware<T>) {
|
||||
this._fns = [...this._fns, fn]
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenAPIConfig = {
|
||||
BASE: string
|
||||
CREDENTIALS: "include" | "omit" | "same-origin"
|
||||
ENCODE_PATH?: ((path: string) => string) | undefined
|
||||
HEADERS?: Headers | Resolver<Headers> | undefined
|
||||
PASSWORD?: string | Resolver<string> | undefined
|
||||
RESULT?: TResult
|
||||
TOKEN?: string | Resolver<string> | undefined
|
||||
USERNAME?: string | Resolver<string> | undefined
|
||||
VERSION: string
|
||||
WITH_CREDENTIALS: boolean
|
||||
interceptors: {
|
||||
request: Interceptors<AxiosRequestConfig>
|
||||
response: Interceptors<AxiosResponse>
|
||||
}
|
||||
}
|
||||
|
||||
export const OpenAPI: OpenAPIConfig = {
|
||||
BASE: "",
|
||||
CREDENTIALS: "include",
|
||||
ENCODE_PATH: undefined,
|
||||
HEADERS: undefined,
|
||||
PASSWORD: undefined,
|
||||
RESULT: "body",
|
||||
TOKEN: undefined,
|
||||
USERNAME: undefined,
|
||||
VERSION: "0.1.0",
|
||||
WITH_CREDENTIALS: false,
|
||||
interceptors: { request: new Interceptors(), response: new Interceptors() },
|
||||
}
|
376
frontend/src/client/core/request.ts
Normal file
376
frontend/src/client/core/request.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import axios from "axios"
|
||||
import type {
|
||||
AxiosError,
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
AxiosResponse,
|
||||
} from "axios"
|
||||
|
||||
import { ApiError } from "./ApiError"
|
||||
import type { ApiRequestOptions } from "./ApiRequestOptions"
|
||||
import type { ApiResult } from "./ApiResult"
|
||||
import { CancelablePromise } from "./CancelablePromise"
|
||||
import type { OnCancel } from "./CancelablePromise"
|
||||
import type { OpenAPIConfig } from "./OpenAPI"
|
||||
|
||||
export const isString = (value: unknown): value is string => {
|
||||
return typeof value === "string"
|
||||
}
|
||||
|
||||
export const isStringWithValue = (value: unknown): value is string => {
|
||||
return isString(value) && value !== ""
|
||||
}
|
||||
|
||||
export const isBlob = (value: any): value is Blob => {
|
||||
return value instanceof Blob
|
||||
}
|
||||
|
||||
export const isFormData = (value: unknown): value is FormData => {
|
||||
return value instanceof FormData
|
||||
}
|
||||
|
||||
export const isSuccess = (status: number): boolean => {
|
||||
return status >= 200 && status < 300
|
||||
}
|
||||
|
||||
export const base64 = (str: string): string => {
|
||||
try {
|
||||
return btoa(str)
|
||||
} catch (err) {
|
||||
// @ts-ignore
|
||||
return Buffer.from(str).toString("base64")
|
||||
}
|
||||
}
|
||||
|
||||
export const getQueryString = (params: Record<string, unknown>): string => {
|
||||
const qs: string[] = []
|
||||
|
||||
const append = (key: string, value: unknown) => {
|
||||
qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
||||
}
|
||||
|
||||
const encodePair = (key: string, value: unknown) => {
|
||||
if (value === undefined || value === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => encodePair(key, v))
|
||||
} else if (typeof value === "object") {
|
||||
Object.entries(value).forEach(([k, v]) => encodePair(`${key}[${k}]`, v))
|
||||
} else {
|
||||
append(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => encodePair(key, value))
|
||||
|
||||
return qs.length ? `?${qs.join("&")}` : ""
|
||||
}
|
||||
|
||||
const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => {
|
||||
const encoder = config.ENCODE_PATH || encodeURI
|
||||
|
||||
const path = options.url
|
||||
.replace("{api-version}", config.VERSION)
|
||||
.replace(/{(.*?)}/g, (substring: string, group: string) => {
|
||||
if (options.path?.hasOwnProperty(group)) {
|
||||
return encoder(String(options.path[group]))
|
||||
}
|
||||
return substring
|
||||
})
|
||||
|
||||
const url = config.BASE + path
|
||||
return options.query ? url + getQueryString(options.query) : url
|
||||
}
|
||||
|
||||
export const getFormData = (
|
||||
options: ApiRequestOptions,
|
||||
): FormData | undefined => {
|
||||
if (options.formData) {
|
||||
const formData = new FormData()
|
||||
|
||||
const process = (key: string, value: unknown) => {
|
||||
if (isString(value) || isBlob(value)) {
|
||||
formData.append(key, value)
|
||||
} else {
|
||||
formData.append(key, JSON.stringify(value))
|
||||
}
|
||||
}
|
||||
|
||||
Object.entries(options.formData)
|
||||
.filter(([, value]) => value !== undefined && value !== null)
|
||||
.forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => process(key, v))
|
||||
} else {
|
||||
process(key, value)
|
||||
}
|
||||
})
|
||||
|
||||
return formData
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>
|
||||
|
||||
export const resolve = async <T>(
|
||||
options: ApiRequestOptions,
|
||||
resolver?: T | Resolver<T>,
|
||||
): Promise<T | undefined> => {
|
||||
if (typeof resolver === "function") {
|
||||
return (resolver as Resolver<T>)(options)
|
||||
}
|
||||
return resolver
|
||||
}
|
||||
|
||||
export const getHeaders = async (
|
||||
config: OpenAPIConfig,
|
||||
options: ApiRequestOptions,
|
||||
): Promise<Record<string, string>> => {
|
||||
const [token, username, password, additionalHeaders] = await Promise.all([
|
||||
resolve(options, config.TOKEN),
|
||||
resolve(options, config.USERNAME),
|
||||
resolve(options, config.PASSWORD),
|
||||
resolve(options, config.HEADERS),
|
||||
])
|
||||
|
||||
const headers = Object.entries({
|
||||
Accept: "application/json",
|
||||
...additionalHeaders,
|
||||
...options.headers,
|
||||
})
|
||||
.filter(([, value]) => value !== undefined && value !== null)
|
||||
.reduce(
|
||||
(headers, [key, value]) => ({
|
||||
...headers,
|
||||
[key]: String(value),
|
||||
}),
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
|
||||
if (isStringWithValue(token)) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
if (isStringWithValue(username) && isStringWithValue(password)) {
|
||||
const credentials = base64(`${username}:${password}`)
|
||||
headers.Authorization = `Basic ${credentials}`
|
||||
}
|
||||
|
||||
if (options.body !== undefined) {
|
||||
if (options.mediaType) {
|
||||
headers["Content-Type"] = options.mediaType
|
||||
} else if (isBlob(options.body)) {
|
||||
headers["Content-Type"] = options.body.type || "application/octet-stream"
|
||||
} else if (isString(options.body)) {
|
||||
headers["Content-Type"] = "text/plain"
|
||||
} else if (!isFormData(options.body)) {
|
||||
headers["Content-Type"] = "application/json"
|
||||
}
|
||||
} else if (options.formData !== undefined) {
|
||||
if (options.mediaType) {
|
||||
headers["Content-Type"] = options.mediaType
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
export const getRequestBody = (options: ApiRequestOptions): unknown => {
|
||||
if (options.body) {
|
||||
return options.body
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const sendRequest = async <T>(
|
||||
config: OpenAPIConfig,
|
||||
options: ApiRequestOptions,
|
||||
url: string,
|
||||
body: unknown,
|
||||
formData: FormData | undefined,
|
||||
headers: Record<string, string>,
|
||||
onCancel: OnCancel,
|
||||
axiosClient: AxiosInstance,
|
||||
): Promise<AxiosResponse<T>> => {
|
||||
const controller = new AbortController()
|
||||
|
||||
let requestConfig: AxiosRequestConfig = {
|
||||
data: body ?? formData,
|
||||
headers,
|
||||
method: options.method,
|
||||
signal: controller.signal,
|
||||
url,
|
||||
withCredentials: config.WITH_CREDENTIALS,
|
||||
}
|
||||
|
||||
onCancel(() => controller.abort())
|
||||
|
||||
for (const fn of config.interceptors.request._fns) {
|
||||
requestConfig = await fn(requestConfig)
|
||||
}
|
||||
|
||||
try {
|
||||
return await axiosClient.request(requestConfig)
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<T>
|
||||
if (axiosError.response) {
|
||||
return axiosError.response
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const getResponseHeader = (
|
||||
response: AxiosResponse<unknown>,
|
||||
responseHeader?: string,
|
||||
): string | undefined => {
|
||||
if (responseHeader) {
|
||||
const content = response.headers[responseHeader]
|
||||
if (isString(content)) {
|
||||
return content
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const getResponseBody = (response: AxiosResponse<unknown>): unknown => {
|
||||
if (response.status !== 204) {
|
||||
return response.data
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const catchErrorCodes = (
|
||||
options: ApiRequestOptions,
|
||||
result: ApiResult,
|
||||
): void => {
|
||||
const errors: Record<number, string> = {
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
402: "Payment Required",
|
||||
403: "Forbidden",
|
||||
404: "Not Found",
|
||||
405: "Method Not Allowed",
|
||||
406: "Not Acceptable",
|
||||
407: "Proxy Authentication Required",
|
||||
408: "Request Timeout",
|
||||
409: "Conflict",
|
||||
410: "Gone",
|
||||
411: "Length Required",
|
||||
412: "Precondition Failed",
|
||||
413: "Payload Too Large",
|
||||
414: "URI Too Long",
|
||||
415: "Unsupported Media Type",
|
||||
416: "Range Not Satisfiable",
|
||||
417: "Expectation Failed",
|
||||
418: "Im a teapot",
|
||||
421: "Misdirected Request",
|
||||
422: "Unprocessable Content",
|
||||
423: "Locked",
|
||||
424: "Failed Dependency",
|
||||
425: "Too Early",
|
||||
426: "Upgrade Required",
|
||||
428: "Precondition Required",
|
||||
429: "Too Many Requests",
|
||||
431: "Request Header Fields Too Large",
|
||||
451: "Unavailable For Legal Reasons",
|
||||
500: "Internal Server Error",
|
||||
501: "Not Implemented",
|
||||
502: "Bad Gateway",
|
||||
503: "Service Unavailable",
|
||||
504: "Gateway Timeout",
|
||||
505: "HTTP Version Not Supported",
|
||||
506: "Variant Also Negotiates",
|
||||
507: "Insufficient Storage",
|
||||
508: "Loop Detected",
|
||||
510: "Not Extended",
|
||||
511: "Network Authentication Required",
|
||||
...options.errors,
|
||||
}
|
||||
|
||||
const error = errors[result.status]
|
||||
if (error) {
|
||||
throw new ApiError(options, result, error)
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
const errorStatus = result.status ?? "unknown"
|
||||
const errorStatusText = result.statusText ?? "unknown"
|
||||
const errorBody = (() => {
|
||||
try {
|
||||
return JSON.stringify(result.body, null, 2)
|
||||
} catch (e) {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
|
||||
throw new ApiError(
|
||||
options,
|
||||
result,
|
||||
`Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request method
|
||||
* @param config The OpenAPI configuration object
|
||||
* @param options The request options from the service
|
||||
* @param axiosClient The axios client instance to use
|
||||
* @returns CancelablePromise<T>
|
||||
* @throws ApiError
|
||||
*/
|
||||
export const request = <T>(
|
||||
config: OpenAPIConfig,
|
||||
options: ApiRequestOptions,
|
||||
axiosClient: AxiosInstance = axios,
|
||||
): CancelablePromise<T> => {
|
||||
return new CancelablePromise(async (resolve, reject, onCancel) => {
|
||||
try {
|
||||
const url = getUrl(config, options)
|
||||
const formData = getFormData(options)
|
||||
const body = getRequestBody(options)
|
||||
const headers = await getHeaders(config, options)
|
||||
|
||||
if (!onCancel.isCancelled) {
|
||||
let response = await sendRequest<T>(
|
||||
config,
|
||||
options,
|
||||
url,
|
||||
body,
|
||||
formData,
|
||||
headers,
|
||||
onCancel,
|
||||
axiosClient,
|
||||
)
|
||||
|
||||
for (const fn of config.interceptors.response._fns) {
|
||||
response = await fn(response)
|
||||
}
|
||||
|
||||
const responseBody = getResponseBody(response)
|
||||
const responseHeader = getResponseHeader(
|
||||
response,
|
||||
options.responseHeader,
|
||||
)
|
||||
|
||||
const result: ApiResult = {
|
||||
url,
|
||||
ok: isSuccess(response.status),
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
body: responseHeader ?? responseBody,
|
||||
}
|
||||
|
||||
catchErrorCodes(options, result)
|
||||
|
||||
resolve(result.body)
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
14
frontend/src/client/core/types.ts
Normal file
14
frontend/src/client/core/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ApiResult } from "./ApiResult"
|
||||
|
||||
export type TResult = "body" | "raw"
|
||||
|
||||
export type TApiResponse<T extends TResult, TData> = Exclude<
|
||||
T,
|
||||
"raw"
|
||||
> extends never
|
||||
? ApiResult<TData>
|
||||
: ApiResult<TData>["body"]
|
||||
|
||||
export type TConfig<T extends TResult> = {
|
||||
_result?: T
|
||||
}
|
8
frontend/src/client/index.ts
Normal file
8
frontend/src/client/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { ApiError } from "./core/ApiError"
|
||||
export { CancelablePromise, CancelError } from "./core/CancelablePromise"
|
||||
export { OpenAPI } from "./core/OpenAPI"
|
||||
export type { OpenAPIConfig } from "./core/OpenAPI"
|
||||
|
||||
export * from "./models"
|
||||
export * from "./schemas"
|
||||
export * from "./services"
|
284
frontend/src/client/models.ts
Normal file
284
frontend/src/client/models.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
export type Body_login_login_access_token = {
|
||||
grant_type?: string | null
|
||||
username: string
|
||||
password: string
|
||||
scope?: string
|
||||
client_id?: string | null
|
||||
client_secret?: string | null
|
||||
}
|
||||
|
||||
export type HTTPValidationError = {
|
||||
detail?: Array<ValidationError>
|
||||
}
|
||||
|
||||
export type ItemCreate = {
|
||||
title: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export type ItemPublic = {
|
||||
title: string
|
||||
description?: string | null
|
||||
id: string
|
||||
owner_id: string
|
||||
}
|
||||
|
||||
export type ItemUpdate = {
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export type ItemsPublic = {
|
||||
data: Array<ItemPublic>
|
||||
count: number
|
||||
}
|
||||
|
||||
export type ClientMessagePublic = {
|
||||
name: string,
|
||||
phone: string,
|
||||
email: string,
|
||||
message: string,
|
||||
id: string,
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type ClientMessagesPublic = {
|
||||
data: Array<ClientMessagePublic>,
|
||||
count: number
|
||||
}
|
||||
|
||||
export type Message = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export type NewPassword = {
|
||||
token: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
export type Token = {
|
||||
access_token: string
|
||||
token_type?: string
|
||||
}
|
||||
|
||||
export type UpdatePassword = {
|
||||
current_password: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
export type UserCreate = {
|
||||
email: string
|
||||
is_active?: boolean
|
||||
is_superuser?: boolean
|
||||
full_name?: string | null
|
||||
password: string
|
||||
}
|
||||
|
||||
export type UserPublic = {
|
||||
email: string
|
||||
is_active?: boolean
|
||||
is_superuser?: boolean
|
||||
full_name?: string | null
|
||||
id: string
|
||||
}
|
||||
|
||||
export type UserRegister = {
|
||||
email: string
|
||||
password: string
|
||||
full_name?: string | null
|
||||
}
|
||||
|
||||
export type UserUpdate = {
|
||||
email?: string | null
|
||||
is_active?: boolean
|
||||
is_superuser?: boolean
|
||||
full_name?: string | null
|
||||
password?: string | null
|
||||
}
|
||||
|
||||
export type UserUpdateMe = {
|
||||
full_name?: string | null
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
export type UsersPublic = {
|
||||
data: Array<UserPublic>
|
||||
count: number
|
||||
}
|
||||
|
||||
export type ValidationError = {
|
||||
loc: Array<string | number>
|
||||
msg: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export type WebSettingPublic = {
|
||||
address: string,
|
||||
google_map_api_key: string,
|
||||
latitude: Number,
|
||||
longitude: Number,
|
||||
phone: string,
|
||||
email: string,
|
||||
facebook: string,
|
||||
instagram: string,
|
||||
youtube: string,
|
||||
youtube_link: string,
|
||||
whatsapp: string,
|
||||
id: string
|
||||
}
|
||||
|
||||
export type WebSettingUpdate = {
|
||||
address: string,
|
||||
google_map_api_key: string,
|
||||
latitude: Number,
|
||||
longitude: Number,
|
||||
phone: string,
|
||||
email: string,
|
||||
facebook: string,
|
||||
instagram: string,
|
||||
youtube: string,
|
||||
youtube_link: string,
|
||||
whatsapp: string,
|
||||
}
|
||||
|
||||
|
||||
export type AboutUssPublic = {
|
||||
data: Array<AboutUsPublic>
|
||||
count: number
|
||||
}
|
||||
|
||||
export type AboutUsPublic = {
|
||||
index: number,
|
||||
description: string,
|
||||
image: string,
|
||||
id: string
|
||||
}
|
||||
|
||||
export type AboutUsCreate = {
|
||||
index: number,
|
||||
description: string,
|
||||
image: File,
|
||||
}
|
||||
|
||||
export type AboutUsUpdate = {
|
||||
index: number,
|
||||
description: string,
|
||||
image?: File | undefined | null,
|
||||
}
|
||||
|
||||
export type CoursesPublic = {
|
||||
data: Array<CoursePublic>
|
||||
count: number
|
||||
}
|
||||
|
||||
export type CoursePublic = {
|
||||
title: string,
|
||||
sort_description: string,
|
||||
long_description: string,
|
||||
information: string,
|
||||
contant: string,
|
||||
remark: string,
|
||||
id: string,
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type CourseCreate = {
|
||||
title: string,
|
||||
sort_description: string,
|
||||
long_description: string,
|
||||
information: string,
|
||||
contant: string,
|
||||
remark: string,
|
||||
}
|
||||
|
||||
export type CourseDetailsPublic = {
|
||||
title: string,
|
||||
sort_description: string,
|
||||
long_description: string,
|
||||
information: string,
|
||||
contant: string,
|
||||
remark: string,
|
||||
id: string,
|
||||
created_at: string
|
||||
images: Array<ImagePublic>,
|
||||
info_images: Array<Info_imagePublic>,
|
||||
schedule: Array<SchedulePublic>
|
||||
}
|
||||
|
||||
export type CourseUpdate = {
|
||||
title: string,
|
||||
sort_description: string,
|
||||
long_description: string,
|
||||
information: string,
|
||||
contant: string,
|
||||
remark: string,
|
||||
}
|
||||
|
||||
export type ImagesPublic = {
|
||||
data: Array<ImagePublic>,
|
||||
}
|
||||
|
||||
export type ImagePublic = {
|
||||
image: string,
|
||||
course_id: string,
|
||||
index: number,
|
||||
id:string
|
||||
}
|
||||
|
||||
export type ImageUpdate = {
|
||||
index: number,
|
||||
}
|
||||
|
||||
export type ImageCreate = {
|
||||
image: File,
|
||||
index: number,
|
||||
course_id: string
|
||||
}
|
||||
|
||||
export type Info_imagesPublic = {
|
||||
data: Array<Info_imagePublic>,
|
||||
}
|
||||
|
||||
export type Info_imagePublic = {
|
||||
image: string,
|
||||
course_id: string,
|
||||
index: number
|
||||
id: string
|
||||
}
|
||||
|
||||
export type Info_imageUpdate = {
|
||||
index: number,
|
||||
}
|
||||
|
||||
export type Info_imagesCreate = {
|
||||
image: File,
|
||||
index: number,
|
||||
course_id: string
|
||||
}
|
||||
|
||||
export type SchedulesPublic = {
|
||||
data: Array<SchedulePublic>,
|
||||
}
|
||||
|
||||
export type SchedulePublic = {
|
||||
title: string,
|
||||
info1: string,
|
||||
info2: string,
|
||||
date: string,
|
||||
course_id: string,
|
||||
id:string
|
||||
}
|
||||
export type ScheduleCreate = {
|
||||
title: string,
|
||||
info1: string,
|
||||
info2: string,
|
||||
date: string,
|
||||
course_id: string,
|
||||
}
|
||||
|
||||
export type ScheduleUpdate = {
|
||||
title: string,
|
||||
info1: string,
|
||||
info2: string,
|
||||
date: string,
|
||||
}
|
444
frontend/src/client/schemas.ts
Normal file
444
frontend/src/client/schemas.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
export const $Body_login_login_access_token = {
|
||||
properties: {
|
||||
grant_type: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
pattern: "password",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
username: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
},
|
||||
password: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
},
|
||||
scope: {
|
||||
type: "string",
|
||||
default: "",
|
||||
},
|
||||
client_id: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
client_secret: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $HTTPValidationError = {
|
||||
properties: {
|
||||
detail: {
|
||||
type: "array",
|
||||
contains: {
|
||||
type: "ValidationError",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $ItemCreate = {
|
||||
properties: {
|
||||
title: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
maxLength: 255,
|
||||
minLength: 1,
|
||||
},
|
||||
description: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $ItemPublic = {
|
||||
properties: {
|
||||
title: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
maxLength: 255,
|
||||
minLength: 1,
|
||||
},
|
||||
description: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
id: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
format: "uuid",
|
||||
},
|
||||
owner_id: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
format: "uuid",
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $ItemUpdate = {
|
||||
properties: {
|
||||
title: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
minLength: 1,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
description: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $ItemsPublic = {
|
||||
properties: {
|
||||
data: {
|
||||
type: "array",
|
||||
contains: {
|
||||
type: "ItemPublic",
|
||||
},
|
||||
isRequired: true,
|
||||
},
|
||||
count: {
|
||||
type: "number",
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $Message = {
|
||||
properties: {
|
||||
message: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $NewPassword = {
|
||||
properties: {
|
||||
token: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
},
|
||||
new_password: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
maxLength: 40,
|
||||
minLength: 8,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $Token = {
|
||||
properties: {
|
||||
access_token: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
},
|
||||
token_type: {
|
||||
type: "string",
|
||||
default: "bearer",
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $UpdatePassword = {
|
||||
properties: {
|
||||
current_password: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
maxLength: 40,
|
||||
minLength: 8,
|
||||
},
|
||||
new_password: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
maxLength: 40,
|
||||
minLength: 8,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $UserCreate = {
|
||||
properties: {
|
||||
email: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
format: "email",
|
||||
maxLength: 255,
|
||||
},
|
||||
is_active: {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
},
|
||||
is_superuser: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
full_name: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
password: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
maxLength: 40,
|
||||
minLength: 8,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $UserPublic = {
|
||||
properties: {
|
||||
email: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
format: "email",
|
||||
maxLength: 255,
|
||||
},
|
||||
is_active: {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
},
|
||||
is_superuser: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
full_name: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
id: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
format: "uuid",
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $UserRegister = {
|
||||
properties: {
|
||||
email: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
format: "email",
|
||||
maxLength: 255,
|
||||
},
|
||||
password: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
maxLength: 40,
|
||||
minLength: 8,
|
||||
},
|
||||
full_name: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $UserUpdate = {
|
||||
properties: {
|
||||
email: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
format: "email",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
is_active: {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
},
|
||||
is_superuser: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
full_name: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
password: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 40,
|
||||
minLength: 8,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $UserUpdateMe = {
|
||||
properties: {
|
||||
full_name: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
email: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
format: "email",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $UsersPublic = {
|
||||
properties: {
|
||||
data: {
|
||||
type: "array",
|
||||
contains: {
|
||||
type: "UserPublic",
|
||||
},
|
||||
isRequired: true,
|
||||
},
|
||||
count: {
|
||||
type: "number",
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $ValidationError = {
|
||||
properties: {
|
||||
loc: {
|
||||
type: "array",
|
||||
contains: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
},
|
||||
],
|
||||
},
|
||||
isRequired: true,
|
||||
},
|
||||
msg: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
},
|
||||
type: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
} as const
|
1045
frontend/src/client/services.ts
Normal file
1045
frontend/src/client/services.ts
Normal file
File diff suppressed because it is too large
Load Diff
235
frontend/src/components/AboutUs/AddAboutUs.tsx
Normal file
235
frontend/src/components/AboutUs/AddAboutUs.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import React, { useRef, ReactNode, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
InputGroup,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
} from "@chakra-ui/react"
|
||||
import { writeFileSync, createReadStream } from "fs";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm, UseFormRegisterReturn } from "react-hook-form"
|
||||
import { type ApiError, type AboutUsCreate, AboutUsService } from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
import { EditorState, ContentState, convertToRaw } from 'draft-js';
|
||||
import { Editor } from "react-draft-wysiwyg";
|
||||
import draftToHtml from 'draftjs-to-html';
|
||||
import htmlToDraft from 'html-to-draftjs';
|
||||
import "react-draft-wysiwyg/dist/react-draft-wysiwyg.css";
|
||||
|
||||
interface AddAboutUsProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type FileUploadProps = {
|
||||
register: UseFormRegisterReturn
|
||||
accept?: string
|
||||
multiple?: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const FileUpload = (props: FileUploadProps) => {
|
||||
const { register, accept, multiple, children } = props
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const { ref, ...rest } = register as { ref: (instance: HTMLInputElement | null) => void }
|
||||
|
||||
const handleClick = () => inputRef.current?.click()
|
||||
|
||||
return (
|
||||
<InputGroup onClick={handleClick}>
|
||||
<input
|
||||
type={'file'}
|
||||
multiple={multiple || false}
|
||||
hidden
|
||||
accept={accept}
|
||||
{...rest}
|
||||
ref={(e) => {
|
||||
ref(e)
|
||||
inputRef.current = e
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const AddAboutUs = ({ isOpen, onClose }: AddAboutUsProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<AboutUsCreate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
index: 0,
|
||||
description: "",
|
||||
image: undefined,
|
||||
},
|
||||
})
|
||||
const [editorState, setEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [content, setContent] = useState<string>('');
|
||||
|
||||
const validateFiles = (value: File) => {
|
||||
if (typeof value === 'string') return true;
|
||||
|
||||
|
||||
const fsMb = value.size / (1024 * 1024)
|
||||
const MAX_FILE_SIZE = 10
|
||||
if (fsMb > MAX_FILE_SIZE) {
|
||||
return 'Max file size 10mb'
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
type FormValues = {
|
||||
file_: FileList
|
||||
}
|
||||
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: AboutUsCreate) =>
|
||||
AboutUsService.createAboutUs({ formData: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "About Us created successfully.", "success")
|
||||
reset()
|
||||
setEditorState(EditorState.createEmpty());
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["aboutUs"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<AboutUsCreate> = (data) => {
|
||||
if (data.image instanceof FileList && data.image.length > 0) {
|
||||
data.image = data.image[0]
|
||||
}
|
||||
mutation.mutate(data)
|
||||
console.log(data)
|
||||
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={'xl'}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Add About Us</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
|
||||
<ModalBody pb={30}>
|
||||
<FormControl isRequired isInvalid={!!errors.description}>
|
||||
<Editor
|
||||
editorState={editorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setEditorState(newState);
|
||||
setContent(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
reset({
|
||||
description: content,
|
||||
});
|
||||
|
||||
}}
|
||||
toolbar={{
|
||||
options: ['inline', 'blockType', 'fontSize', 'list', 'textAlign', 'history', 'embedded', 'emoji', 'image'],
|
||||
inline: { inDropdown: true },
|
||||
list: { inDropdown: true },
|
||||
textAlign: { inDropdown: true },
|
||||
link: { inDropdown: true },
|
||||
history: { inDropdown: true },
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl isRequired isInvalid={!!errors.index}>
|
||||
<FormLabel htmlFor="index">Index</FormLabel >
|
||||
<NumberInput min={0} max={20} >
|
||||
<NumberInputField {...register("index", {
|
||||
required: "index is required.",
|
||||
})} />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
{/* <Input
|
||||
id="index"
|
||||
{...register("index", {
|
||||
required: "index is required.",
|
||||
})}
|
||||
placeholder="Index"
|
||||
type="Number"
|
||||
/> */}
|
||||
{errors.index && (
|
||||
<FormErrorMessage>{errors.index.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!errors.image} isRequired>
|
||||
<FormLabel>{'Image Upload'}</FormLabel>
|
||||
|
||||
{/* <FileUpload
|
||||
accept={'image/*'}
|
||||
multiple={false}
|
||||
register={register('image', { validate: validateFiles })}
|
||||
>
|
||||
<Button >
|
||||
Upload
|
||||
</Button>
|
||||
</FileUpload> */}
|
||||
<input type="file" {...register("image", {
|
||||
required: "index is required.",
|
||||
})} />
|
||||
<FormErrorMessage>
|
||||
{errors.image && errors?.image.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter gap={3}>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default AddAboutUs
|
232
frontend/src/components/AboutUs/EditAboutUs.tsx
Normal file
232
frontend/src/components/AboutUs/EditAboutUs.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import React, { useRef, ReactNode, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
InputGroup,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
Box,
|
||||
Image,
|
||||
} from "@chakra-ui/react"
|
||||
import { writeFileSync, createReadStream } from "fs";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm, UseFormRegisterReturn } from "react-hook-form"
|
||||
import { type ApiError, type AboutUsCreate, AboutUsService, AboutUsUpdate, AboutUsPublic } from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
import { EditorState, ContentState, convertToRaw } from 'draft-js';
|
||||
import { Editor } from "react-draft-wysiwyg";
|
||||
import draftToHtml from 'draftjs-to-html';
|
||||
import htmlToDraft from 'html-to-draftjs';
|
||||
import "react-draft-wysiwyg/dist/react-draft-wysiwyg.css";
|
||||
|
||||
interface EditAboutUsProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
aboutUs: AboutUsPublic
|
||||
}
|
||||
|
||||
type FileUploadProps = {
|
||||
register: UseFormRegisterReturn
|
||||
accept?: string
|
||||
multiple?: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const FileUpload = (props: FileUploadProps) => {
|
||||
const { register, accept, multiple, children } = props
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const { ref, ...rest } = register as { ref: (instance: HTMLInputElement | null) => void }
|
||||
|
||||
const handleClick = () => inputRef.current?.click()
|
||||
|
||||
return (
|
||||
<InputGroup onClick={handleClick}>
|
||||
<input
|
||||
type={'file'}
|
||||
multiple={multiple || false}
|
||||
hidden
|
||||
accept={accept}
|
||||
{...rest}
|
||||
ref={(e) => {
|
||||
ref(e)
|
||||
inputRef.current = e
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const EditAboutUs = ({ aboutUs, isOpen, onClose }: EditAboutUsProps) => {
|
||||
const url = import.meta.env.VITE_API_URL
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<AboutUsUpdate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
index: aboutUs.index,
|
||||
description: aboutUs.description,
|
||||
image: undefined,
|
||||
},
|
||||
})
|
||||
const [editorState, setEditorState] = useState<EditorState>(() => {
|
||||
const contentBlock = htmlToDraft(aboutUs.description);
|
||||
if (contentBlock) {
|
||||
const contentState = ContentState.createFromBlockArray(contentBlock.contentBlocks);
|
||||
return EditorState.createWithContent(contentState);
|
||||
}
|
||||
return EditorState.createEmpty();
|
||||
});
|
||||
const [content, setContent] = useState<string>(aboutUs.description);
|
||||
|
||||
const validateFiles = (value: File) => {
|
||||
if (typeof value === 'string') return true;
|
||||
|
||||
|
||||
const fsMb = value.size / (1024 * 1024)
|
||||
const MAX_FILE_SIZE = 10
|
||||
if (fsMb > MAX_FILE_SIZE) {
|
||||
return 'Max file size 10mb'
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
type FormValues = {
|
||||
file_: FileList
|
||||
}
|
||||
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: AboutUsUpdate) =>
|
||||
AboutUsService.updateAboutUs({ id: aboutUs.id, formData: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "About Us update successfully.", "success")
|
||||
reset()
|
||||
setEditorState(EditorState.createEmpty());
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["aboutUs"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<AboutUsUpdate> = (data) => {
|
||||
if (data.image instanceof FileList && data.image.length > 0) {
|
||||
data.image = data.image[0]
|
||||
}else{
|
||||
data.image = null
|
||||
}
|
||||
mutation.mutate(data)
|
||||
console.log(data)
|
||||
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={'xl'}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Edit About Us</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={30}>
|
||||
<Box boxSize='auto'>
|
||||
<Image src={url + "/" + aboutUs.image} />
|
||||
</Box>
|
||||
<FormControl isRequired isInvalid={!!errors.description}>
|
||||
<Editor
|
||||
editorState={editorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setEditorState(newState);
|
||||
const newContent = draftToHtml(convertToRaw(newState.getCurrentContent()));
|
||||
setContent(newContent);
|
||||
reset({
|
||||
description: newContent,
|
||||
});
|
||||
|
||||
}}
|
||||
toolbar={{
|
||||
options: ['inline', 'blockType', 'fontSize', 'list', 'textAlign', 'history', 'embedded', 'emoji', 'image'],
|
||||
inline: { inDropdown: true },
|
||||
list: { inDropdown: true },
|
||||
textAlign: { inDropdown: true },
|
||||
link: { inDropdown: true },
|
||||
history: { inDropdown: true },
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl isRequired isInvalid={!!errors.index}>
|
||||
<FormLabel htmlFor="index">Index</FormLabel >
|
||||
<NumberInput min={0} max={20} >
|
||||
<NumberInputField {...register("index", {
|
||||
required: "index is required.",
|
||||
})} />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
{errors.index && (
|
||||
<FormErrorMessage>{errors.index.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!errors.image} isRequired>
|
||||
<FormLabel>{'Image Upload'}</FormLabel>
|
||||
|
||||
<input type="file" {...register("image")} />
|
||||
<FormErrorMessage>
|
||||
{errors.image && errors?.image.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter gap={3}>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default EditAboutUs
|
182
frontend/src/components/Admin/AddUser.tsx
Normal file
182
frontend/src/components/Admin/AddUser.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { type UserCreate, UsersService } from "../../client"
|
||||
import type { ApiError } from "../../client/core/ApiError"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { emailPattern, handleError } from "../../utils"
|
||||
|
||||
interface AddUserProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface UserCreateForm extends UserCreate {
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
const AddUser = ({ isOpen, onClose }: AddUserProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<UserCreateForm>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
email: "",
|
||||
full_name: "",
|
||||
password: "",
|
||||
confirm_password: "",
|
||||
is_superuser: false,
|
||||
is_active: false,
|
||||
},
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: UserCreate) =>
|
||||
UsersService.createUser({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "User created successfully.", "success")
|
||||
reset()
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<UserCreateForm> = (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Add User</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<FormControl isRequired isInvalid={!!errors.email}>
|
||||
<FormLabel htmlFor="email">Email</FormLabel>
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
/>
|
||||
{errors.email && (
|
||||
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isInvalid={!!errors.full_name}>
|
||||
<FormLabel htmlFor="name">Full name</FormLabel>
|
||||
<Input
|
||||
id="name"
|
||||
{...register("full_name")}
|
||||
placeholder="Full name"
|
||||
type="text"
|
||||
/>
|
||||
{errors.full_name && (
|
||||
<FormErrorMessage>{errors.full_name.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isRequired isInvalid={!!errors.password}>
|
||||
<FormLabel htmlFor="password">Set Password</FormLabel>
|
||||
<Input
|
||||
id="password"
|
||||
{...register("password", {
|
||||
required: "Password is required",
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: "Password must be at least 8 characters",
|
||||
},
|
||||
})}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<FormErrorMessage>{errors.password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl
|
||||
mt={4}
|
||||
isRequired
|
||||
isInvalid={!!errors.confirm_password}
|
||||
>
|
||||
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
{...register("confirm_password", {
|
||||
required: "Please confirm your password",
|
||||
validate: (value) =>
|
||||
value === getValues().password ||
|
||||
"The passwords do not match",
|
||||
})}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.confirm_password && (
|
||||
<FormErrorMessage>
|
||||
{errors.confirm_password.message}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Flex mt={4}>
|
||||
<FormControl>
|
||||
<Checkbox {...register("is_superuser")} colorScheme="teal">
|
||||
Is superuser?
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<Checkbox {...register("is_active")} colorScheme="teal">
|
||||
Is active?
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
<ModalFooter gap={3}>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddUser
|
180
frontend/src/components/Admin/EditUser.tsx
Normal file
180
frontend/src/components/Admin/EditUser.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import {
|
||||
type ApiError,
|
||||
type UserPublic,
|
||||
type UserUpdate,
|
||||
UsersService,
|
||||
} from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { emailPattern, handleError } from "../../utils"
|
||||
|
||||
interface EditUserProps {
|
||||
user: UserPublic
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface UserUpdateForm extends UserUpdate {
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
const EditUser = ({ user, isOpen, onClose }: EditUserProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting, isDirty },
|
||||
} = useForm<UserUpdateForm>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: user,
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: UserUpdateForm) =>
|
||||
UsersService.updateUser({ userId: user.id, requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "User updated successfully.", "success")
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<UserUpdateForm> = async (data) => {
|
||||
if (data.password === "") {
|
||||
data.password = undefined
|
||||
}
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
reset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Edit User</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<FormControl isInvalid={!!errors.email}>
|
||||
<FormLabel htmlFor="email">Email</FormLabel>
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
/>
|
||||
{errors.email && (
|
||||
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<FormLabel htmlFor="name">Full name</FormLabel>
|
||||
<Input id="name" {...register("full_name")} type="text" />
|
||||
</FormControl>
|
||||
<FormControl mt={4} isInvalid={!!errors.password}>
|
||||
<FormLabel htmlFor="password">Set Password</FormLabel>
|
||||
<Input
|
||||
id="password"
|
||||
{...register("password", {
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: "Password must be at least 8 characters",
|
||||
},
|
||||
})}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<FormErrorMessage>{errors.password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isInvalid={!!errors.confirm_password}>
|
||||
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
{...register("confirm_password", {
|
||||
validate: (value) =>
|
||||
value === getValues().password ||
|
||||
"The passwords do not match",
|
||||
})}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.confirm_password && (
|
||||
<FormErrorMessage>
|
||||
{errors.confirm_password.message}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Flex>
|
||||
<FormControl mt={4}>
|
||||
<Checkbox {...register("is_superuser")} colorScheme="teal">
|
||||
Is superuser?
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<Checkbox {...register("is_active")} colorScheme="teal">
|
||||
Is active?
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter gap={3}>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditUser
|
133
frontend/src/components/Common/ActionsMenu.tsx
Normal file
133
frontend/src/components/Common/ActionsMenu.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react"
|
||||
import { BsThreeDotsVertical } from "react-icons/bs"
|
||||
import { FiEdit, FiTrash } from "react-icons/fi"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import type { ItemPublic, UserPublic, AboutUsPublic, AboutUsUpdate, CoursePublic, ImagePublic, SchedulePublic } from "../../client"
|
||||
import EditUser from "../Admin/EditUser"
|
||||
import EditItem from "../Items/EditItem"
|
||||
import EditCourseImage from "../CourseImage/editCourseImage"
|
||||
import EditAboutUs from "../AboutUs/EditAboutUs"
|
||||
import EditSechedule from "../Courses/EditSechedule"
|
||||
import Delete from "./DeleteAlert"
|
||||
|
||||
interface ActionsMenuProps {
|
||||
type: string
|
||||
value: ItemPublic | UserPublic | AboutUsPublic | CoursePublic | ImagePublic | SchedulePublic
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const ActionsMenu = ({ type, value, disabled }: ActionsMenuProps) => {
|
||||
const editUserModal = useDisclosure()
|
||||
const deleteModal = useDisclosure()
|
||||
|
||||
const renderEditModel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'User':
|
||||
return (
|
||||
<EditUser
|
||||
user={value as UserPublic}
|
||||
isOpen={editUserModal.isOpen}
|
||||
onClose={editUserModal.onClose}
|
||||
/>
|
||||
)
|
||||
case 'Item':
|
||||
return (
|
||||
<EditItem
|
||||
item={value as ItemPublic}
|
||||
isOpen={editUserModal.isOpen}
|
||||
onClose={editUserModal.onClose}
|
||||
/>
|
||||
)
|
||||
case 'AboutUs':
|
||||
return (
|
||||
<EditAboutUs
|
||||
aboutUs={value as AboutUsPublic}
|
||||
isOpen={editUserModal.isOpen}
|
||||
onClose={editUserModal.onClose}
|
||||
/>
|
||||
)
|
||||
case 'Image':
|
||||
return (
|
||||
<EditCourseImage
|
||||
type='Image'
|
||||
imageDetails={value as ImagePublic}
|
||||
isOpen={editUserModal.isOpen}
|
||||
onClose={editUserModal.onClose}
|
||||
/>
|
||||
)
|
||||
case 'Info_Image':
|
||||
return (
|
||||
<EditCourseImage
|
||||
type='Info_Image'
|
||||
imageDetails={value as ImagePublic}
|
||||
isOpen={editUserModal.isOpen}
|
||||
onClose={editUserModal.onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
case "Sechedule":
|
||||
return (
|
||||
<EditSechedule
|
||||
type="Sechedule"
|
||||
sechedule={value as SchedulePublic}
|
||||
isOpen={editUserModal.isOpen}
|
||||
onClose={editUserModal.onClose}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
isDisabled={disabled}
|
||||
as={Button}
|
||||
rightIcon={<BsThreeDotsVertical />}
|
||||
variant="unstyled"
|
||||
/>
|
||||
<MenuList>
|
||||
{type === 'Message' ? (<></>) : (<MenuItem
|
||||
onClick={editUserModal.onOpen}
|
||||
icon={<FiEdit fontSize="16px" />}
|
||||
>
|
||||
Edit {type}
|
||||
</MenuItem>)
|
||||
}
|
||||
|
||||
<MenuItem
|
||||
onClick={deleteModal.onOpen}
|
||||
icon={<FiTrash fontSize="16px" />}
|
||||
color="ui.danger"
|
||||
>
|
||||
Delete {type}
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
{
|
||||
renderEditModel(type)
|
||||
}
|
||||
<Delete
|
||||
type={type}
|
||||
id={value.id}
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={deleteModal.onClose}
|
||||
/>
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default ActionsMenu
|
149
frontend/src/components/Common/DeleteAlert.tsx
Normal file
149
frontend/src/components/Common/DeleteAlert.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
Button,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import React from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
|
||||
import { ItemsService, UsersService, ClientMessagesService, AboutUsService, CoursesService, ImageService, Info_imageService,secheduleService } from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
|
||||
interface DeleteProps {
|
||||
type: string
|
||||
id: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const Delete = ({ type, id, isOpen, onClose }: DeleteProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const cancelRef = React.useRef<HTMLButtonElement | null>(null)
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm()
|
||||
|
||||
const deleteEntity = async (id: string) => {
|
||||
if (type === "Item") {
|
||||
await ItemsService.deleteItem({ id: id })
|
||||
} else if (type === "User") {
|
||||
await UsersService.deleteUser({ userId: id })
|
||||
} else if (type === "Message") {
|
||||
await ClientMessagesService.deleteMessage({ id: id })
|
||||
} else if (type === "AboutUs") {
|
||||
await AboutUsService.deleteAboutUs({ id: id })
|
||||
} else if (type === "Course") {
|
||||
await CoursesService.deleteCourse({ id: id })
|
||||
} else if (type === "Image") {
|
||||
await ImageService.deleteImage({ id: id })
|
||||
} else if (type === "Info_Image") {
|
||||
await Info_imageService.deleteImage({ id: id })
|
||||
} else if (type === "Sechedule") {
|
||||
await secheduleService.deleteSechedule({ id: id })
|
||||
}
|
||||
else {
|
||||
throw new Error(`Unexpected type: ${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: deleteEntity,
|
||||
onSuccess: (data) => {
|
||||
showToast(
|
||||
"Success",
|
||||
`The ${type.toLowerCase()} was deleted successfully.`,
|
||||
"success",
|
||||
)
|
||||
console.log(data)
|
||||
//queryClient.setQueryData(['course'], data)
|
||||
onClose()
|
||||
},
|
||||
onError: () => {
|
||||
showToast(
|
||||
"An error occurred.",
|
||||
`An error occurred while deleting the ${type.toLowerCase()}.`,
|
||||
"error",
|
||||
)
|
||||
},
|
||||
onSettled: () => {
|
||||
var key = ''
|
||||
if (type === "Item") {
|
||||
key = "items"
|
||||
} else if (type === "User") {
|
||||
key = "users"
|
||||
} else if (type === "Message") {
|
||||
key = "messages"
|
||||
} else if (type === "AboutUs") {
|
||||
key = "aboutUs"
|
||||
} else if (type === "Course") {
|
||||
key = "courses"
|
||||
} else if (type === "Image") {
|
||||
key = "course"
|
||||
} else if(type === "Info_Image"){
|
||||
key = "course"
|
||||
} else if (type === "Sechedule") {
|
||||
key = "course"
|
||||
}
|
||||
else {
|
||||
throw new Error(`Unexpected type: ${type}`)
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [key],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async () => {
|
||||
mutation.mutate(id)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertDialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
leastDestructiveRef={cancelRef}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<AlertDialogHeader>Delete {type}</AlertDialogHeader>
|
||||
|
||||
<AlertDialogBody>
|
||||
{type === "User" && (
|
||||
<span>
|
||||
All items associated with this user will also be{" "}
|
||||
<strong>permantly deleted. </strong>
|
||||
</span>
|
||||
)}
|
||||
Are you sure? You will not be able to undo this action.
|
||||
</AlertDialogBody>
|
||||
|
||||
<AlertDialogFooter gap={3}>
|
||||
<Button variant="danger" type="submit" isLoading={isSubmitting}>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
ref={cancelRef}
|
||||
onClick={onClose}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Delete
|
46
frontend/src/components/Common/Navbar.tsx
Normal file
46
frontend/src/components/Common/Navbar.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { ComponentType, ElementType } from "react"
|
||||
|
||||
import { Button, Flex, Icon, useDisclosure } from "@chakra-ui/react"
|
||||
import { FaPlus } from "react-icons/fa"
|
||||
|
||||
interface NavbarProps {
|
||||
type: string
|
||||
addModalAs: ComponentType | ElementType
|
||||
value?: string
|
||||
}
|
||||
|
||||
const Navbar = ({ type, addModalAs, value }: NavbarProps) => {
|
||||
const addModal = useDisclosure()
|
||||
|
||||
const AddModal = addModalAs
|
||||
return (
|
||||
<>
|
||||
<Flex py={8} gap={4}>
|
||||
{/* TODO: Complete search functionality */}
|
||||
{/* <InputGroup w={{ base: '100%', md: 'auto' }}>
|
||||
<InputLeftElement pointerEvents='none'>
|
||||
<Icon as={FaSearch} color='ui.dim' />
|
||||
</InputLeftElement>
|
||||
<Input type='text' placeholder='Search' fontSize={{ base: 'sm', md: 'inherit' }} borderRadius='8px' />
|
||||
</InputGroup> */}
|
||||
<Button
|
||||
variant="primary"
|
||||
gap={1}
|
||||
fontSize={{ base: "sm", md: "inherit" }}
|
||||
onClick={addModal.onOpen}
|
||||
>
|
||||
<Icon as={FaPlus} /> Add {type}
|
||||
</Button>
|
||||
{type === "Sechedule" ? (
|
||||
<AddModal isOpen={addModal.isOpen} onClose={addModal.onClose} courseId={value} />
|
||||
) : (
|
||||
<AddModal isOpen={addModal.isOpen} onClose={addModal.onClose} />
|
||||
)
|
||||
}
|
||||
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Navbar
|
41
frontend/src/components/Common/NotFound.tsx
Normal file
41
frontend/src/components/Common/NotFound.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Button, Container, Text } from "@chakra-ui/react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
|
||||
const NotFound = () => {
|
||||
return (
|
||||
<>
|
||||
<Container
|
||||
h="100vh"
|
||||
alignItems="stretch"
|
||||
justifyContent="center"
|
||||
textAlign="center"
|
||||
maxW="sm"
|
||||
centerContent
|
||||
>
|
||||
<Text
|
||||
fontSize="8xl"
|
||||
color="ui.main"
|
||||
fontWeight="bold"
|
||||
lineHeight="1"
|
||||
mb={4}
|
||||
>
|
||||
404
|
||||
</Text>
|
||||
<Text fontSize="md">Oops!</Text>
|
||||
<Text fontSize="md">Page not found.</Text>
|
||||
<Button
|
||||
as={Link}
|
||||
to="/"
|
||||
color="ui.main"
|
||||
borderColor="ui.main"
|
||||
variant="outline"
|
||||
mt={4}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotFound
|
116
frontend/src/components/Common/Sidebar.tsx
Normal file
116
frontend/src/components/Common/Sidebar.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
Box,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerOverlay,
|
||||
Flex,
|
||||
IconButton,
|
||||
Image,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { FiLogOut, FiMenu } from "react-icons/fi"
|
||||
|
||||
import Logo from "/assets/images/logo.png"
|
||||
import type { UserPublic } from "../../client"
|
||||
import useAuth from "../../hooks/useAuth"
|
||||
import SidebarItems from "./SidebarItems"
|
||||
|
||||
const Sidebar = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const bgColor = useColorModeValue("ui.light", "ui.dark")
|
||||
const textColor = useColorModeValue("ui.dark", "ui.light")
|
||||
const secBgColor = useColorModeValue("ui.secondary", "ui.darkSlate")
|
||||
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const { logout } = useAuth()
|
||||
|
||||
const handleLogout = async () => {
|
||||
logout()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile */}
|
||||
<IconButton
|
||||
onClick={onOpen}
|
||||
display={{ base: "flex", md: "none" }}
|
||||
aria-label="Open Menu"
|
||||
position="absolute"
|
||||
fontSize="20px"
|
||||
m={4}
|
||||
icon={<FiMenu />}
|
||||
/>
|
||||
<Drawer isOpen={isOpen} placement="left" onClose={onClose}>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent maxW="250px">
|
||||
<DrawerCloseButton />
|
||||
<DrawerBody py={8}>
|
||||
<Flex flexDir="column" justify="space-between">
|
||||
<Box>
|
||||
<Image src={Logo} alt="logo" p={8} />
|
||||
<SidebarItems onClose={onClose} />
|
||||
<Flex
|
||||
as="button"
|
||||
onClick={handleLogout}
|
||||
p={2}
|
||||
color="ui.danger"
|
||||
fontWeight="bold"
|
||||
alignItems="center"
|
||||
>
|
||||
<FiLogOut />
|
||||
<Text ml={2}>Log out</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
{currentUser?.email && (
|
||||
<Text color={textColor} noOfLines={2} fontSize="sm" p={2}>
|
||||
Logged in as: {currentUser.email}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
{/* Desktop */}
|
||||
<Box
|
||||
bg={bgColor}
|
||||
p={3}
|
||||
h="100vh"
|
||||
position="sticky"
|
||||
top="0"
|
||||
display={{ base: "none", md: "flex" }}
|
||||
>
|
||||
<Flex
|
||||
flexDir="column"
|
||||
justify="space-between"
|
||||
bg={secBgColor}
|
||||
p={4}
|
||||
borderRadius={12}
|
||||
>
|
||||
<Box>
|
||||
<Image src={Logo} alt="Logo" w="180px" maxW="2xs" p={6} />
|
||||
<SidebarItems />
|
||||
</Box>
|
||||
{currentUser?.email && (
|
||||
<Text
|
||||
color={textColor}
|
||||
noOfLines={2}
|
||||
fontSize="sm"
|
||||
p={2}
|
||||
maxW="180px"
|
||||
>
|
||||
Logged in as: {currentUser.email}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
60
frontend/src/components/Common/SidebarItems.tsx
Normal file
60
frontend/src/components/Common/SidebarItems.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Box, Flex, Icon, Text, useColorModeValue } from "@chakra-ui/react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { FiBriefcase, FiHome, FiSettings, FiUsers, FiMessageSquare, FiAlignLeft, FiBook } from "react-icons/fi"
|
||||
|
||||
import type { UserPublic } from "../../client"
|
||||
|
||||
const items = [
|
||||
{ icon: FiHome, title: "Dashboard", path: "/" },
|
||||
{ icon: FiBriefcase, title: "Items", path: "/items" },
|
||||
{ icon: FiBook, title: "Courses", path: "/Courses/courses" },
|
||||
{ icon: FiMessageSquare, title: "Messages", path: "/clientMessages" },
|
||||
{ icon: FiAlignLeft, title: "About Us", path: "/aboutUs" },
|
||||
{ icon: FiSettings, title: "User Settings", path: "/settings" },
|
||||
{ icon: FiSettings, title: "Web Settings", path: "/webSetting" },
|
||||
]
|
||||
|
||||
interface SidebarItemsProps {
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const SidebarItems = ({ onClose }: SidebarItemsProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const textColor = useColorModeValue("ui.main", "ui.light")
|
||||
const bgActive = useColorModeValue("#E2E8F0", "#4A5568")
|
||||
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
|
||||
|
||||
const finalItems = currentUser?.is_superuser
|
||||
? [...items, { icon: FiUsers, title: "Admin", path: "/admin" }]
|
||||
: items
|
||||
|
||||
const listItems = finalItems.map(({ icon, title, path }) => (
|
||||
<Flex
|
||||
as={Link}
|
||||
to={path}
|
||||
w="100%"
|
||||
p={2}
|
||||
key={title}
|
||||
activeProps={{
|
||||
style: {
|
||||
background: bgActive,
|
||||
borderRadius: "12px",
|
||||
},
|
||||
}}
|
||||
color={textColor}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon as={icon} alignSelf="center" />
|
||||
<Text ml={2}>{title}</Text>
|
||||
</Flex>
|
||||
))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box>{listItems}</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SidebarItems
|
59
frontend/src/components/Common/UserMenu.tsx
Normal file
59
frontend/src/components/Common/UserMenu.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
} from "@chakra-ui/react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { FaUserAstronaut } from "react-icons/fa"
|
||||
import { FiLogOut, FiUser } from "react-icons/fi"
|
||||
|
||||
import useAuth from "../../hooks/useAuth"
|
||||
|
||||
const UserMenu = () => {
|
||||
const { logout } = useAuth()
|
||||
|
||||
const handleLogout = async () => {
|
||||
logout()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop */}
|
||||
<Box
|
||||
display={{ base: "none", md: "block" }}
|
||||
position="fixed"
|
||||
top={4}
|
||||
right={4}
|
||||
>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
aria-label="Options"
|
||||
icon={<FaUserAstronaut color="white" fontSize="18px" />}
|
||||
bg="ui.main"
|
||||
isRound
|
||||
data-testid="user-menu"
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem icon={<FiUser fontSize="18px" />} as={Link} to="settings">
|
||||
My profile
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<FiLogOut fontSize="18px" />}
|
||||
onClick={handleLogout}
|
||||
color="ui.danger"
|
||||
fontWeight="bold"
|
||||
>
|
||||
Log out
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserMenu
|
124
frontend/src/components/CourseImage/editCourseImage.tsx
Normal file
124
frontend/src/components/CourseImage/editCourseImage.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useRef, ReactNode, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
InputGroup,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm, UseFormRegisterReturn } from "react-hook-form"
|
||||
import { type ApiError, ImageService, Info_imageService, ImageUpdate, ImagePublic } from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
import "react-draft-wysiwyg/dist/react-draft-wysiwyg.css";
|
||||
|
||||
interface EditCourseImageProps {
|
||||
type: string
|
||||
imageDetails: ImagePublic
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const EditCourseImage = ({ type, imageDetails, isOpen, onClose }: EditCourseImageProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ImageUpdate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
index: imageDetails.index,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: ImageUpdate) =>
|
||||
type === 'Image' ?
|
||||
ImageService.updateImage({ index: data.index, id: imageDetails.id }) :
|
||||
Info_imageService.updateImage({ index: data.index, id: imageDetails.id }),
|
||||
onSuccess: (data) => {
|
||||
console.log(data)
|
||||
queryClient.setQueryData(['course'], data)
|
||||
reset()
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["courses", "course"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<ImageUpdate> = (data) => {
|
||||
// data.index = Number(data.index)
|
||||
mutation.mutate(data)
|
||||
console.log(data)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={'xl'}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>{type === 'Image' ? "Edit Image" : "Edit Info Image"}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
|
||||
<ModalBody pb={30}>
|
||||
|
||||
<FormControl isRequired isInvalid={!!errors.index}>
|
||||
<FormLabel htmlFor="index">Index</FormLabel >
|
||||
<NumberInput min={0} max={20} >
|
||||
<NumberInputField {...register("index", {
|
||||
required: "index is required.",
|
||||
})} />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
|
||||
|
||||
</FormControl>
|
||||
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter gap={3}>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default EditCourseImage
|
160
frontend/src/components/Courses/AddSechedule.tsx
Normal file
160
frontend/src/components/Courses/AddSechedule.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from "@chakra-ui/react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import DateTimePicker from 'react-datetime-picker';
|
||||
import 'react-calendar/dist/Calendar.css';
|
||||
import { type ApiError, type ScheduleCreate, secheduleService } from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
import moment from "moment";
|
||||
|
||||
interface AddSecheduleProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
courseId: string
|
||||
}
|
||||
|
||||
const AddSechedule = ({ isOpen, onClose, courseId }: AddSecheduleProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ScheduleCreate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
date: "",
|
||||
title: "",
|
||||
info1: "",
|
||||
info2: "",
|
||||
course_id: courseId,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setValue("course_id", courseId);
|
||||
setValue("date", moment.utc(new Date()).format());
|
||||
}, [setValue, courseId]);
|
||||
|
||||
const [datetime, setDatetime] = useState<Date>(new Date());
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: ScheduleCreate) =>
|
||||
secheduleService.createSechedule({ requestBody: data }),
|
||||
onSuccess: (data) => {
|
||||
showToast("Success!", "Sechedule created successfully.", "success")
|
||||
console.log(data)
|
||||
reset(
|
||||
{
|
||||
date: moment.utc(new Date()).format(),
|
||||
title: "",
|
||||
info1: "",
|
||||
info2: "",
|
||||
course_id: courseId,
|
||||
}
|
||||
)
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["course"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<ScheduleCreate> = (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Add Sechedule</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<DateTimePicker onChange={(newDatetime: Date | null) => {
|
||||
if (newDatetime instanceof Date) {
|
||||
setDatetime(newDatetime)
|
||||
setValue("date", moment.utc(newDatetime).format())
|
||||
}
|
||||
}} value={datetime} />
|
||||
<FormControl isRequired isInvalid={!!errors.title}>
|
||||
<FormLabel htmlFor="title">Title</FormLabel>
|
||||
<Input
|
||||
id="title"
|
||||
{...register("title", {
|
||||
required: "Title is required.",
|
||||
})}
|
||||
placeholder="Title"
|
||||
type="text"
|
||||
/>
|
||||
{errors.title && (
|
||||
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<FormLabel htmlFor="info1">Info 1</FormLabel>
|
||||
<Input
|
||||
id="description"
|
||||
{...register("info1")}
|
||||
placeholder="info1"
|
||||
type="text"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<FormLabel htmlFor="info2">Info 2</FormLabel>
|
||||
<Input
|
||||
id="description"
|
||||
{...register("info2")}
|
||||
placeholder="info2"
|
||||
type="text"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter gap={3}>
|
||||
<Button onClick={() => {
|
||||
const values = getValues()
|
||||
console.log(values)
|
||||
}}>
|
||||
test
|
||||
</Button>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default AddSechedule
|
262
frontend/src/components/Courses/CourseDetails.tsx
Normal file
262
frontend/src/components/Courses/CourseDetails.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Textarea,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Container,
|
||||
Heading,
|
||||
Box
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient, useMutation, QueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate, Await, useRouter } from "@tanstack/react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { CoursesService, type ApiError, CourseCreate, CourseDetailsPublic } from "../../client"
|
||||
import { handleError } from "../../utils"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import { EditorState, ContentState, convertToRaw } from 'draft-js';
|
||||
import { Editor } from "react-draft-wysiwyg";
|
||||
import draftToHtml from 'draftjs-to-html';
|
||||
import htmlToDraft from 'html-to-draftjs';
|
||||
import "react-draft-wysiwyg/dist/react-draft-wysiwyg.css";
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const CourseDetails = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const courseDetails = queryClient.getQueryData(['course']) as CourseDetailsPublic | undefined;
|
||||
|
||||
const toolbar = {
|
||||
options: ['inline', 'blockType', 'fontSize', 'list', 'textAlign', 'history', 'embedded', 'emoji', 'image'],
|
||||
inline: { inDropdown: true },
|
||||
list: { inDropdown: true },
|
||||
textAlign: { inDropdown: true },
|
||||
link: { inDropdown: true },
|
||||
history: { inDropdown: true },
|
||||
}
|
||||
|
||||
const showToast = useCustomToast()
|
||||
|
||||
const [contentEditorState, setContentEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [contents, setContent] = useState<string>('');
|
||||
const [infoEditorState, setInfoEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [info, setInfo] = useState<string>('');
|
||||
const [longDescriptionEditorState, setLongDescriptionEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [longDescription, setlongDescription] = useState<string>('');
|
||||
const [remarksEditorState, setRemarksEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [remarks, setRemarks] = useState<string>('');
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
setValue,
|
||||
unregister,
|
||||
formState: { isSubmitting, errors, isDirty },
|
||||
} = useForm<CourseCreate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
title: courseDetails?.title,
|
||||
sort_description: courseDetails?.sort_description,
|
||||
long_description: courseDetails?.long_description,
|
||||
remark: courseDetails?.remark,
|
||||
information: courseDetails?.information,
|
||||
contant: courseDetails?.contant,
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (courseDetails) {
|
||||
setValue('title', courseDetails.title);
|
||||
setValue('sort_description', courseDetails.sort_description);
|
||||
// Update other form fields as needed
|
||||
}
|
||||
if (courseDetails?.long_description) {
|
||||
const contentBlock = htmlToDraft(courseDetails.long_description);
|
||||
if (contentBlock) {
|
||||
const contentState = ContentState.createFromBlockArray(contentBlock.contentBlocks);
|
||||
const editorState = EditorState.createWithContent(contentState);
|
||||
setLongDescriptionEditorState(editorState);
|
||||
setValue('long_description', longDescription);
|
||||
}
|
||||
}
|
||||
if (courseDetails?.remark) {
|
||||
const contentBlock = htmlToDraft(courseDetails.remark);
|
||||
if (contentBlock) {
|
||||
const contentState = ContentState.createFromBlockArray(contentBlock.contentBlocks);
|
||||
const editorState = EditorState.createWithContent(contentState);
|
||||
setRemarksEditorState(editorState);
|
||||
setValue('remark', remarks);
|
||||
}
|
||||
}
|
||||
if (courseDetails?.information) {
|
||||
const contentBlock = htmlToDraft(courseDetails.information);
|
||||
if (contentBlock) {
|
||||
const contentState = ContentState.createFromBlockArray(contentBlock.contentBlocks);
|
||||
const editorState = EditorState.createWithContent(contentState);
|
||||
setInfoEditorState(editorState);
|
||||
setValue('information', info);
|
||||
}
|
||||
}
|
||||
if (courseDetails?.contant) {
|
||||
const contentBlock = htmlToDraft(courseDetails.contant);
|
||||
if (contentBlock) {
|
||||
const contentState = ContentState.createFromBlockArray(contentBlock.contentBlocks);
|
||||
const editorState = EditorState.createWithContent(contentState);
|
||||
setContentEditorState(editorState);
|
||||
setValue('contant', contents);
|
||||
}
|
||||
}
|
||||
}, [courseDetails]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: CourseCreate) =>
|
||||
CoursesService.updateCourse({ id: courseDetails?.id ?? '', requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Course create successfully.", "success")
|
||||
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["courses"] })
|
||||
//history.go(-1)
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<CourseCreate> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="sm" py={4}>
|
||||
Course Details
|
||||
</Heading>
|
||||
<Box
|
||||
w={{ sm: "full", md: "50%" }}
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormControl isInvalid={!!errors.title}>
|
||||
<FormLabel htmlFor="title">Title</FormLabel>
|
||||
<Input
|
||||
defaultValue={courseDetails?.title}
|
||||
id="title"
|
||||
type="text"
|
||||
{...register("title", {
|
||||
required: "title is required",
|
||||
})}
|
||||
/>
|
||||
{errors.title && (
|
||||
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="sort_description">Short Description</FormLabel>
|
||||
<Textarea
|
||||
id="sort_description"
|
||||
{...register("sort_description", {
|
||||
required: "sort_description is required",
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="long_description">Long Description</FormLabel>
|
||||
<Editor
|
||||
editorState={longDescriptionEditorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setLongDescriptionEditorState(newState);
|
||||
setlongDescription(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
setValue("long_description", longDescription);
|
||||
}}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="information">Information</FormLabel>
|
||||
<Editor
|
||||
editorState={infoEditorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setInfoEditorState(newState);
|
||||
setInfo(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
setValue("information", info);
|
||||
}}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="contant">Content</FormLabel>
|
||||
<Editor
|
||||
editorState={contentEditorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setContentEditorState(newState);
|
||||
setContent(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
setValue("contant", contents);
|
||||
}}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="remark">Remark</FormLabel>
|
||||
<Editor
|
||||
editorState={remarksEditorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setRemarksEditorState(newState);
|
||||
setRemarks(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
setValue("remark", remarks);
|
||||
}}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const values = getValues()
|
||||
console.log(values)
|
||||
// history.go(-1)// { test: "test-input", test1: "test1-input" }
|
||||
}}
|
||||
>
|
||||
Get Values
|
||||
</button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<FormControl mt={20}></FormControl>
|
||||
</Box>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
export default CourseDetails;
|
186
frontend/src/components/Courses/CourseImages.tsx
Normal file
186
frontend/src/components/Courses/CourseImages.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Textarea,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Container,
|
||||
Heading,
|
||||
Box,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
Image,
|
||||
Flex,
|
||||
HStack,
|
||||
VStack,
|
||||
Text,
|
||||
Td,
|
||||
Icon,
|
||||
Grid
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate, Await, useRouter } from "@tanstack/react-router"
|
||||
import { FaPlus, FaPen, FaTrashAlt } from "react-icons/fa"
|
||||
import { useEffect, useState } from "react"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { ImageService, type ApiError, CourseDetailsPublic, ImageCreate } from "../../client"
|
||||
import { handleError } from "../../utils"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const CourseImages = () => {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const courseDetails = queryClient.getQueryData(['course']) as CourseDetailsPublic | undefined;
|
||||
const url = import.meta.env.VITE_API_URL
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
setValue,
|
||||
unregister,
|
||||
formState: { isSubmitting, errors, isDirty },
|
||||
} = useForm<ImageCreate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
course_id: '',
|
||||
image: undefined,
|
||||
index: 0,
|
||||
}
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: ImageCreate) =>
|
||||
ImageService.createImage({ formData: data }),
|
||||
onSuccess: (data) => {
|
||||
showToast("Success!", "Image added successfully.", "success")
|
||||
console.log(data)
|
||||
queryClient.setQueryData(['course'], data)
|
||||
reset()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["courses"] })
|
||||
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (courseDetails) {
|
||||
setValue("course_id", courseDetails.id)
|
||||
}
|
||||
}, [courseDetails]);
|
||||
|
||||
const onSubmit: SubmitHandler<ImageCreate> = async (data) => {
|
||||
if (data.image instanceof FileList && data.image.length > 0) {
|
||||
data.image = data.image[0]
|
||||
}
|
||||
if (courseDetails?.images && courseDetails.images.length >= 5) {
|
||||
showToast("Error!", "You can only add 5 images", "error")
|
||||
return
|
||||
} else {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Box
|
||||
w={{ sm: "full", md: "50%" }}
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<Box w="100%" maxW="full" overflowX="auto">
|
||||
<VStack align="flex-start">
|
||||
<HStack spacing={4} overflowX="auto">
|
||||
|
||||
{courseDetails?.images.map((image, index) => (
|
||||
<Box width={200} height={200} >
|
||||
<Grid templateColumns='repeat(2, 1fr)' gap={1}>
|
||||
<Text >{image.index}</Text>
|
||||
<ActionsMenu type={"Image"} value={image} />
|
||||
</Grid>
|
||||
|
||||
<Image key={index} src={url + "/" + image.image} objectFit="cover" />
|
||||
|
||||
</Box>
|
||||
))}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
<Heading size="sm" py={4}>
|
||||
Add Course Image
|
||||
</Heading>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl isRequired isInvalid={!!errors.index}>
|
||||
<FormLabel htmlFor="index">Index</FormLabel >
|
||||
<NumberInput min={0} max={20} >
|
||||
<NumberInputField {...register("index", {
|
||||
required: "index is required.",
|
||||
})} />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
{errors.index && (
|
||||
<FormErrorMessage>{errors.index.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl isInvalid={!!errors.image} isRequired>
|
||||
<FormLabel>{'Image Upload'}</FormLabel>
|
||||
<input type="file" {...register("image", {
|
||||
required: "index is required.",
|
||||
})} />
|
||||
<FormErrorMessage>
|
||||
{errors.image && errors?.image.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const values = getValues()
|
||||
console.log(values)
|
||||
// history.go(-1)// { test: "test-input", test1: "test1-input" }
|
||||
}}
|
||||
>
|
||||
Get Values
|
||||
</button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<FormControl mt={20}></FormControl>
|
||||
</Box>
|
||||
</Container >
|
||||
)
|
||||
}
|
||||
export default CourseImages;
|
186
frontend/src/components/Courses/CourseInfoImages.tsx
Normal file
186
frontend/src/components/Courses/CourseInfoImages.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Textarea,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Container,
|
||||
Heading,
|
||||
Box,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
Image,
|
||||
Flex,
|
||||
HStack,
|
||||
VStack,
|
||||
Text,
|
||||
Td,
|
||||
Icon,
|
||||
Grid
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate, Await, useRouter } from "@tanstack/react-router"
|
||||
import { FaPlus, FaPen, FaTrashAlt } from "react-icons/fa"
|
||||
import { useEffect, useState } from "react"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { Info_imageService, type ApiError, CourseDetailsPublic, ImageCreate } from "../../client"
|
||||
import { handleError } from "../../utils"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const CourseInfoImages = () => {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const courseDetails = queryClient.getQueryData(['course']) as CourseDetailsPublic | undefined;
|
||||
const url = import.meta.env.VITE_API_URL
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
setValue,
|
||||
unregister,
|
||||
formState: { isSubmitting, errors, isDirty },
|
||||
} = useForm<ImageCreate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
course_id: '',
|
||||
image: undefined,
|
||||
index: 0,
|
||||
}
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: ImageCreate) =>
|
||||
Info_imageService.createImage({ formData: data }),
|
||||
onSuccess: (data) => {
|
||||
showToast("Success!", "Image added successfully.", "success")
|
||||
console.log(data)
|
||||
queryClient.setQueryData(['course'], data)
|
||||
reset()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["courses"] })
|
||||
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (courseDetails) {
|
||||
setValue("course_id", courseDetails.id)
|
||||
}
|
||||
}, [courseDetails]);
|
||||
|
||||
const onSubmit: SubmitHandler<ImageCreate> = async (data) => {
|
||||
if (data.image instanceof FileList && data.image.length > 0) {
|
||||
data.image = data.image[0]
|
||||
}
|
||||
if (courseDetails?.info_images && courseDetails.info_images.length >= 5) {
|
||||
showToast("Error!", "You can only add 5 images", "error")
|
||||
return
|
||||
} else {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Box
|
||||
w={{ sm: "full", md: "50%" }}
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<Box w="100%" maxW="full" overflowX="auto">
|
||||
<VStack align="flex-start">
|
||||
<HStack spacing={4} overflowX="auto">
|
||||
|
||||
{courseDetails?.info_images.map((image, index) => (
|
||||
<Box width={200} height={200} >
|
||||
<Grid templateColumns='repeat(2, 1fr)' gap={1}>
|
||||
<Text >{image.index}</Text>
|
||||
<ActionsMenu type={"Info_Image"} value={image} />
|
||||
</Grid>
|
||||
|
||||
<Image key={index} src={url + "/" + image.image} objectFit="cover" />
|
||||
|
||||
</Box>
|
||||
))}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
<Heading size="sm" py={4}>
|
||||
Add Course Image
|
||||
</Heading>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl isRequired isInvalid={!!errors.index}>
|
||||
<FormLabel htmlFor="index">Index</FormLabel >
|
||||
<NumberInput min={0} max={20} >
|
||||
<NumberInputField {...register("index", {
|
||||
required: "index is required.",
|
||||
})} />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
{errors.index && (
|
||||
<FormErrorMessage>{errors.index.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl isInvalid={!!errors.image} isRequired>
|
||||
<FormLabel>{'Image Upload'}</FormLabel>
|
||||
<input type="file" {...register("image", {
|
||||
required: "index is required.",
|
||||
})} />
|
||||
<FormErrorMessage>
|
||||
{errors.image && errors?.image.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
{/* <button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const values = getValues()
|
||||
console.log(values)
|
||||
// history.go(-1)// { test: "test-input", test1: "test1-input" }
|
||||
}}
|
||||
>
|
||||
Get Values
|
||||
</button> */}
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<FormControl mt={20}></FormControl>
|
||||
</Box>
|
||||
</Container >
|
||||
)
|
||||
}
|
||||
export default CourseInfoImages;
|
149
frontend/src/components/Courses/EditSechedule.tsx
Normal file
149
frontend/src/components/Courses/EditSechedule.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from "@chakra-ui/react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import DateTimePicker from 'react-datetime-picker';
|
||||
import 'react-calendar/dist/Calendar.css';
|
||||
import { type ApiError, type ScheduleCreate, SchedulePublic, ScheduleUpdate, secheduleService } from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
import moment from "moment";
|
||||
|
||||
interface EditItemProps {
|
||||
sechedule: SchedulePublic
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
type: string
|
||||
}
|
||||
|
||||
const EditSechedule = ({ sechedule, type, isOpen, onClose }: EditItemProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { isSubmitting, errors, isDirty },
|
||||
} = useForm<ScheduleUpdate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
date: sechedule.date,
|
||||
title: sechedule.title,
|
||||
info1: sechedule.info1,
|
||||
info2: sechedule.info2,
|
||||
},
|
||||
})
|
||||
|
||||
const [datetime, setDatetime] = useState<string>(sechedule.date);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: ScheduleUpdate) =>
|
||||
secheduleService.updateSechedule({ id: sechedule.id, requestBody: data }),
|
||||
onSuccess: (data) => {
|
||||
showToast("Success!", "Sechedule edit successfully.", "success")
|
||||
console.log(data)
|
||||
reset()
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["course"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<ScheduleUpdate> = (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Edit Sechedule</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<DateTimePicker onChange={(newDatetime: Date | null) => {
|
||||
if (newDatetime instanceof Date) {
|
||||
const formattedDate = moment.utc(newDatetime).format()
|
||||
setDatetime(formattedDate)
|
||||
setValue("date", formattedDate)
|
||||
}
|
||||
}} value={moment.utc(datetime).toDate()} />
|
||||
<FormControl isRequired isInvalid={!!errors.title}>
|
||||
<FormLabel htmlFor="title">Title</FormLabel>
|
||||
<Input
|
||||
id="title"
|
||||
{...register("title", {
|
||||
required: "Title is required.",
|
||||
})}
|
||||
placeholder="Title"
|
||||
type="text"
|
||||
/>
|
||||
{errors.title && (
|
||||
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<FormLabel htmlFor="info1">Info 1</FormLabel>
|
||||
<Input
|
||||
id="description"
|
||||
{...register("info1")}
|
||||
placeholder="info1"
|
||||
type="text"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<FormLabel htmlFor="info2">Info 2</FormLabel>
|
||||
<Input
|
||||
id="description"
|
||||
{...register("info2")}
|
||||
placeholder="info2"
|
||||
type="text"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter gap={3}>
|
||||
<Button onClick={() => {
|
||||
const values = getValues()
|
||||
console.log(values)
|
||||
}}>
|
||||
test
|
||||
</Button>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditSechedule
|
76
frontend/src/components/Courses/Sechedule.tsx
Normal file
76
frontend/src/components/Courses/Sechedule.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Heading,
|
||||
SkeletonText,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react"
|
||||
import moment from 'moment';
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate, Await, useRouter } from "@tanstack/react-router"
|
||||
import { FaPlus, FaPen, FaTrashAlt } from "react-icons/fa"
|
||||
import { useEffect, useState } from "react"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { Info_imageService, type ApiError, CourseDetailsPublic, ImageCreate } from "../../client"
|
||||
import { handleError } from "../../utils"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
import DateTimePicker from 'react-datetime-picker';
|
||||
import 'react-datetime-picker/dist/DateTimePicker.css';
|
||||
import Navbar from "../../components/Common/Navbar"
|
||||
import AddSechedule from "./AddSechedule";
|
||||
|
||||
const Sechedule = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const courseDetails = queryClient.getQueryData(['course']) as CourseDetailsPublic | undefined;
|
||||
|
||||
const showToast = useCustomToast()
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Navbar type={"Sechedule"} addModalAs={AddSechedule} value={courseDetails?.id} />
|
||||
<TableContainer>
|
||||
<Table size={{ base: "sm", md: "md" }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Date</Th>
|
||||
<Th>Title</Th>
|
||||
<Th>Info 1</Th>
|
||||
<Th>Info 2</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{courseDetails?.schedule?.map((schedule, index) => (
|
||||
<Tr key={schedule.id}>
|
||||
<Td maxWidth="20px">
|
||||
{moment(schedule.date).utcOffset("+08:00").format('DD-MM-YYYY HH:mm')}
|
||||
</Td>
|
||||
<Td maxWidth="50px">
|
||||
{schedule.title}
|
||||
</Td>
|
||||
<Td maxWidth="300px">
|
||||
{schedule.info1}
|
||||
</Td>
|
||||
<Td maxWidth="300px">
|
||||
{schedule.info2}
|
||||
</Td>
|
||||
<Td>
|
||||
<ActionsMenu type={"Sechedule"} value={schedule} />
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
export default Sechedule;
|
114
frontend/src/components/Items/AddItem.tsx
Normal file
114
frontend/src/components/Items/AddItem.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { type ApiError, type ItemCreate, ItemsService } from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
|
||||
interface AddItemProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const AddItem = ({ isOpen, onClose }: AddItemProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ItemCreate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
title: "",
|
||||
description: "",
|
||||
},
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: ItemCreate) =>
|
||||
ItemsService.createItem({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Item created successfully.", "success")
|
||||
reset()
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<ItemCreate> = (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Add Item</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<FormControl isRequired isInvalid={!!errors.title}>
|
||||
<FormLabel htmlFor="title">Title</FormLabel>
|
||||
<Input
|
||||
id="title"
|
||||
{...register("title", {
|
||||
required: "Title is required.",
|
||||
})}
|
||||
placeholder="Title"
|
||||
type="text"
|
||||
/>
|
||||
{errors.title && (
|
||||
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<FormLabel htmlFor="description">Description</FormLabel>
|
||||
<Input
|
||||
id="description"
|
||||
{...register("description")}
|
||||
placeholder="Description"
|
||||
type="text"
|
||||
/>
|
||||
</FormControl>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter gap={3}>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddItem
|
124
frontend/src/components/Items/EditItem.tsx
Normal file
124
frontend/src/components/Items/EditItem.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import {
|
||||
type ApiError,
|
||||
type ItemPublic,
|
||||
type ItemUpdate,
|
||||
ItemsService,
|
||||
} from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
|
||||
interface EditItemProps {
|
||||
item: ItemPublic
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const EditItem = ({ item, isOpen, onClose }: EditItemProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting, errors, isDirty },
|
||||
} = useForm<ItemUpdate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: item,
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: ItemUpdate) =>
|
||||
ItemsService.updateItem({ id: item.id, requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Item updated successfully.", "success")
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<ItemUpdate> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
reset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Edit Item</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<FormControl isInvalid={!!errors.title}>
|
||||
<FormLabel htmlFor="title">Title</FormLabel>
|
||||
<Input
|
||||
id="title"
|
||||
{...register("title", {
|
||||
required: "Title is required",
|
||||
})}
|
||||
type="text"
|
||||
/>
|
||||
{errors.title && (
|
||||
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<FormLabel htmlFor="description">Description</FormLabel>
|
||||
<Input
|
||||
id="description"
|
||||
{...register("description")}
|
||||
placeholder="Description"
|
||||
type="text"
|
||||
/>
|
||||
</FormControl>
|
||||
</ModalBody>
|
||||
<ModalFooter gap={3}>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditItem
|
38
frontend/src/components/UserSettings/Appearance.tsx
Normal file
38
frontend/src/components/UserSettings/Appearance.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
Badge,
|
||||
Container,
|
||||
Heading,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Stack,
|
||||
useColorMode,
|
||||
} from "@chakra-ui/react"
|
||||
|
||||
const Appearance = () => {
|
||||
const { colorMode, toggleColorMode } = useColorMode()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container maxW="full">
|
||||
<Heading size="sm" py={4}>
|
||||
Appearance
|
||||
</Heading>
|
||||
<RadioGroup onChange={toggleColorMode} value={colorMode}>
|
||||
<Stack>
|
||||
{/* TODO: Add system default option */}
|
||||
<Radio value="light" colorScheme="teal">
|
||||
Light Mode
|
||||
<Badge ml="1" colorScheme="teal">
|
||||
Default
|
||||
</Badge>
|
||||
</Radio>
|
||||
<Radio value="dark" colorScheme="teal">
|
||||
Dark Mode
|
||||
</Radio>
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default Appearance
|
122
frontend/src/components/UserSettings/ChangePassword.tsx
Normal file
122
frontend/src/components/UserSettings/ChangePassword.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { type ApiError, type UpdatePassword, UsersService } from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { confirmPasswordRules, handleError, passwordRules } from "../../utils"
|
||||
|
||||
interface UpdatePasswordForm extends UpdatePassword {
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
const ChangePassword = () => {
|
||||
const color = useColorModeValue("inherit", "ui.light")
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<UpdatePasswordForm>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: UpdatePassword) =>
|
||||
UsersService.updatePasswordMe({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Password updated successfully.", "success")
|
||||
reset()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<UpdatePasswordForm> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container maxW="full">
|
||||
<Heading size="sm" py={4}>
|
||||
Change Password
|
||||
</Heading>
|
||||
<Box
|
||||
w={{ sm: "full", md: "50%" }}
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormControl isRequired isInvalid={!!errors.current_password}>
|
||||
<FormLabel color={color} htmlFor="current_password">
|
||||
Current Password
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="current_password"
|
||||
{...register("current_password")}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
w="auto"
|
||||
/>
|
||||
{errors.current_password && (
|
||||
<FormErrorMessage>
|
||||
{errors.current_password.message}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isRequired isInvalid={!!errors.new_password}>
|
||||
<FormLabel htmlFor="password">Set Password</FormLabel>
|
||||
<Input
|
||||
id="password"
|
||||
{...register("new_password", passwordRules())}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
w="auto"
|
||||
/>
|
||||
{errors.new_password && (
|
||||
<FormErrorMessage>{errors.new_password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isRequired isInvalid={!!errors.confirm_password}>
|
||||
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
{...register("confirm_password", confirmPasswordRules(getValues))}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
w="auto"
|
||||
/>
|
||||
{errors.confirm_password && (
|
||||
<FormErrorMessage>
|
||||
{errors.confirm_password.message}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Button
|
||||
variant="primary"
|
||||
mt={4}
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default ChangePassword
|
35
frontend/src/components/UserSettings/DeleteAccount.tsx
Normal file
35
frontend/src/components/UserSettings/DeleteAccount.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react"
|
||||
|
||||
import DeleteConfirmation from "./DeleteConfirmation"
|
||||
|
||||
const DeleteAccount = () => {
|
||||
const confirmationModal = useDisclosure()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container maxW="full">
|
||||
<Heading size="sm" py={4}>
|
||||
Delete Account
|
||||
</Heading>
|
||||
<Text>
|
||||
Permanently delete your data and everything associated with your
|
||||
account.
|
||||
</Text>
|
||||
<Button variant="danger" mt={4} onClick={confirmationModal.onOpen}>
|
||||
Delete
|
||||
</Button>
|
||||
<DeleteConfirmation
|
||||
isOpen={confirmationModal.isOpen}
|
||||
onClose={confirmationModal.onClose}
|
||||
/>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default DeleteAccount
|
96
frontend/src/components/UserSettings/DeleteConfirmation.tsx
Normal file
96
frontend/src/components/UserSettings/DeleteConfirmation.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
Button,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import React from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
|
||||
import { type ApiError, UsersService } from "../../client"
|
||||
import useAuth from "../../hooks/useAuth"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
|
||||
interface DeleteProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const DeleteConfirmation = ({ isOpen, onClose }: DeleteProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const cancelRef = React.useRef<HTMLButtonElement | null>(null)
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm()
|
||||
const { logout } = useAuth()
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => UsersService.deleteUserMe(),
|
||||
onSuccess: () => {
|
||||
showToast(
|
||||
"Success",
|
||||
"Your account has been successfully deleted.",
|
||||
"success",
|
||||
)
|
||||
logout()
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["currentUser"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async () => {
|
||||
mutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertDialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
leastDestructiveRef={cancelRef}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<AlertDialogHeader>Confirmation Required</AlertDialogHeader>
|
||||
|
||||
<AlertDialogBody>
|
||||
All your account data will be{" "}
|
||||
<strong>permanently deleted.</strong> If you are sure, please
|
||||
click <strong>"Confirm"</strong> to proceed. This action cannot be
|
||||
undone.
|
||||
</AlertDialogBody>
|
||||
|
||||
<AlertDialogFooter gap={3}>
|
||||
<Button variant="danger" type="submit" isLoading={isSubmitting}>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
ref={cancelRef}
|
||||
onClick={onClose}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteConfirmation
|
157
frontend/src/components/UserSettings/UserInformation.tsx
Normal file
157
frontend/src/components/UserSettings/UserInformation.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import {
|
||||
type ApiError,
|
||||
type UserPublic,
|
||||
type UserUpdateMe,
|
||||
UsersService,
|
||||
} from "../../client"
|
||||
import useAuth from "../../hooks/useAuth"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { emailPattern, handleError } from "../../utils"
|
||||
|
||||
const UserInformation = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const color = useColorModeValue("inherit", "ui.light")
|
||||
const showToast = useCustomToast()
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const { user: currentUser } = useAuth()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
formState: { isSubmitting, errors, isDirty },
|
||||
} = useForm<UserPublic>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
full_name: currentUser?.full_name,
|
||||
email: currentUser?.email,
|
||||
},
|
||||
})
|
||||
|
||||
const toggleEditMode = () => {
|
||||
setEditMode(!editMode)
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: UserUpdateMe) =>
|
||||
UsersService.updateUserMe({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "User updated successfully.", "success")
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries()
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<UserUpdateMe> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
reset()
|
||||
toggleEditMode()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container maxW="full">
|
||||
<Heading size="sm" py={4}>
|
||||
User Information
|
||||
</Heading>
|
||||
<Box
|
||||
w={{ sm: "full", md: "50%" }}
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormControl>
|
||||
<FormLabel color={color} htmlFor="name">
|
||||
Full name
|
||||
</FormLabel>
|
||||
{editMode ? (
|
||||
<Input
|
||||
id="name"
|
||||
{...register("full_name", { maxLength: 30 })}
|
||||
type="text"
|
||||
size="md"
|
||||
w="auto"
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
size="md"
|
||||
py={2}
|
||||
color={!currentUser?.full_name ? "ui.dim" : "inherit"}
|
||||
isTruncated
|
||||
maxWidth="250px"
|
||||
>
|
||||
{currentUser?.full_name || "N/A"}
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isInvalid={!!errors.email}>
|
||||
<FormLabel color={color} htmlFor="email">
|
||||
Email
|
||||
</FormLabel>
|
||||
{editMode ? (
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
type="email"
|
||||
size="md"
|
||||
w="auto"
|
||||
/>
|
||||
) : (
|
||||
<Text size="md" py={2} isTruncated maxWidth="250px">
|
||||
{currentUser?.email}
|
||||
</Text>
|
||||
)}
|
||||
{errors.email && (
|
||||
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Flex mt={4} gap={3}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={toggleEditMode}
|
||||
type={editMode ? "button" : "submit"}
|
||||
isLoading={editMode ? isSubmitting : false}
|
||||
isDisabled={editMode ? !isDirty || !getValues("email") : false}
|
||||
>
|
||||
{editMode ? "Save" : "Edit"}
|
||||
</Button>
|
||||
{editMode && (
|
||||
<Button onClick={onCancel} isDisabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserInformation
|
101
frontend/src/hooks/useAuth.ts
Normal file
101
frontend/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
|
||||
import { AxiosError } from "axios"
|
||||
import {
|
||||
type Body_login_login_access_token as AccessToken,
|
||||
type ApiError,
|
||||
LoginService,
|
||||
type UserPublic,
|
||||
type UserRegister,
|
||||
UsersService,
|
||||
} from "../client"
|
||||
import useCustomToast from "./useCustomToast"
|
||||
|
||||
const isLoggedIn = () => {
|
||||
return localStorage.getItem("access_token") !== null
|
||||
}
|
||||
|
||||
const useAuth = () => {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const navigate = useNavigate()
|
||||
const showToast = useCustomToast()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: user, isLoading } = useQuery<UserPublic | null, Error>({
|
||||
queryKey: ["currentUser"],
|
||||
queryFn: UsersService.readUserMe,
|
||||
enabled: isLoggedIn(),
|
||||
})
|
||||
|
||||
const signUpMutation = useMutation({
|
||||
mutationFn: (data: UserRegister) =>
|
||||
UsersService.registerUser({ requestBody: data }),
|
||||
|
||||
onSuccess: () => {
|
||||
navigate({ to: "/login" })
|
||||
showToast(
|
||||
"Account created.",
|
||||
"Your account has been created successfully.",
|
||||
"success",
|
||||
)
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
let errDetail = (err.body as any)?.detail
|
||||
|
||||
if (err instanceof AxiosError) {
|
||||
errDetail = err.message
|
||||
}
|
||||
|
||||
showToast("Something went wrong.", errDetail, "error")
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||
},
|
||||
})
|
||||
|
||||
const login = async (data: AccessToken) => {
|
||||
const response = await LoginService.loginAccessToken({
|
||||
formData: data,
|
||||
})
|
||||
localStorage.setItem("access_token", response.access_token)
|
||||
}
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: login,
|
||||
onSuccess: () => {
|
||||
navigate({ to: "/" })
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
let errDetail = (err.body as any)?.detail
|
||||
|
||||
if (err instanceof AxiosError) {
|
||||
errDetail = err.message
|
||||
}
|
||||
|
||||
if (Array.isArray(errDetail)) {
|
||||
errDetail = "Something went wrong"
|
||||
}
|
||||
|
||||
setError(errDetail)
|
||||
},
|
||||
})
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem("access_token")
|
||||
navigate({ to: "/login" })
|
||||
}
|
||||
|
||||
return {
|
||||
signUpMutation,
|
||||
loginMutation,
|
||||
logout,
|
||||
user,
|
||||
isLoading,
|
||||
error,
|
||||
resetError: () => setError(null),
|
||||
}
|
||||
}
|
||||
|
||||
export { isLoggedIn }
|
||||
export default useAuth
|
23
frontend/src/hooks/useCustomToast.ts
Normal file
23
frontend/src/hooks/useCustomToast.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useToast } from "@chakra-ui/react"
|
||||
import { useCallback } from "react"
|
||||
|
||||
const useCustomToast = () => {
|
||||
const toast = useToast()
|
||||
|
||||
const showToast = useCallback(
|
||||
(title: string, description: string, status: "success" | "error") => {
|
||||
toast({
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
isClosable: true,
|
||||
position: "bottom-right",
|
||||
})
|
||||
},
|
||||
[toast],
|
||||
)
|
||||
|
||||
return showToast
|
||||
}
|
||||
|
||||
export default useCustomToast
|
33
frontend/src/main.tsx
Normal file
33
frontend/src/main.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ChakraProvider } from "@chakra-ui/react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import { routeTree } from "./routeTree.gen"
|
||||
|
||||
import { StrictMode } from "react"
|
||||
import { OpenAPI } from "./client"
|
||||
import theme from "./theme"
|
||||
|
||||
OpenAPI.BASE = import.meta.env.VITE_API_URL
|
||||
OpenAPI.TOKEN = async () => {
|
||||
return localStorage.getItem("access_token") || ""
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const router = createRouter({ routeTree })
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ChakraProvider theme={theme}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ChakraProvider>
|
||||
</StrictMode>,
|
||||
)
|
195
frontend/src/routeTree.gen.ts
Normal file
195
frontend/src/routeTree.gen.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/* prettier-ignore-start */
|
||||
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file is auto-generated by TanStack Router
|
||||
|
||||
// Import Routes
|
||||
|
||||
import { Route as rootRoute } from './routes/__root'
|
||||
import { Route as SignupImport } from './routes/signup'
|
||||
import { Route as ResetPasswordImport } from './routes/reset-password'
|
||||
import { Route as RecoverPasswordImport } from './routes/recover-password'
|
||||
import { Route as LoginImport } from './routes/login'
|
||||
import { Route as LayoutImport } from './routes/_layout'
|
||||
import { Route as LayoutIndexImport } from './routes/_layout/index'
|
||||
import { Route as LayoutWebSettingImport } from './routes/_layout/webSetting'
|
||||
import { Route as LayoutSettingsImport } from './routes/_layout/settings'
|
||||
import { Route as LayoutItemsImport } from './routes/_layout/items'
|
||||
import { Route as LayoutClientMessagesImport } from './routes/_layout/clientMessages'
|
||||
import { Route as LayoutAdminImport } from './routes/_layout/admin'
|
||||
import { Route as LayoutAboutUsImport } from './routes/_layout/aboutUs'
|
||||
import { Route as LayoutCoursesCoursesImport } from './routes/_layout/Courses/Courses'
|
||||
import { Route as LayoutCoursesAddCourseImport } from './routes/_layout/Courses/AddCourse'
|
||||
import { Route as LayoutCoursesIdEditCourseImport } from './routes/_layout/Courses/$id.EditCourse'
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
const SignupRoute = SignupImport.update({
|
||||
path: '/signup',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const ResetPasswordRoute = ResetPasswordImport.update({
|
||||
path: '/reset-password',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const RecoverPasswordRoute = RecoverPasswordImport.update({
|
||||
path: '/recover-password',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const LoginRoute = LoginImport.update({
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutRoute = LayoutImport.update({
|
||||
id: '/_layout',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutIndexRoute = LayoutIndexImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutWebSettingRoute = LayoutWebSettingImport.update({
|
||||
path: '/webSetting',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutSettingsRoute = LayoutSettingsImport.update({
|
||||
path: '/settings',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutItemsRoute = LayoutItemsImport.update({
|
||||
path: '/items',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutClientMessagesRoute = LayoutClientMessagesImport.update({
|
||||
path: '/clientMessages',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutAdminRoute = LayoutAdminImport.update({
|
||||
path: '/admin',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutAboutUsRoute = LayoutAboutUsImport.update({
|
||||
path: '/aboutUs',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutCoursesCoursesRoute = LayoutCoursesCoursesImport.update({
|
||||
path: '/Courses/Courses',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutCoursesAddCourseRoute = LayoutCoursesAddCourseImport.update({
|
||||
path: '/Courses/AddCourse',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutCoursesIdEditCourseRoute = LayoutCoursesIdEditCourseImport.update({
|
||||
path: '/Courses/$id/EditCourse',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
|
||||
// Populate the FileRoutesByPath interface
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/_layout': {
|
||||
preLoaderRoute: typeof LayoutImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/login': {
|
||||
preLoaderRoute: typeof LoginImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/recover-password': {
|
||||
preLoaderRoute: typeof RecoverPasswordImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/reset-password': {
|
||||
preLoaderRoute: typeof ResetPasswordImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/signup': {
|
||||
preLoaderRoute: typeof SignupImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/_layout/aboutUs': {
|
||||
preLoaderRoute: typeof LayoutAboutUsImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
'/_layout/admin': {
|
||||
preLoaderRoute: typeof LayoutAdminImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
'/_layout/clientMessages': {
|
||||
preLoaderRoute: typeof LayoutClientMessagesImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
'/_layout/items': {
|
||||
preLoaderRoute: typeof LayoutItemsImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
'/_layout/settings': {
|
||||
preLoaderRoute: typeof LayoutSettingsImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
'/_layout/webSetting': {
|
||||
preLoaderRoute: typeof LayoutWebSettingImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
'/_layout/': {
|
||||
preLoaderRoute: typeof LayoutIndexImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
'/_layout/Courses/AddCourse': {
|
||||
preLoaderRoute: typeof LayoutCoursesAddCourseImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
'/_layout/Courses/Courses': {
|
||||
preLoaderRoute: typeof LayoutCoursesCoursesImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
'/_layout/Courses/$id/EditCourse': {
|
||||
preLoaderRoute: typeof LayoutCoursesIdEditCourseImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export the route tree
|
||||
|
||||
export const routeTree = rootRoute.addChildren([
|
||||
LayoutRoute.addChildren([
|
||||
LayoutAboutUsRoute,
|
||||
LayoutAdminRoute,
|
||||
LayoutClientMessagesRoute,
|
||||
LayoutItemsRoute,
|
||||
LayoutSettingsRoute,
|
||||
LayoutWebSettingRoute,
|
||||
LayoutIndexRoute,
|
||||
LayoutCoursesAddCourseRoute,
|
||||
LayoutCoursesCoursesRoute,
|
||||
LayoutCoursesIdEditCourseRoute,
|
||||
]),
|
||||
LoginRoute,
|
||||
RecoverPasswordRoute,
|
||||
ResetPasswordRoute,
|
||||
SignupRoute,
|
||||
])
|
||||
|
||||
/* prettier-ignore-end */
|
34
frontend/src/routes/__root.tsx
Normal file
34
frontend/src/routes/__root.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Outlet, createRootRoute } from "@tanstack/react-router"
|
||||
import React, { Suspense } from "react"
|
||||
|
||||
import NotFound from "../components/Common/NotFound"
|
||||
|
||||
const loadDevtools = () =>
|
||||
Promise.all([
|
||||
import("@tanstack/router-devtools"),
|
||||
import("@tanstack/react-query-devtools"),
|
||||
]).then(([routerDevtools, reactQueryDevtools]) => {
|
||||
return {
|
||||
default: () => (
|
||||
<>
|
||||
<routerDevtools.TanStackRouterDevtools />
|
||||
<reactQueryDevtools.ReactQueryDevtools />
|
||||
</>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const TanStackDevtools =
|
||||
process.env.NODE_ENV === "production" ? () => null : React.lazy(loadDevtools)
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: () => (
|
||||
<>
|
||||
<Outlet />
|
||||
<Suspense>
|
||||
<TanStackDevtools />
|
||||
</Suspense>
|
||||
</>
|
||||
),
|
||||
notFoundComponent: () => <NotFound />,
|
||||
})
|
35
frontend/src/routes/_layout.tsx
Normal file
35
frontend/src/routes/_layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Flex, Spinner } from "@chakra-ui/react"
|
||||
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"
|
||||
|
||||
import Sidebar from "../components/Common/Sidebar"
|
||||
import UserMenu from "../components/Common/UserMenu"
|
||||
import useAuth, { isLoggedIn } from "../hooks/useAuth"
|
||||
|
||||
export const Route = createFileRoute("/_layout")({
|
||||
component: Layout,
|
||||
beforeLoad: async () => {
|
||||
if (!isLoggedIn()) {
|
||||
throw redirect({
|
||||
to: "/login",
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function Layout() {
|
||||
const { isLoading } = useAuth()
|
||||
|
||||
return (
|
||||
<Flex maxW="large" h="auto" position="relative">
|
||||
<Sidebar />
|
||||
{isLoading ? (
|
||||
<Flex justify="center" align="center" height="100vh" width="full">
|
||||
<Spinner size="xl" color="ui.main" />
|
||||
</Flex>
|
||||
) : (
|
||||
<Outlet />
|
||||
)}
|
||||
<UserMenu />
|
||||
</Flex>
|
||||
)
|
||||
}
|
73
frontend/src/routes/_layout/Courses/$id.EditCourse.tsx
Normal file
73
frontend/src/routes/_layout/Courses/$id.EditCourse.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Container,
|
||||
Heading,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQueryClient, useQuery } from "@tanstack/react-query"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { UserPublic, CoursesService } from "../../../client"
|
||||
|
||||
import CourseDetails from "../../../components/Courses/CourseDetails"
|
||||
import CourseImages from "../../../components/Courses/CourseImages"
|
||||
import CourseInfoImages from "../../../components/Courses/CourseInfoImages"
|
||||
import Sechedule from "../../../components/Courses/Sechedule"
|
||||
|
||||
const tabsConfig = [
|
||||
{ title: "Course Details", component: CourseDetails },
|
||||
{ title: "Course Images", component: CourseImages },
|
||||
{ title: "Course Info Images", component: CourseInfoImages },
|
||||
{ title: "Schedule", component: Sechedule },
|
||||
|
||||
]
|
||||
|
||||
export const Route = createFileRoute('/_layout/Courses/$id/EditCourse')({
|
||||
component: EditCourse,
|
||||
})
|
||||
|
||||
function getcoursesQueryOptions(id: string) {
|
||||
return {
|
||||
queryFn: () =>
|
||||
CoursesService.readCourse({ id: id }),
|
||||
queryKey: ["course"],
|
||||
}
|
||||
}
|
||||
|
||||
function EditCourse() {
|
||||
|
||||
const {
|
||||
data: course,
|
||||
} = useQuery(
|
||||
{
|
||||
...getcoursesQueryOptions(Route.useParams().id),
|
||||
// placeholderData: (prevData) => prevData,
|
||||
}
|
||||
)
|
||||
console.log(course)
|
||||
const finalTabs = tabsConfig.slice(0, 4)
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} py={12}>
|
||||
User Settings
|
||||
</Heading>
|
||||
<Tabs variant="enclosed">
|
||||
<TabList>
|
||||
{finalTabs.map((tab, index) => (
|
||||
<Tab key={index}>{tab.title}</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{finalTabs.map((tab, index) => (
|
||||
<TabPanel key={index}>
|
||||
{<tab.component />}
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
234
frontend/src/routes/_layout/Courses/AddCourse.tsx
Normal file
234
frontend/src/routes/_layout/Courses/AddCourse.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Textarea,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Container
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate, Await, useRouter } from "@tanstack/react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
import useCustomToast from "../../../hooks/useCustomToast"
|
||||
import { CoursesService, type ApiError, CourseCreate, } from "../../../client"
|
||||
import { handleError } from "../../../utils"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import { EditorState, ContentState, convertToRaw } from 'draft-js';
|
||||
import { Editor } from "react-draft-wysiwyg";
|
||||
import draftToHtml from 'draftjs-to-html';
|
||||
import htmlToDraft from 'html-to-draftjs';
|
||||
import "react-draft-wysiwyg/dist/react-draft-wysiwyg.css";
|
||||
|
||||
|
||||
export const Route = createFileRoute("/_layout/Courses/AddCourse")({
|
||||
component: AddCourse,
|
||||
})
|
||||
|
||||
|
||||
function AddCourseForms() {
|
||||
|
||||
const toolbar = {
|
||||
options: ['inline', 'blockType', 'fontSize', 'list', 'textAlign', 'history', 'embedded', 'emoji', 'image'],
|
||||
inline: { inDropdown: true },
|
||||
list: { inDropdown: true },
|
||||
textAlign: { inDropdown: true },
|
||||
link: { inDropdown: true },
|
||||
history: { inDropdown: true },
|
||||
}
|
||||
|
||||
const showToast = useCustomToast()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [contentEditorState, setContentEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [contents, setContent] = useState<string>('');
|
||||
const [infoEditorState, setInfoEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [info, setInfo] = useState<string>('');
|
||||
const [longDescriptionEditorState, setLongDescriptionEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [longDescription, setlongDescription] = useState<string>('');
|
||||
const [remarksEditorState, setRemarksEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [remarks, setRemarks] = useState<string>('');
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
setValue,
|
||||
formState: { isSubmitting, errors, isDirty },
|
||||
} = useForm<CourseCreate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
title: "",
|
||||
sort_description: "",
|
||||
long_description: "",
|
||||
information: "",
|
||||
contant: "",
|
||||
remark: "",
|
||||
}
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: CourseCreate) =>
|
||||
CoursesService.createCourse({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Course create successfully.", "success")
|
||||
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["courses"] })
|
||||
history.go(-1)
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<CourseCreate> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<Container maxW="full" mt="20" as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
|
||||
<FormControl isInvalid={!!errors.title}>
|
||||
<FormLabel htmlFor="title">Title</FormLabel>
|
||||
<Input
|
||||
id="address"
|
||||
type="text"
|
||||
{...register("title", {
|
||||
required: "title is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
{errors.title && (
|
||||
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="sort_description">Short Description</FormLabel>
|
||||
<Textarea
|
||||
id="sort_description"
|
||||
{...register("sort_description", {
|
||||
required: "sort_description is required",
|
||||
})}
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="long_description">Long Description</FormLabel>
|
||||
<Editor
|
||||
editorState={longDescriptionEditorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setLongDescriptionEditorState(newState);
|
||||
setlongDescription(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
setValue("long_description", longDescription);
|
||||
|
||||
}}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="information">Information</FormLabel>
|
||||
<Editor
|
||||
editorState={infoEditorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setInfoEditorState(newState);
|
||||
setInfo(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
setValue("information", info);
|
||||
|
||||
}}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="contant">Content</FormLabel>
|
||||
<Editor
|
||||
editorState={contentEditorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setContentEditorState(newState);
|
||||
setContent(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
setValue("contant", contents);
|
||||
}}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="remark">Remark</FormLabel>
|
||||
<Editor
|
||||
editorState={remarksEditorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setRemarksEditorState(newState);
|
||||
setRemarks(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
setValue("remark", remarks);
|
||||
|
||||
}}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
|
||||
<FormControl mt={4}></FormControl>
|
||||
{/* <button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const values = getValues()
|
||||
console.log(values)
|
||||
// history.go(-1)// { test: "test-input", test1: "test1-input" }
|
||||
}}
|
||||
>
|
||||
Get Values
|
||||
</button> */}
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isDirty}
|
||||
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<FormControl mt={20}></FormControl>
|
||||
|
||||
</Container>
|
||||
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
function AddCourse() {
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
|
||||
<AddCourseForms />
|
||||
|
||||
</Container>
|
||||
)
|
||||
}
|
193
frontend/src/routes/_layout/Courses/Courses.tsx
Normal file
193
frontend/src/routes/_layout/Courses/Courses.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Heading,
|
||||
SkeletonText,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
Icon,
|
||||
useDisclosure
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
import { z } from "zod"
|
||||
import { FaPlus, FaPen, FaTrashAlt } from "react-icons/fa"
|
||||
import { CoursesService } from "../../../client"
|
||||
import Delete from "../../../components/Common/DeleteAlert"
|
||||
import ActionsMenu from "../../../components/Common/ActionsMenu"
|
||||
import Navbar from "../../../components/Common/Navbar"
|
||||
//import Addcourse from "../../components/courses/Addcourse"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
const CoursesSearchSchema = z.object({
|
||||
page: z.number().catch(1),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/_layout/Courses/Courses")({
|
||||
component: Courses,
|
||||
validateSearch: (search) => CoursesSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
|
||||
const PER_PAGE = 100
|
||||
|
||||
function getcoursesQueryOptions({ page }: { page: number }) {
|
||||
return {
|
||||
queryFn: () =>
|
||||
CoursesService.readCourses({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }),
|
||||
queryKey: ["courses", { page }],
|
||||
}
|
||||
}
|
||||
|
||||
function CoursesTable() {
|
||||
const [id, setId] = useState<string>('');
|
||||
const deleteModal = useDisclosure()
|
||||
const queryClient = useQueryClient()
|
||||
const { page } = Route.useSearch()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const setPage = (page: number) =>
|
||||
navigate({ search: (prev) => ({ ...prev, page }) })
|
||||
|
||||
const {
|
||||
data: courses,
|
||||
isPending,
|
||||
isPlaceholderData,
|
||||
} = useQuery({
|
||||
...getcoursesQueryOptions({ page }),
|
||||
placeholderData: (prevData) => prevData,
|
||||
})
|
||||
|
||||
const hasNextPage = !isPlaceholderData && courses?.data.length === PER_PAGE
|
||||
const hasPreviousPage = page > 1
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
queryClient.prefetchQuery(getcoursesQueryOptions({ page: page + 1 }))
|
||||
}
|
||||
}, [page, queryClient, hasNextPage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size={{ base: "sm", md: "md" }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>ID</Th>
|
||||
<Th>Title</Th>
|
||||
<Th>Short description</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
{isPending ? (
|
||||
<Tbody>
|
||||
<Tr>
|
||||
{new Array(4).fill(null).map((_, index) => (
|
||||
<Td key={index}>
|
||||
<SkeletonText noOfLines={1} paddingBlock="16px" />
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
</Tbody>
|
||||
) : (
|
||||
<Tbody>
|
||||
{courses?.data.map((course) => (
|
||||
<Tr key={course.id} opacity={isPlaceholderData ? 0.5 : 1}>
|
||||
<Td isTruncated maxWidth="20px">{course.id}</Td>
|
||||
<Td isTruncated maxWidth="50px">
|
||||
{course.title}
|
||||
</Td>
|
||||
<Td
|
||||
|
||||
isTruncated
|
||||
maxWidth="250px"
|
||||
>
|
||||
{course.sort_description || "N/A"}
|
||||
</Td>
|
||||
<Td>
|
||||
<Button
|
||||
variant="primary"
|
||||
gap={1}
|
||||
fontSize={{ base: "sm", md: "inherit" }}
|
||||
as={Link}
|
||||
to={`/Courses/${course.id}/EditCourse`}
|
||||
>
|
||||
<Icon as={FaPen} />
|
||||
</Button>
|
||||
<Button
|
||||
marginLeft={2}
|
||||
variant="primary"
|
||||
gap={1}
|
||||
fontSize={{ base: "sm" }}
|
||||
onClick={() => {
|
||||
setId(course.id)
|
||||
deleteModal.onOpen()
|
||||
}}
|
||||
>
|
||||
<Icon as={FaTrashAlt} />
|
||||
</Button>
|
||||
</Td>
|
||||
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
)}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Delete
|
||||
type={'Course'}
|
||||
id={id}
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={deleteModal.onClose}
|
||||
/>
|
||||
<Flex
|
||||
gap={4}
|
||||
//aligncourses="center"
|
||||
mt={4}
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button onClick={() => setPage(page - 1)} isDisabled={!hasPreviousPage}>
|
||||
Previous
|
||||
</Button>
|
||||
<span>Page {page}</span>
|
||||
<Button isDisabled={!hasNextPage} onClick={() => setPage(page + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Courses() {
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
|
||||
Courses Management
|
||||
</Heading>
|
||||
<Flex py={8} gap={4}>
|
||||
{/* TODO: Complete search functionality */}
|
||||
{/* <InputGroup w={{ base: '100%', md: 'auto' }}>
|
||||
<InputLeftElement pointerEvents='none'>
|
||||
<Icon as={FaSearch} color='ui.dim' />
|
||||
</InputLeftElement>
|
||||
<Input type='text' placeholder='Search' fontSize={{ base: 'sm', md: 'inherit' }} borderRadius='8px' />
|
||||
</InputGroup> */}
|
||||
<Button
|
||||
variant="primary"
|
||||
gap={1}
|
||||
fontSize={{ base: "sm", md: "inherit" }}
|
||||
as={Link} to="/Courses/AddCourse"
|
||||
>
|
||||
<Icon as={FaPlus} /> Add Course
|
||||
</Button>
|
||||
</Flex>
|
||||
<CoursesTable />
|
||||
</Container>
|
||||
)
|
||||
}
|
155
frontend/src/routes/_layout/aboutUs.tsx
Normal file
155
frontend/src/routes/_layout/aboutUs.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Heading,
|
||||
SkeletonText,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
import { z } from "zod"
|
||||
import parse from 'html-react-parser';
|
||||
import { AboutUsService } from "../../client"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
import Navbar from "../../components/Common/Navbar"
|
||||
import AddAboutUs from "../../components/AboutUs/AddAboutUs"
|
||||
|
||||
|
||||
|
||||
|
||||
const aboutUsSearchSchema = z.object({
|
||||
page: z.number().catch(1),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/_layout/aboutUs")({
|
||||
component: AboutUs,
|
||||
validateSearch: (search) => aboutUsSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
const PER_PAGE = 100
|
||||
|
||||
function getItemsQueryOptions({ page }: { page: number }) {
|
||||
return {
|
||||
queryFn: () =>
|
||||
AboutUsService.readAboutUs({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }),
|
||||
queryKey: ["aboutUs", { page }],
|
||||
}
|
||||
}
|
||||
|
||||
function AboutUsTable() {
|
||||
const queryClient = useQueryClient()
|
||||
const { page } = Route.useSearch()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const setPage = (page: number) =>
|
||||
navigate({ search: (prev) => ({ ...prev, page }) })
|
||||
|
||||
const {
|
||||
data: aboutUs,
|
||||
isPending,
|
||||
isPlaceholderData,
|
||||
} = useQuery({
|
||||
...getItemsQueryOptions({ page }),
|
||||
placeholderData: (prevData) => prevData,
|
||||
})
|
||||
aboutUs?.data.sort((a, b) => a.index - b.index)
|
||||
const hasNextPage = !isPlaceholderData && aboutUs?.data.length === PER_PAGE
|
||||
const hasPreviousPage = page > 1
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
queryClient.prefetchQuery(getItemsQueryOptions({ page: page + 1 }))
|
||||
}
|
||||
}, [page, queryClient, hasNextPage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size={{ base: "sm", md: "md" }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>ID</Th>
|
||||
<Th>Description</Th>
|
||||
<Th>Image</Th>
|
||||
<Th>Index</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
{isPending ? (
|
||||
<Tbody>
|
||||
<Tr>
|
||||
{new Array(4).fill(null).map((_, index) => (
|
||||
<Td key={index}>
|
||||
<SkeletonText noOfLines={1} paddingBlock="16px" />
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
</Tbody>
|
||||
) : (
|
||||
<Tbody>
|
||||
{aboutUs?.data.map((aboutUs) => (
|
||||
<Tr key={aboutUs.id} opacity={isPlaceholderData ? 0.5 : 1}>
|
||||
<Td isTruncated maxWidth="50">{aboutUs.id}</Td>
|
||||
|
||||
<Td
|
||||
whiteSpace="pre-line"
|
||||
maxWidth="350px"
|
||||
>
|
||||
{parse(aboutUs.description) || "N/A"}
|
||||
</Td>
|
||||
<Td>
|
||||
<img src={import.meta.env.VITE_API_URL+"/"+aboutUs.image} width="100px" height="100px" />
|
||||
</Td>
|
||||
<Td isTruncated maxWidth="10px">
|
||||
{aboutUs.index}
|
||||
</Td>
|
||||
<Td>
|
||||
<ActionsMenu type={"AboutUs"} value={aboutUs} />
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
)}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Flex
|
||||
gap={4}
|
||||
alignItems="center"
|
||||
mt={4}
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button onClick={() => setPage(page - 1)} isDisabled={!hasPreviousPage}>
|
||||
Previous
|
||||
</Button>
|
||||
<span>Page {page}</span>
|
||||
<Button isDisabled={!hasNextPage} onClick={() => setPage(page + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AboutUs() {
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
|
||||
About Us Management
|
||||
</Heading>
|
||||
|
||||
<Navbar type={"AboutUs"} addModalAs={AddAboutUs} />
|
||||
<AboutUsTable />
|
||||
|
||||
|
||||
</Container>
|
||||
)
|
||||
}
|
170
frontend/src/routes/_layout/admin.tsx
Normal file
170
frontend/src/routes/_layout/admin.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Heading,
|
||||
SkeletonText,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect } from "react"
|
||||
import { z } from "zod"
|
||||
|
||||
import { type UserPublic, UsersService } from "../../client"
|
||||
import AddUser from "../../components/Admin/AddUser"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
import Navbar from "../../components/Common/Navbar"
|
||||
|
||||
const usersSearchSchema = z.object({
|
||||
page: z.number().catch(1),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/_layout/admin")({
|
||||
component: Admin,
|
||||
validateSearch: (search) => usersSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
const PER_PAGE = 5
|
||||
|
||||
function getUsersQueryOptions({ page }: { page: number }) {
|
||||
return {
|
||||
queryFn: () =>
|
||||
UsersService.readUsers({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }),
|
||||
queryKey: ["users", { page }],
|
||||
}
|
||||
}
|
||||
|
||||
function UsersTable() {
|
||||
const queryClient = useQueryClient()
|
||||
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
|
||||
const { page } = Route.useSearch()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const setPage = (page: number) =>
|
||||
navigate({ search: (prev) => ({ ...prev, page }) })
|
||||
|
||||
const {
|
||||
data: users,
|
||||
isPending,
|
||||
isPlaceholderData,
|
||||
} = useQuery({
|
||||
...getUsersQueryOptions({ page }),
|
||||
placeholderData: (prevData) => prevData,
|
||||
})
|
||||
|
||||
const hasNextPage = !isPlaceholderData && users?.data.length === PER_PAGE
|
||||
const hasPreviousPage = page > 1
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
queryClient.prefetchQuery(getUsersQueryOptions({ page: page + 1 }))
|
||||
}
|
||||
}, [page, queryClient, hasNextPage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size={{ base: "sm", md: "md" }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th width="20%">Full name</Th>
|
||||
<Th width="50%">Email</Th>
|
||||
<Th width="10%">Role</Th>
|
||||
<Th width="10%">Status</Th>
|
||||
<Th width="10%">Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
{isPending ? (
|
||||
<Tbody>
|
||||
<Tr>
|
||||
{new Array(4).fill(null).map((_, index) => (
|
||||
<Td key={index}>
|
||||
<SkeletonText noOfLines={1} paddingBlock="16px" />
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
</Tbody>
|
||||
) : (
|
||||
<Tbody>
|
||||
{users?.data.map((user) => (
|
||||
<Tr key={user.id}>
|
||||
<Td
|
||||
color={!user.full_name ? "ui.dim" : "inherit"}
|
||||
isTruncated
|
||||
maxWidth="150px"
|
||||
>
|
||||
{user.full_name || "N/A"}
|
||||
{currentUser?.id === user.id && (
|
||||
<Badge ml="1" colorScheme="teal">
|
||||
You
|
||||
</Badge>
|
||||
)}
|
||||
</Td>
|
||||
<Td isTruncated maxWidth="150px">
|
||||
{user.email}
|
||||
</Td>
|
||||
<Td>{user.is_superuser ? "Superuser" : "User"}</Td>
|
||||
<Td>
|
||||
<Flex gap={2}>
|
||||
<Box
|
||||
w="2"
|
||||
h="2"
|
||||
borderRadius="50%"
|
||||
bg={user.is_active ? "ui.success" : "ui.danger"}
|
||||
alignSelf="center"
|
||||
/>
|
||||
{user.is_active ? "Active" : "Inactive"}
|
||||
</Flex>
|
||||
</Td>
|
||||
<Td>
|
||||
<ActionsMenu
|
||||
type="User"
|
||||
value={user}
|
||||
disabled={currentUser?.id === user.id ? true : false}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
)}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Flex
|
||||
gap={4}
|
||||
alignItems="center"
|
||||
mt={4}
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button onClick={() => setPage(page - 1)} isDisabled={!hasPreviousPage}>
|
||||
Previous
|
||||
</Button>
|
||||
<span>Page {page}</span>
|
||||
<Button isDisabled={!hasNextPage} onClick={() => setPage(page + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Admin() {
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
|
||||
Users Management
|
||||
</Heading>
|
||||
|
||||
<Navbar type={"User"} addModalAs={AddUser} />
|
||||
<UsersTable />
|
||||
</Container>
|
||||
)
|
||||
}
|
150
frontend/src/routes/_layout/clientMessages.tsx
Normal file
150
frontend/src/routes/_layout/clientMessages.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Heading,
|
||||
SkeletonText,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect } from "react"
|
||||
import { z } from "zod"
|
||||
import moment from 'moment';
|
||||
import { ClientMessagesService } from "../../client"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
|
||||
|
||||
const messagesSearchSchema = z.object({
|
||||
page: z.number().catch(1),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/_layout/clientMessages")({
|
||||
component: Messages,
|
||||
validateSearch: (search) => messagesSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
const PER_PAGE = 100
|
||||
|
||||
function getMessagesQueryOptions({ page }: { page: number }) {
|
||||
return {
|
||||
queryFn: () =>
|
||||
ClientMessagesService.readMessages({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }),
|
||||
queryKey: ["messages", { page }],
|
||||
}
|
||||
}
|
||||
|
||||
function MessagesTable() {
|
||||
const queryClient = useQueryClient()
|
||||
const { page } = Route.useSearch()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const setPage = (page: number) =>
|
||||
navigate({ search: (prev) => ({ ...prev, page }) })
|
||||
|
||||
const {
|
||||
data: messages,
|
||||
isPending,
|
||||
isPlaceholderData,
|
||||
} = useQuery({
|
||||
...getMessagesQueryOptions({ page }),
|
||||
placeholderData: (prevData) => prevData,
|
||||
})
|
||||
|
||||
const hasNextPage = !isPlaceholderData && messages?.data.length === PER_PAGE
|
||||
const hasPreviousPage = page > 1
|
||||
messages?.data.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
queryClient.prefetchQuery(getMessagesQueryOptions({ page: page + 1 }))
|
||||
}
|
||||
}, [page, queryClient, hasNextPage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size={{ base: "sm", md: "md" }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>id</Th>
|
||||
<Th>Name</Th>
|
||||
<Th>Phone</Th>
|
||||
<Th>Email</Th>
|
||||
<Th>Message</Th>
|
||||
<Th>Created</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
{isPending ? (
|
||||
<Tbody>
|
||||
<Tr>
|
||||
{new Array(4).fill(null).map((_, index) => (
|
||||
<Td key={index}>
|
||||
<SkeletonText noOfLines={1} paddingBlock="16px" />
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
</Tbody>
|
||||
) : (
|
||||
<Tbody>
|
||||
{messages?.data.map((message) => (
|
||||
<Tr key={message.id} opacity={isPlaceholderData ? 0.5 : 1}>
|
||||
<Td isTruncated maxWidth="50">{message.id}</Td>
|
||||
<Td maxWidth="150px">
|
||||
{message.name}
|
||||
</Td>
|
||||
<Td maxWidth="150px">
|
||||
{message.phone}
|
||||
</Td>
|
||||
<Td maxWidth="150px">
|
||||
{message.email}
|
||||
</Td>
|
||||
<Td whiteSpace="pre-line" maxWidth="250px">
|
||||
{message.message}
|
||||
</Td>
|
||||
<Td maxWidth="150px">
|
||||
{moment(message.created_at).utcOffset("+08:00").format('DD-MM-YYYY HH:mm')}
|
||||
</Td>
|
||||
<Td>
|
||||
<ActionsMenu type={"Message"} value={message} />
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
)}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Flex
|
||||
gap={4}
|
||||
alignItems="center"
|
||||
mt={4}
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button onClick={() => setPage(page - 1)} isDisabled={!hasPreviousPage}>
|
||||
Previous
|
||||
</Button>
|
||||
<span>Page {page}</span>
|
||||
<Button isDisabled={!hasNextPage} onClick={() => setPage(page + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Messages() {
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
|
||||
Messages Management
|
||||
</Heading>
|
||||
<MessagesTable />
|
||||
</Container>
|
||||
)
|
||||
}
|
25
frontend/src/routes/_layout/index.tsx
Normal file
25
frontend/src/routes/_layout/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Box, Container, Text } from "@chakra-ui/react"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
import useAuth from "../../hooks/useAuth"
|
||||
|
||||
export const Route = createFileRoute("/_layout/")({
|
||||
component: Dashboard,
|
||||
})
|
||||
|
||||
function Dashboard() {
|
||||
const { user: currentUser } = useAuth()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container maxW="full">
|
||||
<Box pt={12} m={4}>
|
||||
<Text fontSize="2xl">
|
||||
Hi, {currentUser?.full_name || currentUser?.email} 👋🏼
|
||||
</Text>
|
||||
<Text>Welcome back, nice to see you again!</Text>
|
||||
</Box>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
145
frontend/src/routes/_layout/items.tsx
Normal file
145
frontend/src/routes/_layout/items.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Heading,
|
||||
SkeletonText,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect } from "react"
|
||||
import { z } from "zod"
|
||||
|
||||
import { ItemsService } from "../../client"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
import Navbar from "../../components/Common/Navbar"
|
||||
import AddItem from "../../components/Items/AddItem"
|
||||
|
||||
const itemsSearchSchema = z.object({
|
||||
page: z.number().catch(1),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/_layout/items")({
|
||||
component: Items,
|
||||
validateSearch: (search) => itemsSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
const PER_PAGE = 5
|
||||
|
||||
function getItemsQueryOptions({ page }: { page: number }) {
|
||||
return {
|
||||
queryFn: () =>
|
||||
ItemsService.readItems({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }),
|
||||
queryKey: ["items", { page }],
|
||||
}
|
||||
}
|
||||
|
||||
function ItemsTable() {
|
||||
const queryClient = useQueryClient()
|
||||
const { page } = Route.useSearch()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const setPage = (page: number) =>
|
||||
navigate({ search: (prev) => ({ ...prev, page }) })
|
||||
|
||||
const {
|
||||
data: items,
|
||||
isPending,
|
||||
isPlaceholderData,
|
||||
} = useQuery({
|
||||
...getItemsQueryOptions({ page }),
|
||||
placeholderData: (prevData) => prevData,
|
||||
})
|
||||
|
||||
const hasNextPage = !isPlaceholderData && items?.data.length === PER_PAGE
|
||||
const hasPreviousPage = page > 1
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
queryClient.prefetchQuery(getItemsQueryOptions({ page: page + 1 }))
|
||||
}
|
||||
}, [page, queryClient, hasNextPage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size={{ base: "sm", md: "md" }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>ID</Th>
|
||||
<Th>Title</Th>
|
||||
<Th>Description</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
{isPending ? (
|
||||
<Tbody>
|
||||
<Tr>
|
||||
{new Array(4).fill(null).map((_, index) => (
|
||||
<Td key={index}>
|
||||
<SkeletonText noOfLines={1} paddingBlock="16px" />
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
</Tbody>
|
||||
) : (
|
||||
<Tbody>
|
||||
{items?.data.map((item) => (
|
||||
<Tr key={item.id} opacity={isPlaceholderData ? 0.5 : 1}>
|
||||
<Td>{item.id}</Td>
|
||||
<Td isTruncated maxWidth="150px">
|
||||
{item.title}
|
||||
</Td>
|
||||
<Td
|
||||
color={!item.description ? "ui.dim" : "inherit"}
|
||||
isTruncated
|
||||
maxWidth="150px"
|
||||
>
|
||||
{item.description || "N/A"}
|
||||
</Td>
|
||||
<Td>
|
||||
<ActionsMenu type={"Item"} value={item} />
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
)}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Flex
|
||||
gap={4}
|
||||
alignItems="center"
|
||||
mt={4}
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button onClick={() => setPage(page - 1)} isDisabled={!hasPreviousPage}>
|
||||
Previous
|
||||
</Button>
|
||||
<span>Page {page}</span>
|
||||
<Button isDisabled={!hasNextPage} onClick={() => setPage(page + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Items() {
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
|
||||
Items Management
|
||||
</Heading>
|
||||
|
||||
<Navbar type={"Item"} addModalAs={AddItem} />
|
||||
<ItemsTable />
|
||||
</Container>
|
||||
)
|
||||
}
|
58
frontend/src/routes/_layout/settings.tsx
Normal file
58
frontend/src/routes/_layout/settings.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Container,
|
||||
Heading,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
import type { UserPublic } from "../../client"
|
||||
import Appearance from "../../components/UserSettings/Appearance"
|
||||
import ChangePassword from "../../components/UserSettings/ChangePassword"
|
||||
import DeleteAccount from "../../components/UserSettings/DeleteAccount"
|
||||
import UserInformation from "../../components/UserSettings/UserInformation"
|
||||
|
||||
const tabsConfig = [
|
||||
{ title: "My profile", component: UserInformation },
|
||||
{ title: "Password", component: ChangePassword },
|
||||
{ title: "Appearance", component: Appearance },
|
||||
{ title: "Danger zone", component: DeleteAccount },
|
||||
]
|
||||
|
||||
export const Route = createFileRoute("/_layout/settings")({
|
||||
component: UserSettings,
|
||||
})
|
||||
|
||||
function UserSettings() {
|
||||
const queryClient = useQueryClient()
|
||||
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
|
||||
const finalTabs = currentUser?.is_superuser
|
||||
? tabsConfig.slice(0, 3)
|
||||
: tabsConfig
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} py={12}>
|
||||
User Settings
|
||||
</Heading>
|
||||
<Tabs variant="enclosed">
|
||||
<TabList>
|
||||
{finalTabs.map((tab, index) => (
|
||||
<Tab key={index}>{tab.title}</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{finalTabs.map((tab, index) => (
|
||||
<TabPanel key={index}>
|
||||
<tab.component />
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Container>
|
||||
)
|
||||
}
|
269
frontend/src/routes/_layout/webSetting.tsx
Normal file
269
frontend/src/routes/_layout/webSetting.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Container
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate, Await } from "@tanstack/react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { WebSettingsService, type WebSettingUpdate, type ApiError, } from "../../client"
|
||||
import { handleError } from "../../utils"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
|
||||
|
||||
export const Route = createFileRoute("/_layout/webSetting")({
|
||||
component: WebSetting,
|
||||
})
|
||||
|
||||
|
||||
|
||||
function getWebSettingQuery() {
|
||||
return {
|
||||
queryFn: () =>
|
||||
WebSettingsService.readWebSetting(),
|
||||
queryKey: ["webSetting"],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
function WebSettingForms() {
|
||||
|
||||
const showToast = useCustomToast()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// const {
|
||||
// data: webSetting,
|
||||
// isPending,
|
||||
// isPlaceholderData,
|
||||
// } = useQuery({
|
||||
// ...getWebSettingQuery(),
|
||||
// placeholderData: (prevData) => prevData,
|
||||
|
||||
// })
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting, errors, isDirty },
|
||||
} = useForm<WebSettingUpdate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: async() => WebSettingsService.readWebSetting()
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: WebSettingUpdate) =>
|
||||
WebSettingsService.updateWebSetting({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Settings updated successfully.", "success")
|
||||
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["webSetting"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<WebSettingUpdate> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
<Container maxW="full" mt="20" as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
|
||||
<FormControl isInvalid={!!errors.address}>
|
||||
<FormLabel htmlFor="address">Address</FormLabel>
|
||||
<Input
|
||||
id="address"
|
||||
type="text"
|
||||
{...register("address", {
|
||||
required: "address is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
{errors.address && (
|
||||
<FormErrorMessage>{errors.address.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="google_map_api_key">Google Map Api Key</FormLabel>
|
||||
<Input
|
||||
id="google_map_api_key"
|
||||
type="text"
|
||||
{...register("google_map_api_key", {
|
||||
required: "google_map_api_key is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="latitude">Latitude</FormLabel>
|
||||
<Input
|
||||
id="latitude"
|
||||
type="float"
|
||||
{...register("latitude", {
|
||||
required: "latitude is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="longitude">Longitude</FormLabel>
|
||||
<Input
|
||||
id="longitude"
|
||||
type="float"
|
||||
{...register("longitude", {
|
||||
required: "longitude is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="phone">Phone</FormLabel>
|
||||
<Input
|
||||
id="phone"
|
||||
type="text"
|
||||
{...register("phone", {
|
||||
required: "phone is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="email">Email</FormLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="text"
|
||||
{...register("email", {
|
||||
required: "email is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="facebook">Facebook</FormLabel>
|
||||
<Input
|
||||
id="facebook"
|
||||
type="text"
|
||||
{...register("facebook", {
|
||||
required: "facebook is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="instagram">Instagram</FormLabel>
|
||||
<Input
|
||||
id="instagram"
|
||||
type="text"
|
||||
{...register("instagram", {
|
||||
required: "instagram is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="youtube">Youtube Channel</FormLabel>
|
||||
<Input
|
||||
id="youtube"
|
||||
type="text"
|
||||
{...register("youtube", {
|
||||
required: "youtube is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="youtube_link">Youtube Video Link</FormLabel>
|
||||
<Input
|
||||
id="youtube_link"
|
||||
type="text"
|
||||
{...register("youtube_link", {
|
||||
required: "youtube_link is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="whatsapp">Whatsapp</FormLabel>
|
||||
<Input
|
||||
id="whatsapp"
|
||||
type="text"
|
||||
{...register("whatsapp", {
|
||||
required: "whatsapp is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isDirty}
|
||||
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<FormControl mt={20}></FormControl>
|
||||
|
||||
</Container>
|
||||
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
function WebSetting() {
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
|
||||
<WebSettingForms />
|
||||
|
||||
</Container>
|
||||
)
|
||||
}
|
144
frontend/src/routes/login.tsx
Normal file
144
frontend/src/routes/login.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons"
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
Icon,
|
||||
Image,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
Link,
|
||||
Text,
|
||||
useBoolean,
|
||||
} from "@chakra-ui/react"
|
||||
import {
|
||||
Link as RouterLink,
|
||||
createFileRoute,
|
||||
redirect,
|
||||
} from "@tanstack/react-router"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import Logo from "/assets/images/logo-15.png"
|
||||
import type { Body_login_login_access_token as AccessToken } from "../client"
|
||||
import useAuth, { isLoggedIn } from "../hooks/useAuth"
|
||||
import { emailPattern } from "../utils"
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
component: Login,
|
||||
beforeLoad: async () => {
|
||||
if (isLoggedIn()) {
|
||||
throw redirect({
|
||||
to: "/",
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function Login() {
|
||||
const [show, setShow] = useBoolean()
|
||||
const { loginMutation, error, resetError } = useAuth()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<AccessToken>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<AccessToken> = async (data) => {
|
||||
if (isSubmitting) return
|
||||
|
||||
resetError()
|
||||
|
||||
try {
|
||||
await loginMutation.mutateAsync(data)
|
||||
} catch {
|
||||
// error is handled by useAuth hook
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
h="100vh"
|
||||
maxW="sm"
|
||||
alignItems="stretch"
|
||||
justifyContent="center"
|
||||
gap={4}
|
||||
centerContent
|
||||
>
|
||||
<Image
|
||||
src={Logo}
|
||||
alt="FastAPI logo"
|
||||
height="auto"
|
||||
maxW="2xs"
|
||||
alignSelf="center"
|
||||
mb={4}
|
||||
/>
|
||||
<FormControl id="username" isInvalid={!!errors.username || !!error}>
|
||||
<Input
|
||||
id="username"
|
||||
{...register("username", {
|
||||
required: "Username is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
required
|
||||
/>
|
||||
{errors.username && (
|
||||
<FormErrorMessage>{errors.username.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl id="password" isInvalid={!!error}>
|
||||
<InputGroup>
|
||||
<Input
|
||||
{...register("password", {
|
||||
required: "Password is required",
|
||||
})}
|
||||
type={show ? "text" : "password"}
|
||||
placeholder="Password"
|
||||
required
|
||||
/>
|
||||
<InputRightElement
|
||||
color="ui.dim"
|
||||
_hover={{
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
as={show ? ViewOffIcon : ViewIcon}
|
||||
onClick={setShow.toggle}
|
||||
aria-label={show ? "Hide password" : "Show password"}
|
||||
>
|
||||
{show ? <ViewOffIcon /> : <ViewIcon />}
|
||||
</Icon>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
{error && <FormErrorMessage>{error}</FormErrorMessage>}
|
||||
</FormControl>
|
||||
{/* <Link as={RouterLink} to="/recover-password" color="blue.500">
|
||||
Forgot password?
|
||||
</Link> */}
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Log In
|
||||
</Button>
|
||||
{/* <Text>
|
||||
Don't have an account?{" "}
|
||||
<Link as={RouterLink} to="/signup" color="blue.500">
|
||||
Sign up
|
||||
</Link>
|
||||
</Text> */}
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
104
frontend/src/routes/recover-password.tsx
Normal file
104
frontend/src/routes/recover-password.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
Heading,
|
||||
Input,
|
||||
Text,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { type ApiError, LoginService } from "../client"
|
||||
import { isLoggedIn } from "../hooks/useAuth"
|
||||
import useCustomToast from "../hooks/useCustomToast"
|
||||
import { emailPattern, handleError } from "../utils"
|
||||
|
||||
interface FormData {
|
||||
email: string
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/recover-password")({
|
||||
component: RecoverPassword,
|
||||
beforeLoad: async () => {
|
||||
if (isLoggedIn()) {
|
||||
throw redirect({
|
||||
to: "/",
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function RecoverPassword() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormData>()
|
||||
const showToast = useCustomToast()
|
||||
|
||||
const recoverPassword = async (data: FormData) => {
|
||||
await LoginService.recoverPassword({
|
||||
email: data.email,
|
||||
})
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: recoverPassword,
|
||||
onSuccess: () => {
|
||||
showToast(
|
||||
"Email sent.",
|
||||
"We sent an email with a link to get back into your account.",
|
||||
"success",
|
||||
)
|
||||
reset()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<FormData> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
h="100vh"
|
||||
maxW="sm"
|
||||
alignItems="stretch"
|
||||
justifyContent="center"
|
||||
gap={4}
|
||||
centerContent
|
||||
>
|
||||
<Heading size="xl" color="ui.main" textAlign="center" mb={2}>
|
||||
Password Recovery
|
||||
</Heading>
|
||||
<Text align="center">
|
||||
A password recovery email will be sent to the registered account.
|
||||
</Text>
|
||||
<FormControl isInvalid={!!errors.email}>
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
/>
|
||||
{errors.email && (
|
||||
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
122
frontend/src/routes/reset-password.tsx
Normal file
122
frontend/src/routes/reset-password.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
Text,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { type ApiError, LoginService, type NewPassword } from "../client"
|
||||
import { isLoggedIn } from "../hooks/useAuth"
|
||||
import useCustomToast from "../hooks/useCustomToast"
|
||||
import { confirmPasswordRules, handleError, passwordRules } from "../utils"
|
||||
|
||||
interface NewPasswordForm extends NewPassword {
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/reset-password")({
|
||||
component: ResetPassword,
|
||||
beforeLoad: async () => {
|
||||
if (isLoggedIn()) {
|
||||
throw redirect({
|
||||
to: "/",
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function ResetPassword() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
getValues,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<NewPasswordForm>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
new_password: "",
|
||||
},
|
||||
})
|
||||
const showToast = useCustomToast()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const resetPassword = async (data: NewPassword) => {
|
||||
const token = new URLSearchParams(window.location.search).get("token")
|
||||
if (!token) return
|
||||
await LoginService.resetPassword({
|
||||
requestBody: { new_password: data.new_password, token: token },
|
||||
})
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: resetPassword,
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Password updated successfully.", "success")
|
||||
reset()
|
||||
navigate({ to: "/login" })
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<NewPasswordForm> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
h="100vh"
|
||||
maxW="sm"
|
||||
alignItems="stretch"
|
||||
justifyContent="center"
|
||||
gap={4}
|
||||
centerContent
|
||||
>
|
||||
<Heading size="xl" color="ui.main" textAlign="center" mb={2}>
|
||||
Reset Password
|
||||
</Heading>
|
||||
<Text textAlign="center">
|
||||
Please enter your new password and confirm it to reset your password.
|
||||
</Text>
|
||||
<FormControl mt={4} isInvalid={!!errors.new_password}>
|
||||
<FormLabel htmlFor="password">Set Password</FormLabel>
|
||||
<Input
|
||||
id="password"
|
||||
{...register("new_password", passwordRules())}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.new_password && (
|
||||
<FormErrorMessage>{errors.new_password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isInvalid={!!errors.confirm_password}>
|
||||
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
{...register("confirm_password", confirmPasswordRules(getValues))}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.confirm_password && (
|
||||
<FormErrorMessage>{errors.confirm_password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Button variant="primary" type="submit">
|
||||
Reset Password
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
164
frontend/src/routes/signup.tsx
Normal file
164
frontend/src/routes/signup.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Image,
|
||||
Input,
|
||||
Link,
|
||||
Text,
|
||||
} from "@chakra-ui/react"
|
||||
import {
|
||||
Link as RouterLink,
|
||||
createFileRoute,
|
||||
redirect,
|
||||
} from "@tanstack/react-router"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import Logo from "/assets/images/fastapi-logo.svg"
|
||||
import type { UserRegister } from "../client"
|
||||
import useAuth, { isLoggedIn } from "../hooks/useAuth"
|
||||
import { confirmPasswordRules, emailPattern, passwordRules } from "../utils"
|
||||
|
||||
export const Route = createFileRoute("/signup")({
|
||||
component: SignUp,
|
||||
beforeLoad: async () => {
|
||||
if (isLoggedIn()) {
|
||||
throw redirect({
|
||||
to: "/",
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
interface UserRegisterForm extends UserRegister {
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
function SignUp() {
|
||||
const { signUpMutation } = useAuth()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<UserRegisterForm>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
email: "",
|
||||
full_name: "",
|
||||
password: "",
|
||||
confirm_password: "",
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<UserRegisterForm> = (data) => {
|
||||
signUpMutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex flexDir={{ base: "column", md: "row" }} justify="center" h="100vh">
|
||||
<Container
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
h="100vh"
|
||||
maxW="sm"
|
||||
alignItems="stretch"
|
||||
justifyContent="center"
|
||||
gap={4}
|
||||
centerContent
|
||||
>
|
||||
<Image
|
||||
src={Logo}
|
||||
alt="FastAPI logo"
|
||||
height="auto"
|
||||
maxW="2xs"
|
||||
alignSelf="center"
|
||||
mb={4}
|
||||
/>
|
||||
<FormControl id="full_name" isInvalid={!!errors.full_name}>
|
||||
<FormLabel htmlFor="full_name" srOnly>
|
||||
Full Name
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="full_name"
|
||||
minLength={3}
|
||||
{...register("full_name", { required: "Full Name is required" })}
|
||||
placeholder="Full Name"
|
||||
type="text"
|
||||
/>
|
||||
{errors.full_name && (
|
||||
<FormErrorMessage>{errors.full_name.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl id="email" isInvalid={!!errors.email}>
|
||||
<FormLabel htmlFor="username" srOnly>
|
||||
Email
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
/>
|
||||
{errors.email && (
|
||||
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl id="password" isInvalid={!!errors.password}>
|
||||
<FormLabel htmlFor="password" srOnly>
|
||||
Password
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="password"
|
||||
{...register("password", passwordRules())}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<FormErrorMessage>{errors.password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl
|
||||
id="confirm_password"
|
||||
isInvalid={!!errors.confirm_password}
|
||||
>
|
||||
<FormLabel htmlFor="confirm_password" srOnly>
|
||||
Confirm Password
|
||||
</FormLabel>
|
||||
|
||||
<Input
|
||||
id="confirm_password"
|
||||
{...register("confirm_password", confirmPasswordRules(getValues))}
|
||||
placeholder="Repeat Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.confirm_password && (
|
||||
<FormErrorMessage>
|
||||
{errors.confirm_password.message}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Sign Up
|
||||
</Button>
|
||||
<Text>
|
||||
Already have an account?{" "}
|
||||
<Link as={RouterLink} to="/login" color="blue.500">
|
||||
Log In
|
||||
</Link>
|
||||
</Text>
|
||||
</Container>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SignUp
|
61
frontend/src/theme.tsx
Normal file
61
frontend/src/theme.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { extendTheme } from "@chakra-ui/react"
|
||||
|
||||
const disabledStyles = {
|
||||
_disabled: {
|
||||
backgroundColor: "ui.main",
|
||||
},
|
||||
}
|
||||
|
||||
const theme = extendTheme({
|
||||
colors: {
|
||||
ui: {
|
||||
main: "#D60050",
|
||||
secondary: "#EDF2F7",
|
||||
success: "#48BB78",
|
||||
danger: "#E53E3E",
|
||||
light: "#FAFAFA",
|
||||
dark: "#1A202C",
|
||||
darkSlate: "#252D3D",
|
||||
dim: "#A0AEC0",
|
||||
},
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
variants: {
|
||||
primary: {
|
||||
backgroundColor: "ui.main",
|
||||
color: "ui.light",
|
||||
_hover: {
|
||||
backgroundColor: "#D60050",
|
||||
},
|
||||
_disabled: {
|
||||
...disabledStyles,
|
||||
_hover: {
|
||||
...disabledStyles,
|
||||
},
|
||||
},
|
||||
},
|
||||
danger: {
|
||||
backgroundColor: "ui.danger",
|
||||
color: "ui.light",
|
||||
_hover: {
|
||||
backgroundColor: "#E32727",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Tabs: {
|
||||
variants: {
|
||||
enclosed: {
|
||||
tab: {
|
||||
_selected: {
|
||||
color: "ui.main",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default theme
|
53
frontend/src/utils.ts
Normal file
53
frontend/src/utils.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { ApiError } from "./client"
|
||||
|
||||
export const emailPattern = {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
message: "Invalid email address",
|
||||
}
|
||||
|
||||
export const namePattern = {
|
||||
value: /^[A-Za-z\s\u00C0-\u017F]{1,30}$/,
|
||||
message: "Invalid name",
|
||||
}
|
||||
|
||||
export const passwordRules = (isRequired = true) => {
|
||||
const rules: any = {
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: "Password must be at least 8 characters",
|
||||
},
|
||||
}
|
||||
|
||||
if (isRequired) {
|
||||
rules.required = "Password is required"
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
export const confirmPasswordRules = (
|
||||
getValues: () => any,
|
||||
isRequired = true,
|
||||
) => {
|
||||
const rules: any = {
|
||||
validate: (value: string) => {
|
||||
const password = getValues().password || getValues().new_password
|
||||
return value === password ? true : "The passwords do not match"
|
||||
},
|
||||
}
|
||||
|
||||
if (isRequired) {
|
||||
rules.required = "Password confirmation is required"
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
export const handleError = (err: ApiError, showToast: any) => {
|
||||
const errDetail = (err.body as any)?.detail
|
||||
let errorMessage = errDetail || "Something went wrong."
|
||||
if (Array.isArray(errDetail) && errDetail.length > 0) {
|
||||
errorMessage = errDetail[0].msg
|
||||
}
|
||||
showToast("Error", errorMessage, "error")
|
||||
}
|
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
13
frontend/tests/auth.setup.ts
Normal file
13
frontend/tests/auth.setup.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { test as setup } from "@playwright/test"
|
||||
import { firstSuperuser, firstSuperuserPassword } from "./config.ts"
|
||||
|
||||
const authFile = "playwright/.auth/user.json"
|
||||
|
||||
setup("authenticate", async ({ page }) => {
|
||||
await page.goto("/login")
|
||||
await page.getByPlaceholder("Email").fill(firstSuperuser)
|
||||
await page.getByPlaceholder("Password").fill(firstSuperuserPassword)
|
||||
await page.getByRole("button", { name: "Log In" }).click()
|
||||
await page.waitForURL("/")
|
||||
await page.context().storageState({ path: authFile })
|
||||
})
|
21
frontend/tests/config.ts
Normal file
21
frontend/tests/config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import dotenv from "dotenv"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
dotenv.config({ path: path.join(__dirname, "../../.env") })
|
||||
|
||||
const { FIRST_SUPERUSER, FIRST_SUPERUSER_PASSWORD } = process.env
|
||||
|
||||
if (typeof FIRST_SUPERUSER !== "string") {
|
||||
throw new Error("Environment variable FIRST_SUPERUSER is undefined")
|
||||
}
|
||||
|
||||
if (typeof FIRST_SUPERUSER_PASSWORD !== "string") {
|
||||
throw new Error("Environment variable FIRST_SUPERUSER_PASSWORD is undefined")
|
||||
}
|
||||
|
||||
export const firstSuperuser = FIRST_SUPERUSER as string
|
||||
export const firstSuperuserPassword = FIRST_SUPERUSER_PASSWORD as string
|
117
frontend/tests/login.spec.ts
Normal file
117
frontend/tests/login.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { type Page, expect, test } from "@playwright/test"
|
||||
import { firstSuperuser, firstSuperuserPassword } from "./config.ts"
|
||||
import { randomPassword } from "./utils/random.ts"
|
||||
|
||||
test.use({ storageState: { cookies: [], origins: [] } })
|
||||
|
||||
type OptionsType = {
|
||||
exact?: boolean
|
||||
}
|
||||
|
||||
const fillForm = async (page: Page, email: string, password: string) => {
|
||||
await page.getByPlaceholder("Email").fill(email)
|
||||
await page.getByPlaceholder("Password", { exact: true }).fill(password)
|
||||
}
|
||||
|
||||
const verifyInput = async (
|
||||
page: Page,
|
||||
placeholder: string,
|
||||
options?: OptionsType,
|
||||
) => {
|
||||
const input = page.getByPlaceholder(placeholder, options)
|
||||
await expect(input).toBeVisible()
|
||||
await expect(input).toHaveText("")
|
||||
await expect(input).toBeEditable()
|
||||
}
|
||||
|
||||
test("Inputs are visible, empty and editable", async ({ page }) => {
|
||||
await page.goto("/login")
|
||||
|
||||
await verifyInput(page, "Email")
|
||||
await verifyInput(page, "Password", { exact: true })
|
||||
})
|
||||
|
||||
test("Log In button is visible", async ({ page }) => {
|
||||
await page.goto("/login")
|
||||
|
||||
await expect(page.getByRole("button", { name: "Log In" })).toBeVisible()
|
||||
})
|
||||
|
||||
test("Forgot Password link is visible", async ({ page }) => {
|
||||
await page.goto("/login")
|
||||
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Forgot password?" }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("Log in with valid email and password ", async ({ page }) => {
|
||||
await page.goto("/login")
|
||||
|
||||
await fillForm(page, firstSuperuser, firstSuperuserPassword)
|
||||
await page.getByRole("button", { name: "Log In" }).click()
|
||||
|
||||
await page.waitForURL("/")
|
||||
|
||||
await expect(
|
||||
page.getByText("Welcome back, nice to see you again!"),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("Log in with invalid email", async ({ page }) => {
|
||||
await page.goto("/login")
|
||||
|
||||
await fillForm(page, "invalidemail", firstSuperuserPassword)
|
||||
await page.getByRole("button", { name: "Log In" }).click()
|
||||
|
||||
await expect(page.getByText("Invalid email address")).toBeVisible()
|
||||
})
|
||||
|
||||
test("Log in with invalid password", async ({ page }) => {
|
||||
const password = randomPassword()
|
||||
|
||||
await page.goto("/login")
|
||||
await fillForm(page, firstSuperuser, password)
|
||||
await page.getByRole("button", { name: "Log In" }).click()
|
||||
|
||||
await expect(page.getByText("Incorrect email or password")).toBeVisible()
|
||||
})
|
||||
|
||||
// Log out
|
||||
|
||||
test("Successful log out", async ({ page }) => {
|
||||
await page.goto("/login")
|
||||
|
||||
await fillForm(page, firstSuperuser, firstSuperuserPassword)
|
||||
await page.getByRole("button", { name: "Log In" }).click()
|
||||
|
||||
await page.waitForURL("/")
|
||||
|
||||
await expect(
|
||||
page.getByText("Welcome back, nice to see you again!"),
|
||||
).toBeVisible()
|
||||
|
||||
await page.getByTestId("user-menu").click()
|
||||
await page.getByRole("menuitem", { name: "Log out" }).click()
|
||||
await page.waitForURL("/login")
|
||||
})
|
||||
|
||||
test("Logged-out user cannot access protected routes", async ({ page }) => {
|
||||
await page.goto("/login")
|
||||
|
||||
await fillForm(page, firstSuperuser, firstSuperuserPassword)
|
||||
await page.getByRole("button", { name: "Log In" }).click()
|
||||
|
||||
await page.waitForURL("/")
|
||||
|
||||
await expect(
|
||||
page.getByText("Welcome back, nice to see you again!"),
|
||||
).toBeVisible()
|
||||
|
||||
await page.getByTestId("user-menu").click()
|
||||
await page.getByRole("menuitem", { name: "Log out" }).click()
|
||||
await page.waitForURL("/login")
|
||||
|
||||
await page.goto("/settings")
|
||||
await page.waitForURL("/login")
|
||||
})
|
121
frontend/tests/reset-password.spec.ts
Normal file
121
frontend/tests/reset-password.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { findLastEmail } from "./utils/mailcatcher"
|
||||
import { randomEmail, randomPassword } from "./utils/random"
|
||||
import { logInUser, signUpNewUser } from "./utils/user"
|
||||
|
||||
test.use({ storageState: { cookies: [], origins: [] } })
|
||||
|
||||
test("Password Recovery title is visible", async ({ page }) => {
|
||||
await page.goto("/recover-password")
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Password Recovery" }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("Input is visible, empty and editable", async ({ page }) => {
|
||||
await page.goto("/recover-password")
|
||||
|
||||
await expect(page.getByPlaceholder("Email")).toBeVisible()
|
||||
await expect(page.getByPlaceholder("Email")).toHaveText("")
|
||||
await expect(page.getByPlaceholder("Email")).toBeEditable()
|
||||
})
|
||||
|
||||
test("Continue button is visible", async ({ page }) => {
|
||||
await page.goto("/recover-password")
|
||||
|
||||
await expect(page.getByRole("button", { name: "Continue" })).toBeVisible()
|
||||
})
|
||||
|
||||
test("User can reset password successfully using the link", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const fullName = "Test User"
|
||||
const email = randomEmail()
|
||||
const password = randomPassword()
|
||||
const newPassword = randomPassword()
|
||||
|
||||
// Sign up a new user
|
||||
await signUpNewUser(page, fullName, email, password)
|
||||
|
||||
await page.goto("/recover-password")
|
||||
await page.getByPlaceholder("Email").fill(email)
|
||||
|
||||
await page.getByRole("button", { name: "Continue" }).click()
|
||||
|
||||
const emailData = await findLastEmail({
|
||||
request,
|
||||
filter: (e) => e.recipients.includes(`<${email}>`),
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
await page.goto(`http://localhost:1080/messages/${emailData.id}.html`)
|
||||
|
||||
const selector = 'a[href*="/reset-password?token="]'
|
||||
|
||||
let url = await page.getAttribute(selector, "href")
|
||||
|
||||
// TODO: update var instead of doing a replace
|
||||
url = url!.replace("http://localhost/", "http://localhost:5173/")
|
||||
|
||||
// Set the new password and confirm it
|
||||
await page.goto(url)
|
||||
|
||||
await page.getByLabel("Set Password").fill(newPassword)
|
||||
await page.getByLabel("Confirm Password").fill(newPassword)
|
||||
await page.getByRole("button", { name: "Reset Password" }).click()
|
||||
await expect(page.getByText("Password updated successfully")).toBeVisible()
|
||||
|
||||
// Check if the user is able to login with the new password
|
||||
await logInUser(page, email, newPassword)
|
||||
})
|
||||
|
||||
test("Expired or invalid reset link", async ({ page }) => {
|
||||
const password = randomPassword()
|
||||
const invalidUrl = "/reset-password?token=invalidtoken"
|
||||
|
||||
await page.goto(invalidUrl)
|
||||
|
||||
await page.getByLabel("Set Password").fill(password)
|
||||
await page.getByLabel("Confirm Password").fill(password)
|
||||
await page.getByRole("button", { name: "Reset Password" }).click()
|
||||
|
||||
await expect(page.getByText("Invalid token")).toBeVisible()
|
||||
})
|
||||
|
||||
test("Weak new password validation", async ({ page, request }) => {
|
||||
const fullName = "Test User"
|
||||
const email = randomEmail()
|
||||
const password = randomPassword()
|
||||
const weakPassword = "123"
|
||||
|
||||
// Sign up a new user
|
||||
await signUpNewUser(page, fullName, email, password)
|
||||
|
||||
await page.goto("/recover-password")
|
||||
await page.getByPlaceholder("Email").fill(email)
|
||||
await page.getByRole("button", { name: "Continue" }).click()
|
||||
|
||||
const emailData = await findLastEmail({
|
||||
request,
|
||||
filter: (e) => e.recipients.includes(`<${email}>`),
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
await page.goto(`http://localhost:1080/messages/${emailData.id}.html`)
|
||||
|
||||
const selector = 'a[href*="/reset-password?token="]'
|
||||
let url = await page.getAttribute(selector, "href")
|
||||
url = url!.replace("http://localhost/", "http://localhost:5173/")
|
||||
|
||||
// Set a weak new password
|
||||
await page.goto(url)
|
||||
await page.getByLabel("Set Password").fill(weakPassword)
|
||||
await page.getByLabel("Confirm Password").fill(weakPassword)
|
||||
await page.getByRole("button", { name: "Reset Password" }).click()
|
||||
|
||||
await expect(
|
||||
page.getByText("Password must be at least 8 characters"),
|
||||
).toBeVisible()
|
||||
})
|
169
frontend/tests/sign-up.spec.ts
Normal file
169
frontend/tests/sign-up.spec.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { type Page, expect, test } from "@playwright/test"
|
||||
|
||||
import { randomEmail, randomPassword } from "./utils/random"
|
||||
|
||||
test.use({ storageState: { cookies: [], origins: [] } })
|
||||
|
||||
type OptionsType = {
|
||||
exact?: boolean
|
||||
}
|
||||
|
||||
const fillForm = async (
|
||||
page: Page,
|
||||
full_name: string,
|
||||
email: string,
|
||||
password: string,
|
||||
confirm_password: string,
|
||||
) => {
|
||||
await page.getByPlaceholder("Full Name").fill(full_name)
|
||||
await page.getByPlaceholder("Email").fill(email)
|
||||
await page.getByPlaceholder("Password", { exact: true }).fill(password)
|
||||
await page.getByPlaceholder("Repeat Password").fill(confirm_password)
|
||||
}
|
||||
|
||||
const verifyInput = async (
|
||||
page: Page,
|
||||
placeholder: string,
|
||||
options?: OptionsType,
|
||||
) => {
|
||||
const input = page.getByPlaceholder(placeholder, options)
|
||||
await expect(input).toBeVisible()
|
||||
await expect(input).toHaveText("")
|
||||
await expect(input).toBeEditable()
|
||||
}
|
||||
|
||||
test("Inputs are visible, empty and editable", async ({ page }) => {
|
||||
await page.goto("/signup")
|
||||
|
||||
await verifyInput(page, "Full Name")
|
||||
await verifyInput(page, "Email")
|
||||
await verifyInput(page, "Password", { exact: true })
|
||||
await verifyInput(page, "Repeat Password")
|
||||
})
|
||||
|
||||
test("Sign Up button is visible", async ({ page }) => {
|
||||
await page.goto("/signup")
|
||||
|
||||
await expect(page.getByRole("button", { name: "Sign Up" })).toBeVisible()
|
||||
})
|
||||
|
||||
test("Log In link is visible", async ({ page }) => {
|
||||
await page.goto("/signup")
|
||||
|
||||
await expect(page.getByRole("link", { name: "Log In" })).toBeVisible()
|
||||
})
|
||||
|
||||
test("Sign up with valid name, email, and password", async ({ page }) => {
|
||||
const full_name = "Test User"
|
||||
const email = randomEmail()
|
||||
const password = randomPassword()
|
||||
|
||||
await page.goto("/signup")
|
||||
await fillForm(page, full_name, email, password, password)
|
||||
await page.getByRole("button", { name: "Sign Up" }).click()
|
||||
})
|
||||
|
||||
test("Sign up with invalid email", async ({ page }) => {
|
||||
await page.goto("/signup")
|
||||
|
||||
await fillForm(
|
||||
page,
|
||||
"Playwright Test",
|
||||
"invalid-email",
|
||||
"changethis",
|
||||
"changethis",
|
||||
)
|
||||
await page.getByRole("button", { name: "Sign Up" }).click()
|
||||
|
||||
await expect(page.getByText("Invalid email address")).toBeVisible()
|
||||
})
|
||||
|
||||
test("Sign up with existing email", async ({ page }) => {
|
||||
const fullName = "Test User"
|
||||
const email = randomEmail()
|
||||
const password = randomPassword()
|
||||
|
||||
// Sign up with an email
|
||||
await page.goto("/signup")
|
||||
|
||||
await fillForm(page, fullName, email, password, password)
|
||||
await page.getByRole("button", { name: "Sign Up" }).click()
|
||||
|
||||
// Sign up again with the same email
|
||||
await page.goto("/signup")
|
||||
|
||||
await fillForm(page, fullName, email, password, password)
|
||||
await page.getByRole("button", { name: "Sign Up" }).click()
|
||||
|
||||
await page
|
||||
.getByText("The user with this email already exists in the system")
|
||||
.click()
|
||||
})
|
||||
|
||||
test("Sign up with weak password", async ({ page }) => {
|
||||
const fullName = "Test User"
|
||||
const email = randomEmail()
|
||||
const password = "weak"
|
||||
|
||||
await page.goto("/signup")
|
||||
|
||||
await fillForm(page, fullName, email, password, password)
|
||||
await page.getByRole("button", { name: "Sign Up" }).click()
|
||||
|
||||
await expect(
|
||||
page.getByText("Password must be at least 8 characters"),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("Sign up with mismatched passwords", async ({ page }) => {
|
||||
const fullName = "Test User"
|
||||
const email = randomEmail()
|
||||
const password = randomPassword()
|
||||
const password2 = randomPassword()
|
||||
|
||||
await page.goto("/signup")
|
||||
|
||||
await fillForm(page, fullName, email, password, password2)
|
||||
await page.getByRole("button", { name: "Sign Up" }).click()
|
||||
|
||||
await expect(page.getByText("Passwords do not match")).toBeVisible()
|
||||
})
|
||||
|
||||
test("Sign up with missing full name", async ({ page }) => {
|
||||
const fullName = ""
|
||||
const email = randomEmail()
|
||||
const password = randomPassword()
|
||||
|
||||
await page.goto("/signup")
|
||||
|
||||
await fillForm(page, fullName, email, password, password)
|
||||
await page.getByRole("button", { name: "Sign Up" }).click()
|
||||
|
||||
await expect(page.getByText("Full Name is required")).toBeVisible()
|
||||
})
|
||||
|
||||
test("Sign up with missing email", async ({ page }) => {
|
||||
const fullName = "Test User"
|
||||
const email = ""
|
||||
const password = randomPassword()
|
||||
|
||||
await page.goto("/signup")
|
||||
|
||||
await fillForm(page, fullName, email, password, password)
|
||||
await page.getByRole("button", { name: "Sign Up" }).click()
|
||||
|
||||
await expect(page.getByText("Email is required")).toBeVisible()
|
||||
})
|
||||
|
||||
test("Sign up with missing password", async ({ page }) => {
|
||||
const fullName = ""
|
||||
const email = randomEmail()
|
||||
const password = ""
|
||||
|
||||
await page.goto("/signup")
|
||||
|
||||
await fillForm(page, fullName, email, password, password)
|
||||
await page.getByRole("button", { name: "Sign Up" }).click()
|
||||
|
||||
await expect(page.getByText("Password is required")).toBeVisible()
|
||||
})
|
288
frontend/tests/user-settings.spec.ts
Normal file
288
frontend/tests/user-settings.spec.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { firstSuperuser, firstSuperuserPassword } from "./config.ts"
|
||||
import { randomEmail, randomPassword } from "./utils/random"
|
||||
import { logInUser, logOutUser, signUpNewUser } from "./utils/user"
|
||||
|
||||
const tabs = ["My profile", "Password", "Appearance"]
|
||||
|
||||
// User Information
|
||||
|
||||
test("My profile tab is active by default", async ({ page }) => {
|
||||
await page.goto("/settings")
|
||||
await expect(page.getByRole("tab", { name: "My profile" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
})
|
||||
|
||||
test("All tabs are visible", async ({ page }) => {
|
||||
await page.goto("/settings")
|
||||
for (const tab of tabs) {
|
||||
await expect(page.getByRole("tab", { name: tab })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test.describe("Edit user full name and email successfully", () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } })
|
||||
|
||||
test("Edit user name with a valid name", async ({ page }) => {
|
||||
const fullName = "Test User"
|
||||
const email = randomEmail()
|
||||
const updatedName = "Test User 2"
|
||||
const password = randomPassword()
|
||||
|
||||
// Sign up a new user
|
||||
await signUpNewUser(page, fullName, email, password)
|
||||
|
||||
// Log in the user
|
||||
await logInUser(page, email, password)
|
||||
|
||||
await page.goto("/settings")
|
||||
await page.getByRole("tab", { name: "My profile" }).click()
|
||||
await page.getByRole("button", { name: "Edit" }).click()
|
||||
await page.getByLabel("Full name").fill(updatedName)
|
||||
await page.getByRole("button", { name: "Save" }).click()
|
||||
await expect(page.getByText("User updated successfully")).toBeVisible()
|
||||
// Check if the new name is displayed on the page
|
||||
await expect(
|
||||
page.getByLabel("My profile").getByText(updatedName, { exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("Edit user email with a valid email", async ({ page }) => {
|
||||
const fullName = "Test User"
|
||||
const email = randomEmail()
|
||||
const updatedEmail = randomEmail()
|
||||
const password = randomPassword()
|
||||
|
||||
// Sign up a new user
|
||||
await signUpNewUser(page, fullName, email, password)
|
||||
|
||||
// Log in the user
|
||||
await logInUser(page, email, password)
|
||||
|
||||
await page.goto("/settings")
|
||||
await page.getByRole("tab", { name: "My profile" }).click()
|
||||
await page.getByRole("button", { name: "Edit" }).click()
|
||||
await page.getByLabel("Email").fill(updatedEmail)
|
||||
await page.getByRole("button", { name: "Save" }).click()
|
||||
await expect(page.getByText("User updated successfully")).toBeVisible()
|
||||
await expect(
|
||||
page.getByLabel("My profile").getByText(updatedEmail, { exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Edit user with invalid data", () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } })
|
||||
|
||||
test("Edit user email with an invalid email", async ({ page }) => {
|
||||
const fullName = "Test User"
|
||||
const email = randomEmail()
|
||||
const password = randomPassword()
|
||||
const invalidEmail = ""
|
||||
|
||||
// Sign up a new user
|
||||
await signUpNewUser(page, fullName, email, password)
|
||||
|
||||
// Log in the user
|
||||
await logInUser(page, email, password)
|
||||
|
||||
await page.goto("/settings")
|
||||
await page.getByRole("tab", { name: "My profile" }).click()
|
||||
await page.getByRole("button", { name: "Edit" }).click()
|
||||
await page.getByLabel("Email").fill(invalidEmail)
|
||||
await page.locator("body").click()
|
||||
await expect(page.getByText("Email is required")).toBeVisible()
|
||||
})
|
||||
|
||||
test("Cancel edit action restores original name", async ({ page }) => {
|
||||
const fullName = "Test User"
|
||||
const email = randomEmail()
|
||||
const password = randomPassword()
|
||||
const updatedName = "Test User"
|
||||
|
||||
// Sign up a new user
|
||||
await signUpNewUser(page, fullName, email, password)
|
||||
|
||||
// Log in the user
|
||||
await logInUser(page, email, password)
|
||||
|
||||
await page.goto("/settings")
|
||||
await page.getByRole("tab", { name: "My profile" }).click()
|
||||
await page.getByRole("button", { name: "Edit" }).click()
|
||||
await page.getByLabel("Full name").fill(updatedName)
|
||||
await page.getByRole("button", { name: "Cancel" }).first().click()
|
||||
await expect(
|
||||
page.getByLabel("My profile").getByText(fullName, { exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("Cancel edit action restores original email", async ({ page }) => {
|
||||
const fullName = "Test User"
|
||||
const email = randomEmail()
|
||||
const password = randomPassword()
|
||||
const updatedEmail = randomEmail()
|
||||
|
||||
// Sign up a new user
|
||||
await signUpNewUser(page, fullName, email, password)
|
||||
|
||||
// Log in the user
|
||||
await logInUser(page, email, password)
|
||||
|
||||
await page.goto("/settings")
|
||||
await page.getByRole("tab", { name: "My profile" }).click()
|
||||
await page.getByRole("button", { name: "Edit" }).click()
|
||||
await page.getByLabel("Email").fill(updatedEmail)
|
||||
await page.getByRole("button", { name: "Cancel" }).first().click()
|
||||
await expect(
|
||||
page.getByLabel("My profile").getByText(email, { exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// Change Password
|
||||
|
||||
test.describe("Change password successfully", () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } })
|
||||
|
||||
test("Update password successfully", async ({ page }) => {
|
||||
const fullName = "Test User"
|
||||
const email = randomEmail()
|
||||
const password = randomPassword()
|
||||
const NewPassword = randomPassword()
|
||||
|
||||
// Sign up a new user
|
||||
await signUpNewUser(page, fullName, email, password)
|
||||
|
||||
// Log in the user
|
||||
await logInUser(page, email, password)
|
||||
|
||||
await page.goto("/settings")
|
||||
await page.getByRole("tab", { name: "Password" }).click()
|
||||
await page.getByLabel("Current Password*").fill(password)
|
||||
await page.getByLabel("Set Password*").fill(NewPassword)
|
||||
await page.getByLabel("Confirm Password*").fill(NewPassword)
|
||||
await page.getByRole("button", { name: "Save" }).click()
|
||||
await expect(page.getByText("Password updated successfully.")).toBeVisible()
|
||||
|
||||
await logOutUser(page)
|
||||
|
||||
// Check if the user can log in with the new password
|
||||
await logInUser(page, email, NewPassword)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Change password with invalid data", () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } })
|
||||
|
||||
test("Update password with weak passwords", async ({ page }) => {
|
||||
const fullName = "Test User"
|
||||
const email = randomEmail()
|
||||
const password = randomPassword()
|
||||
const weakPassword = "weak"
|
||||
|
||||
// Sign up a new user
|
||||
await signUpNewUser(page, fullName, email, password)
|
||||
|
||||
// Log in the user
|
||||
await logInUser(page, email, password)
|
||||
|
||||
await page.goto("/settings")
|
||||
await page.getByRole("tab", { name: "Password" }).click()
|
||||
await page.getByLabel("Current Password*").fill(password)
|
||||
await page.getByLabel("Set Password*").fill(weakPassword)
|
||||
await page.getByLabel("Confirm Password*").fill(weakPassword)
|
||||
await expect(
|
||||
page.getByText("Password must be at least 8 characters"),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("New password and confirmation password do not match", async ({
|
||||
page,
|
||||
}) => {
|
||||
const fullName = "Test User"
|
||||
const email = randomEmail()
|
||||
const password = randomPassword()
|
||||
const newPassword = randomPassword()
|
||||
const confirmPassword = randomPassword()
|
||||
|
||||
// Sign up a new user
|
||||
await signUpNewUser(page, fullName, email, password)
|
||||
|
||||
// Log in the user
|
||||
await logInUser(page, email, password)
|
||||
|
||||
await page.goto("/settings")
|
||||
await page.getByRole("tab", { name: "Password" }).click()
|
||||
await page.getByLabel("Current Password*").fill(password)
|
||||
await page.getByLabel("Set Password*").fill(newPassword)
|
||||
await page.getByLabel("Confirm Password*").fill(confirmPassword)
|
||||
await page.getByRole("button", { name: "Save" }).click()
|
||||
await expect(page.getByText("Passwords do not match")).toBeVisible()
|
||||
})
|
||||
|
||||
test("Current password and new password are the same", async ({ page }) => {
|
||||
const fullName = "Test User"
|
||||
const email = randomEmail()
|
||||
const password = randomPassword()
|
||||
|
||||
// Sign up a new user
|
||||
await signUpNewUser(page, fullName, email, password)
|
||||
|
||||
// Log in the user
|
||||
await logInUser(page, email, password)
|
||||
|
||||
await page.goto("/settings")
|
||||
await page.getByRole("tab", { name: "Password" }).click()
|
||||
await page.getByLabel("Current Password*").fill(password)
|
||||
await page.getByLabel("Set Password*").fill(password)
|
||||
await page.getByLabel("Confirm Password*").fill(password)
|
||||
await page.getByRole("button", { name: "Save" }).click()
|
||||
await expect(
|
||||
page.getByText("New password cannot be the same as the current one"),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// Appearance
|
||||
|
||||
test("Appearance tab is visible", async ({ page }) => {
|
||||
await page.goto("/settings")
|
||||
await page.getByRole("tab", { name: "Appearance" }).click()
|
||||
await expect(page.getByLabel("Appearance")).toBeVisible()
|
||||
})
|
||||
|
||||
test("User can switch from light mode to dark mode", async ({ page }) => {
|
||||
await page.goto("/settings")
|
||||
await page.getByRole("tab", { name: "Appearance" }).click()
|
||||
await page.getByLabel("Appearance").locator("span").nth(3).click()
|
||||
const isDarkMode = await page.evaluate(() =>
|
||||
document.body.classList.contains("chakra-ui-dark"),
|
||||
)
|
||||
expect(isDarkMode).toBe(true)
|
||||
})
|
||||
|
||||
test("User can switch from dark mode to light mode", async ({ page }) => {
|
||||
await page.goto("/settings")
|
||||
await page.getByRole("tab", { name: "Appearance" }).click()
|
||||
await page.getByLabel("Appearance").locator("span").first().click()
|
||||
const isLightMode = await page.evaluate(() =>
|
||||
document.body.classList.contains("chakra-ui-light"),
|
||||
)
|
||||
expect(isLightMode).toBe(true)
|
||||
})
|
||||
|
||||
test("Selected mode is preserved across sessions", async ({ page }) => {
|
||||
await page.goto("/settings")
|
||||
await page.getByRole("tab", { name: "Appearance" }).click()
|
||||
await page.getByLabel("Appearance").locator("span").nth(3).click()
|
||||
|
||||
await logOutUser(page)
|
||||
|
||||
await logInUser(page, firstSuperuser, firstSuperuserPassword)
|
||||
const isDarkMode = await page.evaluate(() =>
|
||||
document.body.classList.contains("chakra-ui-dark"),
|
||||
)
|
||||
expect(isDarkMode).toBe(true)
|
||||
})
|
59
frontend/tests/utils/mailcatcher.ts
Normal file
59
frontend/tests/utils/mailcatcher.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { APIRequestContext } from "@playwright/test"
|
||||
|
||||
type Email = {
|
||||
id: number
|
||||
recipients: string[]
|
||||
subject: string
|
||||
}
|
||||
|
||||
async function findEmail({
|
||||
request,
|
||||
filter,
|
||||
}: { request: APIRequestContext; filter?: (email: Email) => boolean }) {
|
||||
const response = await request.get("http://localhost:1080/messages")
|
||||
|
||||
let emails = await response.json()
|
||||
|
||||
if (filter) {
|
||||
emails = emails.filter(filter)
|
||||
}
|
||||
|
||||
const email = emails[emails.length - 1]
|
||||
|
||||
if (email) {
|
||||
return email as Email
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function findLastEmail({
|
||||
request,
|
||||
filter,
|
||||
timeout = 5000,
|
||||
}: {
|
||||
request: APIRequestContext
|
||||
filter?: (email: Email) => boolean
|
||||
timeout?: number
|
||||
}) {
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("Timeout while trying to get latest email")),
|
||||
timeout,
|
||||
),
|
||||
)
|
||||
|
||||
const checkEmails = async () => {
|
||||
while (true) {
|
||||
const emailData = await findEmail({ request, filter })
|
||||
|
||||
if (emailData) {
|
||||
return emailData
|
||||
}
|
||||
// Wait for 100ms before checking again
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.race([timeoutPromise, checkEmails()])
|
||||
}
|
13
frontend/tests/utils/random.ts
Normal file
13
frontend/tests/utils/random.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const randomEmail = () =>
|
||||
`test_${Math.random().toString(36).substring(7)}@example.com`
|
||||
|
||||
export const randomTeamName = () =>
|
||||
`Team ${Math.random().toString(36).substring(7)}`
|
||||
|
||||
export const randomPassword = () => `${Math.random().toString(36).substring(2)}`
|
||||
|
||||
export const slugify = (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^\w-]+/g, "")
|
38
frontend/tests/utils/user.ts
Normal file
38
frontend/tests/utils/user.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { type Page, expect } from "@playwright/test"
|
||||
|
||||
export async function signUpNewUser(
|
||||
page: Page,
|
||||
name: string,
|
||||
email: string,
|
||||
password: string,
|
||||
) {
|
||||
await page.goto("/signup")
|
||||
|
||||
await page.getByPlaceholder("Full Name").fill(name)
|
||||
await page.getByPlaceholder("Email").fill(email)
|
||||
await page.getByPlaceholder("Password", { exact: true }).fill(password)
|
||||
await page.getByPlaceholder("Repeat Password").fill(password)
|
||||
await page.getByRole("button", { name: "Sign Up" }).click()
|
||||
await expect(
|
||||
page.getByText("Your account has been created successfully"),
|
||||
).toBeVisible()
|
||||
await page.goto("/login")
|
||||
}
|
||||
|
||||
export async function logInUser(page: Page, email: string, password: string) {
|
||||
await page.goto("/login")
|
||||
|
||||
await page.getByPlaceholder("Email").fill(email)
|
||||
await page.getByPlaceholder("Password", { exact: true }).fill(password)
|
||||
await page.getByRole("button", { name: "Log In" }).click()
|
||||
await page.waitForURL("/")
|
||||
await expect(
|
||||
page.getByText("Welcome back, nice to see you again!"),
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
export async function logOutUser(page: Page) {
|
||||
await page.getByTestId("user-menu").click()
|
||||
await page.getByRole("menuitem", { name: "Log out" }).click()
|
||||
await page.goto("/login")
|
||||
}
|
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src", "*.ts", "**/*.ts"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
8
frontend/vite.config.ts
Normal file
8
frontend/vite.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { TanStackRouterVite } from "@tanstack/router-vite-plugin"
|
||||
import react from "@vitejs/plugin-react-swc"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), TanStackRouterVite()],
|
||||
})
|
Reference in New Issue
Block a user