diff --git a/.eslintrc.json b/.eslintrc.json index bffb357a7122523ec94045523758c4b825b448ef..09937b6bc985db1393bd7b0a1e713e700a97ced4 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,3 +1,6 @@ { - "extends": "next/core-web-vitals" + "extends": "next/core-web-vitals", + "rules": { + "react-hooks/exhaustive-deps": "off" + } } diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b899124e174846f40a805bddba0aebde03c75510..56e123401195c3eb3ac4f025f00fc684346b0551 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -17,6 +17,7 @@ deploy: - echo "$DO_ACCESS_TOKEN" | docker login registry.digitalocean.com --username $DO_ACCESS_TOKEN --password-stdin script: - echo "NEXT_PUBLIC_API_URL=$BACKEND_API" > .env.local + - echo "NEXT_PUBLIC_BUCKET_URL=$BUCKET_ENDPOINT" >> .env.local - docker build -t registry.digitalocean.com/ocw-container/ocw-frontend:latest -t registry.digitalocean.com/ocw-container/ocw-frontend:$CI_COMMIT_TAG . - docker push registry.digitalocean.com/ocw-container/ocw-frontend:$CI_COMMIT_TAG - docker push registry.digitalocean.com/ocw-container/ocw-frontend:latest diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000000000000000000000000000000000..55712c19f1dffd66d4fed7a406a354f215c43a14 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} \ No newline at end of file diff --git a/cypress.config.ts b/cypress.config.ts index a5b268ac66008f7ab4d8ce117d8dfe07baea4eed..042a59c313d9631bccbdc3ee577bf2959d979169 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'cypress'; export default defineConfig({ + projectId: 'd434om', env: { NEXT_PUBLIC_API_URL: 'http://localhost:8888', }, diff --git a/src/components/course_banner.tsx b/src/components/course_banner.tsx index f3a77d8c26df13f2e0d0aca9cd0a721fc5e8d97f..d70b8a300c90d1a4248725fe6f8d7eb680f7e20e 100644 --- a/src/components/course_banner.tsx +++ b/src/components/course_banner.tsx @@ -1,11 +1,12 @@ import { - HStack, - VStack, Box, + BoxProps, Container, - Text, Divider, - BoxProps, + HStack, + Skeleton, + Text, + VStack, } from '@chakra-ui/react'; export interface BannerProps extends BoxProps { @@ -35,24 +36,28 @@ const CourseBanner: React.FC<BannerProps> = ({ > {children} </Box> - <Container minHeight="90vh" bgColor="biru.500" width="50vh" padding={10}> + <Container minHeight="90vh" bgColor="biru.500" width="50vh" padding={10} display={{ base: 'none', lg: 'flex' }}> <VStack align={'start'} spacing="6" marginStart="2vh" maxWidth="30vh"> <VStack spacing="1" align={'start'}> <Text align={'start'} fontSize={'m'}> Kode Mata Kuliah </Text> - <Text align={'start'} fontSize={'xl'} wordBreak="break-word"> - {course_code} - </Text> + <Skeleton isLoaded={course_code != null}> + <Text align={'start'} fontSize={'xl'} wordBreak="break-word"> + {course_code} + </Text> + </Skeleton> </VStack> <Divider borderColor={'black'} borderWidth={'1px'} w="75%" my={4} /> <VStack spacing="1" align={'start'}> <Text align={'start'} fontSize={'m'}> Mata Kuliah </Text> - <Text align={'start'} fontSize={'xl'} wordBreak="break-word"> - {course_name} - </Text> + <Skeleton isLoaded={course_name != null}> + <Text align={'start'} fontSize={'xl'} wordBreak="break-word"> + {course_name} + </Text> + </Skeleton> </VStack> <Divider borderColor={'black'} borderWidth={'1px'} w="75%" my={4} /> <VStack spacing="1" align={'start'}> @@ -60,7 +65,7 @@ const CourseBanner: React.FC<BannerProps> = ({ Dosen Pengajar </Text> <Text align={'start'} fontSize={'xl'} wordBreak="break-word"> - {lecturer} + {lecturer ?? '-'} </Text> </VStack> </VStack> diff --git a/src/components/course_card.tsx b/src/components/course_card.tsx index a7657e2e7a16fb7c2225fb0412e42a44c4698b9b..ebc6098354965f42f08a5ae90b0210c3f94792ef 100644 --- a/src/components/course_card.tsx +++ b/src/components/course_card.tsx @@ -36,7 +36,10 @@ export default function CourseCard({ > <Image alt="Course Thumbnail" - src={thumbnail ?? 'https://via.placeholder.com/150x100'} + src={ + thumbnail ?? + 'https://images.unsplash.com/photo-1495465798138-718f86d1a4bc?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&q=80' + } borderRadius="inherit" /> <Box mt={{ base: 2, lg: 5 }} mb={2}> diff --git a/src/components/management/content/add-content-modal.tsx b/src/components/management/content/add-content-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0807eea812bcb1706bbea785b427f5cc76686409 --- /dev/null +++ b/src/components/management/content/add-content-modal.tsx @@ -0,0 +1,82 @@ +import Modal from '@/components/modal'; +import { Major } from '@/types/major'; +import { + FormControl, + FormLabel, + Input, + Select, + Stack, + Textarea, +} from '@chakra-ui/react'; + +export interface AddContentModalProps { + isOpen: boolean; + onClose: () => void; + handleConfirm: () => void; + type: string; + setType: (name: string) => void; + link: string; + setLink: (link: string) => void; + file: File | undefined; + setFile: (file: File | undefined) => void; +} + +const AddContentModal = ({ + isOpen, + onClose, + handleConfirm, + type, + setType, + link, + setLink, + file, + setFile, +}: AddContentModalProps) => { + const uploadToClient = (e: any) => { + if(e.target.files && e.target.files[0]){ + const i = e.target.files[0]; + + setFile(i); + } + } + return ( + <Modal + isOpen={isOpen} + onClose={onClose} + onConfirm={handleConfirm} + header="Menambahkan Materi Baru" + > + <Stack> + <FormControl isRequired> + <FormLabel>Tipe Materi</FormLabel> + <Select onChange={(e) => {setType(e.target.value)}}> + <option value="handout">File</option> + <option value="video">Video</option> + </Select> + </FormControl> + { + type == 'video' ?( + <FormControl> + <FormLabel>Link</FormLabel> + <Input + name="Link" + onChange={(e) => setLink(e.target.value)} + /> + </FormControl> + ) : ( + <FormControl> + <FormLabel>File</FormLabel> + <Input + name="File" + type="file" + onChange={(e) => uploadToClient(e)} + /> + </FormControl> + ) + } + </Stack> + </Modal> + ); +} + +export default AddContentModal; \ No newline at end of file diff --git a/src/components/management/content/delete-content-modal.tsx b/src/components/management/content/delete-content-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..29c8c6964d66a47ffe7cea15c60fcd8b7fd5ac0e --- /dev/null +++ b/src/components/management/content/delete-content-modal.tsx @@ -0,0 +1,25 @@ +import Modal from '@/components/modal'; +import { Text } from '@chakra-ui/react'; + +export interface DeleteMaterialModalProps { + isOpen: boolean; + onClose: () => void; + handleConfirm: () => void; +} + +export default function DeleteMaterialModal({ + isOpen, + onClose, + handleConfirm, +}: DeleteMaterialModalProps) { + return ( + <Modal + isOpen={isOpen} + onClose={onClose} + onConfirm={handleConfirm} + header="Hapus Konten" + > + <Text>Apakah anda yakin ingin menghapus konten ini?</Text> + </Modal> + ); +} diff --git a/src/components/management/course/quiz/problem-item.tsx b/src/components/management/course/quiz/problem-item.tsx index 1254ce01f5e790d6374fe411ea503e144ab1f112..dc5aaa502c5e25123c54bb0e77e3f348ba41d328 100644 --- a/src/components/management/course/quiz/problem-item.tsx +++ b/src/components/management/course/quiz/problem-item.tsx @@ -31,7 +31,7 @@ export default function ProblemItem({ problems, setProblems, }: ProblemItemProps) { - const [answers, setAnswers] = useState<AnswerOption[]>(problem.answers); + const [answers, setAnswers] = useState<AnswerOption[]>(problem.answers ?? []); const handleChangeQuestion = (e: React.ChangeEvent<HTMLTextAreaElement>) => { setProblems( diff --git a/src/components/management/material/add-material-modal.tsx b/src/components/management/material/add-material-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c716a1790d77d7ec4d059974d775b6aaff6e4b34 --- /dev/null +++ b/src/components/management/material/add-material-modal.tsx @@ -0,0 +1,56 @@ +import Modal from '@/components/modal'; +import { Major } from '@/types/major'; +import { + FormControl, + FormLabel, + Input, + Select, + Stack, + Textarea, +} from '@chakra-ui/react'; + +export interface AddMaterialModalProps { + isOpen: boolean; + onClose: () => void; + handleConfirm: () => void; + name: string; + setName: (name: string) => void; + week: number; + setWeek: (week: number) => void; +} + +const AddMaterialModal = ({ + isOpen, + onClose, + handleConfirm, + name, + setName, + week, + setWeek +}: AddMaterialModalProps) => { + return ( + <Modal + isOpen={isOpen} + onClose={onClose} + onConfirm={handleConfirm} + header="Menambahkan Materi Baru" + > + <Stack> + <FormControl isRequired> + <FormLabel>Judul Materi</FormLabel> + <Input name="name" onChange={(e) => setName(e.target.value)}/> + </FormControl> + <FormControl> + <FormLabel>Week</FormLabel> + <Input + name="Week" + type="number" + onChange={(e) => setWeek(+e.target.value)} + /> + </FormControl> + </Stack> + </Modal> + ); +} + +export default AddMaterialModal; \ No newline at end of file diff --git a/src/components/management/material/delete-material-modal.tsx b/src/components/management/material/delete-material-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..41b25fcbfa63261cfa983be0cf7e858b16abbe95 --- /dev/null +++ b/src/components/management/material/delete-material-modal.tsx @@ -0,0 +1,25 @@ +import Modal from '@/components/modal'; +import { Text } from '@chakra-ui/react'; + +export interface DeleteMaterialModalProps { + isOpen: boolean; + onClose: () => void; + handleConfirm: () => void; +} + +export default function DeleteMaterialModal({ + isOpen, + onClose, + handleConfirm, +}: DeleteMaterialModalProps) { + return ( + <Modal + isOpen={isOpen} + onClose={onClose} + onConfirm={handleConfirm} + header="Hapus Materi" + > + <Text>Apakah anda yakin ingin menghapus materi ini?</Text> + </Modal> + ); +} diff --git a/src/components/management/material/edit-material-modal.tsx b/src/components/management/material/edit-material-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5dfa51646b79a7eb07a2d1270e96023b8d2e2ed7 --- /dev/null +++ b/src/components/management/material/edit-material-modal.tsx @@ -0,0 +1,118 @@ +import Modal from '@/components/modal'; +import http from '@/lib/http'; +import { Major } from '@/types/major'; +import { + FormControl, + FormLabel, + Input, + Select, + Stack, + TableContainer, + Table, + Thead, + Tbody, + Text, + Tr, + Th, + Td, + Button, +} from '@chakra-ui/react'; +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { MdDelete } from 'react-icons/md'; + +export interface EditMaterialModalProps { + materialID: string; + isOpen: boolean; + onClose: () => void; + handleConfirm: () => void; + name: string; + week: number; + contents: Content[]; +} + +export interface Content { + id : string, + type : string +} + +const EditMaterialModal = ({ + materialID, + isOpen, + onClose, + handleConfirm, + name, + week, + contents +}: EditMaterialModalProps) => { + console.log(contents); + return ( + <Modal + isOpen={isOpen} + onClose={onClose} + onConfirm={handleConfirm} + header="Menambahkan Materi Baru" + > + <Stack> + <FormControl isRequired> + <FormLabel>Judul Materi</FormLabel> + <Input + variant="filled" + name="name" + value={name} + isDisabled + /> + </FormControl> + <FormControl> + <FormLabel>Week</FormLabel> + <Input + name="Week" + type="number" + variant="filled" + value={week} + isDisabled + /> + </FormControl> + <Button> + <Text> + Add Content + </Text> + </Button> + { + contents.length > 0 ? ( + <TableContainer> + <Table variant="simple" bg="white" borderRadius={'lg'} mt={5}> + <Thead textTransform="capitalize"> + <Tr> + <Th>Content Type</Th> + <Th>Action</Th> + </Tr> + </Thead> + <Tbody> + {contents.map((m: Content) => ( + <Tr key={m.id}> + <Td>{m.type}</Td> + <Td> + <Button size="sm" colorScheme="red"> + <MdDelete /> + <Text ml={2} display={{ base: 'none', lg: 'flex' }}> + Hapus + </Text> + </Button> + </Td> + </Tr> + )) + } + </Tbody> + </Table> + </TableContainer> + ) : ( + <Text>No Content</Text> + ) + } + </Stack> + </Modal> + ); +} + +export default EditMaterialModal; \ No newline at end of file diff --git a/src/components/select_search.tsx b/src/components/select_search.tsx index 1565893686873e203dcb1b5d995efd1be5928a80..872eeedd674e97c68d657e79a7995be9ad238ed8 100644 --- a/src/components/select_search.tsx +++ b/src/components/select_search.tsx @@ -11,6 +11,7 @@ import { MenuButton, MenuItem, MenuList, + MenuOptionGroup, } from '@chakra-ui/react'; import Link from 'next/link'; import { useEffect, useState } from 'react'; @@ -62,26 +63,24 @@ export function SelectSearch() { </MenuButton> <MenuList> {/* TODO: list all major and faculty */} - <MenuItem display="flex" w="50rem" isDisabled> - Fakultas - </MenuItem> - {faculty.map((faculty: Faculty) => ( - <Link href={`/courses/faculty/${faculty.id}`} key={faculty.id}> - <MenuItem display="flex" w="50rem"> - {faculty.abbreviation} - {faculty.name} - </MenuItem> - </Link> - ))} - <MenuItem display="flex" w="50rem" isDisabled> - Jurusan - </MenuItem> - {majors.map((major: Major) => ( - <Link href={`/courses/major/${major.id}`} key={major.id}> - <MenuItem display="flex" w="50rem"> - {major.abbreviation} - {major.name} - </MenuItem> - </Link> - ))} + <MenuOptionGroup title="Fakultas"> + {faculty.map((faculty: Faculty) => ( + <Link href={`/courses/faculty/${faculty.id}`} key={faculty.id}> + <MenuItem display="flex" w="50rem"> + {faculty.abbreviation} - {faculty.name} + </MenuItem> + </Link> + ))} + </MenuOptionGroup> + <MenuOptionGroup title="Jurusan"> + {majors.map((major: Major) => ( + <Link href={`/courses/major/${major.id}`} key={major.id}> + <MenuItem display="flex" w="50rem"> + {major.abbreviation} - {major.name} + </MenuItem> + </Link> + ))} + </MenuOptionGroup> </MenuList> </Menu> </Flex> diff --git a/src/lib/http.ts b/src/lib/http.ts index d1b7ec4a816f22a0c120ef06974e90aa55a932fe..8b0a96d2e1b7460f4c009f79d58652229d9c06b2 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -29,21 +29,24 @@ http.interceptors.response.use( async (err) => { if (axios.isAxiosError(err)) { const config = err.config as any; + console.log(err.response?.status == HttpResponse.BadRequest); if ( (err.response?.status == HttpResponse.Unauthorized || - err.response?.status == HttpResponse.UnprocessableEntity) && - config.url === '/auth/refresh' - ) { + err.response?.status == HttpResponse.UnprocessableEntity || + err.response?.status == HttpResponse.BadRequest) && + config.url === '/auth/refresh' + ) { unsetToken(); } if ( - err.response?.status == HttpResponse.Unauthorized && + (err.response?.status == HttpResponse.Unauthorized || + err.response?.status == HttpResponse.BadRequest) && !config?.sent && config.url !== `/auth/refresh` ) { const refreshKey = localStorage.getItem(TOKEN_REFRESH_KEY); - + if (refreshKey) { try { const { @@ -56,9 +59,9 @@ http.interceptors.response.use( authorization: `Bearer ${refreshKey}`, }, } - ); - - const newToken = data.token.access as string; + ); + + const newToken = data.access_token as string; sessionStorage.setItem(TOKEN_ACCESS_KEY, newToken); config.sent = true; @@ -70,7 +73,8 @@ http.interceptors.response.use( if (axios.isAxiosError(errNew)) { if ( errNew.response?.status == HttpResponse.Unauthorized || - errNew.response?.status == HttpResponse.UnprocessableEntity + errNew.response?.status == HttpResponse.UnprocessableEntity || + errNew.response?.status == HttpResponse.BadRequest ) { unsetToken(); } diff --git a/src/pages/admin.tsx b/src/pages/admin.tsx index afaafc28493da9bf5caad8142eab48380a1a54e5..fadd06da6faa0417ed7de21df9be03a84d023846 100644 --- a/src/pages/admin.tsx +++ b/src/pages/admin.tsx @@ -190,12 +190,12 @@ export default function Admin() { > <HStack justifyContent="space-between"> <Heading>Daftar Pengguna</Heading> - <Button bg="biru.600" color="white" onClick={onOpenAdd}> + {/* <Button bg="biru.600" color="white" onClick={onOpenAdd}> <MdAdd /> <Text ml={2} display={{ base: 'none', lg: 'flex' }}> Tambah Pengguna </Text> - </Button> + </Button> */} </HStack> <TableContainer> <Table variant="simple" bg={'white'} borderRadius="lg" mt={5}> diff --git a/src/pages/courses/details/[id].tsx b/src/pages/courses/details/[id].tsx index 3976115cbba9d53f51298bb3fef55c185668ce25..34e2d010283d31a7345795e904567245ca702546 100644 --- a/src/pages/courses/details/[id].tsx +++ b/src/pages/courses/details/[id].tsx @@ -85,38 +85,42 @@ export default function CourseDetails() { <Td>{material.week}</Td> <Td> <HStack my={0} py={0}> - {material.contents.map((content) => ( - <Link - href={ - content.type === 'handout' - ? process.env.NEXT_PUBLIC_BUCKET_URL + - '/' + - content.link - : content.link + {/* {material.contents.map((content) => ( + ))} */} + <Link href={`/content/${material.id}/slide`}> + <Button + size="sm" + colorScheme={ + 'yellow' } - target="_blank" - key={content.id} > - <Button - size="sm" - colorScheme={ - content.type === 'video' ? 'red' : 'yellow' - } + <MdAttachFile /> + + <Text + ml={2} + display={{ base: 'none', lg: 'flex' }} > - {content.type === 'video' ? ( - <MdPlayArrow /> - ) : ( - <MdAttachFile /> - )} - <Text - ml={2} - display={{ base: 'none', lg: 'flex' }} - > - {content.type} - </Text> - </Button> - </Link> - ))} + handout + </Text> + </Button> + </Link> + <Link href={`/content/${material.id}/video`}> + <Button + size="sm" + colorScheme={ + 'red' + } + > + <MdPlayArrow /> + + <Text + ml={2} + display={{ base: 'none', lg: 'flex' }} + > + video + </Text> + </Button> + </Link> </HStack> </Td> </Tr> diff --git a/src/pages/courses/faculty/[id].tsx b/src/pages/courses/faculty/[id].tsx index 09663d831501a1902ece508c8f8848099cd169be..657544a869ac83210d4c22d9a495d622b3c8e22a 100644 --- a/src/pages/courses/faculty/[id].tsx +++ b/src/pages/courses/faculty/[id].tsx @@ -20,6 +20,8 @@ import { MdArrowBackIos, MdSearch } from 'react-icons/md'; export default function Courses() { const [courses, setCourses] = useState<Course[]>([]); + const [rawCourses, setRawCourses] = useState<Course[]>([]); + const [searchQuery, setSearchQuery] = useState(''); const [faculty, setFaculty] = useState<Faculty>(); const toast = useToast(); @@ -32,6 +34,7 @@ export default function Courses() { http .get(`/course/faculty/courses/${id}`) .then((res) => { + setRawCourses(res.data.data); setCourses(res.data.data); }) .catch((err) => { @@ -43,6 +46,23 @@ export default function Courses() { }); }, [id]); + useEffect(() => { + if (searchQuery === '') { + setCourses(rawCourses); + return; + } + console.log(searchQuery); + // filter courses name or lecturer name or course code + setCourses( + rawCourses.filter( + (c) => + c.name.toLowerCase().includes(searchQuery.toLowerCase()) || + c.lecturer.toLowerCase().includes(searchQuery.toLowerCase()) || + c.id.toLowerCase().includes(searchQuery.toLowerCase()) + ) + ); + }, [searchQuery]); + useEffect(() => { if (!id) return; http @@ -77,7 +97,12 @@ export default function Courses() { <InputLeftElement> <MdSearch /> </InputLeftElement> - <Input name="search" placeholder="Cari Mata Kuliah" bg="white" /> + <Input + name="search" + placeholder="Cari Mata Kuliah" + bg="white" + onChange={(e) => setSearchQuery(e.target.value)} + /> </InputGroup> </Stack> <Heading size="lg"> diff --git a/src/pages/courses/index.tsx b/src/pages/courses/index.tsx index 170da39dfe8a08fc91132481de61869f5d4a7b2e..6cc38bd0110c5eb197fcb0e116d478c57bf8a0b4 100644 --- a/src/pages/courses/index.tsx +++ b/src/pages/courses/index.tsx @@ -18,6 +18,8 @@ import { MdArrowBackIos, MdSearch } from 'react-icons/md'; export default function Courses() { const [courses, setCourses] = useState<Course[]>([]); + const [rawCourses, setRawCourses] = useState<Course[]>([]); + const [searchQuery, setSearchQuery] = useState(''); const toast = useToast(); const router = useRouter(); @@ -26,6 +28,7 @@ export default function Courses() { http .get('/course') .then((res) => { + setRawCourses(res.data.data); setCourses(res.data.data); }) .catch((err) => { @@ -38,6 +41,23 @@ export default function Courses() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (searchQuery === '') { + setCourses(rawCourses); + return; + } + console.log(searchQuery); + // filter courses name or lecturer name or course code + setCourses( + rawCourses.filter( + (c) => + c.name.toLowerCase().includes(searchQuery.toLowerCase()) || + c.lecturer.toLowerCase().includes(searchQuery.toLowerCase()) || + c.id.toLowerCase().includes(searchQuery.toLowerCase()) + ) + ); + }, [searchQuery]); + return ( <Layout title="Courses List"> <Stack @@ -56,7 +76,12 @@ export default function Courses() { <InputLeftElement> <MdSearch /> </InputLeftElement> - <Input name="search" placeholder="Cari Mata Kuliah" bg="white" /> + <Input + name="search" + placeholder="Cari Mata Kuliah" + bg="white" + onChange={(e) => setSearchQuery(e.target.value)} + /> </InputGroup> </Stack> </Stack> diff --git a/src/pages/courses/major/[id].tsx b/src/pages/courses/major/[id].tsx index 7a3b3fe5961f54f81d09eb2f7af83b04738091e1..2ea935558b4a65136397ae96f36ba26df9e51896 100644 --- a/src/pages/courses/major/[id].tsx +++ b/src/pages/courses/major/[id].tsx @@ -20,6 +20,8 @@ import { MdArrowBackIos, MdSearch } from 'react-icons/md'; export default function Courses() { const [courses, setCourses] = useState<Course[]>([]); + const [rawCourses, setRawCourses] = useState<Course[]>([]); + const [searchQuery, setSearchQuery] = useState(''); const [major, setMajor] = useState<Major>(); const toast = useToast(); @@ -32,6 +34,7 @@ export default function Courses() { http .get(`/course/major/courses/${id}`) .then((res) => { + setRawCourses(res.data.data); setCourses(res.data.data); }) .catch((err) => { @@ -43,6 +46,23 @@ export default function Courses() { }); }, [id]); + useEffect(() => { + if (searchQuery === '') { + setCourses(rawCourses); + return; + } + console.log(searchQuery); + // filter courses name or lecturer name or course code + setCourses( + rawCourses.filter( + (c) => + c.name.toLowerCase().includes(searchQuery.toLowerCase()) || + c.lecturer.toLowerCase().includes(searchQuery.toLowerCase()) || + c.id.toLowerCase().includes(searchQuery.toLowerCase()) + ) + ); + }, [searchQuery]); + useEffect(() => { if (!id) return; http @@ -77,7 +97,12 @@ export default function Courses() { <InputLeftElement> <MdSearch /> </InputLeftElement> - <Input name="search" placeholder="Cari Mata Kuliah" bg="white" /> + <Input + name="search" + placeholder="Cari Mata Kuliah" + bg="white" + onChange={(e) => setSearchQuery(e.target.value)} + /> </InputGroup> </Stack> <Heading size="lg"> diff --git a/src/pages/management/course/[course_id]/index.tsx b/src/pages/management/course/[course_id]/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bc7399d0c11bed209076535eb215462142a1c578 --- /dev/null +++ b/src/pages/management/course/[course_id]/index.tsx @@ -0,0 +1,367 @@ +import RowAction from '@/components/admin/row-action'; +import CourseBanner from '@/components/course_banner'; +import Layout from '@/components/layout'; +import AddMaterialModal from '@/components/management/material/add-material-modal'; +import DeleteMaterialModal from '@/components/management/material/delete-material-modal'; +import Modal from '@/components/modal'; +import NotFound from '@/components/not_found'; +import http from '@/lib/http'; +import { getAvailableUserData } from '@/lib/token'; +import { Content } from '@/types/content'; +import { Material } from '@/types/material'; +import { Quiz } from '@/types/quiz'; +import { UserClaim } from '@/types/token'; +import { + Box, + Button, + Heading, + HStack, + IconButton, + Link, + Table, + TableContainer, + Tbody, + Td, + Text, + Th, + Thead, + Tr, + useDisclosure, + useToast, +} from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; +import { MdAdd, MdArrowBackIos } from 'react-icons/md'; + +interface CourseBannerProps { + course_code: string; + course_name: string; + lecturer: string; +} + +const MaterialManagementPage = () => { + const router = useRouter(); + const query = router.query; + const course_id = query.course_id; + + const toast = useToast(); + const { + isOpen: isOpenAdd, + onOpen: onOpenAdd, + onClose: onCloseAdd, + } = useDisclosure(); + + const { + isOpen: isOpenDeleteMaterial, + onOpen: onOpenDeleteMaterial, + onClose: onCloseDeleteMaterial, + } = useDisclosure(); + const { + isOpen: isOpenDeleteQuiz, + onOpen: onOpenDeleteQuiz, + onClose: onCloseDeleteQuiz, + } = useDisclosure(); + const [courseBannerProps, setCourseBannerProps] = useState<CourseBannerProps>( + { + course_code: '', + course_name: '', + lecturer: '', + } + ); + const [materialID, setMaterialID] = useState<string>(''); + const [materialName, setMaterialName] = useState<string>(''); + const [materialWeek, setMaterialWeek] = useState<number>(0); + const [isValid, setIsValid] = useState<boolean>(true); + const [materials, setMaterials] = useState<Material[]>([]); + const [contents, setContents] = useState<Content[]>([]); + const [quizzes, setQuizzes] = useState<Quiz[]>([]); + const [selectedQuizId, setSelectedQuizId] = useState<string>(''); + + useEffect(() => { + if (!router.isReady) return; + http + .get(`/course/${router.query.course_id}/quiz`) + .then((res) => { + setQuizzes(res.data.data); + }) + .catch((err) => { + console.log(err); + }); + }, [router.isReady, router.query.course_id]); + + useEffect(() => { + if (!router.isReady) return; + console.log('t'); + if (course_id) { + let user: UserClaim | null = getAvailableUserData(); + const getCourse = async (course_id: string) => { + const course = await http + .get(`${process.env.NEXT_PUBLIC_API_URL}/course/${course_id}`) + .catch((res) => { + toast({ + title: 'Get Course Failed', + description: res.data.message, + status: 'error', + duration: 9000, + isClosable: true, + }); + setIsValid(false); + }); + if (user && course!.data.data.email == user!.email) { + setCourseBannerProps({ + course_code: course_id, + course_name: course!.data.data.name, + lecturer: course!.data.data.lecturer, + }); + } else { + toast({ + title: 'Forbidden Page', + status: 'error', + duration: 9000, + isClosable: true, + }); + setIsValid(false); + } + }; + getCourse(course_id as string).catch((res) => { + setIsValid(false); + }); + } + }, [isValid, router.isReady]); + + const [materialReady, setMaterialReady] = useState<boolean>(false); + useEffect(() => { + http + .get(`/course/${course_id}/materials`) + .then((res) => { + setMaterials(res.data.data); + setMaterialReady(true); + }) + .catch((err) => { + toast({ + title: 'Error', + description: 'Gagal mengambil data materi', + status: 'error', + }); + }); + }, [materialReady]); + + const handleAdd = () => { + http + .post(`/course/${course_id}/material`, { + name: materialName, + week: materialWeek, + }) + .then((res) => { + toast({ + title: 'Success', + description: 'Berhasil menambah materi', + status: 'success', + duration: 1000, + isClosable: true, + }); + setMaterialReady(false); + }) + .catch((err) => { + toast({ + title: 'Gagal menambah material', + description: `${err.response?.data}`, + status: 'error', + duration: 1000, + isClosable: true, + }); + }); + onCloseAdd(); + }; + + const handleEditButton = (material_id: string) => { + router.push(`/management/course/${course_id}/material/${material_id}`); + }; + + const handleDeleteButton = (material_id: string) => { + setMaterialID(material_id); + onOpenDeleteMaterial(); + }; + + const handleDelete = () => { + http + .delete(`/material/${materialID}`) + .then((res) => { + toast({ + title: 'Success', + description: 'Berhasil menghapus materi.', + status: 'success', + duration: 1000, + isClosable: true, + }); + setMaterialReady(false); + }) + .catch((res) => { + toast({ + title: 'Gagal menghapus materi', + description: 'Entah mengapa', + status: 'error', + duration: 1000, + isClosable: true, + }); + }); + + onCloseDeleteMaterial(); + }; + + const handleConfirmDeleteQuiz = () => { + onCloseDeleteQuiz(); + http + .delete(`/quiz/${selectedQuizId}`) + .then(() => { + toast({ + title: 'Latihan berhasil dihapus', + status: 'success', + duration: 3000, + isClosable: true, + }); + }) + .catch((err) => { + toast({ + title: 'Gagal menghapus latihan', + status: 'error', + duration: 3000, + isClosable: true, + }); + console.log(err); + }); + }; + + if (isValid) + return ( + <> + <Layout title="Edit Konten Course" py={0} px={0}> + <CourseBanner {...courseBannerProps}> + <HStack justifyContent="space-between"> + <HStack> + <IconButton + aria-label="back" + icon={<MdArrowBackIos />} + variant="ghost" + onClick={router.back} + /> + <Heading>Daftar Materi</Heading> + </HStack> + <Button bg="biru.600" color="white" onClick={onOpenAdd}> + <MdAdd /> + <Text ml={2} display={{ base: 'none', lg: 'flex' }}> + New Content + </Text> + </Button> + </HStack> + <TableContainer> + <Table variant="simple" bg={'white'} borderRadius="lg" mt={5}> + <Thead textTransform="capitalize"> + <Tr> + <Th>Material</Th> + <Th>Week</Th> + <Th>Action</Th> + </Tr> + </Thead> + <Tbody> + {materials.map((m: Material) => ( + <Tr key={m.id}> + <Td>{m.name}</Td> + <Td>{m.week}</Td> + <Td> + <HStack> + <RowAction + onOpenEdit={() => { + handleEditButton(m.id); + }} + onOpenDelete={() => { + handleDeleteButton(m.id); + }} + /> + </HStack> + </Td> + </Tr> + ))} + </Tbody> + </Table> + </TableContainer> + + <HStack justifyContent="space-between" mt={10}> + <Box> + <Heading>Daftar Latihan Soal</Heading> + </Box> + <Link href={`${router.asPath}/quiz/add`}> + <Button bg="biru.600" color="white"> + <MdAdd /> + <Text ml={2} display={{ base: 'none', lg: 'flex' }}> + Latihan Baru + </Text> + </Button> + </Link> + </HStack> + <TableContainer> + <Table variant="simple" bg={'white'} borderRadius="lg" mt={5}> + <Thead textTransform="capitalize"> + <Tr> + <Th>Judul Latihan Soal</Th> + <Th>Action</Th> + </Tr> + </Thead> + <Tbody> + {quizzes.map((q: Quiz) => ( + <Tr key={q.id}> + <Td>{q.nama}</Td> + <Td> + <RowAction + onOpenEdit={() => { + router.push(`${router.asPath}/quiz/${q.id}/edit`); + }} + onOpenDelete={() => { + setSelectedQuizId(q.id); + onOpenDeleteQuiz(); + }} + /> + </Td> + </Tr> + ))} + </Tbody> + </Table> + </TableContainer> + </CourseBanner> + </Layout> + + <AddMaterialModal + isOpen={isOpenAdd} + onClose={onCloseAdd} + handleConfirm={() => handleAdd()} + name={materialName} + setName={setMaterialName} + week={materialWeek} + setWeek={setMaterialWeek} + /> + + <DeleteMaterialModal + isOpen={isOpenDeleteMaterial} + onClose={onCloseDeleteMaterial} + handleConfirm={() => handleDelete()} + /> + + <Modal + isOpen={isOpenDeleteQuiz} + onClose={onCloseDeleteQuiz} + header={'Hapus Latihan'} + onConfirm={handleConfirmDeleteQuiz} + > + Yakin ingin menghapus latihan? + </Modal> + </> + ); + else + return ( + <Layout title="Not Found"> + <NotFound /> + </Layout> + ); +}; + +export default MaterialManagementPage; diff --git a/src/pages/management/course/[course_id]/material/[material_id]/file.tsx b/src/pages/management/course/[course_id]/material/[material_id]/file.tsx index e7488a32339877621dd04abd7b4dfcf028c66b9e..81a452ec5ad8083a772b3af9b4d41b3a89ea2eea 100644 --- a/src/pages/management/course/[course_id]/material/[material_id]/file.tsx +++ b/src/pages/management/course/[course_id]/material/[material_id]/file.tsx @@ -83,7 +83,7 @@ const EditContentPage = ({ console.log("success req to endpoint"); const upload_link = res.data.data.upload_link; axios - .put(upload_link, file, { + .put(upload_link, file, { headers: { 'Content-Type' : file!.type, 'x-amz-acl' : 'public-read', diff --git a/src/pages/management/course/[course_id]/material/[material_id]/index.tsx b/src/pages/management/course/[course_id]/material/[material_id]/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5922d81efc3377890962f8c1aef378115f1011c4 --- /dev/null +++ b/src/pages/management/course/[course_id]/material/[material_id]/index.tsx @@ -0,0 +1,381 @@ +import CourseBanner from "@/components/course_banner"; +import Layout from "@/components/layout"; +import http from "@/lib/http"; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import NotFound from "@/components/not_found"; +import { UserClaim } from "@/types/token"; +import { getAvailableUserData } from "@/lib/token"; +import { + useToast, + HStack, + Heading, + Button, + Text, + useDisclosure, + Accordion, + AccordionItem, + AccordionButton, + AccordionPanel, + AccordionIcon, + Box, + Card, + IconButton, + VStack, + Skeleton, + AspectRatio, +} from "@chakra-ui/react"; +import { MdAdd, MdDelete, MdArrowBackIos } from "react-icons/md"; +import AddContentModal from "@/components/management/content/add-content-modal"; +import DeleteContentModal from "@/components/management/content/delete-content-modal"; +import { Material } from "@/types/material"; +import { Content } from "@/types/content"; +import axios from "axios"; + + +interface CourseBannerProps { + course_code : string; + course_name : string; + lecturer : string; +} + +const ContentManagementPage = () => { + const router = useRouter(); + const query = router.query; + const course_id = query.course_id; + const material_id = query.material_id; + + const toast = useToast(); + const { + isOpen: isOpenAdd, + onOpen: onOpenAdd, + onClose: onCloseAdd + } = useDisclosure(); + const { + isOpen: isOpenDelete, + onOpen: onOpenDelete, + onClose: onCloseDelete + } = useDisclosure(); + const [courseBannerProps, setCourseBannerProps] = useState<CourseBannerProps>({ + course_code : '', + course_name : '', + lecturer : '' + }); + + const [material, setMaterial] = useState<Material>({ + id: '', + course_id: '', + creator_email: '', + name: '', + week: 0, + contents: [] + }); + const [isValid, setIsValid] = useState<boolean>(true); + const [file, setFile] = useState<File|undefined>(); + const [type, setType] = useState<string>('handout'); + const [link, setLink] = useState<string>(''); + const [contentID, setContentID] = useState<string>(''); + const [indexExpanded, setIndexExpanded] = useState<number>(-1); + + useEffect(() => { + if(!router.isReady) return; + if(course_id && material_id){ + let user : UserClaim | null = getAvailableUserData(); + const getCourse = async(course_id : string) => { + const course = await http + .get(`/course/${course_id}`) + .catch((res) => { + toast({ + title: 'Get Course Failed', + description: res.data.message, + status: 'error', + duration: 9000, + isClosable: true + }); + setIsValid(false); + }); + if(user && course!.data.data.email == user!.email){ + setCourseBannerProps({ + course_code : course_id, + course_name : course!.data.data.name, + lecturer : course!.data.data.lecturer + }); + }else{ + toast({ + title: 'Forbidden Page', + description: "Pengguna bukan contributor", + status: 'error', + duration: 9000, + isClosable: true + }); + setIsValid(false); + } + } + + const getMaterial = async (material_id : string, course_id : string) => { + const material = await http.get(`/material/${material_id}`).catch( + (err) => { + toast({ + title: 'Get Course Failed', + description: err.data.message, + status: 'error', + duration: 9000, + isClosable: true + }); + setIsValid(false); + } + ); + + if(material){ + if(material!.data.data.course_id != course_id){ + toast({ + title: 'Wrong Material or Course', + description: material.data.message, + status: 'error', + duration: 9000, + isClosable: true + }); + setIsValid(false); + } + } + } + + getCourse(course_id as string) + .then((res) => {getMaterial(material_id as string, course_id as string) + .catch((err) => {throw err})}) + .catch((err) => {setIsValid(false)}); + } + + }, [isValid, router.isReady]); + + const [materialReady, setMaterialReady] = useState<boolean>(false); + useEffect(() => { + if(!router.isReady) return; + + if(material_id){ + http + .get(`/material/${material_id}`) + .then((res) => { + const material = res.data.data; + setMaterial(material); + setMaterialReady(true); + }).catch((err) => { + toast({ + title: 'Error', + description: 'Gagal mengambil data materi', + status: 'error' + }) + }) + } + }, [materialReady, router.isReady]); + + const handleAdd = () => { + const body = { + link : (type === "handout") ? "" : link, + type : type + } + http + .post(`/material/${material_id}/content`,body).then((res) => { + if(type === "video"){ + toast({ + title: 'Success', + description: 'Berhasil menambah materi', + status: 'success', + duration: 1000, + isClosable: true, + }); + setMaterialReady(false); + }else{ + const upload_link = res.data.data.upload_link; + axios + .put(upload_link, file, { + headers: { + 'Content-Type' : file!.type, + 'x-amz-acl' : 'public-read', + } + }) + .then( + res => { + console.log(res); + toast({ + title: 'Adding material success!', + description: res.data.message, + status: 'success', + duration: 9000, + isClosable: true + }); + setMaterialReady(false); + }, res => { + console.log(res); + toast({ + title: 'Adding material failed!', + description: res.message, + status: 'error', + duration: 9000, + isClosable: true, + }) + } + ) + } + }).catch((err) => { + toast({ + title: 'Gagal menambah material', + description: `${err.response?.data}`, + status: 'error', + duration: 1000, + isClosable: true, + }) + }); + onCloseAdd(); + } + + const handleDeleteButton = (content_id : string) => { + setContentID(content_id); + onOpenDelete(); + } + + const handleDelete = () => { + if(!contentID){ + toast({ + title: 'Gagal menghapus', + description: 'Konten belum dipilih', + status: 'error', + duration: 1000, + isClosable: true, + }); + return; + } + http.delete(`/material/${material_id}/content/${contentID}`).then( + (res) => { + toast({ + title: 'Success', + description: 'Berhasil menghapus materi.', + status: 'success', + duration: 1000, + isClosable: true, + }); + setMaterialReady(false); + } + ).catch( + err => { + console.log(err); + toast({ + title: 'Gagal menghapus materi', + description: err.message, + status: 'error', + duration: 1000, + isClosable: true, + }); + } + ) + onCloseDelete(); + } + + if(isValid) return ( + <> + <Layout title="Edit Konten" py={0} px={0}> + <CourseBanner {...courseBannerProps}> + <HStack justifyContent="space-between"> + <HStack> + <IconButton + aria-label="back" + icon={<MdArrowBackIos />} + variant="ghost" + onClick={router.back} + /> + <VStack alignItems={'flex-start'}> + <Heading>Edit Konten</Heading> + <Skeleton isLoaded = {materialReady}> + <Text>{material.name} - Week {material.week}</Text> + </Skeleton> + </VStack> + </HStack> + <Button bg="biru.600" color="white" onClick={onOpenAdd}> + <MdAdd /> + <Text ml={2} display={{ base: 'none', lg: 'flex' }}> + New Content + </Text> + </Button> + </HStack> + <Card mt={10} p={5}> + <Accordion allowToggle onChange={(idx) => {setIndexExpanded(idx as number)}}> + {material.contents.map((c: Content) => ( + <AccordionItem key={c.id}> + <AccordionButton> + <Box as="span" flex='1' textAlign='left'> + Content {c.type} + </Box> + </AccordionButton> + <AccordionPanel overflow={'auto'} maxH={'fit-content'} width={'100%'}> + { + //TODO : Tampilin konten di sini + c.type == "handout" ? ( + <AspectRatio + height={'100%'} + width={'100%'} + > + <iframe + src={`https://ocw-bucket.s3.idcloudhost.com/static/${c.link}`} + width="100%" + height="100%" + style={{ border: 'none' }} + + /> + </AspectRatio> + ):( + <AspectRatio + maxWidth={'5xl'} + maxHeight={'7xl'} + > + <iframe + width="100%" + height="100%" + src={c.link.replace("watch?v=","embed/")} + title="video player" + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" + allowFullScreen + /> + </AspectRatio> + ) + } + <Button bg="red" color="white" onClick={e => handleDeleteButton(c.id)} mt={5}> + <MdDelete /> + <Text ml={2} display={{ base: 'none', lg: 'flex' }}> + Delete + </Text> + </Button> + </AccordionPanel> + </AccordionItem> + ))} + </Accordion> + </Card> + </CourseBanner> + </Layout> + + <AddContentModal + isOpen={isOpenAdd} + onClose={onCloseAdd} + handleConfirm={() => {handleAdd()}} + type={type} + setType={setType} + link={link} + setLink={setLink} + file={file} + setFile={setFile} + /> + + <DeleteContentModal + isOpen={isOpenDelete} + onClose={onCloseDelete} + handleConfirm={() => handleDelete()} + /> + </> + ) + else return ( + <Layout title="Not Found"> + <NotFound/> + </Layout> + ) +} + +export default ContentManagementPage; \ No newline at end of file diff --git a/src/pages/management/course/[course_id]/quiz/[quiz_id]/edit.tsx b/src/pages/management/course/[course_id]/quiz/[quiz_id]/edit.tsx index fc923f6f56af254359c91ff67f320e3cf113f054..4a57f7678a19f7438765c6b730f67d194b454bf1 100644 --- a/src/pages/management/course/[course_id]/quiz/[quiz_id]/edit.tsx +++ b/src/pages/management/course/[course_id]/quiz/[quiz_id]/edit.tsx @@ -19,7 +19,7 @@ import { useEffect, useState } from 'react'; import { MdAdd } from 'react-icons/md'; import { v4 as uuidv4 } from 'uuid'; -export default function NewQuiz() { +export default function EditQuiz() { const router = useRouter(); const toast = useToast(); const quizId = router.query.quiz_id as string; @@ -47,13 +47,13 @@ export default function NewQuiz() { }) .then((res) => { // parse the link - const link = res.data.data.upload_link; + const link = res.data.data.path; axios .get(`${process.env.NEXT_PUBLIC_BUCKET_URL}/${link}`) .then((res) => { setProblems(res.data.problems); - console.log(problems); - }); + }) + .catch((err) => console.log(err)); }) .catch((err) => console.log(err)); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -61,7 +61,7 @@ export default function NewQuiz() { const handleSubmit = () => { http - .put( + .patch( `/quiz/${quizId}`, { name: quizName, @@ -75,14 +75,27 @@ export default function NewQuiz() { ) .then((res) => { console.log(res.data); + const id = res.data.data.id; const uploadLink = res.data.data.upload_link; axios - .put(uploadLink, problems, { - headers: { - 'Content-Type': 'application/json', - 'x-amz-acl': 'public-read', + .put( + uploadLink, + { + id, + name: quizName, + course_id, + description: '', + help: '', + media: [], + problems, }, - }) + { + headers: { + 'Content-Type': 'application/json', + 'x-amz-acl': 'public-read', + }, + } + ) .then((res) => { console.log(res.data); if (res.status === 200) { @@ -102,13 +115,6 @@ export default function NewQuiz() { .catch((err) => { console.log(err); }); - // toast({ - // title: 'Latihan berhasil dibuat', - // status: 'success', - // duration: 3000, - // isClosable: true, - // }); - // router.back(); }; return ( diff --git a/src/pages/management/course/[course_id]/quiz/add.tsx b/src/pages/management/course/[course_id]/quiz/add.tsx index e8bbcbf89dcbe34af6c0ad4f50c3ee96403a0515..6dd7a8162992ef5defbe8eb4c1dde4b0deb73493 100644 --- a/src/pages/management/course/[course_id]/quiz/add.tsx +++ b/src/pages/management/course/[course_id]/quiz/add.tsx @@ -22,7 +22,6 @@ import { v4 as uuidv4 } from 'uuid'; export default function NewQuiz() { const router = useRouter(); const toast = useToast(); - const quizId = uuidv4(); const course_id = router.query.course_id as string; const [quizName, setQuizName] = useState(''); const [problems, setProblems] = useState<Problem[]>([]); @@ -30,7 +29,7 @@ export default function NewQuiz() { const handleSubmit = () => { http .put( - `/quiz/${quizId}`, + `/quiz`, { name: quizName, course_id, @@ -43,14 +42,27 @@ export default function NewQuiz() { ) .then((res) => { console.log(res.data); + const id = res.data.data.id; const uploadLink = res.data.data.upload_link; axios - .put(uploadLink, problems, { - headers: { - 'Content-Type': 'application/json', - 'x-amz-acl': 'public-read', + .put( + uploadLink, + { + id, + name: quizName, + course_id, + description: '', + help: '', + media: [], + problems, }, - }) + { + headers: { + 'Content-Type': 'application/json', + 'x-amz-acl': 'public-read', + }, + } + ) .then((res) => { console.log(res.data); if (res.status === 200) { diff --git a/src/pages/management/course/[course_id]/quiz/index.tsx b/src/pages/management/course/[course_id]/quiz/index.tsx deleted file mode 100644 index d5b3d609f3bc60acc96431963abd82b676273142..0000000000000000000000000000000000000000 --- a/src/pages/management/course/[course_id]/quiz/index.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import RowAction from '@/components/admin/row-action'; -import Layout from '@/components/layout'; -import Modal from '@/components/modal'; -import http from '@/lib/http'; -import { Quiz } from '@/types/quiz'; -import { - Box, - Button, - Heading, - HStack, - Table, - TableContainer, - Tbody, - Td, - Text, - Th, - Thead, - Tr, - useDisclosure, - useToast, -} from '@chakra-ui/react'; -import Link from 'next/link'; -import { useRouter } from 'next/router'; -import { MdAdd } from 'react-icons/md'; - -const quizzes = [ - { - id: '440e9a7b-5fe5-481c-a98d-a1736e91f42b', - nama: 'Latihan Sample', - course_id: 'IF3270', - creator_email: 'contributor@example.com', - }, - { - id: 'ca45c775-bb74-422e-943c-08e9601d6d41', - nama: 'Latihan Beneran', - course_id: 'IF3270', - creator_email: 'contributor@example.com', - }, -]; - -export default function QuizManagement() { - const router = useRouter(); - const toast = useToast(); - const { isOpen, onOpen, onClose } = useDisclosure(); - - const handleConfirmDelete = () => { - onClose(); - http - .delete(`/quiz/${router.query.quiz_id}`, { - headers: { - Authorization: `Bearer ${localStorage.getItem('access_token')}`, - }, - }) - .then(() => { - toast({ - title: 'Latihan berhasil dihapus', - status: 'success', - duration: 3000, - isClosable: true, - }); - }) - .catch((err) => { - toast({ - title: 'Gagal menghapus latihan', - status: 'error', - duration: 3000, - isClosable: true, - }); - console.log(err); - }); - }; - - return ( - <> - <Layout title="Quiz Management"> - <HStack justifyContent="space-between"> - <Box> - <Heading>Daftar Latihan</Heading> - <Text mt={3}>IF3270 Pembelajaran Mesin</Text> - </Box> - <Link href={`${router.asPath}/add`}> - <Button bg="biru.600" color="white"> - <MdAdd /> - <Text ml={2} display={{ base: 'none', lg: 'flex' }}> - Latihan Baru - </Text> - </Button> - </Link> - </HStack> - <TableContainer> - <Table variant="simple" bg={'white'} borderRadius="lg" mt={5}> - <Thead textTransform="capitalize"> - <Tr> - <Th>Judul Latihan Soal</Th> - <Th>Action</Th> - </Tr> - </Thead> - <Tbody> - {quizzes.map((q: Quiz) => ( - <Tr key={q.id}> - <Td>{q.nama}</Td> - <Td> - <RowAction - onOpenEdit={() => { - router.push(`${router.asPath}/${q.id}/edit`); - }} - onOpenDelete={onOpen} - /> - </Td> - </Tr> - ))} - </Tbody> - </Table> - </TableContainer> - </Layout> - - <Modal - isOpen={isOpen} - onClose={onClose} - header={'Hapus Latihan'} - onConfirm={handleConfirmDelete} - > - Yakin ingin menghapus latihan? - </Modal> - </> - ); -} diff --git a/src/pages/management/course/index.tsx b/src/pages/management/course/index.tsx index c6cc3f3c3c5e5a352497eda25e83265a3f5f1dcc..8c2e662e4969ac06b14fd02d63ad5fe3081987b6 100644 --- a/src/pages/management/course/index.tsx +++ b/src/pages/management/course/index.tsx @@ -252,25 +252,17 @@ export default function CourseManagement() { <Tbody> {courses.map((c: Course) => ( <Tr key={c.id}> - <Td> - <Text>{c.name}</Text> - <HStack my={1}> - <Link href={router.asPath + '/'}> - <Button size="sm" onClick={onOpenEdit}> - <Text ml={2} display={{ base: 'none', lg: 'flex' }}> - Materi - </Text> - </Button> - </Link> - <Link href={router.asPath + '/'}> - <Button size="sm" onClick={onOpenEdit}> - <Text ml={2} display={{ base: 'none', lg: 'flex' }}> - Quiz - </Text> - </Button> - </Link> - </HStack> - </Td> + <Link href={`/management/course/${c.id}`}> + <Td> + <Text + _hover={{ + textDecoration: 'underline', + }} + > + {c.name} + </Text> + </Td> + </Link> {/* TODO: ask backend for abbreviation */} <Td>{c.id.slice(0, 2)}</Td> <Td>{c.lecturer.length > 0 ? c.lecturer : '-'}</Td> diff --git a/src/pages/quiz/[id]/pembahasan.tsx b/src/pages/quiz/[id]/pembahasan.tsx index 24543d80e5235a0adedd790c6eec4069385ed6cb..734f6ea2269f468ec367f48802f80727a52d72b2 100644 --- a/src/pages/quiz/[id]/pembahasan.tsx +++ b/src/pages/quiz/[id]/pembahasan.tsx @@ -24,6 +24,9 @@ function Pembahasan() { const [userAnswers, setUserAnswers] = useState<UserAnswer[]>([]); useEffect(() => { + if (!router.query.id || !router.query.userAnswers) { + return; + } setUserAnswers(JSON.parse(router.query.userAnswers as string)); http .get(`/quiz/${router.query.id}/solution`, { @@ -44,49 +47,53 @@ function Pembahasan() { <Layout> <Heading my={5}>Pembahasan {name}</Heading> <Stack gap={3} mt={10}> - {problems.map((problem, index) => ( - <> - <Box key={problem.id} bg="white" borderRadius="lg" p={5}> - <Text fontWeight="bold">Nomor {index + 1}</Text> - <Text my={3}>{problem.question}</Text> - <RadioGroup> - <Stack gap={2}> - {problem.answers.map((answer) => ( - <Radio - key={answer.id} - value={answer.id} - borderWidth="thick" - borderColor={ - answer.is_solution - ? 'green.500' - : userAnswers.find( - (userAnswer) => - userAnswer.problem_id == problem.id && - userAnswer.answer_id == answer.id - ) - ? 'red.500' - : 'gray.300' - } - isReadOnly - > - {answer.answer} - </Radio> - ))} - </Stack> - </RadioGroup> - </Box> - <Flex key={problem.id} bg="white" borderRadius="lg" p={5}> - <Text fontWeight="bold">Jawaban:</Text> - <Text ml={3}> - { - // find the answer that has is_solution = true - problem.answers.find((answer) => answer.is_solution == true) - ?.answer || 'Tidak ada jawaban' - } - </Text> - </Flex> - </> - ))} + {problems.length > 0 ? ( + problems.map((problem, index) => ( + <> + <Box key={problem.id} bg="white" borderRadius="lg" p={5}> + <Text fontWeight="bold">Nomor {index + 1}</Text> + <Text my={3}>{problem.question}</Text> + <RadioGroup> + <Stack gap={2}> + {problem.answers.map((answer) => ( + <Radio + key={answer.id} + value={answer.id} + borderWidth="thick" + borderColor={ + answer.is_solution + ? 'green.500' + : userAnswers.find( + (userAnswer) => + userAnswer.problem_id == problem.id && + userAnswer.answer_id == answer.id + ) + ? 'red.500' + : 'gray.300' + } + isReadOnly + > + {answer.answer} + </Radio> + ))} + </Stack> + </RadioGroup> + </Box> + <Flex key={problem.id} bg="white" borderRadius="lg" p={5}> + <Text fontWeight="bold">Jawaban:</Text> + <Text ml={3}> + { + // find the answer that has is_solution = true + problem.answers.find((answer) => answer.is_solution == true) + ?.answer || 'Tidak ada jawaban' + } + </Text> + </Flex> + </> + )) + ) : ( + <Text>Belum ada pembahasan</Text> + )} </Stack> <Flex justifyContent="flex-end" mt={10}> <Link href="/"> diff --git a/src/pages/quiz/[id]/result.tsx b/src/pages/quiz/[id]/result.tsx index 44c6e6352e70bbe59e5e44c092ec41a71dcec8ec..70acbd581c026f6acae0eaaeb5b29a4d4931f57b 100644 --- a/src/pages/quiz/[id]/result.tsx +++ b/src/pages/quiz/[id]/result.tsx @@ -9,25 +9,44 @@ import { useEffect, useState } from 'react'; function Result() { const router = useRouter(); - const [score, setScore] = useState(0); + const [score, setScore] = useState(-1); const [userAnswers, setUserAnswers] = useState<UserAnswer[]>([]); + const [isDoneLoading, setIsDoneLoading] = useState(false); useEffect(() => { + if (!router.query.id || !router.query.userAnswers) { + return; + } + // if score is already calculated, return + if (score !== -1) { + return; + } // parse user answer as UserAnswer[] from router.query.userAnswers setUserAnswers(JSON.parse(router.query.userAnswers as string)); // POST http - .post(`/quiz/${router.query.id}/finish`, { - data: userAnswers, - headers: { - Authorization: `Bearer ${getAvailableUserData()}`, + .post( + `/quiz/${router.query.id}/finish`, + { + data: userAnswers, }, - }) + { + headers: { + Authorization: `Bearer ${getAvailableUserData()}`, + }, + } + ) .then((res) => { console.log(res.data.data); setScore(res.data.data.score); + }) + .catch((err) => { + console.log(err.response.data); + }) + .finally(() => { + setIsDoneLoading(true); }); - }, [router.query.id, router.query.userAnswers]); + }, [router.query.id, router.query.userAnswers, userAnswers]); return ( <Layout> @@ -40,31 +59,37 @@ function Result() { width={{ base: '100%', lg: '50%' }} > <Text fontSize="2xl">Kuis Selesai</Text> - <Text mt={10}>Nilai:</Text> - <Text mx={10} fontSize="3xl" fontFamily="Merriweather"> - {score} / 100 - </Text> - <Stack - mt={10} - justifyContent="space-between" - direction={{ base: 'column', lg: 'row' }} - > - <Link - href={{ - pathname: router.asPath + '/../pembahasan', - query: { userAnswers: JSON.stringify(userAnswers) }, - }} - > - <Button bg="#4F4F4F" color="white"> - Cek Pembahasan - </Button> - </Link> - <Link href={`/`}> - <Button bg="biru.600" color="white"> - Kembali ke Course - </Button> - </Link> - </Stack> + {isDoneLoading ? ( + <> + <Text mt={10}>Nilai:</Text> + <Text mx={10} fontSize="3xl" fontFamily="Merriweather"> + {score} / 100 + </Text> + <Stack + mt={10} + justifyContent="space-between" + direction={{ base: 'column', lg: 'row' }} + > + <Link + href={{ + pathname: `${router.asPath}/../pembahasan`, + query: { userAnswers: JSON.stringify(userAnswers) }, + }} + > + <Button bg="#4F4F4F" color="white"> + Cek Pembahasan + </Button> + </Link> + <Link href={`/`}> + <Button bg="biru.600" color="white"> + Kembali ke Course + </Button> + </Link> + </Stack> + </> + ) : ( + <Text my={10}>Loading...</Text> + )} </Box> </Flex> </Layout> diff --git a/src/pages/quiz/[id]/start.tsx b/src/pages/quiz/[id]/start.tsx index 3a1e5b34ccfbe85f4374376ce1107adaad6fe8fa..c05abd31668f0dfb6b224640691aa7b6236fcc1a 100644 --- a/src/pages/quiz/[id]/start.tsx +++ b/src/pages/quiz/[id]/start.tsx @@ -24,25 +24,28 @@ function Quiz() { // create countdown from 100 minutes const [minutes, setMinutes] = useState(30); const [seconds, setSeconds] = useState(0); + const [isDoneLoading, setIsDoneLoading] = useState(false); const router = useRouter(); useEffect(() => { - const interval = setInterval(() => { - if (seconds > 0) { - setSeconds(seconds - 1); - } - if (seconds === 0) { - if (minutes === 0) { - clearInterval(interval); - } else { - setMinutes(minutes - 1); - setSeconds(59); + if (isDoneLoading) { + const interval = setInterval(() => { + if (seconds > 0) { + setSeconds(seconds - 1); } - } - }, 1000); - return () => clearInterval(interval); - }, [minutes, seconds]); + if (seconds === 0) { + if (minutes === 0) { + clearInterval(interval); + } else { + setMinutes(minutes - 1); + setSeconds(59); + } + } + }, 1000); + return () => clearInterval(interval); + } + }, [isDoneLoading, minutes, seconds]); // if minutes is 0, then the quiz is over useEffect(() => { @@ -56,6 +59,9 @@ function Quiz() { const hours = Math.floor(minutes / 60); useEffect(() => { + if (!router.query.id) { + return; + } http .post(`/quiz/${router.query.id}/take`, { Authorization: `Bearer ${getAvailableUserData()}`, @@ -63,8 +69,12 @@ function Quiz() { .then((res) => { setQuizName(res.data.data.name); setProblems(res.data.data.problems); - }); - }); + }) + .catch((err) => { + console.log(err.response.data); + }) + .finally(() => setIsDoneLoading(true)); + }, [router.query.id]); const handleChangeAnswer = (problemId: string, answerId: string) => { // if same problemId already exists, then replace it @@ -100,34 +110,38 @@ function Quiz() { <Stack mb={10} px={{ base: 5, md: 20 }} py={{ base: 5, md: 10 }}> <Heading my={5}>{quizName}</Heading> <Stack gap={3} mt={10}> - {problems.map((problem, index) => ( - <Box key={problem.id} bg="white" borderRadius="lg" p={5}> - <Text fontWeight="bold">Nomor {index + 1}</Text> - <Text my={3}>{problem.question}</Text> - <RadioGroup> - <Stack gap={2}> - {problem.answers.map((answer) => ( - <Radio - key={answer.id} - value={answer.id} - onChange={(e: any) => - handleChangeAnswer(problem.id, e.target.value) - } - checked={ - userAnswers.some( - (userAnswer) => - userAnswer.problem_id === problem.id && - userAnswer.answer_id === answer.id - ) ?? false - } - > - {answer.answer} - </Radio> - ))} - </Stack> - </RadioGroup> - </Box> - ))} + {isDoneLoading ? ( + problems.map((problem, index) => ( + <Box key={problem.id} bg="white" borderRadius="lg" p={5}> + <Text fontWeight="bold">Nomor {index + 1}</Text> + <Text my={3}>{problem.question}</Text> + <RadioGroup> + <Stack gap={2}> + {problem.answers.map((answer) => ( + <Radio + key={answer.id} + value={answer.id} + onChange={(e: any) => + handleChangeAnswer(problem.id, e.target.value) + } + checked={ + userAnswers.some( + (userAnswer) => + userAnswer.problem_id === problem.id && + userAnswer.answer_id === answer.id + ) ?? false + } + > + {answer.answer} + </Radio> + ))} + </Stack> + </RadioGroup> + </Box> + )) + ) : ( + <Text>Loading...</Text> + )} </Stack> <Flex justifyContent="flex-end" mt={20}> <Button bg="biru.600" color="white" onClick={handleSubmit}>