diff --git a/cspell.json b/cspell.json index 4a7bdd34..e453c5d4 100644 --- a/cspell.json +++ b/cspell.json @@ -24,6 +24,7 @@ "svgs", "swipeable", "taglib", + "taglibs", "Tanstack", "Uncapitalize", "vaul", diff --git a/package-lock.json b/package-lock.json index cf746795..e5ef3b2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "axios": "^1.7.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "date-fns": "^3.6.0", "emoji-picker-react": "^4.9.4", "firebase": "^11.0.2", "framer-motion": "^11.2.6", @@ -50,6 +51,7 @@ "qs": "^6.12.1", "react": "^18", "react-canvas-confetti": "^2.0.7", + "react-day-picker": "^8.10.1", "react-div-100vh": "^0.7.0", "react-dom": "^18", "react-hook-form": "^7.52.0", @@ -12804,6 +12806,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -20884,6 +20895,19 @@ "react": "^16.3.0 || ^17.0.1 || ^18.0.0" } }, + "node_modules/react-day-picker": { + "version": "8.10.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz", + "integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "date-fns": "^2.28.0 || ^3.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-div-100vh": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/react-div-100vh/-/react-div-100vh-0.7.0.tgz", diff --git a/package.json b/package.json index b1249cea..4108fde1 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "axios": "^1.7.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "date-fns": "^3.6.0", "emoji-picker-react": "^4.9.4", "firebase": "^11.0.2", "framer-motion": "^11.2.6", @@ -61,6 +62,7 @@ "qs": "^6.12.1", "react": "^18", "react-canvas-confetti": "^2.0.7", + "react-day-picker": "^8.10.1", "react-div-100vh": "^0.7.0", "react-dom": "^18", "react-hook-form": "^7.52.0", diff --git a/src/app/(routes)/main/page.tsx b/src/app/(routes)/main/page.tsx index 900c4144..9345f5fb 100644 --- a/src/app/(routes)/main/page.tsx +++ b/src/app/(routes)/main/page.tsx @@ -60,7 +60,10 @@ const Home = async () => { {/* 연속으로 푸는 중 */} - + {/* 복습 필수 노트 TOP5 */} diff --git a/src/app/(routes)/record/(detail)/[id]/layout.tsx b/src/app/(routes)/record/(detail)/[id]/layout.tsx new file mode 100644 index 00000000..466c4fe7 --- /dev/null +++ b/src/app/(routes)/record/(detail)/[id]/layout.tsx @@ -0,0 +1,12 @@ +import { FunctionComponent, PropsWithChildren } from 'react' +import type { Metadata } from 'next' + +export const metadata: Metadata = {} + +interface InnerLayoutProps extends PropsWithChildren {} + +const Layout: FunctionComponent = ({ children }) => { + return
{children}
+} + +export default Layout diff --git a/src/app/(routes)/record/(detail)/[id]/page.tsx b/src/app/(routes)/record/(detail)/[id]/page.tsx new file mode 100644 index 00000000..52dc1b9f --- /dev/null +++ b/src/app/(routes)/record/(detail)/[id]/page.tsx @@ -0,0 +1,112 @@ +import QuizCard from '@/features/quiz/components/quiz-card' +import { getQuizDetailRecord } from '@/requests/quiz/server' +import Icon from '@/shared/components/custom/icon' +import Text from '@/shared/components/ui/text' +import { msToElapsedTimeKorean } from '@/shared/utils/time' + +interface Props { + params: { + id: string + } + searchParams: { + type: Quiz.Set.Type + name: string + quizCount: number + score: number + } +} + +const RecordDetailPage = async ({ params, searchParams }: Props) => { + const date = params.id.split('_')[0] + const quizSetId = params.id + .split('_') + .filter((value) => value !== date) + .join('_') + const quizSetType = searchParams.type + const quizSetName = searchParams.name + const quizCount = searchParams.quizCount + const correctRate = Math.round((searchParams.score / searchParams.quizCount) * 100) + + const { totalElapsedTimeMs, quizzes } = await getQuizDetailRecord({ quizSetId, quizSetType }) + + return ( +
+
+
+ {quizSetName} + + {date} + +
+ +
+
+
+ +
+ + 문제 수 + + {quizCount}문제 +
+
+
+ +
+ + 소요시간 + + {msToElapsedTimeKorean(totalElapsedTimeMs)} +
+
+
+ +
+ + 정답률 + + {correctRate}% +
+
+
+ +
+ {quizzes.map((quiz, index) => ( + + {quiz.answer === quiz.choseAnswer ? ( + + 정답 + + ) : ( + + 오답 + + )} + + {quiz.quizSetType === 'COLLECTION_QUIZ_SET' ? ( + + {quiz.collectionName} + + ) : ( + + {quiz.directoryName} + {'>'} + {quiz.documentName} + + )} +
+ } + quiz={quiz} + /> + ))} + +
+ ) +} + +export default RecordDetailPage diff --git a/src/app/(routes)/record/(detail)/all/layout.tsx b/src/app/(routes)/record/(detail)/all/layout.tsx new file mode 100644 index 00000000..466c4fe7 --- /dev/null +++ b/src/app/(routes)/record/(detail)/all/layout.tsx @@ -0,0 +1,12 @@ +import { FunctionComponent, PropsWithChildren } from 'react' +import type { Metadata } from 'next' + +export const metadata: Metadata = {} + +interface InnerLayoutProps extends PropsWithChildren {} + +const Layout: FunctionComponent = ({ children }) => { + return
{children}
+} + +export default Layout diff --git a/src/app/(routes)/record/(detail)/all/page.tsx b/src/app/(routes)/record/(detail)/all/page.tsx new file mode 100644 index 00000000..1b1d6fbc --- /dev/null +++ b/src/app/(routes)/record/(detail)/all/page.tsx @@ -0,0 +1,11 @@ +import AllRecordDetail from '@/features/record/screen/all-record-detail' + +const AllRecordPage = () => { + return ( +
+ +
+ ) +} + +export default AllRecordPage diff --git a/src/app/(routes)/record/@header/default.tsx b/src/app/(routes)/record/@header/default.tsx new file mode 100644 index 00000000..9e06f91f --- /dev/null +++ b/src/app/(routes)/record/@header/default.tsx @@ -0,0 +1,26 @@ +'use client' + +import GoBackButton from '@/shared/components/custom/go-back-button' +import Text from '@/shared/components/ui/text' +import { useParams, usePathname } from 'next/navigation' + +const Header = () => { + const pathname = usePathname() + const params = useParams() + + const isDetailPage = params.id ? true : false + const isAllPage = pathname === '/record/all' + + const headerText = isDetailPage ? '퀴즈 상세' : isAllPage ? '퀴즈 기록 전체' : '퀴즈 기록' + + return ( +
+ + + {headerText} + +
+ ) +} + +export default Header diff --git a/src/app/(routes)/record/layout.tsx b/src/app/(routes)/record/layout.tsx new file mode 100644 index 00000000..b66de0a4 --- /dev/null +++ b/src/app/(routes)/record/layout.tsx @@ -0,0 +1,19 @@ +import { FunctionComponent, PropsWithChildren } from 'react' +import type { Metadata } from 'next' + +export const metadata: Metadata = {} + +interface LayoutProps extends PropsWithChildren { + header: React.ReactNode +} + +const Layout: FunctionComponent = ({ children, header }) => { + return ( + <> + {header} + {children} + + ) +} + +export default Layout diff --git a/src/app/(routes)/record/page.tsx b/src/app/(routes)/record/page.tsx new file mode 100644 index 00000000..36ad296d --- /dev/null +++ b/src/app/(routes)/record/page.tsx @@ -0,0 +1,73 @@ +import CustomCalendar from '@/features/record/calendar' +import RecordItem from '@/features/record/components/record-item' +import { getQuizRecords } from '@/requests/quiz/server' +import Icon from '@/shared/components/custom/icon' +import { Button } from '@/shared/components/ui/button' +import Text from '@/shared/components/ui/text' +import { formatDateKorean, getFormattedDate } from '@/shared/utils/date' +import Link from 'next/link' + +interface Props { + searchParams: { + selectedDate: string + } +} + +const RecordPage = async ({ searchParams }: Props) => { + const today = new Date() + const selectedDate = searchParams.selectedDate ?? getFormattedDate(today) + const { currentConsecutiveDays, maxConsecutiveDays, quizRecords } = await getQuizRecords() + + // 데이터가 많아지면 다른 방법으로 처리해보는 것을 고민 + // 백엔드 측에 특정 solvedDate를 api 요청 시 보내면 일치하는 데이터를 보내주는 방법 제안해봐도 좋을 듯 + const dateRecord = quizRecords.find((quizInfo) => quizInfo.solvedDate === selectedDate) + + return ( +
+
+ + + {currentConsecutiveDays} + + 일 연속으로 푸는 중 + + + + 최장 연속일: {maxConsecutiveDays}일 + +
+ + + +
+ + {formatDateKorean(selectedDate, { month: true, day: true })} + + {dateRecord?.quizRecords.map((record, index) => ( + + ))} +
+ + + + +
+ ) +} + +export default RecordPage diff --git a/src/features/collection/components/create-collection-form.tsx b/src/features/collection/components/create-collection-form.tsx index 46a4ae7d..6f2eff69 100644 --- a/src/features/collection/components/create-collection-form.tsx +++ b/src/features/collection/components/create-collection-form.tsx @@ -51,7 +51,7 @@ const CreateCollectionForm = () => { const [emoji, setEmoji] = useState('🥹') const [title, setTitle] = useState('') const [description, setDescription] = useState('') - const [categoryCode, setCategoryCode] = useState(CATEGORIES[0].code) + const [categoryCode, setCategoryCode] = useState(CATEGORIES[0]?.code ?? 'IT') const { data: directoryQuizzesData, isLoading: directoryQuizzesLoading } = useDirectoryQuizzes(selectedDirectoryId) @@ -85,13 +85,13 @@ const CreateCollectionForm = () => { { name: title, description, - collectionField: categoryCode, + collectionCategory: categoryCode, emoji, quizzes: selectedQuizIds, }, { onSuccess: (data) => { - router.replace(`/collections/${data.id}`) + router.replace(`/collections/${data.collectionId}`) }, } ) @@ -99,7 +99,7 @@ const CreateCollectionForm = () => { useEffect(() => { if (!directoriesData) return - setSelectedDirectoryId(directoriesData.directories[0].id) + setSelectedDirectoryId(directoriesData.directories[0]?.id || null) }, [directoriesData]) useEffect(() => { diff --git a/src/features/collection/components/my-collection.tsx b/src/features/collection/components/my-collection.tsx index 4867fd53..b8470df7 100644 --- a/src/features/collection/components/my-collection.tsx +++ b/src/features/collection/components/my-collection.tsx @@ -31,7 +31,7 @@ const MyCollection = () => { useBookmarkedCollections() const handleTabChange = (tab: TabType) => { - const params = new URLSearchParams(searchParams) + const params = new URLSearchParams(searchParams as unknown as URLSearchParams) params.set('sort', tab) router.replace(`?${params.toString()}`) } @@ -83,6 +83,7 @@ const MyCollection = () => { lastUpdated="2일 전" isOwner={true} bookMarkCount={collection.bookmarkCount} + creatorName={collection.member.creatorName} /> ))} @@ -103,6 +104,7 @@ const MyCollection = () => { lastUpdated="2일 전" isBookMarked={collection.bookmarked} bookMarkCount={collection.bookmarkCount} + creatorName={collection.member.creatorName} /> ))} diff --git a/src/features/modify/components/visual-editor.tsx b/src/features/modify/components/visual-editor.tsx index 9c96df70..bd5c85da 100644 --- a/src/features/modify/components/visual-editor.tsx +++ b/src/features/modify/components/visual-editor.tsx @@ -32,8 +32,10 @@ export default function VisualEditor({ prevContent }: VisualEditorProps) { manager={manager} autoRender="end" initialContent={prevContent} - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - onChange={({ helpers, state }) => setEditorMarkdownContent(helpers.getMarkdown(state))} + onChange={({ helpers, state }) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + setEditorMarkdownContent(helpers.getMarkdown && helpers.getMarkdown(state)) + } placeholder="본문을 작성해보세요!" classNames={[ css` diff --git a/src/features/quiz/components/multiple-option/index.tsx b/src/features/quiz/components/multiple-option/index.tsx index 93349534..0f60293b 100644 --- a/src/features/quiz/components/multiple-option/index.tsx +++ b/src/features/quiz/components/multiple-option/index.tsx @@ -5,7 +5,7 @@ import { ButtonHTMLAttributes } from 'react' interface MultipleOptionProps extends ButtonHTMLAttributes { option: string - condition: QuizCondition + condition: Quiz.Condition index: number } diff --git a/src/features/quiz/components/new-quiz-drawer.tsx b/src/features/quiz/components/new-quiz-drawer.tsx index ea18dffe..46827e0f 100644 --- a/src/features/quiz/components/new-quiz-drawer.tsx +++ b/src/features/quiz/components/new-quiz-drawer.tsx @@ -17,10 +17,15 @@ interface Props { // NewQuizDrawer 컴포넌트 const NewQuizDrawer = ({ triggerComponent, documentId }: Props) => { + const DEFAULT_QUIZ_COUNT = 10 // 초기값 10 const [quizType, setQuizType] = useState('MULTIPLE_CHOICE') - const [quizCount, setQuizCount] = useState(10) // 초기값 10 + const [quizCount, setQuizCount] = useState(DEFAULT_QUIZ_COUNT) const [isOpenMoreStar, setIsOpenMoreStar] = useState(false) + // 임시 (문서 글자 수에 따라 생성할 수 있는 최대 문제 개수 필요) + const DOCUMENT_MIN_QUIZ_COUNT = 1 + const DOCUMENT_MAX_QUIZ_COUNT = 40 + const handleClickStart = () => { // 기존 문서에서 새로운 퀴즈 생성하는 api 호출 // /quiz?documentId={documentId}로 이동 @@ -84,11 +89,11 @@ const NewQuizDrawer = ({ triggerComponent, documentId }: Props) => { {/* 문제 개수 슬라이더 */} setQuizCount(value[0])} + onValueChange={(value) => setQuizCount(value[0] || DEFAULT_QUIZ_COUNT)} />
diff --git a/src/features/quiz/components/today-quiz-setting/set-quiz-count/index.tsx b/src/features/quiz/components/today-quiz-setting/set-quiz-count/index.tsx index 5d2c1b65..b005d9db 100644 --- a/src/features/quiz/components/today-quiz-setting/set-quiz-count/index.tsx +++ b/src/features/quiz/components/today-quiz-setting/set-quiz-count/index.tsx @@ -5,6 +5,10 @@ import Text from '@/shared/components/ui/text' import { useTodayQuizSetting } from '../../../context/today-quiz-setting-context' const SetQuizCount = () => { + const DEFAULT_QUIZ_COUNT = 10 + const DOCUMENT_MIN_QUIZ_COUNT = 5 + const DOCUMENT_MAX_QUIZ_COUNT = 20 + const { quizCount, setQuizCount } = useTodayQuizSetting() return ( @@ -17,17 +21,17 @@ const SetQuizCount = () => {
setQuizCount(value[0])} + defaultValue={[DEFAULT_QUIZ_COUNT]} + onValueChange={(value) => setQuizCount(value[0] || DEFAULT_QUIZ_COUNT)} />
- 5 문제 - 20 문제 + {DOCUMENT_MIN_QUIZ_COUNT} 문제 + {DOCUMENT_MAX_QUIZ_COUNT} 문제
) diff --git a/src/features/quiz/screen/bomb-quiz-view.tsx b/src/features/quiz/screen/bomb-quiz-view.tsx index f654e3b5..bfbfa9af 100644 --- a/src/features/quiz/screen/bomb-quiz-view.tsx +++ b/src/features/quiz/screen/bomb-quiz-view.tsx @@ -17,6 +17,7 @@ import { useUpdateWrongQuizResult } from '@/requests/quiz/hooks' import { useRouter } from 'next/navigation' const BombQuizView = () => { + // TODO: 남은 퀴즈 수가 3개정도일 때, 미리 서버에서 오답 리스트 불러와서 현재 리스트에 추가하기 const router = useRouter() const { data, isPending } = useQuery(queries.quiz.bomb()) const { mutate: updateWrongQuizResultMutate } = useUpdateWrongQuizResult() @@ -31,7 +32,7 @@ const BombQuizView = () => { currentIndex: currentIndex, }) - const currentQuizInfo = bombQuizList && bombQuizList[currentIndex] + const currentQuizInfo = bombQuizList[currentIndex] const currentAnswerState = quizResults[currentIndex]?.answer const onAnswer = ({ @@ -146,10 +147,10 @@ const BombQuizView = () => { diff --git a/src/features/record/calendar.tsx b/src/features/record/calendar.tsx new file mode 100644 index 00000000..35046212 --- /dev/null +++ b/src/features/record/calendar.tsx @@ -0,0 +1,57 @@ +'use client' + +import { Calendar } from '@/shared/components/ui/calendar' +import { cn } from '@/shared/lib/utils' +import { getFormattedDate } from '@/shared/utils/date' +import { format } from 'date-fns' +import { useRouter, useSearchParams } from 'next/navigation' +import { useMemo } from 'react' + +interface Props { + className?: HTMLElement['className'] +} + +const CustomCalendar = ({ className }: Props) => { + const today = useMemo(() => new Date(), []) + + const router = useRouter() + const searchParams = useSearchParams() + const selectedDateString = searchParams.get('selectedDate') + + const selectedDate = useMemo(() => { + if (selectedDateString) { + const [year, month, day] = selectedDateString.split('-').map(Number) + return new Date(year!, month! - 1, day) + } + return today + }, [selectedDateString, today]) + + const handleSelect = (selected?: Date) => { + if (selected) { + const formattedDate = getFormattedDate(selected) + + router.replace(`?selectedDate=${formattedDate}`) + } + } + + return ( + `${format(Date, 'M')}월`, + formatWeekdayName: (Date: Date) => { + const weekdays = ['일', '월', '화', '수', '목', '금', '토'] + return weekdays[Date.getDay()] + }, + }} + className={cn('w-full', className)} + selected={selectedDate} + onSelect={(date?: Date) => handleSelect(date)} + selectedMonth={selectedDate} + /> + ) +} + +export default CustomCalendar diff --git a/src/features/record/components/record-item.tsx b/src/features/record/components/record-item.tsx new file mode 100644 index 00000000..313f9bff --- /dev/null +++ b/src/features/record/components/record-item.tsx @@ -0,0 +1,39 @@ +import Text from '@/shared/components/ui/text' +import RecordQuizTypeIcon from './record-quiz-type-icon' +import Icon from '@/shared/components/custom/icon' +import Link from 'next/link' + +interface Props { + type: Quiz.Set.Type + name: string + quizCount: number + score: number + date: string + quizSetId: string +} + +const RecordItem = ({ type, name, quizCount, score, date, quizSetId }: Props) => { + return ( + + + +
+ {name} +
+ + {quizCount}문제 + + + + {score}/{quizCount} + +
+
+ + ) +} + +export default RecordItem diff --git a/src/features/record/components/record-quiz-type-icon.tsx b/src/features/record/components/record-quiz-type-icon.tsx new file mode 100644 index 00000000..ea604a51 --- /dev/null +++ b/src/features/record/components/record-quiz-type-icon.tsx @@ -0,0 +1,31 @@ +import Icon from '@/shared/components/custom/icon' +import { SwitchCase } from '@/shared/components/custom/react/switch-case' +import { cn } from '@/shared/lib/utils' + +interface Props { + type: Quiz.Set.Type + className?: HTMLElement['className'] +} + +const RecordQuizTypeIcon = ({ type, className }: Props) => { + return ( + + ), + + TODAY_QUIZ_SET: ( + + ), + + COLLECTION_QUIZ_SET: ( + + ), + }} + /> + ) +} + +export default RecordQuizTypeIcon diff --git a/src/features/record/config/index.tsx b/src/features/record/config/index.tsx new file mode 100644 index 00000000..8fb16bdf --- /dev/null +++ b/src/features/record/config/index.tsx @@ -0,0 +1,118 @@ +export const mockRecords = [ + { + quizSetId: '1', + name: '오늘의 퀴즈', + quizCount: 20, + score: 17, + quizSetType: 'TODAY_QUIZ_SET', + solvedDate: '2024-12-13T08:46:30.363Z', + }, + { + quizSetId: '2', + name: '자바스크립트의 이해', + quizCount: 20, + score: 17, + quizSetType: 'DOCUMENT_QUIZ_SET', + solvedDate: '2024-12-13T08:46:30.363Z', + }, + { + quizSetId: '3', + name: '오늘의 퀴즈', + quizCount: 20, + score: 17, + quizSetType: 'TODAY_QUIZ_SET', + solvedDate: '2024-12-12T08:46:30.363Z', + }, + { + quizSetId: '4', + name: '자바스크립트의 이해', + quizCount: 20, + score: 17, + quizSetType: 'DOCUMENT_QUIZ_SET', + solvedDate: '2024-12-12T08:46:30.363Z', + }, + { + quizSetId: '5', + name: '자바스크립트의 이해', + quizCount: 20, + score: 17, + quizSetType: 'DOCUMENT_QUIZ_SET', + solvedDate: '2024-12-12T08:46:30.363Z', + }, +] + +export const quizzes = [ + { + documentName: '최근 이슈', + directoryName: '전공 공부', + collectionName: null, + quizSetType: 'TODAY_QUIZ_SET', + id: 1, + quizType: 'MULTIPLE_CHOICE' as Quiz.Type, + question: '식물기반 단백질 시장에서 대기업의 참여가 늘어나는 이유는 무엇인가요?', + options: [ + '기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다', + '배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다', + '기존의 배양육이 기존방식에서 생산되는 육류보다 토양이', + '기존의 배양육이 기존방식에서 육류보다 토양이 비축된다', + ], + answer: '기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다', + choseAnswer: '기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다', + explanation: '기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다', + }, + { + documentName: '최근 이슈', + directoryName: '전공 공부', + collectionName: null, + quizSetType: 'TODAY_QUIZ_SET', + id: 2, + quizType: 'MIX_UP' as Quiz.Type, + question: '식물기반 단백질 시장에서 대기업의 참여가 늘어나는 이유는 무엇인가요?', + answer: 'correct', + choseAnswer: 'incorrect', + explanation: + '기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다 기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다 기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다 기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다 기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다 기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다 기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다 기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다 기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다 기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다 기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다 기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다 기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다 기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다 기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다', + }, + { + documentName: '최근 이슈', + directoryName: '전공 공부', + collectionName: null, + quizSetType: 'TODAY_QUIZ_SET', + id: 3, + quizType: 'MULTIPLE_CHOICE' as Quiz.Type, + question: '식물기반 단백질 시장에서 대기업의 참여가 늘어나는 이유는 무엇인가요?', + options: [ + '기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다', + '배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다', + '기존의 배양육이 기존방식에서 생산되는 육류보다 토양이', + '기존의 배양육이 기존방식에서 육류보다 토양이 비축된다', + ], + answer: '기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다', + choseAnswer: '기존의 배양육이 기존방식에서 생산되는 육류보다 토양이', + explanation: '기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다', + }, + { + documentName: '최근 이슈', + directoryName: '전공 공부', + collectionName: null, + quizSetType: 'TODAY_QUIZ_SET', + id: 5, + quizType: 'MIX_UP' as Quiz.Type, + question: '식물기반 단백질 시장에서 대기업의 참여가 늘어나는 이유는 무엇인가요?', + answer: 'correct', + choseAnswer: 'correct', + explanation: '기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다', + }, + { + documentName: '최근 이슈', + directoryName: '전공 공부', + collectionName: null, + quizSetType: 'TODAY_QUIZ_SET', + id: 4, + quizType: 'MIX_UP' as Quiz.Type, + question: '식물기반 단백질 시장에서 대기업의 참여가 늘어나는 이유는 무엇인가요?', + answer: 'correct', + choseAnswer: 'correct', + explanation: '기존의 배양육이 기존방식에서 생산되는 육류보다 토양이 비축된다', + }, +] diff --git a/src/features/record/screen/all-record-detail.tsx b/src/features/record/screen/all-record-detail.tsx new file mode 100644 index 00000000..a89ea681 --- /dev/null +++ b/src/features/record/screen/all-record-detail.tsx @@ -0,0 +1,55 @@ +'use client' + +import Loading from '@/shared/components/custom/loading' +import Text from '@/shared/components/ui/text' +import { queries } from '@/shared/lib/tanstack-query/query-keys' +import { formatDateKorean, getFormattedDate } from '@/shared/utils/date' +import { useQuery } from '@tanstack/react-query' +import { useSearchParams } from 'next/navigation' +import RecordItem from '../components/record-item' +import { useEffect } from 'react' + +const AllRecordDetail = () => { + const today = new Date() + const searchParams = useSearchParams() + const selectedDate = searchParams.get('selectedDate') ?? getFormattedDate(today) + + const { data, isPending } = useQuery(queries.quiz.allRecords()) + + useEffect(() => { + if (selectedDate) { + const targetElement = document.getElementById(selectedDate) + if (targetElement) { + // 스크롤 이동 + targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + } + }, [selectedDate, data]) + + if (isPending) return + + return ( + <> + {data?.quizRecords.map((dateGroup) => ( +
+ + {formatDateKorean(dateGroup.solvedDate, { year: true, month: true, day: true })} + + {dateGroup.quizRecords.map((record, index) => ( + + ))} +
+ ))} + + ) +} + +export default AllRecordDetail diff --git a/src/features/write/components/create-quiz-drawer.tsx b/src/features/write/components/create-quiz-drawer.tsx index 4f0bde20..361e1e91 100644 --- a/src/features/write/components/create-quiz-drawer.tsx +++ b/src/features/write/components/create-quiz-drawer.tsx @@ -14,7 +14,8 @@ interface Props { } const CreateQuizDrawer = ({ handleCreateDocument }: Props) => { - const [selectedQuizCount, setSelectedQuizCount] = useState(10) + const DEFAULT_QUIZ_COUNT = 10 // 초기값 10 + const [selectedQuizCount, setSelectedQuizCount] = useState(DEFAULT_QUIZ_COUNT) const [selectedQuizType, setSelectedQuizType] = useState('MULTIPLE_CHOICE') const [isOpenMoreStar, setIsOpenMoreStar] = useState(false) @@ -105,7 +106,7 @@ const CreateQuizDrawer = ({ handleCreateDocument }: Props) => { step={1} defaultValue={[10]} value={[selectedQuizCount]} - onValueChange={(value) => setSelectedQuizCount(value[0])} + onValueChange={(value) => setSelectedQuizCount(value[0] || DEFAULT_QUIZ_COUNT)} />
diff --git a/src/features/write/components/editor.tsx b/src/features/write/components/editor.tsx index 24043f51..2edae873 100644 --- a/src/features/write/components/editor.tsx +++ b/src/features/write/components/editor.tsx @@ -25,8 +25,10 @@ export default function Editor({ initialContent, handleContentChange }: EditorPr manager={manager} autoRender="end" initialContent={initialContent} - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - onChange={({ helpers, state }) => handleContentChange(helpers.getMarkdown(state))} + onChange={({ helpers, state }) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + handleContentChange(helpers.getMarkdown && helpers.getMarkdown(state)) + } placeholder="본문을 작성해보세요!" classNames={[ css` diff --git a/src/requests/directory/hooks.ts b/src/requests/directory/hooks.ts index cbede863..6218ec47 100644 --- a/src/requests/directory/hooks.ts +++ b/src/requests/directory/hooks.ts @@ -50,7 +50,7 @@ export const useCreateDirectory = () => { const optimisticDirectory = { id: -1, ...newDirectory, - tag: '', + tag: 'NORMAL' as Directory.Item['tag'], documentCount: 0, } diff --git a/src/requests/quiz/client.tsx b/src/requests/quiz/client.tsx index cd6acb9d..0fc1cbbe 100644 --- a/src/requests/quiz/client.tsx +++ b/src/requests/quiz/client.tsx @@ -129,3 +129,14 @@ export const collectionQuizzesInfo = async ({ collectionId }: { collectionId: nu throw error } } + +export const getQuizRecords = async () => { + try { + const { data } = await http.get( + API_ENDPOINTS.QUIZ.GET.ALL_RECORDS + ) + return data + } catch (error: unknown) { + throw error + } +} diff --git a/src/requests/quiz/server.tsx b/src/requests/quiz/server.tsx index e9635968..8b78be3a 100644 --- a/src/requests/quiz/server.tsx +++ b/src/requests/quiz/server.tsx @@ -35,3 +35,31 @@ export const getQuizSetById = async ({ throw error } } + +export const getQuizRecords = async () => { + try { + const { data } = await httpServer.get( + API_ENDPOINTS.QUIZ.GET.ALL_RECORDS + ) + return data + } catch (error: unknown) { + throw error + } +} + +export const getQuizDetailRecord = async ({ + quizSetId, + quizSetType, +}: { + quizSetId: string + quizSetType: Quiz.Set.Type +}) => { + try { + const { data } = await httpServer.get( + API_ENDPOINTS.QUIZ.GET.RECORD(quizSetId, quizSetType) + ) + return data + } catch (error: unknown) { + throw error + } +} diff --git a/src/shared/components/custom/icon/svg-components.tsx b/src/shared/components/custom/icon/svg-components.tsx index 914d1d9c..2b4f94da 100644 --- a/src/shared/components/custom/icon/svg-components.tsx +++ b/src/shared/components/custom/icon/svg-components.tsx @@ -2533,3 +2533,74 @@ export function SpeechBubbleColor({ ...props }) { ) } + +// 퀴즈 기록 아이콘 +export const DocumentTypeRoundIcon = ({ ...props }) => { + return ( + + + + + ) +} + +export const TodayQuizTypeRoundIcon = ({ ...props }) => { + return ( + + + + + ) +} + +export const CollectionTypeRoundIcon = ({ ...props }) => { + const clipId = useId() + + return ( + + + + + + + + + + + + ) +} diff --git a/src/shared/components/ui/calendar.tsx b/src/shared/components/ui/calendar.tsx new file mode 100644 index 00000000..d0612e43 --- /dev/null +++ b/src/shared/components/ui/calendar.tsx @@ -0,0 +1,93 @@ +'use client' + +import * as React from 'react' +import { DayPicker } from 'react-day-picker' + +import { cn } from '@/shared/lib/utils' +import Icon from '../custom/icon' +import { isSameMonth } from 'date-fns' + +export type CalendarProps = React.ComponentProps & { + today: Date + selectedMonth: Date +} + +function Calendar({ + className, + classNames, + showOutsideDays = false, + selectedMonth, + today, + ...props +}: CalendarProps) { + const [month, setMonth] = React.useState(selectedMonth || today) + const isCurrentMonth = (month: Date) => isSameMonth(month, today) + + return ( + , + IconRight: ({ ...props }) => ( + + ), + }} + {...props} + /> + ) +} +Calendar.displayName = 'Calendar' + +export { Calendar } diff --git a/src/shared/hooks/use-check-list-ignore-ids.ts b/src/shared/hooks/use-check-list-ignore-ids.ts index 0bbff64e..f7070a08 100644 --- a/src/shared/hooks/use-check-list-ignore-ids.ts +++ b/src/shared/hooks/use-check-list-ignore-ids.ts @@ -56,9 +56,9 @@ export function useCheckListIgnoreIds( if (idx > -1) { const item = listRef.current[idx] - if (item.checked !== checked) { + if (item?.checked !== checked) { const arr = [...listRef.current] - arr[idx] = { ...item, id, checked } + arr[idx] = { ...item, id, checked } as T set(arr) } } diff --git a/src/shared/hooks/use-check-list.ts b/src/shared/hooks/use-check-list.ts index 9fac3b40..07792ea9 100644 --- a/src/shared/hooks/use-check-list.ts +++ b/src/shared/hooks/use-check-list.ts @@ -41,9 +41,9 @@ export function useCheckList(initialItems: T[]) { if (idx > -1) { const item = listRef.current[idx] - if (item.checked !== checked) { + if (item?.checked !== checked) { const arr = [...listRef.current] - arr[idx] = { ...item, checked } + arr[idx] = { ...item, checked } as T set(arr) } } diff --git a/src/shared/lib/tanstack-query/query-keys.ts b/src/shared/lib/tanstack-query/query-keys.ts index de5d375c..90c23434 100644 --- a/src/shared/lib/tanstack-query/query-keys.ts +++ b/src/shared/lib/tanstack-query/query-keys.ts @@ -38,6 +38,10 @@ export const queries = createQueryKeyStore({ queryFn: () => REQUEST.quiz.fetchDocumentQuizzes(params), enabled: !!params.documentId, }), + allRecords: () => ({ + queryKey: [''], + queryFn: () => REQUEST.quiz.getQuizRecords(), + }), setRecord: (params: { quizSetId: string; quizSetType: Quiz.Set.Type }) => ({ queryKey: [params], queryFn: () => REQUEST.quiz.fetchQuizSetRecord(params), diff --git a/src/shared/utils/date.ts b/src/shared/utils/date.ts index e7bed85c..049d1b4e 100644 --- a/src/shared/utils/date.ts +++ b/src/shared/utils/date.ts @@ -124,3 +124,13 @@ export function getCurrentTime() { return `${hours}:${minutes}` } + +// YYYY-MM-DD 형식으로 반환 +export const getFormattedDate = (date: Date) => { + const formattedDate = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart( + 2, + '0' + )}-${String(date.getDate()).padStart(2, '0')}` + + return formattedDate +} diff --git a/src/shared/utils/time.ts b/src/shared/utils/time.ts index c438d97a..f8a00543 100644 --- a/src/shared/utils/time.ts +++ b/src/shared/utils/time.ts @@ -14,6 +14,17 @@ export const msToMin = (ms: number) => { return minutes } +export const msToElapsedTimeKorean = (ms: number) => { + const totalSeconds = Math.floor(ms / 1000) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = Math.floor(totalSeconds % 60) + + return [hours && `${hours}시간`, minutes && `${minutes}분`, seconds && `${seconds}초`] + .filter((value) => value) + .join(' ') +} + export const getTimeUntilMidnight = () => { const now = new Date() const midnight = new Date(now) diff --git a/src/types/schema.d.ts b/src/types/schema.d.ts index 53b55bf2..dc8f29b8 100644 --- a/src/types/schema.d.ts +++ b/src/types/schema.d.ts @@ -1589,7 +1589,7 @@ export interface components { UpdateQuizResultQuizDto: { /** Format: int64 */ id?: number - answer: boolean + answer?: boolean choseAnswer?: string /** Format: int32 */ elapsedTime?: number @@ -1695,9 +1695,13 @@ export interface components { maxConsecutiveDays?: number } GetSingleQuizSetRecordDto: { + /** Format: int64 */ + id?: number question?: string answer?: string explanation?: string + /** @enum {string} */ + quizType?: 'MIX_UP' | 'MULTIPLE_CHOICE' options?: string[] choseAnswer?: string documentName?: string @@ -1730,14 +1734,17 @@ export interface components { | 'DOCUMENT_QUIZ_SET' | 'COLLECTION_QUIZ_SET' | 'FIRST_QUIZ_SET' - /** Format: date-time */ - solvedDate?: string } GetQuizRecordResponse: { /** Format: int32 */ currentConsecutiveDays?: number /** Format: int32 */ maxConsecutiveDays?: number + quizRecords?: components['schemas']['GetQuizRecordSolvedDateDto'][] + } + GetQuizRecordSolvedDateDto: { + /** Format: date */ + solvedDate?: string quizRecords?: components['schemas']['GetQuizRecordDto'][] } GetQuizSetDirectoryDto: { @@ -1923,8 +1930,8 @@ export interface components { is3xxRedirection?: boolean } JspConfigDescriptor: { - jspPropertyGroups?: components['schemas']['JspPropertyGroupDescriptor'][] taglibs?: components['schemas']['TaglibDescriptor'][] + jspPropertyGroups?: components['schemas']['JspPropertyGroupDescriptor'][] } JspPropertyGroupDescriptor: { defaultContentType?: string @@ -1933,11 +1940,11 @@ export interface components { errorOnELNotFound?: string pageEncoding?: string scriptingInvalid?: string + isXml?: string includePreludes?: string[] includeCodas?: string[] trimDirectiveWhitespaces?: string errorOnUndeclaredNamespace?: string - isXml?: string buffer?: string urlPatterns?: string[] } @@ -1964,13 +1971,13 @@ export interface components { hosts?: string[] redirectView?: boolean propagateQueryProperties?: boolean - attributesCSV?: string attributesMap?: { [key: string]: Record } attributes?: { [key: string]: string } + attributesCSV?: string } ServletContext: { sessionCookieConfig?: components['schemas']['SessionCookieConfig'] @@ -2011,10 +2018,10 @@ export interface components { effectiveMinorVersion?: number serverInfo?: string servletContextName?: string + defaultSessionTrackingModes?: ('COOKIE' | 'URL' | 'SSL')[] filterRegistrations?: { [key: string]: components['schemas']['FilterRegistration'] } - defaultSessionTrackingModes?: ('COOKIE' | 'URL' | 'SSL')[] effectiveSessionTrackingModes?: ('COOKIE' | 'URL' | 'SSL')[] jspConfigDescriptor?: components['schemas']['JspConfigDescriptor'] requestCharacterEncoding?: string