diff --git a/package.json b/package.json index ea011e26..88bdc983 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "sha256": "^0.2.0", "siwe": "^2.1.4", "slick-carousel": "^1.8.1", + "striptags": "3.2.0", "survey-analytics": "^1.9.109", "survey-core": "^1.9.90", "survey-creator-react": "^1.9.90", diff --git a/src/app/quiz/List.js b/src/app/quiz/List.js index b9dcfbdf..d1a378ab 100644 --- a/src/app/quiz/List.js +++ b/src/app/quiz/List.js @@ -30,10 +30,12 @@ import { fetchTeamList } from '#/domain/quiz/repository'; import { ReactSelect } from '@/components/Select/ReactSelect'; import { SearchIcon } from '@/components/Icons'; import Input from '@/components/Input'; +import { markdownToPlainText } from '@/utils/markdown'; import useMounted from '@/hooks/useMounted'; function List({ data }) { const mediaUrl = useMediaUrl(); + return ( <Link href={`/quiz/${data.id}`} className="p-6 bg-white flex max-md:flex-col gap-4 md:gap-9 mb-4 rounded-xl transition-all hover:shadow-[0_4px_24px_rgba(0,0,0,0.08)]"> <div className="relative"> @@ -43,7 +45,7 @@ function List({ data }) { <div className="flex flex-col justify-between flex-1"> <div> <h3 className="text-2xl mb-2">{data?.title}</h3> - <p className="text-base md:mb-2 opacity-60 md:line-clamp-2">{data?.describe}</p> + <p className="text-base md:mb-2 opacity-60 md:line-clamp-2">{markdownToPlainText(data?.describe)}</p> {data?.reward_text && <div className="flex w-fit pr-2 items-center h-6 bg-[rgba(239,78,22,0.1)] rounded-full max-md:mt-4"> <div className="w-6 h-6 rounded-full flex items-center justify-center bg-[#EF4E16] mr-2"> <Image width={16} height={16} src={TrophiesSvg} alt="Trophies" /> diff --git a/src/app/quiz/[id]/RankList.js b/src/app/quiz/[id]/RankList.js new file mode 100644 index 00000000..307c7c99 --- /dev/null +++ b/src/app/quiz/[id]/RankList.js @@ -0,0 +1,72 @@ +/** + * Copyright 2024 OpenBuild + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use client'; + +import Image from 'next/image'; +import Rank1Icon from 'public/images/svg/rank-1.svg'; +import Rank2Icon from 'public/images/svg/rank-2.svg'; +import Rank3Icon from 'public/images/svg/rank-3.svg'; +import { useMediaUrl } from '#/state/application/hooks'; + +export default function RankList({ rank, list }) { + const mediaUrl = useMediaUrl(); + + return ( + <div className="flex flex-col h-full"> + <div className="border border-gray-600 rounded flex flex-col h-full overflow-hidden"> + <h6 className="h-12 bg-gray-1000 text-center leading-[48px] relative flex-shrink-0"> + Quiz Scoreboard + {rank > 0 && ( + <p className="absolute right-6 top-[14px] text-sm font-normal"> + <span className="opacity-60">My ranking: </span> + {rank} + </p> + )} + </h6> + <ul className="p-4 overflow-y-auto flex-grow"> + {list?.map((i, k) => ( + <li key={`QuizScoreboard-${k}`} className="flex items-center justify-between mb-4 last:mb-0"> + <div className="flex items-center"> + {k === 0 && <Image alt="" src={Rank1Icon} className="mr-2 w-5" />} + {k === 1 && <Image alt="" src={Rank2Icon} className="mr-2 w-5" />} + {k === 2 && <Image alt="" src={Rank3Icon} className="mr-2 w-5" />} + {k > 2 && <span className="inline-block w-5 text-center mr-2 text-xs opacity-40">{k + 1}</span>} + {mediaUrl && i?.user?.user_avatar ? ( + <Image + className="h-6 w-6 rounded object-cover mr-2" + height={24} + width={24} + alt={'user_avatar'} + src={`${mediaUrl}${i.user.user_avatar}`} + /> + ) : ( + <span className="h-6 w-6 rounded-full bg-gray-200 mr-2 flex items-center justify-center text-xs"> + {i?.user?.user_nick_name?.[0]?.toUpperCase() || 'U'} + </span> + )} + <p className="text-[12px] max-md:leading-[20px] md:text-sm"> + <a href={`/u/${i?.user?.user_handle}`}>{i?.user?.user_nick_name}</a> + </p> + </div> + <p className="max-md:text-[12px] max-md:leading-[24px]">{i?.score}</p> + </li> + ))} + </ul> + </div> + </div> + ); +} diff --git a/src/app/quiz/[id]/RankListModal.js b/src/app/quiz/[id]/RankListModal.js new file mode 100644 index 00000000..b3126c28 --- /dev/null +++ b/src/app/quiz/[id]/RankListModal.js @@ -0,0 +1,52 @@ +/** + * Copyright 2024 OpenBuild + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Modal } from '@/components/Modal'; +import RankList from './RankList'; +import { useState } from 'react'; +import { fetchRankList } from '#/domain/quiz/repository'; +import Loader from '@/components/Loader'; +import { ModalCloseIcon } from '@/components/Icons'; +import useMounted from '@/hooks/useMounted'; + +export function RankListModal({ quizId, shown, onClose, rank }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useMounted(() => { + setLoading(true); + fetchRankList({ quizId }) + .then(res => { + setData(res?.data?.list?.rank); + }) + .finally(() => { + setLoading(false); + }); + }); + + return ( + <Modal isOpen={shown} closeModal={onClose} container mode="640"> + <ModalCloseIcon + onClick={onClose} + className="absolute top-[-48px] md:top-[-32px] right-0 md:right-[-32px] cursor-pointer" + /> + <div className="md:h-[600px] h-[400px] flex flex-col overflow-y-auto rounded-inherit overflow-hidden"> + {loading && <Loader classname="mr-2" />} + <RankList rank={rank} list={data} /> + </div> + </Modal> + ); +} diff --git a/src/app/quiz/[id]/Record.js b/src/app/quiz/[id]/Record.js index 5e4725a4..b10ce5cd 100644 --- a/src/app/quiz/[id]/Record.js +++ b/src/app/quiz/[id]/Record.js @@ -25,14 +25,14 @@ import { formatTime, fromUtcOffset, formatTimeMeridiem } from '@/utils/date'; import { NoData } from '@/components/NoData'; import Link from 'next/link'; -export function Record({id, openModal, closeModal}) { +export function Record({quizId, shown, onClose}) { const mediaUrl = useMediaUrl(); - const { data } = useSWR(openModal ? `/ts/v1/quiz/${id}/answer` : null, fetcher); + const { data } = useSWR(shown ? `/ts/v1/quiz/${quizId}/answer` : null, fetcher); return ( - <Modal isOpen={openModal} closeModal={closeModal} container mode="640"> + <Modal isOpen={shown} onClose={onClose} container mode="640"> <div > - <ModalCloseIcon onClick={closeModal} className="absolute top-[-48px] md:top-[-32px] right-0 md:right-[-32px] cursor-pointer" /> + <ModalCloseIcon onClick={onClose} className="absolute top-[-48px] md:top-[-32px] right-0 md:right-[-32px] cursor-pointer" /> <div> <h3 className="text-center py-4 border-b border-gray-600"> Challenge Record @@ -85,7 +85,7 @@ export function Record({id, openModal, closeModal}) { ))} </div> </div> - {data?.length === 0 && <div className="pb-12"><NoData /></div>} + {(!data|| data?.length === 0) && <div className="pb-12"><NoData /></div>} </div> </div> </Modal> diff --git a/src/app/quiz/[id]/page.js b/src/app/quiz/[id]/page.js index 9a8bbbaa..7d1d5013 100644 --- a/src/app/quiz/[id]/page.js +++ b/src/app/quiz/[id]/page.js @@ -22,9 +22,6 @@ import { useMediaUrl } from '#/state/application/hooks'; import { ArrowUturnLeftIcon } from '@heroicons/react/24/solid'; import { HistoryIcon } from '@/components/Icons'; import { Button } from '@/components/Button'; -import Rank1Icon from 'public/images/svg/rank-1.svg'; -import Rank2Icon from 'public/images/svg/rank-2.svg'; -import Rank3Icon from 'public/images/svg/rank-3.svg'; import { useState } from 'react'; import useSWR from 'swr'; import { fetcher } from '@/utils/request'; @@ -33,10 +30,14 @@ import { useSession } from 'next-auth/react'; import { useRouter } from 'next/navigation'; import { Record } from './Record'; import QuizLimiterWidget from '#/domain/quiz/widgets/quiz-limiter'; +import RankList from './RankList'; +import { RankListModal } from './RankListModal'; +import { OViewer } from '@/components/MarkDown'; export default function Quiz({params}) { const mediaUrl = useMediaUrl(); - const [openModal, setOpenModal] = useState(false); + const [openChallenge, setOpenChallenge] = useState(false); + const [openRankList, setOpenRankList] = useState(false); const [checkLimit, setCheckLimit] = useState(false); const { data } = useSWR(`/ts/v1/quiz/${params.id}/index`, fetcher); const { data: coursesList } = useSWR(`v1/learn/course/opencourse?skip=0&take=2&order=default&quiz_bind_id=${params.id}`, fetcher); @@ -62,7 +63,7 @@ export default function Quiz({params}) { if (status !== 'authenticated') { router.push(`/signin?from=/quiz/${params.id}`); } else { - setOpenModal(true); + setOpenChallenge(true); } }} className="cursor-pointer transition-all flex text-sm items-center opacity-80 rounded py-2 px-3 border border-gray-1100 text-black hover:border-gray"> <HistoryIcon className="mr-2" />Challenge Record @@ -84,55 +85,26 @@ export default function Quiz({params}) { </div> <div className="max-w-[800px] mx-auto bg-white rounded-xl p-6 md:px-9 md:pt-10 md:pb-6 relative z-[2] md:top-[-155px]"> <h5 className="text-lg mb-4 md:mb-3">Quiz Describe</h5> - <p dangerouslySetInnerHTML={{__html: data?.describe.replace('\n', '<br>')}}> - {/* {data?.describe} */} - </p> + <OViewer value={data?.describe} /> <Button onClick={() => setCheckLimit(true)} className="mt-4 md:mt-6 mb-9 md:mb-10 !font-bold px-[64px] !text-base max-md:w-full"> Challenge now </Button> - <div> - <div className="border border-gray-600 rounded"> - <h6 className="h-12 bg-gray-1000 text-center leading-[48px] rounded-t relative"> - Quiz Scoreboard - {data?.my_rank > 0 && <p className="absolute right-6 top-[14px] text-sm font-normal"> - <span className="opacity-60">My ranking: </span> - {data?.my_rank} - </p>} - </h6> - <ul className="p-4"> - {data?.rank?.map((i, k) => ( - <li key={`QuizScoreboard-${k}`} className="flex items-center justify-between mb-4 last:mb-0"> - <div className="flex items-center"> - {k === 0 && <Image alt="" src={Rank1Icon} className="mr-2 w-5" />} - {k === 1 && <Image alt="" src={Rank2Icon} className="mr-2 w-5" />} - {k === 2 && <Image alt="" src={Rank3Icon} className="mr-2 w-5" />} - {k > 2 && <span className="inline-block w-5 text-center mr-2 text-xs opacity-40">{k + 1}</span>} - <Image - className="h-6 w-6 rounded object-cover mr-2" - height={24} - width={24} - alt={'user_avatar'} - src={mediaUrl + i?.user?.user_avatar} - /> - <p className="text-[12px] max-md:leading-[20px] md:text-sm"><a href={`/u/${i?.user?.user_handle}`}>{i?.user?.user_nick_name}</a></p> - </div> - <p className="max-md:text-[12px] max-md:leading-[24px]">{i.score}</p> - </li> - ))} - </ul> - </div> - <p className="text-sm text-center mt-6"><strong>{data?.user_num}</strong> builders have participated</p> - </div> - </div> - <div className="max-w-[800px] max-md:mt-9 mx-6 md:mx-auto relative md:top-[-105px] max-md:pb-14"> - <h3 className="text-[18px] max-md:leading-[24px] md:text-lg mb-6">Related courses</h3> - <div className="grid gap-y-6 md:gap-4 md:grid-cols-2"> - {coursesList?.list?.map(i => <CourseCard data={i} key={`open-courses-${i.base.course_series_id}`} />)} - </div> + <RankList rank={data?.my_rank} list={data?.rank}/> + <p className="text-sm text-center mt-6 cursor-pointer" onClick={()=>{setOpenRankList(true)}}><strong>{data?.user_num}</strong> builders have participated</p> </div> - <Record id={params.id} openModal={openModal} closeModal={() => setOpenModal(false)} /> + { + coursesList?.count > 0 && ( + <div className="max-w-[800px] max-md:mt-9 mx-6 md:mx-auto relative md:top-[-105px] max-md:pb-14"> + <h3 className="text-[18px] max-md:leading-[24px] md:text-lg mb-6">Related courses</h3> + <div className="grid gap-y-6 md:gap-4 md:grid-cols-2"> + {coursesList?.list?.map(i => <CourseCard data={i} key={`open-courses-${i.base.course_series_id}`} />)} + </div> + </div>) + } + <RankListModal quizId={params.id} shown={openRankList} onClose={() => setOpenRankList(false)} rank={data?.my_rank}/> + <Record quizId={params.id} shown={openChallenge} onClose={() => setOpenChallenge(false)} /> </QuizLimiterWidget> ); } diff --git a/src/domain/quiz/repository.js b/src/domain/quiz/repository.js index 0680ddc7..1ceea85b 100644 --- a/src/domain/quiz/repository.js +++ b/src/domain/quiz/repository.js @@ -49,4 +49,8 @@ async function fetchTeamList(){ return httpClient.get('/quiz/team'); } -export { updateRespondentContacts, fetchPublishedQuizList, fetchAnsweredQuizList, fetchAnsweredResult, fetchTeamList }; +async function fetchRankList({ quizId }){ + return httpClient.get(`/quiz/${quizId}/users`); +} + +export { updateRespondentContacts, fetchPublishedQuizList, fetchAnsweredQuizList, fetchAnsweredResult, fetchTeamList, fetchRankList }; diff --git a/src/shared/utils/markdown.js b/src/shared/utils/markdown.js index 604af0be..89d2a332 100644 --- a/src/shared/utils/markdown.js +++ b/src/shared/utils/markdown.js @@ -22,6 +22,7 @@ import breaks from '@bytemd/plugin-breaks'; import math from '@bytemd/plugin-math'; import mermaid from '@bytemd/plugin-mermaid'; import gemoji from '@bytemd/plugin-gemoji'; +import striptags from 'striptags'; const plugins = [gfm(), breaks(), highlight(), math(), mermaid(), gemoji()]; @@ -53,4 +54,11 @@ function renderHtml(markdownContent) { return getProcessor({ sanitize, plugins }).processSync(markdownContent).toString(); } -export { getPlugins, sanitize, renderMarkdown, renderHtml }; +function markdownToPlainText(markdownContent) { + const html = renderHtml(markdownContent); + const plainText = striptags(html); + + return plainText.trim().replace(/[\r\n]+/g, ' '); +} + +export { getPlugins, sanitize, renderMarkdown, renderHtml, markdownToPlainText };