first commit

This commit is contained in:
2024-09-17 12:11:39 +08:00
commit 42fbcc5d89
236 changed files with 27582 additions and 0 deletions

2
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

1
frontend/.env Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost

29
frontend/.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1 @@
20

23
frontend/Dockerfile Normal file
View 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
View 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
View 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
View 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>

View 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)

View File

@@ -0,0 +1,9 @@
location /api {
return 404;
}
location /docs {
return 404;
}
location /redoc {
return 404;
}

11
frontend/nginx.conf Normal file
View 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

File diff suppressed because it is too large Load Diff

57
frontend/package.json Normal file
View 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"
}
}

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View 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
}
}

View 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>
}

View 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
}

View 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
}
}

View 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() },
}

View 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)
}
})
}

View 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
}

View 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"

View 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,
}

View 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

File diff suppressed because it is too large Load Diff

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

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

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

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

View 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

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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>,
)

View 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 */

View 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 />,
})

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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
View 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
View File

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

View 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
View 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

View 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")
})

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

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

View 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)
})

View 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()])
}

View 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, "")

View 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
View 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" }]
}

View 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
View 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()],
})