first commit
This commit is contained in:
		
							
								
								
									
										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 | ||||
		Reference in New Issue
	
	Block a user