first commit
This commit is contained in:
25
frontend/src/client/core/ApiError.ts
Normal file
25
frontend/src/client/core/ApiError.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ApiRequestOptions } from "./ApiRequestOptions"
|
||||
import type { ApiResult } from "./ApiResult"
|
||||
|
||||
export class ApiError extends Error {
|
||||
public readonly url: string
|
||||
public readonly status: number
|
||||
public readonly statusText: string
|
||||
public readonly body: unknown
|
||||
public readonly request: ApiRequestOptions
|
||||
|
||||
constructor(
|
||||
request: ApiRequestOptions,
|
||||
response: ApiResult,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
|
||||
this.name = "ApiError"
|
||||
this.url = response.url
|
||||
this.status = response.status
|
||||
this.statusText = response.statusText
|
||||
this.body = response.body
|
||||
this.request = request
|
||||
}
|
||||
}
|
20
frontend/src/client/core/ApiRequestOptions.ts
Normal file
20
frontend/src/client/core/ApiRequestOptions.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type ApiRequestOptions = {
|
||||
readonly method:
|
||||
| "GET"
|
||||
| "PUT"
|
||||
| "POST"
|
||||
| "DELETE"
|
||||
| "OPTIONS"
|
||||
| "HEAD"
|
||||
| "PATCH"
|
||||
readonly url: string
|
||||
readonly path?: Record<string, unknown>
|
||||
readonly cookies?: Record<string, unknown>
|
||||
readonly headers?: Record<string, unknown>
|
||||
readonly query?: Record<string, unknown>
|
||||
readonly formData?: Record<string, unknown>
|
||||
readonly body?: any
|
||||
readonly mediaType?: string
|
||||
readonly responseHeader?: string
|
||||
readonly errors?: Record<number, string>
|
||||
}
|
7
frontend/src/client/core/ApiResult.ts
Normal file
7
frontend/src/client/core/ApiResult.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type ApiResult<TData = any> = {
|
||||
readonly body: TData
|
||||
readonly ok: boolean
|
||||
readonly status: number
|
||||
readonly statusText: string
|
||||
readonly url: string
|
||||
}
|
126
frontend/src/client/core/CancelablePromise.ts
Normal file
126
frontend/src/client/core/CancelablePromise.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
export class CancelError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = "CancelError"
|
||||
}
|
||||
|
||||
public get isCancelled(): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export interface OnCancel {
|
||||
readonly isResolved: boolean
|
||||
readonly isRejected: boolean
|
||||
readonly isCancelled: boolean
|
||||
|
||||
(cancelHandler: () => void): void
|
||||
}
|
||||
|
||||
export class CancelablePromise<T> implements Promise<T> {
|
||||
private _isResolved: boolean
|
||||
private _isRejected: boolean
|
||||
private _isCancelled: boolean
|
||||
readonly cancelHandlers: (() => void)[]
|
||||
readonly promise: Promise<T>
|
||||
private _resolve?: (value: T | PromiseLike<T>) => void
|
||||
private _reject?: (reason?: unknown) => void
|
||||
|
||||
constructor(
|
||||
executor: (
|
||||
resolve: (value: T | PromiseLike<T>) => void,
|
||||
reject: (reason?: unknown) => void,
|
||||
onCancel: OnCancel,
|
||||
) => void,
|
||||
) {
|
||||
this._isResolved = false
|
||||
this._isRejected = false
|
||||
this._isCancelled = false
|
||||
this.cancelHandlers = []
|
||||
this.promise = new Promise<T>((resolve, reject) => {
|
||||
this._resolve = resolve
|
||||
this._reject = reject
|
||||
|
||||
const onResolve = (value: T | PromiseLike<T>): void => {
|
||||
if (this._isResolved || this._isRejected || this._isCancelled) {
|
||||
return
|
||||
}
|
||||
this._isResolved = true
|
||||
if (this._resolve) this._resolve(value)
|
||||
}
|
||||
|
||||
const onReject = (reason?: unknown): void => {
|
||||
if (this._isResolved || this._isRejected || this._isCancelled) {
|
||||
return
|
||||
}
|
||||
this._isRejected = true
|
||||
if (this._reject) this._reject(reason)
|
||||
}
|
||||
|
||||
const onCancel = (cancelHandler: () => void): void => {
|
||||
if (this._isResolved || this._isRejected || this._isCancelled) {
|
||||
return
|
||||
}
|
||||
this.cancelHandlers.push(cancelHandler)
|
||||
}
|
||||
|
||||
Object.defineProperty(onCancel, "isResolved", {
|
||||
get: (): boolean => this._isResolved,
|
||||
})
|
||||
|
||||
Object.defineProperty(onCancel, "isRejected", {
|
||||
get: (): boolean => this._isRejected,
|
||||
})
|
||||
|
||||
Object.defineProperty(onCancel, "isCancelled", {
|
||||
get: (): boolean => this._isCancelled,
|
||||
})
|
||||
|
||||
return executor(onResolve, onReject, onCancel as OnCancel)
|
||||
})
|
||||
}
|
||||
|
||||
get [Symbol.toStringTag]() {
|
||||
return "Cancellable Promise"
|
||||
}
|
||||
|
||||
public then<TResult1 = T, TResult2 = never>(
|
||||
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
|
||||
onRejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
||||
): Promise<TResult1 | TResult2> {
|
||||
return this.promise.then(onFulfilled, onRejected)
|
||||
}
|
||||
|
||||
public catch<TResult = never>(
|
||||
onRejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null,
|
||||
): Promise<T | TResult> {
|
||||
return this.promise.catch(onRejected)
|
||||
}
|
||||
|
||||
public finally(onFinally?: (() => void) | null): Promise<T> {
|
||||
return this.promise.finally(onFinally)
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
if (this._isResolved || this._isRejected || this._isCancelled) {
|
||||
return
|
||||
}
|
||||
this._isCancelled = true
|
||||
if (this.cancelHandlers.length) {
|
||||
try {
|
||||
for (const cancelHandler of this.cancelHandlers) {
|
||||
cancelHandler()
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Cancellation threw an error", error)
|
||||
return
|
||||
}
|
||||
}
|
||||
this.cancelHandlers.length = 0
|
||||
if (this._reject) this._reject(new CancelError("Request aborted"))
|
||||
}
|
||||
|
||||
public get isCancelled(): boolean {
|
||||
return this._isCancelled
|
||||
}
|
||||
}
|
57
frontend/src/client/core/OpenAPI.ts
Normal file
57
frontend/src/client/core/OpenAPI.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { AxiosRequestConfig, AxiosResponse } from "axios"
|
||||
import type { ApiRequestOptions } from "./ApiRequestOptions"
|
||||
import type { TResult } from "./types"
|
||||
|
||||
type Headers = Record<string, string>
|
||||
type Middleware<T> = (value: T) => T | Promise<T>
|
||||
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>
|
||||
|
||||
export class Interceptors<T> {
|
||||
_fns: Middleware<T>[]
|
||||
|
||||
constructor() {
|
||||
this._fns = []
|
||||
}
|
||||
|
||||
eject(fn: Middleware<T>) {
|
||||
const index = this._fns.indexOf(fn)
|
||||
if (index !== -1) {
|
||||
this._fns = [...this._fns.slice(0, index), ...this._fns.slice(index + 1)]
|
||||
}
|
||||
}
|
||||
|
||||
use(fn: Middleware<T>) {
|
||||
this._fns = [...this._fns, fn]
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenAPIConfig = {
|
||||
BASE: string
|
||||
CREDENTIALS: "include" | "omit" | "same-origin"
|
||||
ENCODE_PATH?: ((path: string) => string) | undefined
|
||||
HEADERS?: Headers | Resolver<Headers> | undefined
|
||||
PASSWORD?: string | Resolver<string> | undefined
|
||||
RESULT?: TResult
|
||||
TOKEN?: string | Resolver<string> | undefined
|
||||
USERNAME?: string | Resolver<string> | undefined
|
||||
VERSION: string
|
||||
WITH_CREDENTIALS: boolean
|
||||
interceptors: {
|
||||
request: Interceptors<AxiosRequestConfig>
|
||||
response: Interceptors<AxiosResponse>
|
||||
}
|
||||
}
|
||||
|
||||
export const OpenAPI: OpenAPIConfig = {
|
||||
BASE: "",
|
||||
CREDENTIALS: "include",
|
||||
ENCODE_PATH: undefined,
|
||||
HEADERS: undefined,
|
||||
PASSWORD: undefined,
|
||||
RESULT: "body",
|
||||
TOKEN: undefined,
|
||||
USERNAME: undefined,
|
||||
VERSION: "0.1.0",
|
||||
WITH_CREDENTIALS: false,
|
||||
interceptors: { request: new Interceptors(), response: new Interceptors() },
|
||||
}
|
376
frontend/src/client/core/request.ts
Normal file
376
frontend/src/client/core/request.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import axios from "axios"
|
||||
import type {
|
||||
AxiosError,
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
AxiosResponse,
|
||||
} from "axios"
|
||||
|
||||
import { ApiError } from "./ApiError"
|
||||
import type { ApiRequestOptions } from "./ApiRequestOptions"
|
||||
import type { ApiResult } from "./ApiResult"
|
||||
import { CancelablePromise } from "./CancelablePromise"
|
||||
import type { OnCancel } from "./CancelablePromise"
|
||||
import type { OpenAPIConfig } from "./OpenAPI"
|
||||
|
||||
export const isString = (value: unknown): value is string => {
|
||||
return typeof value === "string"
|
||||
}
|
||||
|
||||
export const isStringWithValue = (value: unknown): value is string => {
|
||||
return isString(value) && value !== ""
|
||||
}
|
||||
|
||||
export const isBlob = (value: any): value is Blob => {
|
||||
return value instanceof Blob
|
||||
}
|
||||
|
||||
export const isFormData = (value: unknown): value is FormData => {
|
||||
return value instanceof FormData
|
||||
}
|
||||
|
||||
export const isSuccess = (status: number): boolean => {
|
||||
return status >= 200 && status < 300
|
||||
}
|
||||
|
||||
export const base64 = (str: string): string => {
|
||||
try {
|
||||
return btoa(str)
|
||||
} catch (err) {
|
||||
// @ts-ignore
|
||||
return Buffer.from(str).toString("base64")
|
||||
}
|
||||
}
|
||||
|
||||
export const getQueryString = (params: Record<string, unknown>): string => {
|
||||
const qs: string[] = []
|
||||
|
||||
const append = (key: string, value: unknown) => {
|
||||
qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
||||
}
|
||||
|
||||
const encodePair = (key: string, value: unknown) => {
|
||||
if (value === undefined || value === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => encodePair(key, v))
|
||||
} else if (typeof value === "object") {
|
||||
Object.entries(value).forEach(([k, v]) => encodePair(`${key}[${k}]`, v))
|
||||
} else {
|
||||
append(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => encodePair(key, value))
|
||||
|
||||
return qs.length ? `?${qs.join("&")}` : ""
|
||||
}
|
||||
|
||||
const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => {
|
||||
const encoder = config.ENCODE_PATH || encodeURI
|
||||
|
||||
const path = options.url
|
||||
.replace("{api-version}", config.VERSION)
|
||||
.replace(/{(.*?)}/g, (substring: string, group: string) => {
|
||||
if (options.path?.hasOwnProperty(group)) {
|
||||
return encoder(String(options.path[group]))
|
||||
}
|
||||
return substring
|
||||
})
|
||||
|
||||
const url = config.BASE + path
|
||||
return options.query ? url + getQueryString(options.query) : url
|
||||
}
|
||||
|
||||
export const getFormData = (
|
||||
options: ApiRequestOptions,
|
||||
): FormData | undefined => {
|
||||
if (options.formData) {
|
||||
const formData = new FormData()
|
||||
|
||||
const process = (key: string, value: unknown) => {
|
||||
if (isString(value) || isBlob(value)) {
|
||||
formData.append(key, value)
|
||||
} else {
|
||||
formData.append(key, JSON.stringify(value))
|
||||
}
|
||||
}
|
||||
|
||||
Object.entries(options.formData)
|
||||
.filter(([, value]) => value !== undefined && value !== null)
|
||||
.forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => process(key, v))
|
||||
} else {
|
||||
process(key, value)
|
||||
}
|
||||
})
|
||||
|
||||
return formData
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
type Resolver<T> = (options: ApiRequestOptions) => Promise<T>
|
||||
|
||||
export const resolve = async <T>(
|
||||
options: ApiRequestOptions,
|
||||
resolver?: T | Resolver<T>,
|
||||
): Promise<T | undefined> => {
|
||||
if (typeof resolver === "function") {
|
||||
return (resolver as Resolver<T>)(options)
|
||||
}
|
||||
return resolver
|
||||
}
|
||||
|
||||
export const getHeaders = async (
|
||||
config: OpenAPIConfig,
|
||||
options: ApiRequestOptions,
|
||||
): Promise<Record<string, string>> => {
|
||||
const [token, username, password, additionalHeaders] = await Promise.all([
|
||||
resolve(options, config.TOKEN),
|
||||
resolve(options, config.USERNAME),
|
||||
resolve(options, config.PASSWORD),
|
||||
resolve(options, config.HEADERS),
|
||||
])
|
||||
|
||||
const headers = Object.entries({
|
||||
Accept: "application/json",
|
||||
...additionalHeaders,
|
||||
...options.headers,
|
||||
})
|
||||
.filter(([, value]) => value !== undefined && value !== null)
|
||||
.reduce(
|
||||
(headers, [key, value]) => ({
|
||||
...headers,
|
||||
[key]: String(value),
|
||||
}),
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
|
||||
if (isStringWithValue(token)) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
if (isStringWithValue(username) && isStringWithValue(password)) {
|
||||
const credentials = base64(`${username}:${password}`)
|
||||
headers.Authorization = `Basic ${credentials}`
|
||||
}
|
||||
|
||||
if (options.body !== undefined) {
|
||||
if (options.mediaType) {
|
||||
headers["Content-Type"] = options.mediaType
|
||||
} else if (isBlob(options.body)) {
|
||||
headers["Content-Type"] = options.body.type || "application/octet-stream"
|
||||
} else if (isString(options.body)) {
|
||||
headers["Content-Type"] = "text/plain"
|
||||
} else if (!isFormData(options.body)) {
|
||||
headers["Content-Type"] = "application/json"
|
||||
}
|
||||
} else if (options.formData !== undefined) {
|
||||
if (options.mediaType) {
|
||||
headers["Content-Type"] = options.mediaType
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
export const getRequestBody = (options: ApiRequestOptions): unknown => {
|
||||
if (options.body) {
|
||||
return options.body
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const sendRequest = async <T>(
|
||||
config: OpenAPIConfig,
|
||||
options: ApiRequestOptions,
|
||||
url: string,
|
||||
body: unknown,
|
||||
formData: FormData | undefined,
|
||||
headers: Record<string, string>,
|
||||
onCancel: OnCancel,
|
||||
axiosClient: AxiosInstance,
|
||||
): Promise<AxiosResponse<T>> => {
|
||||
const controller = new AbortController()
|
||||
|
||||
let requestConfig: AxiosRequestConfig = {
|
||||
data: body ?? formData,
|
||||
headers,
|
||||
method: options.method,
|
||||
signal: controller.signal,
|
||||
url,
|
||||
withCredentials: config.WITH_CREDENTIALS,
|
||||
}
|
||||
|
||||
onCancel(() => controller.abort())
|
||||
|
||||
for (const fn of config.interceptors.request._fns) {
|
||||
requestConfig = await fn(requestConfig)
|
||||
}
|
||||
|
||||
try {
|
||||
return await axiosClient.request(requestConfig)
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<T>
|
||||
if (axiosError.response) {
|
||||
return axiosError.response
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const getResponseHeader = (
|
||||
response: AxiosResponse<unknown>,
|
||||
responseHeader?: string,
|
||||
): string | undefined => {
|
||||
if (responseHeader) {
|
||||
const content = response.headers[responseHeader]
|
||||
if (isString(content)) {
|
||||
return content
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const getResponseBody = (response: AxiosResponse<unknown>): unknown => {
|
||||
if (response.status !== 204) {
|
||||
return response.data
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const catchErrorCodes = (
|
||||
options: ApiRequestOptions,
|
||||
result: ApiResult,
|
||||
): void => {
|
||||
const errors: Record<number, string> = {
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
402: "Payment Required",
|
||||
403: "Forbidden",
|
||||
404: "Not Found",
|
||||
405: "Method Not Allowed",
|
||||
406: "Not Acceptable",
|
||||
407: "Proxy Authentication Required",
|
||||
408: "Request Timeout",
|
||||
409: "Conflict",
|
||||
410: "Gone",
|
||||
411: "Length Required",
|
||||
412: "Precondition Failed",
|
||||
413: "Payload Too Large",
|
||||
414: "URI Too Long",
|
||||
415: "Unsupported Media Type",
|
||||
416: "Range Not Satisfiable",
|
||||
417: "Expectation Failed",
|
||||
418: "Im a teapot",
|
||||
421: "Misdirected Request",
|
||||
422: "Unprocessable Content",
|
||||
423: "Locked",
|
||||
424: "Failed Dependency",
|
||||
425: "Too Early",
|
||||
426: "Upgrade Required",
|
||||
428: "Precondition Required",
|
||||
429: "Too Many Requests",
|
||||
431: "Request Header Fields Too Large",
|
||||
451: "Unavailable For Legal Reasons",
|
||||
500: "Internal Server Error",
|
||||
501: "Not Implemented",
|
||||
502: "Bad Gateway",
|
||||
503: "Service Unavailable",
|
||||
504: "Gateway Timeout",
|
||||
505: "HTTP Version Not Supported",
|
||||
506: "Variant Also Negotiates",
|
||||
507: "Insufficient Storage",
|
||||
508: "Loop Detected",
|
||||
510: "Not Extended",
|
||||
511: "Network Authentication Required",
|
||||
...options.errors,
|
||||
}
|
||||
|
||||
const error = errors[result.status]
|
||||
if (error) {
|
||||
throw new ApiError(options, result, error)
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
const errorStatus = result.status ?? "unknown"
|
||||
const errorStatusText = result.statusText ?? "unknown"
|
||||
const errorBody = (() => {
|
||||
try {
|
||||
return JSON.stringify(result.body, null, 2)
|
||||
} catch (e) {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
|
||||
throw new ApiError(
|
||||
options,
|
||||
result,
|
||||
`Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request method
|
||||
* @param config The OpenAPI configuration object
|
||||
* @param options The request options from the service
|
||||
* @param axiosClient The axios client instance to use
|
||||
* @returns CancelablePromise<T>
|
||||
* @throws ApiError
|
||||
*/
|
||||
export const request = <T>(
|
||||
config: OpenAPIConfig,
|
||||
options: ApiRequestOptions,
|
||||
axiosClient: AxiosInstance = axios,
|
||||
): CancelablePromise<T> => {
|
||||
return new CancelablePromise(async (resolve, reject, onCancel) => {
|
||||
try {
|
||||
const url = getUrl(config, options)
|
||||
const formData = getFormData(options)
|
||||
const body = getRequestBody(options)
|
||||
const headers = await getHeaders(config, options)
|
||||
|
||||
if (!onCancel.isCancelled) {
|
||||
let response = await sendRequest<T>(
|
||||
config,
|
||||
options,
|
||||
url,
|
||||
body,
|
||||
formData,
|
||||
headers,
|
||||
onCancel,
|
||||
axiosClient,
|
||||
)
|
||||
|
||||
for (const fn of config.interceptors.response._fns) {
|
||||
response = await fn(response)
|
||||
}
|
||||
|
||||
const responseBody = getResponseBody(response)
|
||||
const responseHeader = getResponseHeader(
|
||||
response,
|
||||
options.responseHeader,
|
||||
)
|
||||
|
||||
const result: ApiResult = {
|
||||
url,
|
||||
ok: isSuccess(response.status),
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
body: responseHeader ?? responseBody,
|
||||
}
|
||||
|
||||
catchErrorCodes(options, result)
|
||||
|
||||
resolve(result.body)
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
14
frontend/src/client/core/types.ts
Normal file
14
frontend/src/client/core/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ApiResult } from "./ApiResult"
|
||||
|
||||
export type TResult = "body" | "raw"
|
||||
|
||||
export type TApiResponse<T extends TResult, TData> = Exclude<
|
||||
T,
|
||||
"raw"
|
||||
> extends never
|
||||
? ApiResult<TData>
|
||||
: ApiResult<TData>["body"]
|
||||
|
||||
export type TConfig<T extends TResult> = {
|
||||
_result?: T
|
||||
}
|
8
frontend/src/client/index.ts
Normal file
8
frontend/src/client/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { ApiError } from "./core/ApiError"
|
||||
export { CancelablePromise, CancelError } from "./core/CancelablePromise"
|
||||
export { OpenAPI } from "./core/OpenAPI"
|
||||
export type { OpenAPIConfig } from "./core/OpenAPI"
|
||||
|
||||
export * from "./models"
|
||||
export * from "./schemas"
|
||||
export * from "./services"
|
284
frontend/src/client/models.ts
Normal file
284
frontend/src/client/models.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
export type Body_login_login_access_token = {
|
||||
grant_type?: string | null
|
||||
username: string
|
||||
password: string
|
||||
scope?: string
|
||||
client_id?: string | null
|
||||
client_secret?: string | null
|
||||
}
|
||||
|
||||
export type HTTPValidationError = {
|
||||
detail?: Array<ValidationError>
|
||||
}
|
||||
|
||||
export type ItemCreate = {
|
||||
title: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export type ItemPublic = {
|
||||
title: string
|
||||
description?: string | null
|
||||
id: string
|
||||
owner_id: string
|
||||
}
|
||||
|
||||
export type ItemUpdate = {
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export type ItemsPublic = {
|
||||
data: Array<ItemPublic>
|
||||
count: number
|
||||
}
|
||||
|
||||
export type ClientMessagePublic = {
|
||||
name: string,
|
||||
phone: string,
|
||||
email: string,
|
||||
message: string,
|
||||
id: string,
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type ClientMessagesPublic = {
|
||||
data: Array<ClientMessagePublic>,
|
||||
count: number
|
||||
}
|
||||
|
||||
export type Message = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export type NewPassword = {
|
||||
token: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
export type Token = {
|
||||
access_token: string
|
||||
token_type?: string
|
||||
}
|
||||
|
||||
export type UpdatePassword = {
|
||||
current_password: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
export type UserCreate = {
|
||||
email: string
|
||||
is_active?: boolean
|
||||
is_superuser?: boolean
|
||||
full_name?: string | null
|
||||
password: string
|
||||
}
|
||||
|
||||
export type UserPublic = {
|
||||
email: string
|
||||
is_active?: boolean
|
||||
is_superuser?: boolean
|
||||
full_name?: string | null
|
||||
id: string
|
||||
}
|
||||
|
||||
export type UserRegister = {
|
||||
email: string
|
||||
password: string
|
||||
full_name?: string | null
|
||||
}
|
||||
|
||||
export type UserUpdate = {
|
||||
email?: string | null
|
||||
is_active?: boolean
|
||||
is_superuser?: boolean
|
||||
full_name?: string | null
|
||||
password?: string | null
|
||||
}
|
||||
|
||||
export type UserUpdateMe = {
|
||||
full_name?: string | null
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
export type UsersPublic = {
|
||||
data: Array<UserPublic>
|
||||
count: number
|
||||
}
|
||||
|
||||
export type ValidationError = {
|
||||
loc: Array<string | number>
|
||||
msg: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export type WebSettingPublic = {
|
||||
address: string,
|
||||
google_map_api_key: string,
|
||||
latitude: Number,
|
||||
longitude: Number,
|
||||
phone: string,
|
||||
email: string,
|
||||
facebook: string,
|
||||
instagram: string,
|
||||
youtube: string,
|
||||
youtube_link: string,
|
||||
whatsapp: string,
|
||||
id: string
|
||||
}
|
||||
|
||||
export type WebSettingUpdate = {
|
||||
address: string,
|
||||
google_map_api_key: string,
|
||||
latitude: Number,
|
||||
longitude: Number,
|
||||
phone: string,
|
||||
email: string,
|
||||
facebook: string,
|
||||
instagram: string,
|
||||
youtube: string,
|
||||
youtube_link: string,
|
||||
whatsapp: string,
|
||||
}
|
||||
|
||||
|
||||
export type AboutUssPublic = {
|
||||
data: Array<AboutUsPublic>
|
||||
count: number
|
||||
}
|
||||
|
||||
export type AboutUsPublic = {
|
||||
index: number,
|
||||
description: string,
|
||||
image: string,
|
||||
id: string
|
||||
}
|
||||
|
||||
export type AboutUsCreate = {
|
||||
index: number,
|
||||
description: string,
|
||||
image: File,
|
||||
}
|
||||
|
||||
export type AboutUsUpdate = {
|
||||
index: number,
|
||||
description: string,
|
||||
image?: File | undefined | null,
|
||||
}
|
||||
|
||||
export type CoursesPublic = {
|
||||
data: Array<CoursePublic>
|
||||
count: number
|
||||
}
|
||||
|
||||
export type CoursePublic = {
|
||||
title: string,
|
||||
sort_description: string,
|
||||
long_description: string,
|
||||
information: string,
|
||||
contant: string,
|
||||
remark: string,
|
||||
id: string,
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type CourseCreate = {
|
||||
title: string,
|
||||
sort_description: string,
|
||||
long_description: string,
|
||||
information: string,
|
||||
contant: string,
|
||||
remark: string,
|
||||
}
|
||||
|
||||
export type CourseDetailsPublic = {
|
||||
title: string,
|
||||
sort_description: string,
|
||||
long_description: string,
|
||||
information: string,
|
||||
contant: string,
|
||||
remark: string,
|
||||
id: string,
|
||||
created_at: string
|
||||
images: Array<ImagePublic>,
|
||||
info_images: Array<Info_imagePublic>,
|
||||
schedule: Array<SchedulePublic>
|
||||
}
|
||||
|
||||
export type CourseUpdate = {
|
||||
title: string,
|
||||
sort_description: string,
|
||||
long_description: string,
|
||||
information: string,
|
||||
contant: string,
|
||||
remark: string,
|
||||
}
|
||||
|
||||
export type ImagesPublic = {
|
||||
data: Array<ImagePublic>,
|
||||
}
|
||||
|
||||
export type ImagePublic = {
|
||||
image: string,
|
||||
course_id: string,
|
||||
index: number,
|
||||
id:string
|
||||
}
|
||||
|
||||
export type ImageUpdate = {
|
||||
index: number,
|
||||
}
|
||||
|
||||
export type ImageCreate = {
|
||||
image: File,
|
||||
index: number,
|
||||
course_id: string
|
||||
}
|
||||
|
||||
export type Info_imagesPublic = {
|
||||
data: Array<Info_imagePublic>,
|
||||
}
|
||||
|
||||
export type Info_imagePublic = {
|
||||
image: string,
|
||||
course_id: string,
|
||||
index: number
|
||||
id: string
|
||||
}
|
||||
|
||||
export type Info_imageUpdate = {
|
||||
index: number,
|
||||
}
|
||||
|
||||
export type Info_imagesCreate = {
|
||||
image: File,
|
||||
index: number,
|
||||
course_id: string
|
||||
}
|
||||
|
||||
export type SchedulesPublic = {
|
||||
data: Array<SchedulePublic>,
|
||||
}
|
||||
|
||||
export type SchedulePublic = {
|
||||
title: string,
|
||||
info1: string,
|
||||
info2: string,
|
||||
date: string,
|
||||
course_id: string,
|
||||
id:string
|
||||
}
|
||||
export type ScheduleCreate = {
|
||||
title: string,
|
||||
info1: string,
|
||||
info2: string,
|
||||
date: string,
|
||||
course_id: string,
|
||||
}
|
||||
|
||||
export type ScheduleUpdate = {
|
||||
title: string,
|
||||
info1: string,
|
||||
info2: string,
|
||||
date: string,
|
||||
}
|
444
frontend/src/client/schemas.ts
Normal file
444
frontend/src/client/schemas.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
export const $Body_login_login_access_token = {
|
||||
properties: {
|
||||
grant_type: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
pattern: "password",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
username: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
},
|
||||
password: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
},
|
||||
scope: {
|
||||
type: "string",
|
||||
default: "",
|
||||
},
|
||||
client_id: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
client_secret: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $HTTPValidationError = {
|
||||
properties: {
|
||||
detail: {
|
||||
type: "array",
|
||||
contains: {
|
||||
type: "ValidationError",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $ItemCreate = {
|
||||
properties: {
|
||||
title: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
maxLength: 255,
|
||||
minLength: 1,
|
||||
},
|
||||
description: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $ItemPublic = {
|
||||
properties: {
|
||||
title: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
maxLength: 255,
|
||||
minLength: 1,
|
||||
},
|
||||
description: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
id: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
format: "uuid",
|
||||
},
|
||||
owner_id: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
format: "uuid",
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $ItemUpdate = {
|
||||
properties: {
|
||||
title: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
minLength: 1,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
description: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $ItemsPublic = {
|
||||
properties: {
|
||||
data: {
|
||||
type: "array",
|
||||
contains: {
|
||||
type: "ItemPublic",
|
||||
},
|
||||
isRequired: true,
|
||||
},
|
||||
count: {
|
||||
type: "number",
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $Message = {
|
||||
properties: {
|
||||
message: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $NewPassword = {
|
||||
properties: {
|
||||
token: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
},
|
||||
new_password: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
maxLength: 40,
|
||||
minLength: 8,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $Token = {
|
||||
properties: {
|
||||
access_token: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
},
|
||||
token_type: {
|
||||
type: "string",
|
||||
default: "bearer",
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $UpdatePassword = {
|
||||
properties: {
|
||||
current_password: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
maxLength: 40,
|
||||
minLength: 8,
|
||||
},
|
||||
new_password: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
maxLength: 40,
|
||||
minLength: 8,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $UserCreate = {
|
||||
properties: {
|
||||
email: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
format: "email",
|
||||
maxLength: 255,
|
||||
},
|
||||
is_active: {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
},
|
||||
is_superuser: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
full_name: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
password: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
maxLength: 40,
|
||||
minLength: 8,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $UserPublic = {
|
||||
properties: {
|
||||
email: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
format: "email",
|
||||
maxLength: 255,
|
||||
},
|
||||
is_active: {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
},
|
||||
is_superuser: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
full_name: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
id: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
format: "uuid",
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $UserRegister = {
|
||||
properties: {
|
||||
email: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
format: "email",
|
||||
maxLength: 255,
|
||||
},
|
||||
password: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
maxLength: 40,
|
||||
minLength: 8,
|
||||
},
|
||||
full_name: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $UserUpdate = {
|
||||
properties: {
|
||||
email: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
format: "email",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
is_active: {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
},
|
||||
is_superuser: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
full_name: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
password: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 40,
|
||||
minLength: 8,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $UserUpdateMe = {
|
||||
properties: {
|
||||
full_name: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
email: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
format: "email",
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $UsersPublic = {
|
||||
properties: {
|
||||
data: {
|
||||
type: "array",
|
||||
contains: {
|
||||
type: "UserPublic",
|
||||
},
|
||||
isRequired: true,
|
||||
},
|
||||
count: {
|
||||
type: "number",
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const $ValidationError = {
|
||||
properties: {
|
||||
loc: {
|
||||
type: "array",
|
||||
contains: {
|
||||
type: "any-of",
|
||||
contains: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
},
|
||||
],
|
||||
},
|
||||
isRequired: true,
|
||||
},
|
||||
msg: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
},
|
||||
type: {
|
||||
type: "string",
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
} as const
|
1045
frontend/src/client/services.ts
Normal file
1045
frontend/src/client/services.ts
Normal file
File diff suppressed because it is too large
Load Diff
235
frontend/src/components/AboutUs/AddAboutUs.tsx
Normal file
235
frontend/src/components/AboutUs/AddAboutUs.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import React, { useRef, ReactNode, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
InputGroup,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
} from "@chakra-ui/react"
|
||||
import { writeFileSync, createReadStream } from "fs";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm, UseFormRegisterReturn } from "react-hook-form"
|
||||
import { type ApiError, type AboutUsCreate, AboutUsService } from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
import { EditorState, ContentState, convertToRaw } from 'draft-js';
|
||||
import { Editor } from "react-draft-wysiwyg";
|
||||
import draftToHtml from 'draftjs-to-html';
|
||||
import htmlToDraft from 'html-to-draftjs';
|
||||
import "react-draft-wysiwyg/dist/react-draft-wysiwyg.css";
|
||||
|
||||
interface AddAboutUsProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type FileUploadProps = {
|
||||
register: UseFormRegisterReturn
|
||||
accept?: string
|
||||
multiple?: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const FileUpload = (props: FileUploadProps) => {
|
||||
const { register, accept, multiple, children } = props
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const { ref, ...rest } = register as { ref: (instance: HTMLInputElement | null) => void }
|
||||
|
||||
const handleClick = () => inputRef.current?.click()
|
||||
|
||||
return (
|
||||
<InputGroup onClick={handleClick}>
|
||||
<input
|
||||
type={'file'}
|
||||
multiple={multiple || false}
|
||||
hidden
|
||||
accept={accept}
|
||||
{...rest}
|
||||
ref={(e) => {
|
||||
ref(e)
|
||||
inputRef.current = e
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const AddAboutUs = ({ isOpen, onClose }: AddAboutUsProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<AboutUsCreate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
index: 0,
|
||||
description: "",
|
||||
image: undefined,
|
||||
},
|
||||
})
|
||||
const [editorState, setEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [content, setContent] = useState<string>('');
|
||||
|
||||
const validateFiles = (value: File) => {
|
||||
if (typeof value === 'string') return true;
|
||||
|
||||
|
||||
const fsMb = value.size / (1024 * 1024)
|
||||
const MAX_FILE_SIZE = 10
|
||||
if (fsMb > MAX_FILE_SIZE) {
|
||||
return 'Max file size 10mb'
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
type FormValues = {
|
||||
file_: FileList
|
||||
}
|
||||
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: AboutUsCreate) =>
|
||||
AboutUsService.createAboutUs({ formData: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "About Us created successfully.", "success")
|
||||
reset()
|
||||
setEditorState(EditorState.createEmpty());
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["aboutUs"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<AboutUsCreate> = (data) => {
|
||||
if (data.image instanceof FileList && data.image.length > 0) {
|
||||
data.image = data.image[0]
|
||||
}
|
||||
mutation.mutate(data)
|
||||
console.log(data)
|
||||
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={'xl'}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Add About Us</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
|
||||
<ModalBody pb={30}>
|
||||
<FormControl isRequired isInvalid={!!errors.description}>
|
||||
<Editor
|
||||
editorState={editorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setEditorState(newState);
|
||||
setContent(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
reset({
|
||||
description: content,
|
||||
});
|
||||
|
||||
}}
|
||||
toolbar={{
|
||||
options: ['inline', 'blockType', 'fontSize', 'list', 'textAlign', 'history', 'embedded', 'emoji', 'image'],
|
||||
inline: { inDropdown: true },
|
||||
list: { inDropdown: true },
|
||||
textAlign: { inDropdown: true },
|
||||
link: { inDropdown: true },
|
||||
history: { inDropdown: true },
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl isRequired isInvalid={!!errors.index}>
|
||||
<FormLabel htmlFor="index">Index</FormLabel >
|
||||
<NumberInput min={0} max={20} >
|
||||
<NumberInputField {...register("index", {
|
||||
required: "index is required.",
|
||||
})} />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
{/* <Input
|
||||
id="index"
|
||||
{...register("index", {
|
||||
required: "index is required.",
|
||||
})}
|
||||
placeholder="Index"
|
||||
type="Number"
|
||||
/> */}
|
||||
{errors.index && (
|
||||
<FormErrorMessage>{errors.index.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!errors.image} isRequired>
|
||||
<FormLabel>{'Image Upload'}</FormLabel>
|
||||
|
||||
{/* <FileUpload
|
||||
accept={'image/*'}
|
||||
multiple={false}
|
||||
register={register('image', { validate: validateFiles })}
|
||||
>
|
||||
<Button >
|
||||
Upload
|
||||
</Button>
|
||||
</FileUpload> */}
|
||||
<input type="file" {...register("image", {
|
||||
required: "index is required.",
|
||||
})} />
|
||||
<FormErrorMessage>
|
||||
{errors.image && errors?.image.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter gap={3}>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default AddAboutUs
|
232
frontend/src/components/AboutUs/EditAboutUs.tsx
Normal file
232
frontend/src/components/AboutUs/EditAboutUs.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import React, { useRef, ReactNode, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
InputGroup,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
Box,
|
||||
Image,
|
||||
} from "@chakra-ui/react"
|
||||
import { writeFileSync, createReadStream } from "fs";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm, UseFormRegisterReturn } from "react-hook-form"
|
||||
import { type ApiError, type AboutUsCreate, AboutUsService, AboutUsUpdate, AboutUsPublic } from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
import { EditorState, ContentState, convertToRaw } from 'draft-js';
|
||||
import { Editor } from "react-draft-wysiwyg";
|
||||
import draftToHtml from 'draftjs-to-html';
|
||||
import htmlToDraft from 'html-to-draftjs';
|
||||
import "react-draft-wysiwyg/dist/react-draft-wysiwyg.css";
|
||||
|
||||
interface EditAboutUsProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
aboutUs: AboutUsPublic
|
||||
}
|
||||
|
||||
type FileUploadProps = {
|
||||
register: UseFormRegisterReturn
|
||||
accept?: string
|
||||
multiple?: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const FileUpload = (props: FileUploadProps) => {
|
||||
const { register, accept, multiple, children } = props
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const { ref, ...rest } = register as { ref: (instance: HTMLInputElement | null) => void }
|
||||
|
||||
const handleClick = () => inputRef.current?.click()
|
||||
|
||||
return (
|
||||
<InputGroup onClick={handleClick}>
|
||||
<input
|
||||
type={'file'}
|
||||
multiple={multiple || false}
|
||||
hidden
|
||||
accept={accept}
|
||||
{...rest}
|
||||
ref={(e) => {
|
||||
ref(e)
|
||||
inputRef.current = e
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const EditAboutUs = ({ aboutUs, isOpen, onClose }: EditAboutUsProps) => {
|
||||
const url = import.meta.env.VITE_API_URL
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<AboutUsUpdate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
index: aboutUs.index,
|
||||
description: aboutUs.description,
|
||||
image: undefined,
|
||||
},
|
||||
})
|
||||
const [editorState, setEditorState] = useState<EditorState>(() => {
|
||||
const contentBlock = htmlToDraft(aboutUs.description);
|
||||
if (contentBlock) {
|
||||
const contentState = ContentState.createFromBlockArray(contentBlock.contentBlocks);
|
||||
return EditorState.createWithContent(contentState);
|
||||
}
|
||||
return EditorState.createEmpty();
|
||||
});
|
||||
const [content, setContent] = useState<string>(aboutUs.description);
|
||||
|
||||
const validateFiles = (value: File) => {
|
||||
if (typeof value === 'string') return true;
|
||||
|
||||
|
||||
const fsMb = value.size / (1024 * 1024)
|
||||
const MAX_FILE_SIZE = 10
|
||||
if (fsMb > MAX_FILE_SIZE) {
|
||||
return 'Max file size 10mb'
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
type FormValues = {
|
||||
file_: FileList
|
||||
}
|
||||
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: AboutUsUpdate) =>
|
||||
AboutUsService.updateAboutUs({ id: aboutUs.id, formData: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "About Us update successfully.", "success")
|
||||
reset()
|
||||
setEditorState(EditorState.createEmpty());
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["aboutUs"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<AboutUsUpdate> = (data) => {
|
||||
if (data.image instanceof FileList && data.image.length > 0) {
|
||||
data.image = data.image[0]
|
||||
}else{
|
||||
data.image = null
|
||||
}
|
||||
mutation.mutate(data)
|
||||
console.log(data)
|
||||
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={'xl'}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Edit About Us</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={30}>
|
||||
<Box boxSize='auto'>
|
||||
<Image src={url + "/" + aboutUs.image} />
|
||||
</Box>
|
||||
<FormControl isRequired isInvalid={!!errors.description}>
|
||||
<Editor
|
||||
editorState={editorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setEditorState(newState);
|
||||
const newContent = draftToHtml(convertToRaw(newState.getCurrentContent()));
|
||||
setContent(newContent);
|
||||
reset({
|
||||
description: newContent,
|
||||
});
|
||||
|
||||
}}
|
||||
toolbar={{
|
||||
options: ['inline', 'blockType', 'fontSize', 'list', 'textAlign', 'history', 'embedded', 'emoji', 'image'],
|
||||
inline: { inDropdown: true },
|
||||
list: { inDropdown: true },
|
||||
textAlign: { inDropdown: true },
|
||||
link: { inDropdown: true },
|
||||
history: { inDropdown: true },
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl isRequired isInvalid={!!errors.index}>
|
||||
<FormLabel htmlFor="index">Index</FormLabel >
|
||||
<NumberInput min={0} max={20} >
|
||||
<NumberInputField {...register("index", {
|
||||
required: "index is required.",
|
||||
})} />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
{errors.index && (
|
||||
<FormErrorMessage>{errors.index.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!errors.image} isRequired>
|
||||
<FormLabel>{'Image Upload'}</FormLabel>
|
||||
|
||||
<input type="file" {...register("image")} />
|
||||
<FormErrorMessage>
|
||||
{errors.image && errors?.image.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter gap={3}>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default EditAboutUs
|
182
frontend/src/components/Admin/AddUser.tsx
Normal file
182
frontend/src/components/Admin/AddUser.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { type UserCreate, UsersService } from "../../client"
|
||||
import type { ApiError } from "../../client/core/ApiError"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { emailPattern, handleError } from "../../utils"
|
||||
|
||||
interface AddUserProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface UserCreateForm extends UserCreate {
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
const AddUser = ({ isOpen, onClose }: AddUserProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<UserCreateForm>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
email: "",
|
||||
full_name: "",
|
||||
password: "",
|
||||
confirm_password: "",
|
||||
is_superuser: false,
|
||||
is_active: false,
|
||||
},
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: UserCreate) =>
|
||||
UsersService.createUser({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "User created successfully.", "success")
|
||||
reset()
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<UserCreateForm> = (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Add User</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<FormControl isRequired isInvalid={!!errors.email}>
|
||||
<FormLabel htmlFor="email">Email</FormLabel>
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
/>
|
||||
{errors.email && (
|
||||
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isInvalid={!!errors.full_name}>
|
||||
<FormLabel htmlFor="name">Full name</FormLabel>
|
||||
<Input
|
||||
id="name"
|
||||
{...register("full_name")}
|
||||
placeholder="Full name"
|
||||
type="text"
|
||||
/>
|
||||
{errors.full_name && (
|
||||
<FormErrorMessage>{errors.full_name.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isRequired isInvalid={!!errors.password}>
|
||||
<FormLabel htmlFor="password">Set Password</FormLabel>
|
||||
<Input
|
||||
id="password"
|
||||
{...register("password", {
|
||||
required: "Password is required",
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: "Password must be at least 8 characters",
|
||||
},
|
||||
})}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<FormErrorMessage>{errors.password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl
|
||||
mt={4}
|
||||
isRequired
|
||||
isInvalid={!!errors.confirm_password}
|
||||
>
|
||||
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
{...register("confirm_password", {
|
||||
required: "Please confirm your password",
|
||||
validate: (value) =>
|
||||
value === getValues().password ||
|
||||
"The passwords do not match",
|
||||
})}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.confirm_password && (
|
||||
<FormErrorMessage>
|
||||
{errors.confirm_password.message}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Flex mt={4}>
|
||||
<FormControl>
|
||||
<Checkbox {...register("is_superuser")} colorScheme="teal">
|
||||
Is superuser?
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<Checkbox {...register("is_active")} colorScheme="teal">
|
||||
Is active?
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
<ModalFooter gap={3}>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddUser
|
180
frontend/src/components/Admin/EditUser.tsx
Normal file
180
frontend/src/components/Admin/EditUser.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import {
|
||||
type ApiError,
|
||||
type UserPublic,
|
||||
type UserUpdate,
|
||||
UsersService,
|
||||
} from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { emailPattern, handleError } from "../../utils"
|
||||
|
||||
interface EditUserProps {
|
||||
user: UserPublic
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface UserUpdateForm extends UserUpdate {
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
const EditUser = ({ user, isOpen, onClose }: EditUserProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting, isDirty },
|
||||
} = useForm<UserUpdateForm>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: user,
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: UserUpdateForm) =>
|
||||
UsersService.updateUser({ userId: user.id, requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "User updated successfully.", "success")
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<UserUpdateForm> = async (data) => {
|
||||
if (data.password === "") {
|
||||
data.password = undefined
|
||||
}
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
reset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Edit User</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<FormControl isInvalid={!!errors.email}>
|
||||
<FormLabel htmlFor="email">Email</FormLabel>
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
/>
|
||||
{errors.email && (
|
||||
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<FormLabel htmlFor="name">Full name</FormLabel>
|
||||
<Input id="name" {...register("full_name")} type="text" />
|
||||
</FormControl>
|
||||
<FormControl mt={4} isInvalid={!!errors.password}>
|
||||
<FormLabel htmlFor="password">Set Password</FormLabel>
|
||||
<Input
|
||||
id="password"
|
||||
{...register("password", {
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: "Password must be at least 8 characters",
|
||||
},
|
||||
})}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<FormErrorMessage>{errors.password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isInvalid={!!errors.confirm_password}>
|
||||
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
{...register("confirm_password", {
|
||||
validate: (value) =>
|
||||
value === getValues().password ||
|
||||
"The passwords do not match",
|
||||
})}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.confirm_password && (
|
||||
<FormErrorMessage>
|
||||
{errors.confirm_password.message}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Flex>
|
||||
<FormControl mt={4}>
|
||||
<Checkbox {...register("is_superuser")} colorScheme="teal">
|
||||
Is superuser?
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<Checkbox {...register("is_active")} colorScheme="teal">
|
||||
Is active?
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter gap={3}>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditUser
|
133
frontend/src/components/Common/ActionsMenu.tsx
Normal file
133
frontend/src/components/Common/ActionsMenu.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react"
|
||||
import { BsThreeDotsVertical } from "react-icons/bs"
|
||||
import { FiEdit, FiTrash } from "react-icons/fi"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import type { ItemPublic, UserPublic, AboutUsPublic, AboutUsUpdate, CoursePublic, ImagePublic, SchedulePublic } from "../../client"
|
||||
import EditUser from "../Admin/EditUser"
|
||||
import EditItem from "../Items/EditItem"
|
||||
import EditCourseImage from "../CourseImage/editCourseImage"
|
||||
import EditAboutUs from "../AboutUs/EditAboutUs"
|
||||
import EditSechedule from "../Courses/EditSechedule"
|
||||
import Delete from "./DeleteAlert"
|
||||
|
||||
interface ActionsMenuProps {
|
||||
type: string
|
||||
value: ItemPublic | UserPublic | AboutUsPublic | CoursePublic | ImagePublic | SchedulePublic
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const ActionsMenu = ({ type, value, disabled }: ActionsMenuProps) => {
|
||||
const editUserModal = useDisclosure()
|
||||
const deleteModal = useDisclosure()
|
||||
|
||||
const renderEditModel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'User':
|
||||
return (
|
||||
<EditUser
|
||||
user={value as UserPublic}
|
||||
isOpen={editUserModal.isOpen}
|
||||
onClose={editUserModal.onClose}
|
||||
/>
|
||||
)
|
||||
case 'Item':
|
||||
return (
|
||||
<EditItem
|
||||
item={value as ItemPublic}
|
||||
isOpen={editUserModal.isOpen}
|
||||
onClose={editUserModal.onClose}
|
||||
/>
|
||||
)
|
||||
case 'AboutUs':
|
||||
return (
|
||||
<EditAboutUs
|
||||
aboutUs={value as AboutUsPublic}
|
||||
isOpen={editUserModal.isOpen}
|
||||
onClose={editUserModal.onClose}
|
||||
/>
|
||||
)
|
||||
case 'Image':
|
||||
return (
|
||||
<EditCourseImage
|
||||
type='Image'
|
||||
imageDetails={value as ImagePublic}
|
||||
isOpen={editUserModal.isOpen}
|
||||
onClose={editUserModal.onClose}
|
||||
/>
|
||||
)
|
||||
case 'Info_Image':
|
||||
return (
|
||||
<EditCourseImage
|
||||
type='Info_Image'
|
||||
imageDetails={value as ImagePublic}
|
||||
isOpen={editUserModal.isOpen}
|
||||
onClose={editUserModal.onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
case "Sechedule":
|
||||
return (
|
||||
<EditSechedule
|
||||
type="Sechedule"
|
||||
sechedule={value as SchedulePublic}
|
||||
isOpen={editUserModal.isOpen}
|
||||
onClose={editUserModal.onClose}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
isDisabled={disabled}
|
||||
as={Button}
|
||||
rightIcon={<BsThreeDotsVertical />}
|
||||
variant="unstyled"
|
||||
/>
|
||||
<MenuList>
|
||||
{type === 'Message' ? (<></>) : (<MenuItem
|
||||
onClick={editUserModal.onOpen}
|
||||
icon={<FiEdit fontSize="16px" />}
|
||||
>
|
||||
Edit {type}
|
||||
</MenuItem>)
|
||||
}
|
||||
|
||||
<MenuItem
|
||||
onClick={deleteModal.onOpen}
|
||||
icon={<FiTrash fontSize="16px" />}
|
||||
color="ui.danger"
|
||||
>
|
||||
Delete {type}
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
{
|
||||
renderEditModel(type)
|
||||
}
|
||||
<Delete
|
||||
type={type}
|
||||
id={value.id}
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={deleteModal.onClose}
|
||||
/>
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default ActionsMenu
|
149
frontend/src/components/Common/DeleteAlert.tsx
Normal file
149
frontend/src/components/Common/DeleteAlert.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
Button,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import React from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
|
||||
import { ItemsService, UsersService, ClientMessagesService, AboutUsService, CoursesService, ImageService, Info_imageService,secheduleService } from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
|
||||
interface DeleteProps {
|
||||
type: string
|
||||
id: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const Delete = ({ type, id, isOpen, onClose }: DeleteProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const cancelRef = React.useRef<HTMLButtonElement | null>(null)
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm()
|
||||
|
||||
const deleteEntity = async (id: string) => {
|
||||
if (type === "Item") {
|
||||
await ItemsService.deleteItem({ id: id })
|
||||
} else if (type === "User") {
|
||||
await UsersService.deleteUser({ userId: id })
|
||||
} else if (type === "Message") {
|
||||
await ClientMessagesService.deleteMessage({ id: id })
|
||||
} else if (type === "AboutUs") {
|
||||
await AboutUsService.deleteAboutUs({ id: id })
|
||||
} else if (type === "Course") {
|
||||
await CoursesService.deleteCourse({ id: id })
|
||||
} else if (type === "Image") {
|
||||
await ImageService.deleteImage({ id: id })
|
||||
} else if (type === "Info_Image") {
|
||||
await Info_imageService.deleteImage({ id: id })
|
||||
} else if (type === "Sechedule") {
|
||||
await secheduleService.deleteSechedule({ id: id })
|
||||
}
|
||||
else {
|
||||
throw new Error(`Unexpected type: ${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: deleteEntity,
|
||||
onSuccess: (data) => {
|
||||
showToast(
|
||||
"Success",
|
||||
`The ${type.toLowerCase()} was deleted successfully.`,
|
||||
"success",
|
||||
)
|
||||
console.log(data)
|
||||
//queryClient.setQueryData(['course'], data)
|
||||
onClose()
|
||||
},
|
||||
onError: () => {
|
||||
showToast(
|
||||
"An error occurred.",
|
||||
`An error occurred while deleting the ${type.toLowerCase()}.`,
|
||||
"error",
|
||||
)
|
||||
},
|
||||
onSettled: () => {
|
||||
var key = ''
|
||||
if (type === "Item") {
|
||||
key = "items"
|
||||
} else if (type === "User") {
|
||||
key = "users"
|
||||
} else if (type === "Message") {
|
||||
key = "messages"
|
||||
} else if (type === "AboutUs") {
|
||||
key = "aboutUs"
|
||||
} else if (type === "Course") {
|
||||
key = "courses"
|
||||
} else if (type === "Image") {
|
||||
key = "course"
|
||||
} else if(type === "Info_Image"){
|
||||
key = "course"
|
||||
} else if (type === "Sechedule") {
|
||||
key = "course"
|
||||
}
|
||||
else {
|
||||
throw new Error(`Unexpected type: ${type}`)
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [key],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async () => {
|
||||
mutation.mutate(id)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertDialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
leastDestructiveRef={cancelRef}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<AlertDialogHeader>Delete {type}</AlertDialogHeader>
|
||||
|
||||
<AlertDialogBody>
|
||||
{type === "User" && (
|
||||
<span>
|
||||
All items associated with this user will also be{" "}
|
||||
<strong>permantly deleted. </strong>
|
||||
</span>
|
||||
)}
|
||||
Are you sure? You will not be able to undo this action.
|
||||
</AlertDialogBody>
|
||||
|
||||
<AlertDialogFooter gap={3}>
|
||||
<Button variant="danger" type="submit" isLoading={isSubmitting}>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
ref={cancelRef}
|
||||
onClick={onClose}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Delete
|
46
frontend/src/components/Common/Navbar.tsx
Normal file
46
frontend/src/components/Common/Navbar.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { ComponentType, ElementType } from "react"
|
||||
|
||||
import { Button, Flex, Icon, useDisclosure } from "@chakra-ui/react"
|
||||
import { FaPlus } from "react-icons/fa"
|
||||
|
||||
interface NavbarProps {
|
||||
type: string
|
||||
addModalAs: ComponentType | ElementType
|
||||
value?: string
|
||||
}
|
||||
|
||||
const Navbar = ({ type, addModalAs, value }: NavbarProps) => {
|
||||
const addModal = useDisclosure()
|
||||
|
||||
const AddModal = addModalAs
|
||||
return (
|
||||
<>
|
||||
<Flex py={8} gap={4}>
|
||||
{/* TODO: Complete search functionality */}
|
||||
{/* <InputGroup w={{ base: '100%', md: 'auto' }}>
|
||||
<InputLeftElement pointerEvents='none'>
|
||||
<Icon as={FaSearch} color='ui.dim' />
|
||||
</InputLeftElement>
|
||||
<Input type='text' placeholder='Search' fontSize={{ base: 'sm', md: 'inherit' }} borderRadius='8px' />
|
||||
</InputGroup> */}
|
||||
<Button
|
||||
variant="primary"
|
||||
gap={1}
|
||||
fontSize={{ base: "sm", md: "inherit" }}
|
||||
onClick={addModal.onOpen}
|
||||
>
|
||||
<Icon as={FaPlus} /> Add {type}
|
||||
</Button>
|
||||
{type === "Sechedule" ? (
|
||||
<AddModal isOpen={addModal.isOpen} onClose={addModal.onClose} courseId={value} />
|
||||
) : (
|
||||
<AddModal isOpen={addModal.isOpen} onClose={addModal.onClose} />
|
||||
)
|
||||
}
|
||||
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Navbar
|
41
frontend/src/components/Common/NotFound.tsx
Normal file
41
frontend/src/components/Common/NotFound.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Button, Container, Text } from "@chakra-ui/react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
|
||||
const NotFound = () => {
|
||||
return (
|
||||
<>
|
||||
<Container
|
||||
h="100vh"
|
||||
alignItems="stretch"
|
||||
justifyContent="center"
|
||||
textAlign="center"
|
||||
maxW="sm"
|
||||
centerContent
|
||||
>
|
||||
<Text
|
||||
fontSize="8xl"
|
||||
color="ui.main"
|
||||
fontWeight="bold"
|
||||
lineHeight="1"
|
||||
mb={4}
|
||||
>
|
||||
404
|
||||
</Text>
|
||||
<Text fontSize="md">Oops!</Text>
|
||||
<Text fontSize="md">Page not found.</Text>
|
||||
<Button
|
||||
as={Link}
|
||||
to="/"
|
||||
color="ui.main"
|
||||
borderColor="ui.main"
|
||||
variant="outline"
|
||||
mt={4}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotFound
|
116
frontend/src/components/Common/Sidebar.tsx
Normal file
116
frontend/src/components/Common/Sidebar.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
Box,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerOverlay,
|
||||
Flex,
|
||||
IconButton,
|
||||
Image,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { FiLogOut, FiMenu } from "react-icons/fi"
|
||||
|
||||
import Logo from "/assets/images/logo.png"
|
||||
import type { UserPublic } from "../../client"
|
||||
import useAuth from "../../hooks/useAuth"
|
||||
import SidebarItems from "./SidebarItems"
|
||||
|
||||
const Sidebar = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const bgColor = useColorModeValue("ui.light", "ui.dark")
|
||||
const textColor = useColorModeValue("ui.dark", "ui.light")
|
||||
const secBgColor = useColorModeValue("ui.secondary", "ui.darkSlate")
|
||||
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const { logout } = useAuth()
|
||||
|
||||
const handleLogout = async () => {
|
||||
logout()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile */}
|
||||
<IconButton
|
||||
onClick={onOpen}
|
||||
display={{ base: "flex", md: "none" }}
|
||||
aria-label="Open Menu"
|
||||
position="absolute"
|
||||
fontSize="20px"
|
||||
m={4}
|
||||
icon={<FiMenu />}
|
||||
/>
|
||||
<Drawer isOpen={isOpen} placement="left" onClose={onClose}>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent maxW="250px">
|
||||
<DrawerCloseButton />
|
||||
<DrawerBody py={8}>
|
||||
<Flex flexDir="column" justify="space-between">
|
||||
<Box>
|
||||
<Image src={Logo} alt="logo" p={8} />
|
||||
<SidebarItems onClose={onClose} />
|
||||
<Flex
|
||||
as="button"
|
||||
onClick={handleLogout}
|
||||
p={2}
|
||||
color="ui.danger"
|
||||
fontWeight="bold"
|
||||
alignItems="center"
|
||||
>
|
||||
<FiLogOut />
|
||||
<Text ml={2}>Log out</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
{currentUser?.email && (
|
||||
<Text color={textColor} noOfLines={2} fontSize="sm" p={2}>
|
||||
Logged in as: {currentUser.email}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
{/* Desktop */}
|
||||
<Box
|
||||
bg={bgColor}
|
||||
p={3}
|
||||
h="100vh"
|
||||
position="sticky"
|
||||
top="0"
|
||||
display={{ base: "none", md: "flex" }}
|
||||
>
|
||||
<Flex
|
||||
flexDir="column"
|
||||
justify="space-between"
|
||||
bg={secBgColor}
|
||||
p={4}
|
||||
borderRadius={12}
|
||||
>
|
||||
<Box>
|
||||
<Image src={Logo} alt="Logo" w="180px" maxW="2xs" p={6} />
|
||||
<SidebarItems />
|
||||
</Box>
|
||||
{currentUser?.email && (
|
||||
<Text
|
||||
color={textColor}
|
||||
noOfLines={2}
|
||||
fontSize="sm"
|
||||
p={2}
|
||||
maxW="180px"
|
||||
>
|
||||
Logged in as: {currentUser.email}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
60
frontend/src/components/Common/SidebarItems.tsx
Normal file
60
frontend/src/components/Common/SidebarItems.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Box, Flex, Icon, Text, useColorModeValue } from "@chakra-ui/react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { FiBriefcase, FiHome, FiSettings, FiUsers, FiMessageSquare, FiAlignLeft, FiBook } from "react-icons/fi"
|
||||
|
||||
import type { UserPublic } from "../../client"
|
||||
|
||||
const items = [
|
||||
{ icon: FiHome, title: "Dashboard", path: "/" },
|
||||
{ icon: FiBriefcase, title: "Items", path: "/items" },
|
||||
{ icon: FiBook, title: "Courses", path: "/Courses/courses" },
|
||||
{ icon: FiMessageSquare, title: "Messages", path: "/clientMessages" },
|
||||
{ icon: FiAlignLeft, title: "About Us", path: "/aboutUs" },
|
||||
{ icon: FiSettings, title: "User Settings", path: "/settings" },
|
||||
{ icon: FiSettings, title: "Web Settings", path: "/webSetting" },
|
||||
]
|
||||
|
||||
interface SidebarItemsProps {
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const SidebarItems = ({ onClose }: SidebarItemsProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const textColor = useColorModeValue("ui.main", "ui.light")
|
||||
const bgActive = useColorModeValue("#E2E8F0", "#4A5568")
|
||||
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
|
||||
|
||||
const finalItems = currentUser?.is_superuser
|
||||
? [...items, { icon: FiUsers, title: "Admin", path: "/admin" }]
|
||||
: items
|
||||
|
||||
const listItems = finalItems.map(({ icon, title, path }) => (
|
||||
<Flex
|
||||
as={Link}
|
||||
to={path}
|
||||
w="100%"
|
||||
p={2}
|
||||
key={title}
|
||||
activeProps={{
|
||||
style: {
|
||||
background: bgActive,
|
||||
borderRadius: "12px",
|
||||
},
|
||||
}}
|
||||
color={textColor}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon as={icon} alignSelf="center" />
|
||||
<Text ml={2}>{title}</Text>
|
||||
</Flex>
|
||||
))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box>{listItems}</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SidebarItems
|
59
frontend/src/components/Common/UserMenu.tsx
Normal file
59
frontend/src/components/Common/UserMenu.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
} from "@chakra-ui/react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { FaUserAstronaut } from "react-icons/fa"
|
||||
import { FiLogOut, FiUser } from "react-icons/fi"
|
||||
|
||||
import useAuth from "../../hooks/useAuth"
|
||||
|
||||
const UserMenu = () => {
|
||||
const { logout } = useAuth()
|
||||
|
||||
const handleLogout = async () => {
|
||||
logout()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop */}
|
||||
<Box
|
||||
display={{ base: "none", md: "block" }}
|
||||
position="fixed"
|
||||
top={4}
|
||||
right={4}
|
||||
>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
aria-label="Options"
|
||||
icon={<FaUserAstronaut color="white" fontSize="18px" />}
|
||||
bg="ui.main"
|
||||
isRound
|
||||
data-testid="user-menu"
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem icon={<FiUser fontSize="18px" />} as={Link} to="settings">
|
||||
My profile
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<FiLogOut fontSize="18px" />}
|
||||
onClick={handleLogout}
|
||||
color="ui.danger"
|
||||
fontWeight="bold"
|
||||
>
|
||||
Log out
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserMenu
|
124
frontend/src/components/CourseImage/editCourseImage.tsx
Normal file
124
frontend/src/components/CourseImage/editCourseImage.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useRef, ReactNode, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
InputGroup,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm, UseFormRegisterReturn } from "react-hook-form"
|
||||
import { type ApiError, ImageService, Info_imageService, ImageUpdate, ImagePublic } from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
import "react-draft-wysiwyg/dist/react-draft-wysiwyg.css";
|
||||
|
||||
interface EditCourseImageProps {
|
||||
type: string
|
||||
imageDetails: ImagePublic
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const EditCourseImage = ({ type, imageDetails, isOpen, onClose }: EditCourseImageProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ImageUpdate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
index: imageDetails.index,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: ImageUpdate) =>
|
||||
type === 'Image' ?
|
||||
ImageService.updateImage({ index: data.index, id: imageDetails.id }) :
|
||||
Info_imageService.updateImage({ index: data.index, id: imageDetails.id }),
|
||||
onSuccess: (data) => {
|
||||
console.log(data)
|
||||
queryClient.setQueryData(['course'], data)
|
||||
reset()
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["courses", "course"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<ImageUpdate> = (data) => {
|
||||
// data.index = Number(data.index)
|
||||
mutation.mutate(data)
|
||||
console.log(data)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={'xl'}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>{type === 'Image' ? "Edit Image" : "Edit Info Image"}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
|
||||
<ModalBody pb={30}>
|
||||
|
||||
<FormControl isRequired isInvalid={!!errors.index}>
|
||||
<FormLabel htmlFor="index">Index</FormLabel >
|
||||
<NumberInput min={0} max={20} >
|
||||
<NumberInputField {...register("index", {
|
||||
required: "index is required.",
|
||||
})} />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
|
||||
|
||||
</FormControl>
|
||||
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter gap={3}>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default EditCourseImage
|
160
frontend/src/components/Courses/AddSechedule.tsx
Normal file
160
frontend/src/components/Courses/AddSechedule.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from "@chakra-ui/react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import DateTimePicker from 'react-datetime-picker';
|
||||
import 'react-calendar/dist/Calendar.css';
|
||||
import { type ApiError, type ScheduleCreate, secheduleService } from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
import moment from "moment";
|
||||
|
||||
interface AddSecheduleProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
courseId: string
|
||||
}
|
||||
|
||||
const AddSechedule = ({ isOpen, onClose, courseId }: AddSecheduleProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ScheduleCreate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
date: "",
|
||||
title: "",
|
||||
info1: "",
|
||||
info2: "",
|
||||
course_id: courseId,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setValue("course_id", courseId);
|
||||
setValue("date", moment.utc(new Date()).format());
|
||||
}, [setValue, courseId]);
|
||||
|
||||
const [datetime, setDatetime] = useState<Date>(new Date());
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: ScheduleCreate) =>
|
||||
secheduleService.createSechedule({ requestBody: data }),
|
||||
onSuccess: (data) => {
|
||||
showToast("Success!", "Sechedule created successfully.", "success")
|
||||
console.log(data)
|
||||
reset(
|
||||
{
|
||||
date: moment.utc(new Date()).format(),
|
||||
title: "",
|
||||
info1: "",
|
||||
info2: "",
|
||||
course_id: courseId,
|
||||
}
|
||||
)
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["course"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<ScheduleCreate> = (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Add Sechedule</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<DateTimePicker onChange={(newDatetime: Date | null) => {
|
||||
if (newDatetime instanceof Date) {
|
||||
setDatetime(newDatetime)
|
||||
setValue("date", moment.utc(newDatetime).format())
|
||||
}
|
||||
}} value={datetime} />
|
||||
<FormControl isRequired isInvalid={!!errors.title}>
|
||||
<FormLabel htmlFor="title">Title</FormLabel>
|
||||
<Input
|
||||
id="title"
|
||||
{...register("title", {
|
||||
required: "Title is required.",
|
||||
})}
|
||||
placeholder="Title"
|
||||
type="text"
|
||||
/>
|
||||
{errors.title && (
|
||||
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<FormLabel htmlFor="info1">Info 1</FormLabel>
|
||||
<Input
|
||||
id="description"
|
||||
{...register("info1")}
|
||||
placeholder="info1"
|
||||
type="text"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<FormLabel htmlFor="info2">Info 2</FormLabel>
|
||||
<Input
|
||||
id="description"
|
||||
{...register("info2")}
|
||||
placeholder="info2"
|
||||
type="text"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter gap={3}>
|
||||
<Button onClick={() => {
|
||||
const values = getValues()
|
||||
console.log(values)
|
||||
}}>
|
||||
test
|
||||
</Button>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default AddSechedule
|
262
frontend/src/components/Courses/CourseDetails.tsx
Normal file
262
frontend/src/components/Courses/CourseDetails.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Textarea,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Container,
|
||||
Heading,
|
||||
Box
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient, useMutation, QueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate, Await, useRouter } from "@tanstack/react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { CoursesService, type ApiError, CourseCreate, CourseDetailsPublic } from "../../client"
|
||||
import { handleError } from "../../utils"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import { EditorState, ContentState, convertToRaw } from 'draft-js';
|
||||
import { Editor } from "react-draft-wysiwyg";
|
||||
import draftToHtml from 'draftjs-to-html';
|
||||
import htmlToDraft from 'html-to-draftjs';
|
||||
import "react-draft-wysiwyg/dist/react-draft-wysiwyg.css";
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const CourseDetails = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const courseDetails = queryClient.getQueryData(['course']) as CourseDetailsPublic | undefined;
|
||||
|
||||
const toolbar = {
|
||||
options: ['inline', 'blockType', 'fontSize', 'list', 'textAlign', 'history', 'embedded', 'emoji', 'image'],
|
||||
inline: { inDropdown: true },
|
||||
list: { inDropdown: true },
|
||||
textAlign: { inDropdown: true },
|
||||
link: { inDropdown: true },
|
||||
history: { inDropdown: true },
|
||||
}
|
||||
|
||||
const showToast = useCustomToast()
|
||||
|
||||
const [contentEditorState, setContentEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [contents, setContent] = useState<string>('');
|
||||
const [infoEditorState, setInfoEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [info, setInfo] = useState<string>('');
|
||||
const [longDescriptionEditorState, setLongDescriptionEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [longDescription, setlongDescription] = useState<string>('');
|
||||
const [remarksEditorState, setRemarksEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [remarks, setRemarks] = useState<string>('');
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
setValue,
|
||||
unregister,
|
||||
formState: { isSubmitting, errors, isDirty },
|
||||
} = useForm<CourseCreate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
title: courseDetails?.title,
|
||||
sort_description: courseDetails?.sort_description,
|
||||
long_description: courseDetails?.long_description,
|
||||
remark: courseDetails?.remark,
|
||||
information: courseDetails?.information,
|
||||
contant: courseDetails?.contant,
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (courseDetails) {
|
||||
setValue('title', courseDetails.title);
|
||||
setValue('sort_description', courseDetails.sort_description);
|
||||
// Update other form fields as needed
|
||||
}
|
||||
if (courseDetails?.long_description) {
|
||||
const contentBlock = htmlToDraft(courseDetails.long_description);
|
||||
if (contentBlock) {
|
||||
const contentState = ContentState.createFromBlockArray(contentBlock.contentBlocks);
|
||||
const editorState = EditorState.createWithContent(contentState);
|
||||
setLongDescriptionEditorState(editorState);
|
||||
setValue('long_description', longDescription);
|
||||
}
|
||||
}
|
||||
if (courseDetails?.remark) {
|
||||
const contentBlock = htmlToDraft(courseDetails.remark);
|
||||
if (contentBlock) {
|
||||
const contentState = ContentState.createFromBlockArray(contentBlock.contentBlocks);
|
||||
const editorState = EditorState.createWithContent(contentState);
|
||||
setRemarksEditorState(editorState);
|
||||
setValue('remark', remarks);
|
||||
}
|
||||
}
|
||||
if (courseDetails?.information) {
|
||||
const contentBlock = htmlToDraft(courseDetails.information);
|
||||
if (contentBlock) {
|
||||
const contentState = ContentState.createFromBlockArray(contentBlock.contentBlocks);
|
||||
const editorState = EditorState.createWithContent(contentState);
|
||||
setInfoEditorState(editorState);
|
||||
setValue('information', info);
|
||||
}
|
||||
}
|
||||
if (courseDetails?.contant) {
|
||||
const contentBlock = htmlToDraft(courseDetails.contant);
|
||||
if (contentBlock) {
|
||||
const contentState = ContentState.createFromBlockArray(contentBlock.contentBlocks);
|
||||
const editorState = EditorState.createWithContent(contentState);
|
||||
setContentEditorState(editorState);
|
||||
setValue('contant', contents);
|
||||
}
|
||||
}
|
||||
}, [courseDetails]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: CourseCreate) =>
|
||||
CoursesService.updateCourse({ id: courseDetails?.id ?? '', requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Course create successfully.", "success")
|
||||
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["courses"] })
|
||||
//history.go(-1)
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<CourseCreate> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="sm" py={4}>
|
||||
Course Details
|
||||
</Heading>
|
||||
<Box
|
||||
w={{ sm: "full", md: "50%" }}
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormControl isInvalid={!!errors.title}>
|
||||
<FormLabel htmlFor="title">Title</FormLabel>
|
||||
<Input
|
||||
defaultValue={courseDetails?.title}
|
||||
id="title"
|
||||
type="text"
|
||||
{...register("title", {
|
||||
required: "title is required",
|
||||
})}
|
||||
/>
|
||||
{errors.title && (
|
||||
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="sort_description">Short Description</FormLabel>
|
||||
<Textarea
|
||||
id="sort_description"
|
||||
{...register("sort_description", {
|
||||
required: "sort_description is required",
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="long_description">Long Description</FormLabel>
|
||||
<Editor
|
||||
editorState={longDescriptionEditorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setLongDescriptionEditorState(newState);
|
||||
setlongDescription(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
setValue("long_description", longDescription);
|
||||
}}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="information">Information</FormLabel>
|
||||
<Editor
|
||||
editorState={infoEditorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setInfoEditorState(newState);
|
||||
setInfo(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
setValue("information", info);
|
||||
}}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="contant">Content</FormLabel>
|
||||
<Editor
|
||||
editorState={contentEditorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setContentEditorState(newState);
|
||||
setContent(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
setValue("contant", contents);
|
||||
}}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="remark">Remark</FormLabel>
|
||||
<Editor
|
||||
editorState={remarksEditorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setRemarksEditorState(newState);
|
||||
setRemarks(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
setValue("remark", remarks);
|
||||
}}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const values = getValues()
|
||||
console.log(values)
|
||||
// history.go(-1)// { test: "test-input", test1: "test1-input" }
|
||||
}}
|
||||
>
|
||||
Get Values
|
||||
</button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<FormControl mt={20}></FormControl>
|
||||
</Box>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
export default CourseDetails;
|
186
frontend/src/components/Courses/CourseImages.tsx
Normal file
186
frontend/src/components/Courses/CourseImages.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Textarea,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Container,
|
||||
Heading,
|
||||
Box,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
Image,
|
||||
Flex,
|
||||
HStack,
|
||||
VStack,
|
||||
Text,
|
||||
Td,
|
||||
Icon,
|
||||
Grid
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate, Await, useRouter } from "@tanstack/react-router"
|
||||
import { FaPlus, FaPen, FaTrashAlt } from "react-icons/fa"
|
||||
import { useEffect, useState } from "react"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { ImageService, type ApiError, CourseDetailsPublic, ImageCreate } from "../../client"
|
||||
import { handleError } from "../../utils"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const CourseImages = () => {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const courseDetails = queryClient.getQueryData(['course']) as CourseDetailsPublic | undefined;
|
||||
const url = import.meta.env.VITE_API_URL
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
setValue,
|
||||
unregister,
|
||||
formState: { isSubmitting, errors, isDirty },
|
||||
} = useForm<ImageCreate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
course_id: '',
|
||||
image: undefined,
|
||||
index: 0,
|
||||
}
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: ImageCreate) =>
|
||||
ImageService.createImage({ formData: data }),
|
||||
onSuccess: (data) => {
|
||||
showToast("Success!", "Image added successfully.", "success")
|
||||
console.log(data)
|
||||
queryClient.setQueryData(['course'], data)
|
||||
reset()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["courses"] })
|
||||
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (courseDetails) {
|
||||
setValue("course_id", courseDetails.id)
|
||||
}
|
||||
}, [courseDetails]);
|
||||
|
||||
const onSubmit: SubmitHandler<ImageCreate> = async (data) => {
|
||||
if (data.image instanceof FileList && data.image.length > 0) {
|
||||
data.image = data.image[0]
|
||||
}
|
||||
if (courseDetails?.images && courseDetails.images.length >= 5) {
|
||||
showToast("Error!", "You can only add 5 images", "error")
|
||||
return
|
||||
} else {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Box
|
||||
w={{ sm: "full", md: "50%" }}
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<Box w="100%" maxW="full" overflowX="auto">
|
||||
<VStack align="flex-start">
|
||||
<HStack spacing={4} overflowX="auto">
|
||||
|
||||
{courseDetails?.images.map((image, index) => (
|
||||
<Box width={200} height={200} >
|
||||
<Grid templateColumns='repeat(2, 1fr)' gap={1}>
|
||||
<Text >{image.index}</Text>
|
||||
<ActionsMenu type={"Image"} value={image} />
|
||||
</Grid>
|
||||
|
||||
<Image key={index} src={url + "/" + image.image} objectFit="cover" />
|
||||
|
||||
</Box>
|
||||
))}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
<Heading size="sm" py={4}>
|
||||
Add Course Image
|
||||
</Heading>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl isRequired isInvalid={!!errors.index}>
|
||||
<FormLabel htmlFor="index">Index</FormLabel >
|
||||
<NumberInput min={0} max={20} >
|
||||
<NumberInputField {...register("index", {
|
||||
required: "index is required.",
|
||||
})} />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
{errors.index && (
|
||||
<FormErrorMessage>{errors.index.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl isInvalid={!!errors.image} isRequired>
|
||||
<FormLabel>{'Image Upload'}</FormLabel>
|
||||
<input type="file" {...register("image", {
|
||||
required: "index is required.",
|
||||
})} />
|
||||
<FormErrorMessage>
|
||||
{errors.image && errors?.image.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const values = getValues()
|
||||
console.log(values)
|
||||
// history.go(-1)// { test: "test-input", test1: "test1-input" }
|
||||
}}
|
||||
>
|
||||
Get Values
|
||||
</button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<FormControl mt={20}></FormControl>
|
||||
</Box>
|
||||
</Container >
|
||||
)
|
||||
}
|
||||
export default CourseImages;
|
186
frontend/src/components/Courses/CourseInfoImages.tsx
Normal file
186
frontend/src/components/Courses/CourseInfoImages.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Textarea,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Container,
|
||||
Heading,
|
||||
Box,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
Image,
|
||||
Flex,
|
||||
HStack,
|
||||
VStack,
|
||||
Text,
|
||||
Td,
|
||||
Icon,
|
||||
Grid
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate, Await, useRouter } from "@tanstack/react-router"
|
||||
import { FaPlus, FaPen, FaTrashAlt } from "react-icons/fa"
|
||||
import { useEffect, useState } from "react"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { Info_imageService, type ApiError, CourseDetailsPublic, ImageCreate } from "../../client"
|
||||
import { handleError } from "../../utils"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const CourseInfoImages = () => {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const courseDetails = queryClient.getQueryData(['course']) as CourseDetailsPublic | undefined;
|
||||
const url = import.meta.env.VITE_API_URL
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
setValue,
|
||||
unregister,
|
||||
formState: { isSubmitting, errors, isDirty },
|
||||
} = useForm<ImageCreate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
course_id: '',
|
||||
image: undefined,
|
||||
index: 0,
|
||||
}
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: ImageCreate) =>
|
||||
Info_imageService.createImage({ formData: data }),
|
||||
onSuccess: (data) => {
|
||||
showToast("Success!", "Image added successfully.", "success")
|
||||
console.log(data)
|
||||
queryClient.setQueryData(['course'], data)
|
||||
reset()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["courses"] })
|
||||
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (courseDetails) {
|
||||
setValue("course_id", courseDetails.id)
|
||||
}
|
||||
}, [courseDetails]);
|
||||
|
||||
const onSubmit: SubmitHandler<ImageCreate> = async (data) => {
|
||||
if (data.image instanceof FileList && data.image.length > 0) {
|
||||
data.image = data.image[0]
|
||||
}
|
||||
if (courseDetails?.info_images && courseDetails.info_images.length >= 5) {
|
||||
showToast("Error!", "You can only add 5 images", "error")
|
||||
return
|
||||
} else {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Box
|
||||
w={{ sm: "full", md: "50%" }}
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<Box w="100%" maxW="full" overflowX="auto">
|
||||
<VStack align="flex-start">
|
||||
<HStack spacing={4} overflowX="auto">
|
||||
|
||||
{courseDetails?.info_images.map((image, index) => (
|
||||
<Box width={200} height={200} >
|
||||
<Grid templateColumns='repeat(2, 1fr)' gap={1}>
|
||||
<Text >{image.index}</Text>
|
||||
<ActionsMenu type={"Info_Image"} value={image} />
|
||||
</Grid>
|
||||
|
||||
<Image key={index} src={url + "/" + image.image} objectFit="cover" />
|
||||
|
||||
</Box>
|
||||
))}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
<Heading size="sm" py={4}>
|
||||
Add Course Image
|
||||
</Heading>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl isRequired isInvalid={!!errors.index}>
|
||||
<FormLabel htmlFor="index">Index</FormLabel >
|
||||
<NumberInput min={0} max={20} >
|
||||
<NumberInputField {...register("index", {
|
||||
required: "index is required.",
|
||||
})} />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
{errors.index && (
|
||||
<FormErrorMessage>{errors.index.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl isInvalid={!!errors.image} isRequired>
|
||||
<FormLabel>{'Image Upload'}</FormLabel>
|
||||
<input type="file" {...register("image", {
|
||||
required: "index is required.",
|
||||
})} />
|
||||
<FormErrorMessage>
|
||||
{errors.image && errors?.image.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
{/* <button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const values = getValues()
|
||||
console.log(values)
|
||||
// history.go(-1)// { test: "test-input", test1: "test1-input" }
|
||||
}}
|
||||
>
|
||||
Get Values
|
||||
</button> */}
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<FormControl mt={20}></FormControl>
|
||||
</Box>
|
||||
</Container >
|
||||
)
|
||||
}
|
||||
export default CourseInfoImages;
|
149
frontend/src/components/Courses/EditSechedule.tsx
Normal file
149
frontend/src/components/Courses/EditSechedule.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from "@chakra-ui/react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import DateTimePicker from 'react-datetime-picker';
|
||||
import 'react-calendar/dist/Calendar.css';
|
||||
import { type ApiError, type ScheduleCreate, SchedulePublic, ScheduleUpdate, secheduleService } from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
import moment from "moment";
|
||||
|
||||
interface EditItemProps {
|
||||
sechedule: SchedulePublic
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
type: string
|
||||
}
|
||||
|
||||
const EditSechedule = ({ sechedule, type, isOpen, onClose }: EditItemProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { isSubmitting, errors, isDirty },
|
||||
} = useForm<ScheduleUpdate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
date: sechedule.date,
|
||||
title: sechedule.title,
|
||||
info1: sechedule.info1,
|
||||
info2: sechedule.info2,
|
||||
},
|
||||
})
|
||||
|
||||
const [datetime, setDatetime] = useState<string>(sechedule.date);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: ScheduleUpdate) =>
|
||||
secheduleService.updateSechedule({ id: sechedule.id, requestBody: data }),
|
||||
onSuccess: (data) => {
|
||||
showToast("Success!", "Sechedule edit successfully.", "success")
|
||||
console.log(data)
|
||||
reset()
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["course"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<ScheduleUpdate> = (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Edit Sechedule</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<DateTimePicker onChange={(newDatetime: Date | null) => {
|
||||
if (newDatetime instanceof Date) {
|
||||
const formattedDate = moment.utc(newDatetime).format()
|
||||
setDatetime(formattedDate)
|
||||
setValue("date", formattedDate)
|
||||
}
|
||||
}} value={moment.utc(datetime).toDate()} />
|
||||
<FormControl isRequired isInvalid={!!errors.title}>
|
||||
<FormLabel htmlFor="title">Title</FormLabel>
|
||||
<Input
|
||||
id="title"
|
||||
{...register("title", {
|
||||
required: "Title is required.",
|
||||
})}
|
||||
placeholder="Title"
|
||||
type="text"
|
||||
/>
|
||||
{errors.title && (
|
||||
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<FormLabel htmlFor="info1">Info 1</FormLabel>
|
||||
<Input
|
||||
id="description"
|
||||
{...register("info1")}
|
||||
placeholder="info1"
|
||||
type="text"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<FormLabel htmlFor="info2">Info 2</FormLabel>
|
||||
<Input
|
||||
id="description"
|
||||
{...register("info2")}
|
||||
placeholder="info2"
|
||||
type="text"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter gap={3}>
|
||||
<Button onClick={() => {
|
||||
const values = getValues()
|
||||
console.log(values)
|
||||
}}>
|
||||
test
|
||||
</Button>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditSechedule
|
76
frontend/src/components/Courses/Sechedule.tsx
Normal file
76
frontend/src/components/Courses/Sechedule.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Heading,
|
||||
SkeletonText,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react"
|
||||
import moment from 'moment';
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate, Await, useRouter } from "@tanstack/react-router"
|
||||
import { FaPlus, FaPen, FaTrashAlt } from "react-icons/fa"
|
||||
import { useEffect, useState } from "react"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { Info_imageService, type ApiError, CourseDetailsPublic, ImageCreate } from "../../client"
|
||||
import { handleError } from "../../utils"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
import DateTimePicker from 'react-datetime-picker';
|
||||
import 'react-datetime-picker/dist/DateTimePicker.css';
|
||||
import Navbar from "../../components/Common/Navbar"
|
||||
import AddSechedule from "./AddSechedule";
|
||||
|
||||
const Sechedule = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const courseDetails = queryClient.getQueryData(['course']) as CourseDetailsPublic | undefined;
|
||||
|
||||
const showToast = useCustomToast()
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Navbar type={"Sechedule"} addModalAs={AddSechedule} value={courseDetails?.id} />
|
||||
<TableContainer>
|
||||
<Table size={{ base: "sm", md: "md" }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Date</Th>
|
||||
<Th>Title</Th>
|
||||
<Th>Info 1</Th>
|
||||
<Th>Info 2</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{courseDetails?.schedule?.map((schedule, index) => (
|
||||
<Tr key={schedule.id}>
|
||||
<Td maxWidth="20px">
|
||||
{moment(schedule.date).utcOffset("+08:00").format('DD-MM-YYYY HH:mm')}
|
||||
</Td>
|
||||
<Td maxWidth="50px">
|
||||
{schedule.title}
|
||||
</Td>
|
||||
<Td maxWidth="300px">
|
||||
{schedule.info1}
|
||||
</Td>
|
||||
<Td maxWidth="300px">
|
||||
{schedule.info2}
|
||||
</Td>
|
||||
<Td>
|
||||
<ActionsMenu type={"Sechedule"} value={schedule} />
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
export default Sechedule;
|
114
frontend/src/components/Items/AddItem.tsx
Normal file
114
frontend/src/components/Items/AddItem.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { type ApiError, type ItemCreate, ItemsService } from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
|
||||
interface AddItemProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const AddItem = ({ isOpen, onClose }: AddItemProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ItemCreate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
title: "",
|
||||
description: "",
|
||||
},
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: ItemCreate) =>
|
||||
ItemsService.createItem({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Item created successfully.", "success")
|
||||
reset()
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<ItemCreate> = (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Add Item</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<FormControl isRequired isInvalid={!!errors.title}>
|
||||
<FormLabel htmlFor="title">Title</FormLabel>
|
||||
<Input
|
||||
id="title"
|
||||
{...register("title", {
|
||||
required: "Title is required.",
|
||||
})}
|
||||
placeholder="Title"
|
||||
type="text"
|
||||
/>
|
||||
{errors.title && (
|
||||
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<FormLabel htmlFor="description">Description</FormLabel>
|
||||
<Input
|
||||
id="description"
|
||||
{...register("description")}
|
||||
placeholder="Description"
|
||||
type="text"
|
||||
/>
|
||||
</FormControl>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter gap={3}>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddItem
|
124
frontend/src/components/Items/EditItem.tsx
Normal file
124
frontend/src/components/Items/EditItem.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import {
|
||||
type ApiError,
|
||||
type ItemPublic,
|
||||
type ItemUpdate,
|
||||
ItemsService,
|
||||
} from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
|
||||
interface EditItemProps {
|
||||
item: ItemPublic
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const EditItem = ({ item, isOpen, onClose }: EditItemProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting, errors, isDirty },
|
||||
} = useForm<ItemUpdate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: item,
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: ItemUpdate) =>
|
||||
ItemsService.updateItem({ id: item.id, requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Item updated successfully.", "success")
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<ItemUpdate> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
reset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Edit Item</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<FormControl isInvalid={!!errors.title}>
|
||||
<FormLabel htmlFor="title">Title</FormLabel>
|
||||
<Input
|
||||
id="title"
|
||||
{...register("title", {
|
||||
required: "Title is required",
|
||||
})}
|
||||
type="text"
|
||||
/>
|
||||
{errors.title && (
|
||||
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<FormLabel htmlFor="description">Description</FormLabel>
|
||||
<Input
|
||||
id="description"
|
||||
{...register("description")}
|
||||
placeholder="Description"
|
||||
type="text"
|
||||
/>
|
||||
</FormControl>
|
||||
</ModalBody>
|
||||
<ModalFooter gap={3}>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditItem
|
38
frontend/src/components/UserSettings/Appearance.tsx
Normal file
38
frontend/src/components/UserSettings/Appearance.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
Badge,
|
||||
Container,
|
||||
Heading,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Stack,
|
||||
useColorMode,
|
||||
} from "@chakra-ui/react"
|
||||
|
||||
const Appearance = () => {
|
||||
const { colorMode, toggleColorMode } = useColorMode()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container maxW="full">
|
||||
<Heading size="sm" py={4}>
|
||||
Appearance
|
||||
</Heading>
|
||||
<RadioGroup onChange={toggleColorMode} value={colorMode}>
|
||||
<Stack>
|
||||
{/* TODO: Add system default option */}
|
||||
<Radio value="light" colorScheme="teal">
|
||||
Light Mode
|
||||
<Badge ml="1" colorScheme="teal">
|
||||
Default
|
||||
</Badge>
|
||||
</Radio>
|
||||
<Radio value="dark" colorScheme="teal">
|
||||
Dark Mode
|
||||
</Radio>
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default Appearance
|
122
frontend/src/components/UserSettings/ChangePassword.tsx
Normal file
122
frontend/src/components/UserSettings/ChangePassword.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { type ApiError, type UpdatePassword, UsersService } from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { confirmPasswordRules, handleError, passwordRules } from "../../utils"
|
||||
|
||||
interface UpdatePasswordForm extends UpdatePassword {
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
const ChangePassword = () => {
|
||||
const color = useColorModeValue("inherit", "ui.light")
|
||||
const showToast = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<UpdatePasswordForm>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: UpdatePassword) =>
|
||||
UsersService.updatePasswordMe({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Password updated successfully.", "success")
|
||||
reset()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<UpdatePasswordForm> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container maxW="full">
|
||||
<Heading size="sm" py={4}>
|
||||
Change Password
|
||||
</Heading>
|
||||
<Box
|
||||
w={{ sm: "full", md: "50%" }}
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormControl isRequired isInvalid={!!errors.current_password}>
|
||||
<FormLabel color={color} htmlFor="current_password">
|
||||
Current Password
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="current_password"
|
||||
{...register("current_password")}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
w="auto"
|
||||
/>
|
||||
{errors.current_password && (
|
||||
<FormErrorMessage>
|
||||
{errors.current_password.message}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isRequired isInvalid={!!errors.new_password}>
|
||||
<FormLabel htmlFor="password">Set Password</FormLabel>
|
||||
<Input
|
||||
id="password"
|
||||
{...register("new_password", passwordRules())}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
w="auto"
|
||||
/>
|
||||
{errors.new_password && (
|
||||
<FormErrorMessage>{errors.new_password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isRequired isInvalid={!!errors.confirm_password}>
|
||||
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
{...register("confirm_password", confirmPasswordRules(getValues))}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
w="auto"
|
||||
/>
|
||||
{errors.confirm_password && (
|
||||
<FormErrorMessage>
|
||||
{errors.confirm_password.message}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Button
|
||||
variant="primary"
|
||||
mt={4}
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default ChangePassword
|
35
frontend/src/components/UserSettings/DeleteAccount.tsx
Normal file
35
frontend/src/components/UserSettings/DeleteAccount.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react"
|
||||
|
||||
import DeleteConfirmation from "./DeleteConfirmation"
|
||||
|
||||
const DeleteAccount = () => {
|
||||
const confirmationModal = useDisclosure()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container maxW="full">
|
||||
<Heading size="sm" py={4}>
|
||||
Delete Account
|
||||
</Heading>
|
||||
<Text>
|
||||
Permanently delete your data and everything associated with your
|
||||
account.
|
||||
</Text>
|
||||
<Button variant="danger" mt={4} onClick={confirmationModal.onOpen}>
|
||||
Delete
|
||||
</Button>
|
||||
<DeleteConfirmation
|
||||
isOpen={confirmationModal.isOpen}
|
||||
onClose={confirmationModal.onClose}
|
||||
/>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default DeleteAccount
|
96
frontend/src/components/UserSettings/DeleteConfirmation.tsx
Normal file
96
frontend/src/components/UserSettings/DeleteConfirmation.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
Button,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import React from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
|
||||
import { type ApiError, UsersService } from "../../client"
|
||||
import useAuth from "../../hooks/useAuth"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
|
||||
interface DeleteProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const DeleteConfirmation = ({ isOpen, onClose }: DeleteProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const cancelRef = React.useRef<HTMLButtonElement | null>(null)
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm()
|
||||
const { logout } = useAuth()
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => UsersService.deleteUserMe(),
|
||||
onSuccess: () => {
|
||||
showToast(
|
||||
"Success",
|
||||
"Your account has been successfully deleted.",
|
||||
"success",
|
||||
)
|
||||
logout()
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["currentUser"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async () => {
|
||||
mutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertDialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
leastDestructiveRef={cancelRef}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<AlertDialogHeader>Confirmation Required</AlertDialogHeader>
|
||||
|
||||
<AlertDialogBody>
|
||||
All your account data will be{" "}
|
||||
<strong>permanently deleted.</strong> If you are sure, please
|
||||
click <strong>"Confirm"</strong> to proceed. This action cannot be
|
||||
undone.
|
||||
</AlertDialogBody>
|
||||
|
||||
<AlertDialogFooter gap={3}>
|
||||
<Button variant="danger" type="submit" isLoading={isSubmitting}>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
ref={cancelRef}
|
||||
onClick={onClose}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteConfirmation
|
157
frontend/src/components/UserSettings/UserInformation.tsx
Normal file
157
frontend/src/components/UserSettings/UserInformation.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import {
|
||||
type ApiError,
|
||||
type UserPublic,
|
||||
type UserUpdateMe,
|
||||
UsersService,
|
||||
} from "../../client"
|
||||
import useAuth from "../../hooks/useAuth"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { emailPattern, handleError } from "../../utils"
|
||||
|
||||
const UserInformation = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const color = useColorModeValue("inherit", "ui.light")
|
||||
const showToast = useCustomToast()
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const { user: currentUser } = useAuth()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
formState: { isSubmitting, errors, isDirty },
|
||||
} = useForm<UserPublic>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
full_name: currentUser?.full_name,
|
||||
email: currentUser?.email,
|
||||
},
|
||||
})
|
||||
|
||||
const toggleEditMode = () => {
|
||||
setEditMode(!editMode)
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: UserUpdateMe) =>
|
||||
UsersService.updateUserMe({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "User updated successfully.", "success")
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries()
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<UserUpdateMe> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
reset()
|
||||
toggleEditMode()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container maxW="full">
|
||||
<Heading size="sm" py={4}>
|
||||
User Information
|
||||
</Heading>
|
||||
<Box
|
||||
w={{ sm: "full", md: "50%" }}
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormControl>
|
||||
<FormLabel color={color} htmlFor="name">
|
||||
Full name
|
||||
</FormLabel>
|
||||
{editMode ? (
|
||||
<Input
|
||||
id="name"
|
||||
{...register("full_name", { maxLength: 30 })}
|
||||
type="text"
|
||||
size="md"
|
||||
w="auto"
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
size="md"
|
||||
py={2}
|
||||
color={!currentUser?.full_name ? "ui.dim" : "inherit"}
|
||||
isTruncated
|
||||
maxWidth="250px"
|
||||
>
|
||||
{currentUser?.full_name || "N/A"}
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isInvalid={!!errors.email}>
|
||||
<FormLabel color={color} htmlFor="email">
|
||||
Email
|
||||
</FormLabel>
|
||||
{editMode ? (
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
type="email"
|
||||
size="md"
|
||||
w="auto"
|
||||
/>
|
||||
) : (
|
||||
<Text size="md" py={2} isTruncated maxWidth="250px">
|
||||
{currentUser?.email}
|
||||
</Text>
|
||||
)}
|
||||
{errors.email && (
|
||||
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Flex mt={4} gap={3}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={toggleEditMode}
|
||||
type={editMode ? "button" : "submit"}
|
||||
isLoading={editMode ? isSubmitting : false}
|
||||
isDisabled={editMode ? !isDirty || !getValues("email") : false}
|
||||
>
|
||||
{editMode ? "Save" : "Edit"}
|
||||
</Button>
|
||||
{editMode && (
|
||||
<Button onClick={onCancel} isDisabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserInformation
|
101
frontend/src/hooks/useAuth.ts
Normal file
101
frontend/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
|
||||
import { AxiosError } from "axios"
|
||||
import {
|
||||
type Body_login_login_access_token as AccessToken,
|
||||
type ApiError,
|
||||
LoginService,
|
||||
type UserPublic,
|
||||
type UserRegister,
|
||||
UsersService,
|
||||
} from "../client"
|
||||
import useCustomToast from "./useCustomToast"
|
||||
|
||||
const isLoggedIn = () => {
|
||||
return localStorage.getItem("access_token") !== null
|
||||
}
|
||||
|
||||
const useAuth = () => {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const navigate = useNavigate()
|
||||
const showToast = useCustomToast()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: user, isLoading } = useQuery<UserPublic | null, Error>({
|
||||
queryKey: ["currentUser"],
|
||||
queryFn: UsersService.readUserMe,
|
||||
enabled: isLoggedIn(),
|
||||
})
|
||||
|
||||
const signUpMutation = useMutation({
|
||||
mutationFn: (data: UserRegister) =>
|
||||
UsersService.registerUser({ requestBody: data }),
|
||||
|
||||
onSuccess: () => {
|
||||
navigate({ to: "/login" })
|
||||
showToast(
|
||||
"Account created.",
|
||||
"Your account has been created successfully.",
|
||||
"success",
|
||||
)
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
let errDetail = (err.body as any)?.detail
|
||||
|
||||
if (err instanceof AxiosError) {
|
||||
errDetail = err.message
|
||||
}
|
||||
|
||||
showToast("Something went wrong.", errDetail, "error")
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||
},
|
||||
})
|
||||
|
||||
const login = async (data: AccessToken) => {
|
||||
const response = await LoginService.loginAccessToken({
|
||||
formData: data,
|
||||
})
|
||||
localStorage.setItem("access_token", response.access_token)
|
||||
}
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: login,
|
||||
onSuccess: () => {
|
||||
navigate({ to: "/" })
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
let errDetail = (err.body as any)?.detail
|
||||
|
||||
if (err instanceof AxiosError) {
|
||||
errDetail = err.message
|
||||
}
|
||||
|
||||
if (Array.isArray(errDetail)) {
|
||||
errDetail = "Something went wrong"
|
||||
}
|
||||
|
||||
setError(errDetail)
|
||||
},
|
||||
})
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem("access_token")
|
||||
navigate({ to: "/login" })
|
||||
}
|
||||
|
||||
return {
|
||||
signUpMutation,
|
||||
loginMutation,
|
||||
logout,
|
||||
user,
|
||||
isLoading,
|
||||
error,
|
||||
resetError: () => setError(null),
|
||||
}
|
||||
}
|
||||
|
||||
export { isLoggedIn }
|
||||
export default useAuth
|
23
frontend/src/hooks/useCustomToast.ts
Normal file
23
frontend/src/hooks/useCustomToast.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useToast } from "@chakra-ui/react"
|
||||
import { useCallback } from "react"
|
||||
|
||||
const useCustomToast = () => {
|
||||
const toast = useToast()
|
||||
|
||||
const showToast = useCallback(
|
||||
(title: string, description: string, status: "success" | "error") => {
|
||||
toast({
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
isClosable: true,
|
||||
position: "bottom-right",
|
||||
})
|
||||
},
|
||||
[toast],
|
||||
)
|
||||
|
||||
return showToast
|
||||
}
|
||||
|
||||
export default useCustomToast
|
33
frontend/src/main.tsx
Normal file
33
frontend/src/main.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ChakraProvider } from "@chakra-ui/react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import { routeTree } from "./routeTree.gen"
|
||||
|
||||
import { StrictMode } from "react"
|
||||
import { OpenAPI } from "./client"
|
||||
import theme from "./theme"
|
||||
|
||||
OpenAPI.BASE = import.meta.env.VITE_API_URL
|
||||
OpenAPI.TOKEN = async () => {
|
||||
return localStorage.getItem("access_token") || ""
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const router = createRouter({ routeTree })
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ChakraProvider theme={theme}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ChakraProvider>
|
||||
</StrictMode>,
|
||||
)
|
195
frontend/src/routeTree.gen.ts
Normal file
195
frontend/src/routeTree.gen.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/* prettier-ignore-start */
|
||||
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file is auto-generated by TanStack Router
|
||||
|
||||
// Import Routes
|
||||
|
||||
import { Route as rootRoute } from './routes/__root'
|
||||
import { Route as SignupImport } from './routes/signup'
|
||||
import { Route as ResetPasswordImport } from './routes/reset-password'
|
||||
import { Route as RecoverPasswordImport } from './routes/recover-password'
|
||||
import { Route as LoginImport } from './routes/login'
|
||||
import { Route as LayoutImport } from './routes/_layout'
|
||||
import { Route as LayoutIndexImport } from './routes/_layout/index'
|
||||
import { Route as LayoutWebSettingImport } from './routes/_layout/webSetting'
|
||||
import { Route as LayoutSettingsImport } from './routes/_layout/settings'
|
||||
import { Route as LayoutItemsImport } from './routes/_layout/items'
|
||||
import { Route as LayoutClientMessagesImport } from './routes/_layout/clientMessages'
|
||||
import { Route as LayoutAdminImport } from './routes/_layout/admin'
|
||||
import { Route as LayoutAboutUsImport } from './routes/_layout/aboutUs'
|
||||
import { Route as LayoutCoursesCoursesImport } from './routes/_layout/Courses/Courses'
|
||||
import { Route as LayoutCoursesAddCourseImport } from './routes/_layout/Courses/AddCourse'
|
||||
import { Route as LayoutCoursesIdEditCourseImport } from './routes/_layout/Courses/$id.EditCourse'
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
const SignupRoute = SignupImport.update({
|
||||
path: '/signup',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const ResetPasswordRoute = ResetPasswordImport.update({
|
||||
path: '/reset-password',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const RecoverPasswordRoute = RecoverPasswordImport.update({
|
||||
path: '/recover-password',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const LoginRoute = LoginImport.update({
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutRoute = LayoutImport.update({
|
||||
id: '/_layout',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutIndexRoute = LayoutIndexImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutWebSettingRoute = LayoutWebSettingImport.update({
|
||||
path: '/webSetting',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutSettingsRoute = LayoutSettingsImport.update({
|
||||
path: '/settings',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutItemsRoute = LayoutItemsImport.update({
|
||||
path: '/items',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutClientMessagesRoute = LayoutClientMessagesImport.update({
|
||||
path: '/clientMessages',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutAdminRoute = LayoutAdminImport.update({
|
||||
path: '/admin',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutAboutUsRoute = LayoutAboutUsImport.update({
|
||||
path: '/aboutUs',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutCoursesCoursesRoute = LayoutCoursesCoursesImport.update({
|
||||
path: '/Courses/Courses',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutCoursesAddCourseRoute = LayoutCoursesAddCourseImport.update({
|
||||
path: '/Courses/AddCourse',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
|
||||
const LayoutCoursesIdEditCourseRoute = LayoutCoursesIdEditCourseImport.update({
|
||||
path: '/Courses/$id/EditCourse',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
|
||||
// Populate the FileRoutesByPath interface
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/_layout': {
|
||||
preLoaderRoute: typeof LayoutImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/login': {
|
||||
preLoaderRoute: typeof LoginImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/recover-password': {
|
||||
preLoaderRoute: typeof RecoverPasswordImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/reset-password': {
|
||||
preLoaderRoute: typeof ResetPasswordImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/signup': {
|
||||
preLoaderRoute: typeof SignupImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/_layout/aboutUs': {
|
||||
preLoaderRoute: typeof LayoutAboutUsImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
'/_layout/admin': {
|
||||
preLoaderRoute: typeof LayoutAdminImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
'/_layout/clientMessages': {
|
||||
preLoaderRoute: typeof LayoutClientMessagesImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
'/_layout/items': {
|
||||
preLoaderRoute: typeof LayoutItemsImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
'/_layout/settings': {
|
||||
preLoaderRoute: typeof LayoutSettingsImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
'/_layout/webSetting': {
|
||||
preLoaderRoute: typeof LayoutWebSettingImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
'/_layout/': {
|
||||
preLoaderRoute: typeof LayoutIndexImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
'/_layout/Courses/AddCourse': {
|
||||
preLoaderRoute: typeof LayoutCoursesAddCourseImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
'/_layout/Courses/Courses': {
|
||||
preLoaderRoute: typeof LayoutCoursesCoursesImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
'/_layout/Courses/$id/EditCourse': {
|
||||
preLoaderRoute: typeof LayoutCoursesIdEditCourseImport
|
||||
parentRoute: typeof LayoutImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export the route tree
|
||||
|
||||
export const routeTree = rootRoute.addChildren([
|
||||
LayoutRoute.addChildren([
|
||||
LayoutAboutUsRoute,
|
||||
LayoutAdminRoute,
|
||||
LayoutClientMessagesRoute,
|
||||
LayoutItemsRoute,
|
||||
LayoutSettingsRoute,
|
||||
LayoutWebSettingRoute,
|
||||
LayoutIndexRoute,
|
||||
LayoutCoursesAddCourseRoute,
|
||||
LayoutCoursesCoursesRoute,
|
||||
LayoutCoursesIdEditCourseRoute,
|
||||
]),
|
||||
LoginRoute,
|
||||
RecoverPasswordRoute,
|
||||
ResetPasswordRoute,
|
||||
SignupRoute,
|
||||
])
|
||||
|
||||
/* prettier-ignore-end */
|
34
frontend/src/routes/__root.tsx
Normal file
34
frontend/src/routes/__root.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Outlet, createRootRoute } from "@tanstack/react-router"
|
||||
import React, { Suspense } from "react"
|
||||
|
||||
import NotFound from "../components/Common/NotFound"
|
||||
|
||||
const loadDevtools = () =>
|
||||
Promise.all([
|
||||
import("@tanstack/router-devtools"),
|
||||
import("@tanstack/react-query-devtools"),
|
||||
]).then(([routerDevtools, reactQueryDevtools]) => {
|
||||
return {
|
||||
default: () => (
|
||||
<>
|
||||
<routerDevtools.TanStackRouterDevtools />
|
||||
<reactQueryDevtools.ReactQueryDevtools />
|
||||
</>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const TanStackDevtools =
|
||||
process.env.NODE_ENV === "production" ? () => null : React.lazy(loadDevtools)
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: () => (
|
||||
<>
|
||||
<Outlet />
|
||||
<Suspense>
|
||||
<TanStackDevtools />
|
||||
</Suspense>
|
||||
</>
|
||||
),
|
||||
notFoundComponent: () => <NotFound />,
|
||||
})
|
35
frontend/src/routes/_layout.tsx
Normal file
35
frontend/src/routes/_layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Flex, Spinner } from "@chakra-ui/react"
|
||||
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"
|
||||
|
||||
import Sidebar from "../components/Common/Sidebar"
|
||||
import UserMenu from "../components/Common/UserMenu"
|
||||
import useAuth, { isLoggedIn } from "../hooks/useAuth"
|
||||
|
||||
export const Route = createFileRoute("/_layout")({
|
||||
component: Layout,
|
||||
beforeLoad: async () => {
|
||||
if (!isLoggedIn()) {
|
||||
throw redirect({
|
||||
to: "/login",
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function Layout() {
|
||||
const { isLoading } = useAuth()
|
||||
|
||||
return (
|
||||
<Flex maxW="large" h="auto" position="relative">
|
||||
<Sidebar />
|
||||
{isLoading ? (
|
||||
<Flex justify="center" align="center" height="100vh" width="full">
|
||||
<Spinner size="xl" color="ui.main" />
|
||||
</Flex>
|
||||
) : (
|
||||
<Outlet />
|
||||
)}
|
||||
<UserMenu />
|
||||
</Flex>
|
||||
)
|
||||
}
|
73
frontend/src/routes/_layout/Courses/$id.EditCourse.tsx
Normal file
73
frontend/src/routes/_layout/Courses/$id.EditCourse.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Container,
|
||||
Heading,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQueryClient, useQuery } from "@tanstack/react-query"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { UserPublic, CoursesService } from "../../../client"
|
||||
|
||||
import CourseDetails from "../../../components/Courses/CourseDetails"
|
||||
import CourseImages from "../../../components/Courses/CourseImages"
|
||||
import CourseInfoImages from "../../../components/Courses/CourseInfoImages"
|
||||
import Sechedule from "../../../components/Courses/Sechedule"
|
||||
|
||||
const tabsConfig = [
|
||||
{ title: "Course Details", component: CourseDetails },
|
||||
{ title: "Course Images", component: CourseImages },
|
||||
{ title: "Course Info Images", component: CourseInfoImages },
|
||||
{ title: "Schedule", component: Sechedule },
|
||||
|
||||
]
|
||||
|
||||
export const Route = createFileRoute('/_layout/Courses/$id/EditCourse')({
|
||||
component: EditCourse,
|
||||
})
|
||||
|
||||
function getcoursesQueryOptions(id: string) {
|
||||
return {
|
||||
queryFn: () =>
|
||||
CoursesService.readCourse({ id: id }),
|
||||
queryKey: ["course"],
|
||||
}
|
||||
}
|
||||
|
||||
function EditCourse() {
|
||||
|
||||
const {
|
||||
data: course,
|
||||
} = useQuery(
|
||||
{
|
||||
...getcoursesQueryOptions(Route.useParams().id),
|
||||
// placeholderData: (prevData) => prevData,
|
||||
}
|
||||
)
|
||||
console.log(course)
|
||||
const finalTabs = tabsConfig.slice(0, 4)
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} py={12}>
|
||||
User Settings
|
||||
</Heading>
|
||||
<Tabs variant="enclosed">
|
||||
<TabList>
|
||||
{finalTabs.map((tab, index) => (
|
||||
<Tab key={index}>{tab.title}</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{finalTabs.map((tab, index) => (
|
||||
<TabPanel key={index}>
|
||||
{<tab.component />}
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
234
frontend/src/routes/_layout/Courses/AddCourse.tsx
Normal file
234
frontend/src/routes/_layout/Courses/AddCourse.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Textarea,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Container
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate, Await, useRouter } from "@tanstack/react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
import useCustomToast from "../../../hooks/useCustomToast"
|
||||
import { CoursesService, type ApiError, CourseCreate, } from "../../../client"
|
||||
import { handleError } from "../../../utils"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import { EditorState, ContentState, convertToRaw } from 'draft-js';
|
||||
import { Editor } from "react-draft-wysiwyg";
|
||||
import draftToHtml from 'draftjs-to-html';
|
||||
import htmlToDraft from 'html-to-draftjs';
|
||||
import "react-draft-wysiwyg/dist/react-draft-wysiwyg.css";
|
||||
|
||||
|
||||
export const Route = createFileRoute("/_layout/Courses/AddCourse")({
|
||||
component: AddCourse,
|
||||
})
|
||||
|
||||
|
||||
function AddCourseForms() {
|
||||
|
||||
const toolbar = {
|
||||
options: ['inline', 'blockType', 'fontSize', 'list', 'textAlign', 'history', 'embedded', 'emoji', 'image'],
|
||||
inline: { inDropdown: true },
|
||||
list: { inDropdown: true },
|
||||
textAlign: { inDropdown: true },
|
||||
link: { inDropdown: true },
|
||||
history: { inDropdown: true },
|
||||
}
|
||||
|
||||
const showToast = useCustomToast()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [contentEditorState, setContentEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [contents, setContent] = useState<string>('');
|
||||
const [infoEditorState, setInfoEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [info, setInfo] = useState<string>('');
|
||||
const [longDescriptionEditorState, setLongDescriptionEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [longDescription, setlongDescription] = useState<string>('');
|
||||
const [remarksEditorState, setRemarksEditorState] = useState<EditorState>(EditorState.createEmpty());
|
||||
const [remarks, setRemarks] = useState<string>('');
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
setValue,
|
||||
formState: { isSubmitting, errors, isDirty },
|
||||
} = useForm<CourseCreate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
title: "",
|
||||
sort_description: "",
|
||||
long_description: "",
|
||||
information: "",
|
||||
contant: "",
|
||||
remark: "",
|
||||
}
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: CourseCreate) =>
|
||||
CoursesService.createCourse({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Course create successfully.", "success")
|
||||
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["courses"] })
|
||||
history.go(-1)
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<CourseCreate> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<Container maxW="full" mt="20" as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
|
||||
<FormControl isInvalid={!!errors.title}>
|
||||
<FormLabel htmlFor="title">Title</FormLabel>
|
||||
<Input
|
||||
id="address"
|
||||
type="text"
|
||||
{...register("title", {
|
||||
required: "title is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
{errors.title && (
|
||||
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="sort_description">Short Description</FormLabel>
|
||||
<Textarea
|
||||
id="sort_description"
|
||||
{...register("sort_description", {
|
||||
required: "sort_description is required",
|
||||
})}
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="long_description">Long Description</FormLabel>
|
||||
<Editor
|
||||
editorState={longDescriptionEditorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setLongDescriptionEditorState(newState);
|
||||
setlongDescription(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
setValue("long_description", longDescription);
|
||||
|
||||
}}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="information">Information</FormLabel>
|
||||
<Editor
|
||||
editorState={infoEditorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setInfoEditorState(newState);
|
||||
setInfo(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
setValue("information", info);
|
||||
|
||||
}}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="contant">Content</FormLabel>
|
||||
<Editor
|
||||
editorState={contentEditorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setContentEditorState(newState);
|
||||
setContent(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
setValue("contant", contents);
|
||||
}}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="remark">Remark</FormLabel>
|
||||
<Editor
|
||||
editorState={remarksEditorState}
|
||||
wrapperClassName="wrapper-class"
|
||||
editorClassName="demo-editor"
|
||||
onEditorStateChange={newState => {
|
||||
setRemarksEditorState(newState);
|
||||
setRemarks(draftToHtml(convertToRaw(newState.getCurrentContent())));
|
||||
setValue("remark", remarks);
|
||||
|
||||
}}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
|
||||
<FormControl mt={4}></FormControl>
|
||||
{/* <button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const values = getValues()
|
||||
console.log(values)
|
||||
// history.go(-1)// { test: "test-input", test1: "test1-input" }
|
||||
}}
|
||||
>
|
||||
Get Values
|
||||
</button> */}
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isDirty}
|
||||
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<FormControl mt={20}></FormControl>
|
||||
|
||||
</Container>
|
||||
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
function AddCourse() {
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
|
||||
<AddCourseForms />
|
||||
|
||||
</Container>
|
||||
)
|
||||
}
|
193
frontend/src/routes/_layout/Courses/Courses.tsx
Normal file
193
frontend/src/routes/_layout/Courses/Courses.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Heading,
|
||||
SkeletonText,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
Icon,
|
||||
useDisclosure
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
import { z } from "zod"
|
||||
import { FaPlus, FaPen, FaTrashAlt } from "react-icons/fa"
|
||||
import { CoursesService } from "../../../client"
|
||||
import Delete from "../../../components/Common/DeleteAlert"
|
||||
import ActionsMenu from "../../../components/Common/ActionsMenu"
|
||||
import Navbar from "../../../components/Common/Navbar"
|
||||
//import Addcourse from "../../components/courses/Addcourse"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
const CoursesSearchSchema = z.object({
|
||||
page: z.number().catch(1),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/_layout/Courses/Courses")({
|
||||
component: Courses,
|
||||
validateSearch: (search) => CoursesSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
|
||||
const PER_PAGE = 100
|
||||
|
||||
function getcoursesQueryOptions({ page }: { page: number }) {
|
||||
return {
|
||||
queryFn: () =>
|
||||
CoursesService.readCourses({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }),
|
||||
queryKey: ["courses", { page }],
|
||||
}
|
||||
}
|
||||
|
||||
function CoursesTable() {
|
||||
const [id, setId] = useState<string>('');
|
||||
const deleteModal = useDisclosure()
|
||||
const queryClient = useQueryClient()
|
||||
const { page } = Route.useSearch()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const setPage = (page: number) =>
|
||||
navigate({ search: (prev) => ({ ...prev, page }) })
|
||||
|
||||
const {
|
||||
data: courses,
|
||||
isPending,
|
||||
isPlaceholderData,
|
||||
} = useQuery({
|
||||
...getcoursesQueryOptions({ page }),
|
||||
placeholderData: (prevData) => prevData,
|
||||
})
|
||||
|
||||
const hasNextPage = !isPlaceholderData && courses?.data.length === PER_PAGE
|
||||
const hasPreviousPage = page > 1
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
queryClient.prefetchQuery(getcoursesQueryOptions({ page: page + 1 }))
|
||||
}
|
||||
}, [page, queryClient, hasNextPage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size={{ base: "sm", md: "md" }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>ID</Th>
|
||||
<Th>Title</Th>
|
||||
<Th>Short description</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
{isPending ? (
|
||||
<Tbody>
|
||||
<Tr>
|
||||
{new Array(4).fill(null).map((_, index) => (
|
||||
<Td key={index}>
|
||||
<SkeletonText noOfLines={1} paddingBlock="16px" />
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
</Tbody>
|
||||
) : (
|
||||
<Tbody>
|
||||
{courses?.data.map((course) => (
|
||||
<Tr key={course.id} opacity={isPlaceholderData ? 0.5 : 1}>
|
||||
<Td isTruncated maxWidth="20px">{course.id}</Td>
|
||||
<Td isTruncated maxWidth="50px">
|
||||
{course.title}
|
||||
</Td>
|
||||
<Td
|
||||
|
||||
isTruncated
|
||||
maxWidth="250px"
|
||||
>
|
||||
{course.sort_description || "N/A"}
|
||||
</Td>
|
||||
<Td>
|
||||
<Button
|
||||
variant="primary"
|
||||
gap={1}
|
||||
fontSize={{ base: "sm", md: "inherit" }}
|
||||
as={Link}
|
||||
to={`/Courses/${course.id}/EditCourse`}
|
||||
>
|
||||
<Icon as={FaPen} />
|
||||
</Button>
|
||||
<Button
|
||||
marginLeft={2}
|
||||
variant="primary"
|
||||
gap={1}
|
||||
fontSize={{ base: "sm" }}
|
||||
onClick={() => {
|
||||
setId(course.id)
|
||||
deleteModal.onOpen()
|
||||
}}
|
||||
>
|
||||
<Icon as={FaTrashAlt} />
|
||||
</Button>
|
||||
</Td>
|
||||
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
)}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Delete
|
||||
type={'Course'}
|
||||
id={id}
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={deleteModal.onClose}
|
||||
/>
|
||||
<Flex
|
||||
gap={4}
|
||||
//aligncourses="center"
|
||||
mt={4}
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button onClick={() => setPage(page - 1)} isDisabled={!hasPreviousPage}>
|
||||
Previous
|
||||
</Button>
|
||||
<span>Page {page}</span>
|
||||
<Button isDisabled={!hasNextPage} onClick={() => setPage(page + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Courses() {
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
|
||||
Courses Management
|
||||
</Heading>
|
||||
<Flex py={8} gap={4}>
|
||||
{/* TODO: Complete search functionality */}
|
||||
{/* <InputGroup w={{ base: '100%', md: 'auto' }}>
|
||||
<InputLeftElement pointerEvents='none'>
|
||||
<Icon as={FaSearch} color='ui.dim' />
|
||||
</InputLeftElement>
|
||||
<Input type='text' placeholder='Search' fontSize={{ base: 'sm', md: 'inherit' }} borderRadius='8px' />
|
||||
</InputGroup> */}
|
||||
<Button
|
||||
variant="primary"
|
||||
gap={1}
|
||||
fontSize={{ base: "sm", md: "inherit" }}
|
||||
as={Link} to="/Courses/AddCourse"
|
||||
>
|
||||
<Icon as={FaPlus} /> Add Course
|
||||
</Button>
|
||||
</Flex>
|
||||
<CoursesTable />
|
||||
</Container>
|
||||
)
|
||||
}
|
155
frontend/src/routes/_layout/aboutUs.tsx
Normal file
155
frontend/src/routes/_layout/aboutUs.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Heading,
|
||||
SkeletonText,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
import { z } from "zod"
|
||||
import parse from 'html-react-parser';
|
||||
import { AboutUsService } from "../../client"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
import Navbar from "../../components/Common/Navbar"
|
||||
import AddAboutUs from "../../components/AboutUs/AddAboutUs"
|
||||
|
||||
|
||||
|
||||
|
||||
const aboutUsSearchSchema = z.object({
|
||||
page: z.number().catch(1),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/_layout/aboutUs")({
|
||||
component: AboutUs,
|
||||
validateSearch: (search) => aboutUsSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
const PER_PAGE = 100
|
||||
|
||||
function getItemsQueryOptions({ page }: { page: number }) {
|
||||
return {
|
||||
queryFn: () =>
|
||||
AboutUsService.readAboutUs({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }),
|
||||
queryKey: ["aboutUs", { page }],
|
||||
}
|
||||
}
|
||||
|
||||
function AboutUsTable() {
|
||||
const queryClient = useQueryClient()
|
||||
const { page } = Route.useSearch()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const setPage = (page: number) =>
|
||||
navigate({ search: (prev) => ({ ...prev, page }) })
|
||||
|
||||
const {
|
||||
data: aboutUs,
|
||||
isPending,
|
||||
isPlaceholderData,
|
||||
} = useQuery({
|
||||
...getItemsQueryOptions({ page }),
|
||||
placeholderData: (prevData) => prevData,
|
||||
})
|
||||
aboutUs?.data.sort((a, b) => a.index - b.index)
|
||||
const hasNextPage = !isPlaceholderData && aboutUs?.data.length === PER_PAGE
|
||||
const hasPreviousPage = page > 1
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
queryClient.prefetchQuery(getItemsQueryOptions({ page: page + 1 }))
|
||||
}
|
||||
}, [page, queryClient, hasNextPage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size={{ base: "sm", md: "md" }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>ID</Th>
|
||||
<Th>Description</Th>
|
||||
<Th>Image</Th>
|
||||
<Th>Index</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
{isPending ? (
|
||||
<Tbody>
|
||||
<Tr>
|
||||
{new Array(4).fill(null).map((_, index) => (
|
||||
<Td key={index}>
|
||||
<SkeletonText noOfLines={1} paddingBlock="16px" />
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
</Tbody>
|
||||
) : (
|
||||
<Tbody>
|
||||
{aboutUs?.data.map((aboutUs) => (
|
||||
<Tr key={aboutUs.id} opacity={isPlaceholderData ? 0.5 : 1}>
|
||||
<Td isTruncated maxWidth="50">{aboutUs.id}</Td>
|
||||
|
||||
<Td
|
||||
whiteSpace="pre-line"
|
||||
maxWidth="350px"
|
||||
>
|
||||
{parse(aboutUs.description) || "N/A"}
|
||||
</Td>
|
||||
<Td>
|
||||
<img src={import.meta.env.VITE_API_URL+"/"+aboutUs.image} width="100px" height="100px" />
|
||||
</Td>
|
||||
<Td isTruncated maxWidth="10px">
|
||||
{aboutUs.index}
|
||||
</Td>
|
||||
<Td>
|
||||
<ActionsMenu type={"AboutUs"} value={aboutUs} />
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
)}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Flex
|
||||
gap={4}
|
||||
alignItems="center"
|
||||
mt={4}
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button onClick={() => setPage(page - 1)} isDisabled={!hasPreviousPage}>
|
||||
Previous
|
||||
</Button>
|
||||
<span>Page {page}</span>
|
||||
<Button isDisabled={!hasNextPage} onClick={() => setPage(page + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AboutUs() {
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
|
||||
About Us Management
|
||||
</Heading>
|
||||
|
||||
<Navbar type={"AboutUs"} addModalAs={AddAboutUs} />
|
||||
<AboutUsTable />
|
||||
|
||||
|
||||
</Container>
|
||||
)
|
||||
}
|
170
frontend/src/routes/_layout/admin.tsx
Normal file
170
frontend/src/routes/_layout/admin.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Heading,
|
||||
SkeletonText,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect } from "react"
|
||||
import { z } from "zod"
|
||||
|
||||
import { type UserPublic, UsersService } from "../../client"
|
||||
import AddUser from "../../components/Admin/AddUser"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
import Navbar from "../../components/Common/Navbar"
|
||||
|
||||
const usersSearchSchema = z.object({
|
||||
page: z.number().catch(1),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/_layout/admin")({
|
||||
component: Admin,
|
||||
validateSearch: (search) => usersSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
const PER_PAGE = 5
|
||||
|
||||
function getUsersQueryOptions({ page }: { page: number }) {
|
||||
return {
|
||||
queryFn: () =>
|
||||
UsersService.readUsers({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }),
|
||||
queryKey: ["users", { page }],
|
||||
}
|
||||
}
|
||||
|
||||
function UsersTable() {
|
||||
const queryClient = useQueryClient()
|
||||
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
|
||||
const { page } = Route.useSearch()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const setPage = (page: number) =>
|
||||
navigate({ search: (prev) => ({ ...prev, page }) })
|
||||
|
||||
const {
|
||||
data: users,
|
||||
isPending,
|
||||
isPlaceholderData,
|
||||
} = useQuery({
|
||||
...getUsersQueryOptions({ page }),
|
||||
placeholderData: (prevData) => prevData,
|
||||
})
|
||||
|
||||
const hasNextPage = !isPlaceholderData && users?.data.length === PER_PAGE
|
||||
const hasPreviousPage = page > 1
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
queryClient.prefetchQuery(getUsersQueryOptions({ page: page + 1 }))
|
||||
}
|
||||
}, [page, queryClient, hasNextPage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size={{ base: "sm", md: "md" }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th width="20%">Full name</Th>
|
||||
<Th width="50%">Email</Th>
|
||||
<Th width="10%">Role</Th>
|
||||
<Th width="10%">Status</Th>
|
||||
<Th width="10%">Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
{isPending ? (
|
||||
<Tbody>
|
||||
<Tr>
|
||||
{new Array(4).fill(null).map((_, index) => (
|
||||
<Td key={index}>
|
||||
<SkeletonText noOfLines={1} paddingBlock="16px" />
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
</Tbody>
|
||||
) : (
|
||||
<Tbody>
|
||||
{users?.data.map((user) => (
|
||||
<Tr key={user.id}>
|
||||
<Td
|
||||
color={!user.full_name ? "ui.dim" : "inherit"}
|
||||
isTruncated
|
||||
maxWidth="150px"
|
||||
>
|
||||
{user.full_name || "N/A"}
|
||||
{currentUser?.id === user.id && (
|
||||
<Badge ml="1" colorScheme="teal">
|
||||
You
|
||||
</Badge>
|
||||
)}
|
||||
</Td>
|
||||
<Td isTruncated maxWidth="150px">
|
||||
{user.email}
|
||||
</Td>
|
||||
<Td>{user.is_superuser ? "Superuser" : "User"}</Td>
|
||||
<Td>
|
||||
<Flex gap={2}>
|
||||
<Box
|
||||
w="2"
|
||||
h="2"
|
||||
borderRadius="50%"
|
||||
bg={user.is_active ? "ui.success" : "ui.danger"}
|
||||
alignSelf="center"
|
||||
/>
|
||||
{user.is_active ? "Active" : "Inactive"}
|
||||
</Flex>
|
||||
</Td>
|
||||
<Td>
|
||||
<ActionsMenu
|
||||
type="User"
|
||||
value={user}
|
||||
disabled={currentUser?.id === user.id ? true : false}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
)}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Flex
|
||||
gap={4}
|
||||
alignItems="center"
|
||||
mt={4}
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button onClick={() => setPage(page - 1)} isDisabled={!hasPreviousPage}>
|
||||
Previous
|
||||
</Button>
|
||||
<span>Page {page}</span>
|
||||
<Button isDisabled={!hasNextPage} onClick={() => setPage(page + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Admin() {
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
|
||||
Users Management
|
||||
</Heading>
|
||||
|
||||
<Navbar type={"User"} addModalAs={AddUser} />
|
||||
<UsersTable />
|
||||
</Container>
|
||||
)
|
||||
}
|
150
frontend/src/routes/_layout/clientMessages.tsx
Normal file
150
frontend/src/routes/_layout/clientMessages.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Heading,
|
||||
SkeletonText,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect } from "react"
|
||||
import { z } from "zod"
|
||||
import moment from 'moment';
|
||||
import { ClientMessagesService } from "../../client"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
|
||||
|
||||
const messagesSearchSchema = z.object({
|
||||
page: z.number().catch(1),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/_layout/clientMessages")({
|
||||
component: Messages,
|
||||
validateSearch: (search) => messagesSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
const PER_PAGE = 100
|
||||
|
||||
function getMessagesQueryOptions({ page }: { page: number }) {
|
||||
return {
|
||||
queryFn: () =>
|
||||
ClientMessagesService.readMessages({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }),
|
||||
queryKey: ["messages", { page }],
|
||||
}
|
||||
}
|
||||
|
||||
function MessagesTable() {
|
||||
const queryClient = useQueryClient()
|
||||
const { page } = Route.useSearch()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const setPage = (page: number) =>
|
||||
navigate({ search: (prev) => ({ ...prev, page }) })
|
||||
|
||||
const {
|
||||
data: messages,
|
||||
isPending,
|
||||
isPlaceholderData,
|
||||
} = useQuery({
|
||||
...getMessagesQueryOptions({ page }),
|
||||
placeholderData: (prevData) => prevData,
|
||||
})
|
||||
|
||||
const hasNextPage = !isPlaceholderData && messages?.data.length === PER_PAGE
|
||||
const hasPreviousPage = page > 1
|
||||
messages?.data.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
queryClient.prefetchQuery(getMessagesQueryOptions({ page: page + 1 }))
|
||||
}
|
||||
}, [page, queryClient, hasNextPage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size={{ base: "sm", md: "md" }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>id</Th>
|
||||
<Th>Name</Th>
|
||||
<Th>Phone</Th>
|
||||
<Th>Email</Th>
|
||||
<Th>Message</Th>
|
||||
<Th>Created</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
{isPending ? (
|
||||
<Tbody>
|
||||
<Tr>
|
||||
{new Array(4).fill(null).map((_, index) => (
|
||||
<Td key={index}>
|
||||
<SkeletonText noOfLines={1} paddingBlock="16px" />
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
</Tbody>
|
||||
) : (
|
||||
<Tbody>
|
||||
{messages?.data.map((message) => (
|
||||
<Tr key={message.id} opacity={isPlaceholderData ? 0.5 : 1}>
|
||||
<Td isTruncated maxWidth="50">{message.id}</Td>
|
||||
<Td maxWidth="150px">
|
||||
{message.name}
|
||||
</Td>
|
||||
<Td maxWidth="150px">
|
||||
{message.phone}
|
||||
</Td>
|
||||
<Td maxWidth="150px">
|
||||
{message.email}
|
||||
</Td>
|
||||
<Td whiteSpace="pre-line" maxWidth="250px">
|
||||
{message.message}
|
||||
</Td>
|
||||
<Td maxWidth="150px">
|
||||
{moment(message.created_at).utcOffset("+08:00").format('DD-MM-YYYY HH:mm')}
|
||||
</Td>
|
||||
<Td>
|
||||
<ActionsMenu type={"Message"} value={message} />
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
)}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Flex
|
||||
gap={4}
|
||||
alignItems="center"
|
||||
mt={4}
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button onClick={() => setPage(page - 1)} isDisabled={!hasPreviousPage}>
|
||||
Previous
|
||||
</Button>
|
||||
<span>Page {page}</span>
|
||||
<Button isDisabled={!hasNextPage} onClick={() => setPage(page + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Messages() {
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
|
||||
Messages Management
|
||||
</Heading>
|
||||
<MessagesTable />
|
||||
</Container>
|
||||
)
|
||||
}
|
25
frontend/src/routes/_layout/index.tsx
Normal file
25
frontend/src/routes/_layout/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Box, Container, Text } from "@chakra-ui/react"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
import useAuth from "../../hooks/useAuth"
|
||||
|
||||
export const Route = createFileRoute("/_layout/")({
|
||||
component: Dashboard,
|
||||
})
|
||||
|
||||
function Dashboard() {
|
||||
const { user: currentUser } = useAuth()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container maxW="full">
|
||||
<Box pt={12} m={4}>
|
||||
<Text fontSize="2xl">
|
||||
Hi, {currentUser?.full_name || currentUser?.email} 👋🏼
|
||||
</Text>
|
||||
<Text>Welcome back, nice to see you again!</Text>
|
||||
</Box>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
145
frontend/src/routes/_layout/items.tsx
Normal file
145
frontend/src/routes/_layout/items.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Heading,
|
||||
SkeletonText,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect } from "react"
|
||||
import { z } from "zod"
|
||||
|
||||
import { ItemsService } from "../../client"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
import Navbar from "../../components/Common/Navbar"
|
||||
import AddItem from "../../components/Items/AddItem"
|
||||
|
||||
const itemsSearchSchema = z.object({
|
||||
page: z.number().catch(1),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/_layout/items")({
|
||||
component: Items,
|
||||
validateSearch: (search) => itemsSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
const PER_PAGE = 5
|
||||
|
||||
function getItemsQueryOptions({ page }: { page: number }) {
|
||||
return {
|
||||
queryFn: () =>
|
||||
ItemsService.readItems({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }),
|
||||
queryKey: ["items", { page }],
|
||||
}
|
||||
}
|
||||
|
||||
function ItemsTable() {
|
||||
const queryClient = useQueryClient()
|
||||
const { page } = Route.useSearch()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const setPage = (page: number) =>
|
||||
navigate({ search: (prev) => ({ ...prev, page }) })
|
||||
|
||||
const {
|
||||
data: items,
|
||||
isPending,
|
||||
isPlaceholderData,
|
||||
} = useQuery({
|
||||
...getItemsQueryOptions({ page }),
|
||||
placeholderData: (prevData) => prevData,
|
||||
})
|
||||
|
||||
const hasNextPage = !isPlaceholderData && items?.data.length === PER_PAGE
|
||||
const hasPreviousPage = page > 1
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
queryClient.prefetchQuery(getItemsQueryOptions({ page: page + 1 }))
|
||||
}
|
||||
}, [page, queryClient, hasNextPage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size={{ base: "sm", md: "md" }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>ID</Th>
|
||||
<Th>Title</Th>
|
||||
<Th>Description</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
{isPending ? (
|
||||
<Tbody>
|
||||
<Tr>
|
||||
{new Array(4).fill(null).map((_, index) => (
|
||||
<Td key={index}>
|
||||
<SkeletonText noOfLines={1} paddingBlock="16px" />
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
</Tbody>
|
||||
) : (
|
||||
<Tbody>
|
||||
{items?.data.map((item) => (
|
||||
<Tr key={item.id} opacity={isPlaceholderData ? 0.5 : 1}>
|
||||
<Td>{item.id}</Td>
|
||||
<Td isTruncated maxWidth="150px">
|
||||
{item.title}
|
||||
</Td>
|
||||
<Td
|
||||
color={!item.description ? "ui.dim" : "inherit"}
|
||||
isTruncated
|
||||
maxWidth="150px"
|
||||
>
|
||||
{item.description || "N/A"}
|
||||
</Td>
|
||||
<Td>
|
||||
<ActionsMenu type={"Item"} value={item} />
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
)}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Flex
|
||||
gap={4}
|
||||
alignItems="center"
|
||||
mt={4}
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button onClick={() => setPage(page - 1)} isDisabled={!hasPreviousPage}>
|
||||
Previous
|
||||
</Button>
|
||||
<span>Page {page}</span>
|
||||
<Button isDisabled={!hasNextPage} onClick={() => setPage(page + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Items() {
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
|
||||
Items Management
|
||||
</Heading>
|
||||
|
||||
<Navbar type={"Item"} addModalAs={AddItem} />
|
||||
<ItemsTable />
|
||||
</Container>
|
||||
)
|
||||
}
|
58
frontend/src/routes/_layout/settings.tsx
Normal file
58
frontend/src/routes/_layout/settings.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Container,
|
||||
Heading,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
import type { UserPublic } from "../../client"
|
||||
import Appearance from "../../components/UserSettings/Appearance"
|
||||
import ChangePassword from "../../components/UserSettings/ChangePassword"
|
||||
import DeleteAccount from "../../components/UserSettings/DeleteAccount"
|
||||
import UserInformation from "../../components/UserSettings/UserInformation"
|
||||
|
||||
const tabsConfig = [
|
||||
{ title: "My profile", component: UserInformation },
|
||||
{ title: "Password", component: ChangePassword },
|
||||
{ title: "Appearance", component: Appearance },
|
||||
{ title: "Danger zone", component: DeleteAccount },
|
||||
]
|
||||
|
||||
export const Route = createFileRoute("/_layout/settings")({
|
||||
component: UserSettings,
|
||||
})
|
||||
|
||||
function UserSettings() {
|
||||
const queryClient = useQueryClient()
|
||||
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
|
||||
const finalTabs = currentUser?.is_superuser
|
||||
? tabsConfig.slice(0, 3)
|
||||
: tabsConfig
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} py={12}>
|
||||
User Settings
|
||||
</Heading>
|
||||
<Tabs variant="enclosed">
|
||||
<TabList>
|
||||
{finalTabs.map((tab, index) => (
|
||||
<Tab key={index}>{tab.title}</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{finalTabs.map((tab, index) => (
|
||||
<TabPanel key={index}>
|
||||
<tab.component />
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Container>
|
||||
)
|
||||
}
|
269
frontend/src/routes/_layout/webSetting.tsx
Normal file
269
frontend/src/routes/_layout/webSetting.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Container
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate, Await } from "@tanstack/react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { WebSettingsService, type WebSettingUpdate, type ApiError, } from "../../client"
|
||||
import { handleError } from "../../utils"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
|
||||
|
||||
export const Route = createFileRoute("/_layout/webSetting")({
|
||||
component: WebSetting,
|
||||
})
|
||||
|
||||
|
||||
|
||||
function getWebSettingQuery() {
|
||||
return {
|
||||
queryFn: () =>
|
||||
WebSettingsService.readWebSetting(),
|
||||
queryKey: ["webSetting"],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
function WebSettingForms() {
|
||||
|
||||
const showToast = useCustomToast()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// const {
|
||||
// data: webSetting,
|
||||
// isPending,
|
||||
// isPlaceholderData,
|
||||
// } = useQuery({
|
||||
// ...getWebSettingQuery(),
|
||||
// placeholderData: (prevData) => prevData,
|
||||
|
||||
// })
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting, errors, isDirty },
|
||||
} = useForm<WebSettingUpdate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: async() => WebSettingsService.readWebSetting()
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: WebSettingUpdate) =>
|
||||
WebSettingsService.updateWebSetting({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Settings updated successfully.", "success")
|
||||
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["webSetting"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<WebSettingUpdate> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
<Container maxW="full" mt="20" as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
|
||||
<FormControl isInvalid={!!errors.address}>
|
||||
<FormLabel htmlFor="address">Address</FormLabel>
|
||||
<Input
|
||||
id="address"
|
||||
type="text"
|
||||
{...register("address", {
|
||||
required: "address is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
{errors.address && (
|
||||
<FormErrorMessage>{errors.address.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="google_map_api_key">Google Map Api Key</FormLabel>
|
||||
<Input
|
||||
id="google_map_api_key"
|
||||
type="text"
|
||||
{...register("google_map_api_key", {
|
||||
required: "google_map_api_key is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="latitude">Latitude</FormLabel>
|
||||
<Input
|
||||
id="latitude"
|
||||
type="float"
|
||||
{...register("latitude", {
|
||||
required: "latitude is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="longitude">Longitude</FormLabel>
|
||||
<Input
|
||||
id="longitude"
|
||||
type="float"
|
||||
{...register("longitude", {
|
||||
required: "longitude is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="phone">Phone</FormLabel>
|
||||
<Input
|
||||
id="phone"
|
||||
type="text"
|
||||
{...register("phone", {
|
||||
required: "phone is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="email">Email</FormLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="text"
|
||||
{...register("email", {
|
||||
required: "email is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="facebook">Facebook</FormLabel>
|
||||
<Input
|
||||
id="facebook"
|
||||
type="text"
|
||||
{...register("facebook", {
|
||||
required: "facebook is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="instagram">Instagram</FormLabel>
|
||||
<Input
|
||||
id="instagram"
|
||||
type="text"
|
||||
{...register("instagram", {
|
||||
required: "instagram is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="youtube">Youtube Channel</FormLabel>
|
||||
<Input
|
||||
id="youtube"
|
||||
type="text"
|
||||
{...register("youtube", {
|
||||
required: "youtube is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="youtube_link">Youtube Video Link</FormLabel>
|
||||
<Input
|
||||
id="youtube_link"
|
||||
type="text"
|
||||
{...register("youtube_link", {
|
||||
required: "youtube_link is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<FormControl >
|
||||
<FormLabel htmlFor="whatsapp">Whatsapp</FormLabel>
|
||||
<Input
|
||||
id="whatsapp"
|
||||
type="text"
|
||||
{...register("whatsapp", {
|
||||
required: "whatsapp is required",
|
||||
})}
|
||||
|
||||
/>
|
||||
|
||||
</FormControl>
|
||||
<FormControl mt={4}></FormControl>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isDirty}
|
||||
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<FormControl mt={20}></FormControl>
|
||||
|
||||
</Container>
|
||||
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
function WebSetting() {
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
|
||||
<WebSettingForms />
|
||||
|
||||
</Container>
|
||||
)
|
||||
}
|
144
frontend/src/routes/login.tsx
Normal file
144
frontend/src/routes/login.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons"
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
Icon,
|
||||
Image,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
Link,
|
||||
Text,
|
||||
useBoolean,
|
||||
} from "@chakra-ui/react"
|
||||
import {
|
||||
Link as RouterLink,
|
||||
createFileRoute,
|
||||
redirect,
|
||||
} from "@tanstack/react-router"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import Logo from "/assets/images/logo-15.png"
|
||||
import type { Body_login_login_access_token as AccessToken } from "../client"
|
||||
import useAuth, { isLoggedIn } from "../hooks/useAuth"
|
||||
import { emailPattern } from "../utils"
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
component: Login,
|
||||
beforeLoad: async () => {
|
||||
if (isLoggedIn()) {
|
||||
throw redirect({
|
||||
to: "/",
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function Login() {
|
||||
const [show, setShow] = useBoolean()
|
||||
const { loginMutation, error, resetError } = useAuth()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<AccessToken>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<AccessToken> = async (data) => {
|
||||
if (isSubmitting) return
|
||||
|
||||
resetError()
|
||||
|
||||
try {
|
||||
await loginMutation.mutateAsync(data)
|
||||
} catch {
|
||||
// error is handled by useAuth hook
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
h="100vh"
|
||||
maxW="sm"
|
||||
alignItems="stretch"
|
||||
justifyContent="center"
|
||||
gap={4}
|
||||
centerContent
|
||||
>
|
||||
<Image
|
||||
src={Logo}
|
||||
alt="FastAPI logo"
|
||||
height="auto"
|
||||
maxW="2xs"
|
||||
alignSelf="center"
|
||||
mb={4}
|
||||
/>
|
||||
<FormControl id="username" isInvalid={!!errors.username || !!error}>
|
||||
<Input
|
||||
id="username"
|
||||
{...register("username", {
|
||||
required: "Username is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
required
|
||||
/>
|
||||
{errors.username && (
|
||||
<FormErrorMessage>{errors.username.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl id="password" isInvalid={!!error}>
|
||||
<InputGroup>
|
||||
<Input
|
||||
{...register("password", {
|
||||
required: "Password is required",
|
||||
})}
|
||||
type={show ? "text" : "password"}
|
||||
placeholder="Password"
|
||||
required
|
||||
/>
|
||||
<InputRightElement
|
||||
color="ui.dim"
|
||||
_hover={{
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
as={show ? ViewOffIcon : ViewIcon}
|
||||
onClick={setShow.toggle}
|
||||
aria-label={show ? "Hide password" : "Show password"}
|
||||
>
|
||||
{show ? <ViewOffIcon /> : <ViewIcon />}
|
||||
</Icon>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
{error && <FormErrorMessage>{error}</FormErrorMessage>}
|
||||
</FormControl>
|
||||
{/* <Link as={RouterLink} to="/recover-password" color="blue.500">
|
||||
Forgot password?
|
||||
</Link> */}
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Log In
|
||||
</Button>
|
||||
{/* <Text>
|
||||
Don't have an account?{" "}
|
||||
<Link as={RouterLink} to="/signup" color="blue.500">
|
||||
Sign up
|
||||
</Link>
|
||||
</Text> */}
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
104
frontend/src/routes/recover-password.tsx
Normal file
104
frontend/src/routes/recover-password.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
Heading,
|
||||
Input,
|
||||
Text,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { type ApiError, LoginService } from "../client"
|
||||
import { isLoggedIn } from "../hooks/useAuth"
|
||||
import useCustomToast from "../hooks/useCustomToast"
|
||||
import { emailPattern, handleError } from "../utils"
|
||||
|
||||
interface FormData {
|
||||
email: string
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/recover-password")({
|
||||
component: RecoverPassword,
|
||||
beforeLoad: async () => {
|
||||
if (isLoggedIn()) {
|
||||
throw redirect({
|
||||
to: "/",
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function RecoverPassword() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormData>()
|
||||
const showToast = useCustomToast()
|
||||
|
||||
const recoverPassword = async (data: FormData) => {
|
||||
await LoginService.recoverPassword({
|
||||
email: data.email,
|
||||
})
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: recoverPassword,
|
||||
onSuccess: () => {
|
||||
showToast(
|
||||
"Email sent.",
|
||||
"We sent an email with a link to get back into your account.",
|
||||
"success",
|
||||
)
|
||||
reset()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<FormData> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
h="100vh"
|
||||
maxW="sm"
|
||||
alignItems="stretch"
|
||||
justifyContent="center"
|
||||
gap={4}
|
||||
centerContent
|
||||
>
|
||||
<Heading size="xl" color="ui.main" textAlign="center" mb={2}>
|
||||
Password Recovery
|
||||
</Heading>
|
||||
<Text align="center">
|
||||
A password recovery email will be sent to the registered account.
|
||||
</Text>
|
||||
<FormControl isInvalid={!!errors.email}>
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
/>
|
||||
{errors.email && (
|
||||
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
122
frontend/src/routes/reset-password.tsx
Normal file
122
frontend/src/routes/reset-password.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
Text,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { type ApiError, LoginService, type NewPassword } from "../client"
|
||||
import { isLoggedIn } from "../hooks/useAuth"
|
||||
import useCustomToast from "../hooks/useCustomToast"
|
||||
import { confirmPasswordRules, handleError, passwordRules } from "../utils"
|
||||
|
||||
interface NewPasswordForm extends NewPassword {
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/reset-password")({
|
||||
component: ResetPassword,
|
||||
beforeLoad: async () => {
|
||||
if (isLoggedIn()) {
|
||||
throw redirect({
|
||||
to: "/",
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function ResetPassword() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
getValues,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<NewPasswordForm>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
new_password: "",
|
||||
},
|
||||
})
|
||||
const showToast = useCustomToast()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const resetPassword = async (data: NewPassword) => {
|
||||
const token = new URLSearchParams(window.location.search).get("token")
|
||||
if (!token) return
|
||||
await LoginService.resetPassword({
|
||||
requestBody: { new_password: data.new_password, token: token },
|
||||
})
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: resetPassword,
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Password updated successfully.", "success")
|
||||
reset()
|
||||
navigate({ to: "/login" })
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<NewPasswordForm> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
h="100vh"
|
||||
maxW="sm"
|
||||
alignItems="stretch"
|
||||
justifyContent="center"
|
||||
gap={4}
|
||||
centerContent
|
||||
>
|
||||
<Heading size="xl" color="ui.main" textAlign="center" mb={2}>
|
||||
Reset Password
|
||||
</Heading>
|
||||
<Text textAlign="center">
|
||||
Please enter your new password and confirm it to reset your password.
|
||||
</Text>
|
||||
<FormControl mt={4} isInvalid={!!errors.new_password}>
|
||||
<FormLabel htmlFor="password">Set Password</FormLabel>
|
||||
<Input
|
||||
id="password"
|
||||
{...register("new_password", passwordRules())}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.new_password && (
|
||||
<FormErrorMessage>{errors.new_password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isInvalid={!!errors.confirm_password}>
|
||||
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
{...register("confirm_password", confirmPasswordRules(getValues))}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.confirm_password && (
|
||||
<FormErrorMessage>{errors.confirm_password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Button variant="primary" type="submit">
|
||||
Reset Password
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
164
frontend/src/routes/signup.tsx
Normal file
164
frontend/src/routes/signup.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Image,
|
||||
Input,
|
||||
Link,
|
||||
Text,
|
||||
} from "@chakra-ui/react"
|
||||
import {
|
||||
Link as RouterLink,
|
||||
createFileRoute,
|
||||
redirect,
|
||||
} from "@tanstack/react-router"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import Logo from "/assets/images/fastapi-logo.svg"
|
||||
import type { UserRegister } from "../client"
|
||||
import useAuth, { isLoggedIn } from "../hooks/useAuth"
|
||||
import { confirmPasswordRules, emailPattern, passwordRules } from "../utils"
|
||||
|
||||
export const Route = createFileRoute("/signup")({
|
||||
component: SignUp,
|
||||
beforeLoad: async () => {
|
||||
if (isLoggedIn()) {
|
||||
throw redirect({
|
||||
to: "/",
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
interface UserRegisterForm extends UserRegister {
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
function SignUp() {
|
||||
const { signUpMutation } = useAuth()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<UserRegisterForm>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
email: "",
|
||||
full_name: "",
|
||||
password: "",
|
||||
confirm_password: "",
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<UserRegisterForm> = (data) => {
|
||||
signUpMutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex flexDir={{ base: "column", md: "row" }} justify="center" h="100vh">
|
||||
<Container
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
h="100vh"
|
||||
maxW="sm"
|
||||
alignItems="stretch"
|
||||
justifyContent="center"
|
||||
gap={4}
|
||||
centerContent
|
||||
>
|
||||
<Image
|
||||
src={Logo}
|
||||
alt="FastAPI logo"
|
||||
height="auto"
|
||||
maxW="2xs"
|
||||
alignSelf="center"
|
||||
mb={4}
|
||||
/>
|
||||
<FormControl id="full_name" isInvalid={!!errors.full_name}>
|
||||
<FormLabel htmlFor="full_name" srOnly>
|
||||
Full Name
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="full_name"
|
||||
minLength={3}
|
||||
{...register("full_name", { required: "Full Name is required" })}
|
||||
placeholder="Full Name"
|
||||
type="text"
|
||||
/>
|
||||
{errors.full_name && (
|
||||
<FormErrorMessage>{errors.full_name.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl id="email" isInvalid={!!errors.email}>
|
||||
<FormLabel htmlFor="username" srOnly>
|
||||
Email
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
/>
|
||||
{errors.email && (
|
||||
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl id="password" isInvalid={!!errors.password}>
|
||||
<FormLabel htmlFor="password" srOnly>
|
||||
Password
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="password"
|
||||
{...register("password", passwordRules())}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<FormErrorMessage>{errors.password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl
|
||||
id="confirm_password"
|
||||
isInvalid={!!errors.confirm_password}
|
||||
>
|
||||
<FormLabel htmlFor="confirm_password" srOnly>
|
||||
Confirm Password
|
||||
</FormLabel>
|
||||
|
||||
<Input
|
||||
id="confirm_password"
|
||||
{...register("confirm_password", confirmPasswordRules(getValues))}
|
||||
placeholder="Repeat Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.confirm_password && (
|
||||
<FormErrorMessage>
|
||||
{errors.confirm_password.message}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
Sign Up
|
||||
</Button>
|
||||
<Text>
|
||||
Already have an account?{" "}
|
||||
<Link as={RouterLink} to="/login" color="blue.500">
|
||||
Log In
|
||||
</Link>
|
||||
</Text>
|
||||
</Container>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SignUp
|
61
frontend/src/theme.tsx
Normal file
61
frontend/src/theme.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { extendTheme } from "@chakra-ui/react"
|
||||
|
||||
const disabledStyles = {
|
||||
_disabled: {
|
||||
backgroundColor: "ui.main",
|
||||
},
|
||||
}
|
||||
|
||||
const theme = extendTheme({
|
||||
colors: {
|
||||
ui: {
|
||||
main: "#D60050",
|
||||
secondary: "#EDF2F7",
|
||||
success: "#48BB78",
|
||||
danger: "#E53E3E",
|
||||
light: "#FAFAFA",
|
||||
dark: "#1A202C",
|
||||
darkSlate: "#252D3D",
|
||||
dim: "#A0AEC0",
|
||||
},
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
variants: {
|
||||
primary: {
|
||||
backgroundColor: "ui.main",
|
||||
color: "ui.light",
|
||||
_hover: {
|
||||
backgroundColor: "#D60050",
|
||||
},
|
||||
_disabled: {
|
||||
...disabledStyles,
|
||||
_hover: {
|
||||
...disabledStyles,
|
||||
},
|
||||
},
|
||||
},
|
||||
danger: {
|
||||
backgroundColor: "ui.danger",
|
||||
color: "ui.light",
|
||||
_hover: {
|
||||
backgroundColor: "#E32727",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Tabs: {
|
||||
variants: {
|
||||
enclosed: {
|
||||
tab: {
|
||||
_selected: {
|
||||
color: "ui.main",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default theme
|
53
frontend/src/utils.ts
Normal file
53
frontend/src/utils.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { ApiError } from "./client"
|
||||
|
||||
export const emailPattern = {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
message: "Invalid email address",
|
||||
}
|
||||
|
||||
export const namePattern = {
|
||||
value: /^[A-Za-z\s\u00C0-\u017F]{1,30}$/,
|
||||
message: "Invalid name",
|
||||
}
|
||||
|
||||
export const passwordRules = (isRequired = true) => {
|
||||
const rules: any = {
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: "Password must be at least 8 characters",
|
||||
},
|
||||
}
|
||||
|
||||
if (isRequired) {
|
||||
rules.required = "Password is required"
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
export const confirmPasswordRules = (
|
||||
getValues: () => any,
|
||||
isRequired = true,
|
||||
) => {
|
||||
const rules: any = {
|
||||
validate: (value: string) => {
|
||||
const password = getValues().password || getValues().new_password
|
||||
return value === password ? true : "The passwords do not match"
|
||||
},
|
||||
}
|
||||
|
||||
if (isRequired) {
|
||||
rules.required = "Password confirmation is required"
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
export const handleError = (err: ApiError, showToast: any) => {
|
||||
const errDetail = (err.body as any)?.detail
|
||||
let errorMessage = errDetail || "Something went wrong."
|
||||
if (Array.isArray(errDetail) && errDetail.length > 0) {
|
||||
errorMessage = errDetail[0].msg
|
||||
}
|
||||
showToast("Error", errorMessage, "error")
|
||||
}
|
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
Reference in New Issue
Block a user