diff --git a/package-lock.json b/package-lock.json index 3420f1b..d287f3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-navigation-menu": "^1.2.13", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.7", @@ -1326,6 +1327,141 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==" + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", diff --git a/package.json b/package.json index c61e0e3..59899f5 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-navigation-menu": "^1.2.13", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.7", diff --git a/src/app/(pages)/(protected)/home/page.tsx b/src/app/(pages)/(protected)/home/page.tsx index 5b8c4e1..3af62c7 100644 --- a/src/app/(pages)/(protected)/home/page.tsx +++ b/src/app/(pages)/(protected)/home/page.tsx @@ -143,6 +143,7 @@ const Home = () => { title={note.title} description={note.description} createdAt={note.createdAt} + updatedAt={note.createdAt} tags={note.tags} /> diff --git a/src/app/(pages)/(protected)/notes/[id]/page.tsx b/src/app/(pages)/(protected)/notes/[id]/page.tsx index bbc4d19..603bbec 100644 --- a/src/app/(pages)/(protected)/notes/[id]/page.tsx +++ b/src/app/(pages)/(protected)/notes/[id]/page.tsx @@ -1,5 +1,5 @@ 'use client' -import { useParams, useRouter } from 'next/navigation' +import { useParams } from 'next/navigation' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { ArrowLeft, CircleChevronLeft, Edit, Save, Tag } from 'lucide-react' @@ -11,9 +11,12 @@ import { Note } from '@/types/note.type' import { getNoteById, updateNote } from '@/services/note.service' import AutoResizeTextarea from '@/components/notes/auto-resize-textarea' import ReactMarkdown from 'react-markdown' +import { useNav } from '@/hooks/use-nav' +import ErrorBlock from '@/components/common/error-block' const NoteDetailsPage = () => { - const router = useRouter() + const nav = useNav() + const { id } = useParams() const [note, setNote] = useState() // const [tags, setTags] = useState(note?.tags || []) @@ -42,10 +45,7 @@ const NoteDetailsPage = () => { fetchData(Number(id)); }, [id]) - const handleBackToNotes = () => { - router.push('/notes') - } - + // ------ Handle mode switching (view / edit) ------ // const handleEditNote = () => { // setTagsInput(tags.join(',')) @@ -56,6 +56,27 @@ const NoteDetailsPage = () => { setIsEditing(false) } + // ------ Handle content changes ------ // + const handleTitleChange = (event: ChangeEvent) => { + if (!note) return + + const newTitle = event.target.value + setNote({...note, title: newTitle }) + } + + const handleContentChange = (event: ChangeEvent) => { + if (!note) return + + const newContent = event.target.value + setNote({...note, content: newContent }) + } + + // const handleTagsInputChange = (event: ChangeEvent) => { + // const newTagsInput = event.target.value + // setTagsInput(newTagsInput) + // } + + // ------ Handle edit note ------ // const handleSaveNote = async () => { setLoading(true) setIsEditing(true) @@ -82,156 +103,134 @@ const NoteDetailsPage = () => { } } - const handleTitleChange = (event: ChangeEvent) => { - if (!note) return - - const newTitle = event.target.value - setNote({...note, title: newTitle }) - } - - const handleContentChange = (event: ChangeEvent) => { - if (!note) return - - const newContent = event.target.value - setNote({...note, content: newContent }) - } - - // const handleTagsInputChange = (event: ChangeEvent) => { - // const newTagsInput = event.target.value - // setTagsInput(newTagsInput) - // } - - if (loading) { - return <> -
-

Loading note content...

-
- - } - - if (!note) { - return ( - <> + return ( + <> + {loading ? ( +
+

Loading note content...

+
+ ) : !note ? (

Note not found.

-
- - ) - } - - return ( - <> -
- - -
- - -
- {!isEditing ? ( - - ) : ( - <> + ) : ( +
+ + +
+ + +
+ {!isEditing ? ( + ) : ( + <> + + + + + )} +
+
- + + +
+ {isEditing ? ( + <> + + + ) : ( +

{note.title}

)}
-
- -
- {isEditing ? ( - <> - - - - ) : ( -

{note.title}

- )} -
- -
- {isEditing ? ( - <> - - - - ) : ( -
- {note.content} + +
+ {isEditing ? ( + <> + + + + ) : ( +
+ {note.content} +
+ )} +
+ + {/* Tagging Feature */} + {/*
*/} + {/* {isEditing ? (*/} + {/* <>*/} + {/* */} + {/* */} + {/* */} + {/* ) : (*/} + {/*
*/} + {/*
*/} + {/* */} + {/*
*/} + {/* {tags.map((tag) => (*/} + {/* */} + {/* {tag}*/} + {/* */} + {/* ))}*/} + {/*
*/} + {/*
*/} + {/*
*/} + {/* )}*/} + {/*
*/} + +
+
+ Updated: {new Date(note.updatedAt).toLocaleString()} +
+
+ Created: {new Date(note.createdAt).toLocaleString()}
- )} -
- - {/* Tagging Feature */} - {/*
*/} - {/* {isEditing ? (*/} - {/* <>*/} - {/* */} - {/* */} - {/* */} - {/* ) : (*/} - {/*
*/} - {/*
*/} - {/* */} - {/*
*/} - {/* {tags.map((tag) => (*/} - {/* */} - {/* {tag}*/} - {/* */} - {/* ))}*/} - {/*
*/} - {/*
*/} - {/*
*/} - {/* )}*/} - {/*
*/} - -
-
- Created: {note.createdAt} | Updated: {note.updatedAt}
-
- - -
+ + +
+ )} ) } diff --git a/src/app/(pages)/(protected)/notes/new/page.tsx b/src/app/(pages)/(protected)/notes/new/page.tsx index 7175555..8fd3a1c 100644 --- a/src/app/(pages)/(protected)/notes/new/page.tsx +++ b/src/app/(pages)/(protected)/notes/new/page.tsx @@ -2,16 +2,17 @@ import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { CircleChevronLeft, Notebook, Save } from 'lucide-react' -import { useRouter } from 'next/navigation' import { ChangeEvent, FormEvent, useState } from 'react' import { createNote } from '@/services/note.service' -import { Note } from '@/types/note.type' import AnimatedSection from '@/components/landing/animated-section' import AutoResizeTextarea from '@/components/notes/auto-resize-textarea' +import { useNav } from '@/hooks/use-nav' +import ErrorBlock from '@/components/common/error-block' const NewNotePage = () => { - const router = useRouter() + const nav = useNav() + const [title, setTitle] = useState('') const [content, setContent] = useState('') // const [tags, setTags] = useState([]) @@ -19,6 +20,7 @@ const NewNotePage = () => { const [error, setError] = useState('') + // ------ Handle content changes ------ // const handleChangeTitle = (event: ChangeEvent) => { const newTitle = event.target.value setTitle(newTitle) @@ -34,6 +36,7 @@ const NewNotePage = () => { // setTagsInput(newTagsInput) // } + // ------ Handle note creation ------ // const handleSaveNote = async (event: FormEvent) => { event.preventDefault() setError('') @@ -44,29 +47,30 @@ const NewNotePage = () => { // .filter((tag) => tag) // // setTags(updatedTags) - const newNote = { - title: title, - content: content - } as Note try { - const createdNote = await createNote(newNote) + const createdNote = await createNote({ + title: title, + content: content + }) // Redirect back to notes list const newId = createdNote?.id - if (newId) router.push(`/notes/${newId}`) + if (newId) nav.toNote(newId) } catch (error: any) { - console.log(error.message) setError(error.message) } } - const handleBack = () => { - router.back() - } - return ( <> + {/* Back to previous */} +
+ +
+
@@ -75,29 +79,14 @@ const NewNotePage = () => { New Note - -
{/* Error Message or Input Form */} +
+

Create a new note by filling out the details below.

+
{error != '' ? ( - <> -
-

Create a new note by filling out the details below.

-
- -
-
-
-

Error Creating Notes

-

{error}

-
-
-
-
- + ) : (
diff --git a/src/app/(pages)/(protected)/notes/page.tsx b/src/app/(pages)/(protected)/notes/page.tsx index 47d347a..d7d8a6d 100644 --- a/src/app/(pages)/(protected)/notes/page.tsx +++ b/src/app/(pages)/(protected)/notes/page.tsx @@ -1,86 +1,12 @@ 'use client' -import { useEffect, useState } from 'react' -import { Plus, Search, Filter, Notebook } from 'lucide-react' +import { Plus, Notebook } from 'lucide-react' import { Button } from '@/components/ui/button' -import Pagination from '@/components/pagination' -import useQueryConfig from '@/hooks/use-query-config' -import useUpdateQueryParam from '@/hooks/use-update-query-param' -import NoteCard from '@/components/notes/note-card' - -import { useRouter } from 'next/navigation' - -import { getAllNotes } from '@/services/note.service' -import { Note } from '@/types/note.type' import FadeInSection from '@/components/animations/fade-in-section' -import AnimatedList from '@/components/animations/animated-list' -import FadeInItem from '@/components/animations/fade-in-item' +import { useNav } from '@/hooks/use-nav' +import NoteList from '@/components/notes/note-list' const NotesListPage = () => { - const router = useRouter() - - const [search, setSearch] = useState('') - const [notes, setNotes] = useState([]) - const [error, setError] = useState('') - const [loading, setLoading] = useState(true) - - const allowSearch = false - const allowFilter = false - - const fetchData = async () => { - setLoading(true) - setError('') - - try { - const data = await getAllNotes() - setNotes(data) - } catch (error: any) { - setNotes([]) - setError(error.message) - } finally { - setLoading(false) - } - } - - useEffect(() => { - fetchData() - }, []) - - const filteredNotes = notes.filter( - (note) => - note.title.toLowerCase().includes(search.toLowerCase()) || - note.content.toLowerCase().includes(search.toLowerCase()) // || - // note.tags.some((tag) => tag.toLowerCase().includes(search.toLowerCase())) - ) - - const pageSize = 6 - const queryConfig = useQueryConfig() - const setQueryParam = useUpdateQueryParam() - const currentPage = Number(queryConfig.page) || 1 - - const paginatedNotes = filteredNotes.slice((currentPage - 1) * pageSize, currentPage * pageSize) - - const handleSearchInputChange = (e: React.ChangeEvent) => { - let keyword = e.target.value - if (keyword.trim() === '') { - setSearch('') - } else { - setSearch(keyword) - } - - // Reset to first page on new search - if (currentPage !== 1) { - setQueryParam('page', '1') - } - } - - const handlePageChange = (page: number) => { - setQueryParam('page', String(page)) - } - - const handleNoteDeleted = (deletedNoteId: number) => { - setNotes((prevNotes) => prevNotes.filter((note) => note.id !== deletedNoteId)) - router.refresh() - } + const nav = useNav() return (
@@ -93,7 +19,7 @@ const NotesListPage = () => { -
- - - {/* Error State */} - {error != '' && ( - -
-
-
-

Error Loading Notes

-

{error}

-
-
-
-
- )} - - {/* Notes Grid */} - - {loading ? ( -

Loading notes...

- ) : !error && filteredNotes.length === 0 ? ( -
- -

No notes found. Try a different search or add a new note!

-
- ) : ( - - {paginatedNotes.map((note) => ( - - - - ))} - - )} -
- - {/* Pagination */} - - {filteredNotes.length > pageSize && ( - - )} - - {/* End - Pagination */} +
) } diff --git a/src/app/(pages)/(protected)/quizzes/[quizId]/attempts/[attemptId]/page.tsx b/src/app/(pages)/(protected)/quizzes/[quizId]/attempts/[attemptId]/page.tsx index a3a8048..fd77c4f 100644 --- a/src/app/(pages)/(protected)/quizzes/[quizId]/attempts/[attemptId]/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/[quizId]/attempts/[attemptId]/page.tsx @@ -3,12 +3,13 @@ import { useEffect, useState } from 'react' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { QuizAttempt } from '@/types/quiz-attempt' +import { QuizAttempt } from '@/types/quiz-attempt.type' import { finishAttempt, getQuizAttempt, startQuizAttempt, updateAttemptProgress } from '@/services/quiz.service' import { useParams } from 'next/navigation' import { useNav } from '@/hooks/use-nav' -import { AttemptQuestion } from '@/types/quesiton.type' +import { AttemptQuestion } from '@/types/quiz-attempt.type' import { toAttemptQuestion } from '@/mapper/attempt-mapper' +import ErrorBlock from '@/components/common/error-block' const QuizQuestionPage = () => { const nav = useNav() @@ -191,6 +192,7 @@ const QuizQuestionPage = () => { return (
+ {/* Progress */}
diff --git a/src/app/(pages)/(protected)/quizzes/[quizId]/attempts/[attemptId]/result/page.tsx b/src/app/(pages)/(protected)/quizzes/[quizId]/attempts/[attemptId]/result/page.tsx index 80c473e..046de3b 100644 --- a/src/app/(pages)/(protected)/quizzes/[quizId]/attempts/[attemptId]/result/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/[quizId]/attempts/[attemptId]/result/page.tsx @@ -7,8 +7,9 @@ import { useParams } from 'next/navigation' import { useEffect, useState } from 'react' import { getAttemptAnswer } from '@/services/quiz.service' import { useNav } from '@/hooks/use-nav' -import { AttemptQuestion, AttemptResult } from '@/types/quesiton.type' +import { AttemptQuestion, AttemptResult } from '@/types/quiz-attempt.type' import { toAttemptQuestion } from '@/mapper/attempt-mapper' +import ErrorBlock from '@/components/common/error-block' const QuizResultPage = () => { const nav = useNav() @@ -60,6 +61,9 @@ const QuizResultPage = () => {

Quiz Results

+ + +
{result.score} / {result.total} diff --git a/src/app/(pages)/(protected)/quizzes/[quizId]/page.tsx b/src/app/(pages)/(protected)/quizzes/[quizId]/page.tsx index 3543f1b..8b27028 100644 --- a/src/app/(pages)/(protected)/quizzes/[quizId]/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/[quizId]/page.tsx @@ -5,15 +5,15 @@ import { Button } from '@/components/ui/button' import { FileText, BookOpen, CircleChevronLeft, Notebook } from 'lucide-react' import { useParams } from 'next/navigation' import { useEffect, useState } from 'react' -import { QuizAttempt } from '@/types/quiz-attempt' +import { QuizAttempt } from '@/types/quiz-attempt.type' import { Quiz } from '@/types/quiz.type' import { getAllQuizAttempts, getQuiz, startQuizAttempt } from '@/services/quiz.service' import AnimatedSection from '@/components/landing/animated-section' import Pagination from '@/components/pagination' -import useQueryConfig from '@/hooks/use-query-config' -import useUpdateQueryParam from '@/hooks/use-update-query-param' import AttemptCard from '@/components/quizzes/attempt-card' import { useNav } from '@/hooks/use-nav' +import { PageInfo } from '@/types/util.type' +import FadeInSection from '@/components/animations/fade-in-section' const QuizPage = () => { const nav = useNav() @@ -21,23 +21,26 @@ const QuizPage = () => { const [quiz, setQuiz] = useState(null) const [attempts, setAttempts] = useState([]) + const [page, setPage] = useState({ currentPage: 1, pageSize: 6, totalPages: 0, totalElements: 0 }) + const [loading, setLoading] = useState(true) const [error, setError] = useState('') // ------ Fetching data ------ // - const fetchData = async (id: number) => { + const fetchData = async (id: number, pageNum: number) => { setLoading(true) setError('') try { const [quizData, attemptsData] = await Promise.all([ getQuiz(id), - getAllQuizAttempts(id) + getAllQuizAttempts(id, pageNum, page.pageSize) ]) setQuiz(quizData) - setAttempts(attemptsData) + setAttempts(attemptsData.pageData) + setPage(attemptsData.pageInfo) } catch (error : any) { setQuiz(null) setAttempts([]) @@ -48,7 +51,7 @@ const QuizPage = () => { } useEffect(() => { - fetchData(Number(quizId)); + fetchData(Number(quizId), page.currentPage); }, [quizId]) // ------ Handle AFTER deletion (update list) ------ // @@ -71,90 +74,87 @@ const QuizPage = () => { } } - // ------ Filter, search and pagination ------ // - const pageSize = 6 - const queryConfig = useQueryConfig() - const setQueryParam = useUpdateQueryParam() - const currentPage = Number(queryConfig.page) || 1 - - const paginatedAttempts = attempts.slice((currentPage - 1) * pageSize, currentPage * pageSize) - - const handlePageChange = (page: number) => { - setQueryParam('page', String(page)) + // ------ Pagination ------ // + const handlePageChange = (pageNumber: number) => { + setPage((prevState) => ({ ...prevState, currentPage: pageNumber })) + fetchData(Number(quizId), pageNumber); } return ( <> - {/* Back to previous */} -
- -
- -
- {/* Introduction */} - - -
-

Quiz: {quiz?.title}

-
-
-
- - Total questions: {quiz?.questions?.length} +
+ {/* Back to previous */} +
+ +
+ +
+ + +
+

Quiz: {quiz?.title}

-
- - Total attempts: {attempts.length} +
+
+ + Total questions: {quiz?.questions?.length} +
+
+ + Total attempts: {attempts.length} +
-
-
- -
-
- - - {/* Attempts Grid */} - - {loading ? ( -

Loading attempts...

- ) : !error && attempts.length === 0 ? ( -
- -

No attempts found. Let's start a new attempt!

+
+
- ) : ( -
- {paginatedAttempts.map((attempt) => ( - handleDeleted(attempt.id)} + + + {/* Attempts Grid */} + + + {loading ? ( +

Loading attempts...

+ ) : !error && attempts.length === 0 ? ( +
+ +

No attempts found. Let's start a new attempt!

+
+ ) : ( +
+ {attempts.map((attempt) => ( + handleDeleted(attempt.id)} + /> + ))} +
+ )} +
+ + {/* Pagination */} + {page.totalPages > 1 && ( + + - ))} -
- )} - - - {/* Pagination */} - - {attempts.length > pageSize && ( - - )} - - {/* End - Pagination */} - + + )} + {/* End - Pagination */} + + +
) diff --git a/src/app/(pages)/(protected)/quizzes/collections/[setId]/page.tsx b/src/app/(pages)/(protected)/quizzes/collections/[setId]/page.tsx index d418603..c0c179f 100644 --- a/src/app/(pages)/(protected)/quizzes/collections/[setId]/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/collections/[setId]/page.tsx @@ -1,51 +1,32 @@ 'use client' import { useEffect, useState } from 'react' -import { Plus, Search, Filter, Notebook, CircleChevronLeft, Folder } from 'lucide-react' +import { Plus, CircleChevronLeft, Folder } from 'lucide-react' import { Button } from '@/components/ui/button' import AnimatedSection from '@/components/landing/animated-section' -import Pagination from '@/components/pagination' -import useQueryConfig from '@/hooks/use-query-config' -import useUpdateQueryParam from '@/hooks/use-update-query-param' -import NoteCard from '@/components/notes/note-card' -import { getAllQuizSets, getQuizSet } from '@/services/quiz-set.service' +import { getQuizSet } from '@/services/quiz-set.service' import { QuizSet } from '@/types/quiz-set.type' import { useNav } from '@/hooks/use-nav' import { useParams } from 'next/navigation' -import { Quiz } from '@/types/quiz.type' -import QuizCard from '@/components/quizzes/quiz-card' +import QuizList from '@/components/quizzes/quiz-list' +import ErrorBlock from '@/components/common/error-block' const QuizSetPage = () => { const nav = useNav() const { setId } = useParams() - const [search, setSearch] = useState('') const [quizSet, setQuizSet] = useState(null) - const [quizzes, setQuizzes] = useState([]) - const [error, setError] = useState('') - const [loading, setLoading] = useState(true) - const allowSearch = false; - const allowFilter = false; + const [error, setError] = useState('') const fetchData = async (id: number) => { - setLoading(true) setError('') - try { const data = await getQuizSet(id) - console.log(data) setQuizSet(data) - if (data.quizzes) { - setQuizzes(data.quizzes) - } else { - setQuizzes([]) - } } catch (error : any) { setQuizSet(null) setError(error.message) - } finally { - setLoading(false) } } @@ -53,39 +34,6 @@ const QuizSetPage = () => { fetchData(Number(setId)) }, []) - useEffect(() => { - - }) - - const filteredData = quizzes.filter( - (quiz) => - quiz.title.toLowerCase().includes(search.toLowerCase()) - ) - - const pageSize = 6 - const queryConfig = useQueryConfig() - const setQueryParam = useUpdateQueryParam() - const currentPage = Number(queryConfig.page) || 1 - - const paginatedData = filteredData.slice((currentPage - 1) * pageSize, currentPage * pageSize) - - const handleSearchInputChange = (e: React.ChangeEvent) => { - let keyword = e.target.value - if (keyword.trim() === '') { - setSearch('') - } else { - setSearch(keyword) - } - } - - const handlePageChange = (page: number) => { - setQueryParam('page', String(page)) - } - - const removeQuizFromList = (deletedId: number) => { - setQuizzes((prevQuizzes) => prevQuizzes.filter((quizSet) => quizSet.id !== deletedId)) - } - return (
{/* Header */} @@ -117,82 +65,10 @@ const QuizSetPage = () => { {/* End - Header */} - {/* Search & Filter */} - -
-
- - -
- -
-
- {/* Error State */} - {error != '' && ( - -
-
-
-

Error When Loading Data!

-

{error}

-
-
-
-
- )} - - {/* Quiz Sets Grid */} - - {loading ? ( -

Loading data...

- ) : !error && filteredData.length === 0 ? ( -
- -

No quizzes found. Create new quiz and add it to this collection.

-
- ) : ( -
- {paginatedData.map((quiz) => ( - removeQuizFromList(quiz.id)} - onFinishDelete={() => removeQuizFromList(quiz.id)} - /> - ))} -
- )} -
+ - {/* Pagination */} - - {filteredData.length > pageSize && ( - - )} - - {/* End - Pagination */} +
) } diff --git a/src/app/(pages)/(protected)/quizzes/collections/page.tsx b/src/app/(pages)/(protected)/quizzes/collections/page.tsx index 2b6f768..4690c7a 100644 --- a/src/app/(pages)/(protected)/quizzes/collections/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/collections/page.tsx @@ -1,73 +1,29 @@ 'use client' -import { useEffect, useState } from 'react' -import { Plus, Search, Filter, Notebook, CircleChevronLeft } from 'lucide-react' +import { useState } from 'react' +import { Plus, CircleChevronLeft } from 'lucide-react' import { Button } from '@/components/ui/button' import AnimatedSection from '@/components/landing/animated-section' -import Pagination from '@/components/pagination' -import useQueryConfig from '@/hooks/use-query-config' -import useUpdateQueryParam from '@/hooks/use-update-query-param' -import { createQuizSet, getAllQuizSets } from '@/services/quiz-set.service' -import { QuizSet } from '@/types/quiz-set.type' +import { createQuizSet } from '@/services/quiz-set.service' import { useNav } from '@/hooks/use-nav' -import QuizSetCard from '@/components/quizzes/quiz-set-card' import QuizSetInfoModal from '@/components/quizzes/quiz-set-info-modal' +import QuizSetList from '@/components/quizzes/quiz-set-list' const QuizSetsListPage = () => { const nav = useNav() - const [quizSets, setQuizSets] = useState([]) - const [loading, setLoading] = useState(true) + const [reloadQuizSetTrigger, setReloadQuizSetTrigger] = useState(false) const [creationModalOpen, setCreationModalOpen] = useState(false) const [isCreating, setIsCreating] = useState(false) - // Search & filter - const [search, setSearch] = useState('') - const [error, setError] = useState('') - - const allowSearch = false; - const allowFilter = false; - - // ------ Fetching data ------ // - const fetchData = async () => { - setLoading(true) - setError('') - - try { - const data = await getAllQuizSets() - setQuizSets(data) - } catch (error : any) { - setQuizSets([]) - setError(error.message) - } finally { - setLoading(false) - } - } - - useEffect(() => { - fetchData() - }, []) - - // ------ Handle AFTER deletion (update list) ------ // - const handleDeleted = (deletedId: number) => { - setQuizSets((prevQuizSets) => - prevQuizSets.filter((quizSet) => quizSet.id !== deletedId) - ) - } - - // ------ Handle AFTER rename (update list) ------ // - const handleRenamed = () => { - fetchData() - } - // ------ Handle create new quiz set ------ // const handleCreateQuizSet = async (title: string) => { setIsCreating(true) try { const createdSet = await createQuizSet({ title }) setCreationModalOpen(false) - await fetchData() + setReloadQuizSetTrigger(!reloadQuizSetTrigger) } catch (error) { console.error('Failed to create quiz set:', error) } finally { @@ -75,32 +31,6 @@ const QuizSetsListPage = () => { } } - // ------ Filter, search and pagination ------ // - const filteredData = quizSets.filter( - (quizSet) => - quizSet.title.toLowerCase().includes(search.toLowerCase()) - ) - - const pageSize = 12 - const queryConfig = useQueryConfig() - const setQueryParam = useUpdateQueryParam() - const currentPage = Number(queryConfig.page) || 1 - - const paginatedData = filteredData.slice((currentPage - 1) * pageSize, currentPage * pageSize) - - const handleSearchInputChange = (e: React.ChangeEvent) => { - let keyword = e.target.value - if (keyword.trim() === '') { - setSearch('') - } else { - setSearch(keyword) - } - } - - const handlePageChange = (page: number) => { - setQueryParam('page', String(page)) - } - return (
{/* Header */} @@ -126,80 +56,7 @@ const QuizSetsListPage = () => { {/* End - Header */} - {/* Search & Filter */} - -
-
- - -
- -
-
- - {/* Error State */} - {error != '' && ( - -
-
-
-

Error When Loading Data!

-

{error}

-
-
-
-
- )} - - {/* Quiz Sets Grid */} - - {loading ? ( -

Loading data...

- ) : !error && filteredData.length === 0 ? ( -
- -

No collection found. Try a different search or add a collection!

-
- ) : ( -
- {paginatedData.map((quizSet) => ( - handleDeleted(quizSet.id)} - /> - ))} -
- )} -
- - {/* Pagination */} - - {filteredData.length > pageSize && ( - - )} - - {/* End - Pagination */} + {/* Create Quiz Set Modal */} {creationModalOpen && ( diff --git a/src/app/(pages)/(protected)/quizzes/generation/note/page.tsx b/src/app/(pages)/(protected)/quizzes/generation/note/page.tsx index 3d5ce9a..c4a9e32 100644 --- a/src/app/(pages)/(protected)/quizzes/generation/note/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/generation/note/page.tsx @@ -9,11 +9,17 @@ import { generateSingleQuiz } from '@/services/quiz.service' import AnimatedSection from '@/components/landing/animated-section' import { getAllNotes } from '@/services/note.service' import { useNav } from '@/hooks/use-nav' +import FadeInSection from '@/components/animations/fade-in-section' +import Pagination from '@/components/pagination' +import { PageInfo } from '@/types/util.type' +import ErrorBlock from '@/components/common/error-block' const NoteSelectionPage = () => { const nav = useNav() const [notes, setNotes] = useState([]) + const [page, setPage] = useState({ currentPage: 1, pageSize: 12, totalPages: 0, totalElements: 0 }) + const [error, setError] = useState('') const [loading, setLoading] = useState(false) const [generating, setGenerating] = useState(false) @@ -25,13 +31,14 @@ const NoteSelectionPage = () => { const [selectedNotes, setSelectedNotes] = useState([]) // const [pdfFile, setPdfFile] = useState(null) - const fetchData = async () => { + const fetchData = async (pageNum: number) => { setLoading(true) setError('') try { - const data = await getAllNotes() - setNotes(data) + const data = await getAllNotes(pageNum, page.pageSize) + setNotes(data.pageData) + setPage(data.pageInfo) } catch (error : any) { setNotes([]) setError(error.message) @@ -42,7 +49,7 @@ const NoteSelectionPage = () => { } useEffect(() => { - fetchData() + fetchData(page.currentPage) }, []) const handleMultipleToggle = () => { @@ -63,14 +70,9 @@ const NoteSelectionPage = () => { // if (e.target.files?.[0]) setPdfFile(e.target.files[0]) // } - // const canGenerate = selectedNotes.length > 0 || pdfFile - const canGenerate = selectedNotes.length > 0 || selectedNoteId - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - - // TODO: Trigger quiz generation + const handleSubmit = async () => { setGenerating(true) setError('') @@ -85,6 +87,11 @@ const NoteSelectionPage = () => { } } + const handlePageChange = (pageNumber: number) => { + setPage((prevState) => ({ ...prevState, currentPage: pageNumber })) + fetchData(pageNumber) + } + return ( <> {/* Buttons */} @@ -117,60 +124,88 @@ const NoteSelectionPage = () => {

Select notes to generate a quiz using AI.

{/*

Select notes or upload a PDF to generate a quiz using AI.

*/} - - - {/* Notes Selection */} +
+ {/* Notes Selection */}

Select Notes

{isMultiple ? ( + /* Multiple Notes Selection */
{notes.length === 0 ? (
No notes available. Please create a note first.
) : ( -
- {notes.map((note) => ( -
) : ( + /* Single Note Selection */
{notes.length === 0 ? (
No notes available. Please create a note first.
) : ( -
- {notes.map((note) => ( -
@@ -197,11 +232,11 @@ const NoteSelectionPage = () => { {/* Generate Button */}
-
- +
{/* Generating Message */} @@ -218,18 +253,8 @@ const NoteSelectionPage = () => { )} {/* Error Message */} - {error != '' && ( - -
-
-
-

Error Loading Notes

-

{error}

-
-
-
-
- )} + +
diff --git a/src/app/(pages)/(protected)/quizzes/page.tsx b/src/app/(pages)/(protected)/quizzes/page.tsx index 7141e3a..cd6c756 100644 --- a/src/app/(pages)/(protected)/quizzes/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/page.tsx @@ -1,59 +1,31 @@ 'use client' import { useEffect, useState } from 'react' -import { Plus, Search, Filter, Notebook } from 'lucide-react' +import { Plus, Notebook } from 'lucide-react' import { Button } from '@/components/ui/button' import AnimatedSection from '@/components/landing/animated-section' -import Pagination from '@/components/pagination' -import useQueryConfig from '@/hooks/use-query-config' -import useUpdateQueryParam from '@/hooks/use-update-query-param' -import QuizCard from '@/components/quizzes/quiz-card' -import { Quiz } from '@/types/quiz.type' -import { getAllQuizSets } from '@/services/quiz-set.service' +import { getRecentQuizSets } from '@/services/quiz-set.service' import { useNav } from '@/hooks/use-nav' -import { getAllQuizzes } from '@/services/quiz.service' import { QuizSet } from '@/types/quiz-set.type' import QuizSetCard from '@/components/quizzes/quiz-set-card' +import AnimatedList from '@/components/animations/animated-list' +import QuizList from '@/components/quizzes/quiz-list' +import ErrorBlock from '@/components/common/error-block' const QuizzesListPage = () => { const nav = useNav() - const [quizzes, setQuizzes] = useState([]) - const [loading, setLoading] = useState(true) - const [quizSets, setQuizSets] = useState([]) + const [quizReload, setQuizReload] = useState(false) const [quizSetLoading, setQuizSetLoading] = useState(true) + const [quizSetError, setQuizSetError] = useState('') - // Search & filter - const [search, setSearch] = useState('') - const [error, setError] = useState('') - - const allowSearch = false; - const allowFilter = false; - - // ------ Fetching data (quizzes and quiz sets) ------ // - // Fetch quizzes - const fetchData = async () => { - setLoading(true) - setError('') - - try { - const data = await getAllQuizzes() - setQuizzes(data) - } catch (error : any) { - setQuizzes([]) - setError(error.message) - } finally { - setLoading(false) - } - } - - // Fetch collections (quiz sets) + // ------ Fetching data (quiz sets) ------ // const fetchCollections = async () => { setQuizSetLoading(true) try { - const data = await getAllQuizSets() - setQuizSets(data) + const data = await getRecentQuizSets() + setQuizSets(data.pageData) } catch (error: any) { console.error('Failed to load collections:', error) } finally { @@ -62,16 +34,9 @@ const QuizzesListPage = () => { } useEffect(() => { - fetchData() fetchCollections() }, []) - - // ------ Handle AFTER deletion (update list) ------ // - const handleDeleted = (deletedId: number) => { - setQuizzes((prevQuizzes) => prevQuizzes.filter((quiz) => quiz.id !== deletedId)) - } - // ------ Handle AFTER renamed (update list) ------ // const handleQuizSetRenamed = () => { fetchCollections() @@ -80,34 +45,8 @@ const QuizzesListPage = () => { // ------ Handle AFTER deletion (update list) ------ // const handleQuizSetDeleted = (deletedId: number) => { // Delete a quiz set may result in deleting all quizzes in it - fetchData() fetchCollections() - } - - // ------ Filter, search and pagination ------ // - const filteredData = quizzes.filter( - (quizSet) => - quizSet.title.toLowerCase().includes(search.toLowerCase()) - ) - - const pageSize = 6 - const queryConfig = useQueryConfig() - const setQueryParam = useUpdateQueryParam() - const currentPage = Number(queryConfig.page) || 1 - - const paginatedData = filteredData.slice((currentPage - 1) * pageSize, currentPage * pageSize) - - const handleSearchInputChange = (e: React.ChangeEvent) => { - let keyword = e.target.value - if (keyword.trim() === '') { - setSearch('') - } else { - setSearch(keyword) - } - } - - const handlePageChange = (page: number) => { - setQueryParam('page', String(page)) + setQuizReload(!quizReload) } return ( @@ -133,139 +72,56 @@ const QuizzesListPage = () => { {/* End - Header */} - {/* Search & Filter */} - -
-
- - -
-
-
- - {/* Error State */} - {error != '' && ( - -
-
-
-

Error Loading Quizzes

-

{error}

-
-
-
-
- )} - - {/* Collection Grid */} - {/* ADD: Collections Section */} - -
-
-

Collections / Quiz Sets

- -
- {quizSetLoading ? ( -
- {[1, 2, 3].map((i) => ( -
-
-
- ))} -
- ) : ( -
-
- {quizSets.map((quizSet) => ( - handleQuizSetDeleted(quizSet.id)} - /> - ))} + - {/* Add New Collection Button */} - {/**/} - {/* */} - {/*
*/} + {quizSetLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+
-
- )} -
- - {/* END: Collections Section */} - - {/* Quizzes Grid */} -

Recent Quizzes

- - {loading ? ( -

Loading quizzes...

- ) : !error && filteredData.length === 0 ? ( + ))} +
+ ) : !quizSetError && quizSets.length === 0 ? (
-

No quiz found. Try a different search or generate new quiz!

+

No collection found.

) : ( -
-
- {paginatedData.map((quiz) => ( - {}} - onFinishDelete={() => handleDeleted(quiz.id)} - /> - ))} -
-
+ + {quizSets.map((quizSet) => ( + handleQuizSetDeleted(quizSet.id)} + /> + ))} + )}
+ {/* END: Collections Grid */} - {/* Pagination */} - - {filteredData.length > pageSize && ( - - )} - - {/* End - Pagination */} +
) } diff --git a/src/components/common/error-block.tsx b/src/components/common/error-block.tsx new file mode 100644 index 0000000..492b86f --- /dev/null +++ b/src/components/common/error-block.tsx @@ -0,0 +1,26 @@ +import AnimatedSection from '@/components/landing/animated-section' + +interface ErrorBlockProps { + errorMessage?: string +} + +const ErrorBlock = ({ errorMessage } : ErrorBlockProps) => { + return ( + <> + {errorMessage != '' && ( + +
+
+
+

Error When Loading Data!

+

{errorMessage}

+
+
+
+
+ )} + + ) +} + +export default ErrorBlock \ No newline at end of file diff --git a/src/components/common/popover/filter-popover.tsx b/src/components/common/popover/filter-popover.tsx new file mode 100644 index 0000000..57b991f --- /dev/null +++ b/src/components/common/popover/filter-popover.tsx @@ -0,0 +1,57 @@ +import { Filter } from 'lucide-react' +import { useEffect, useState } from 'react' +import GenericPopover from '@/components/common/popover/generic-popover' + +export interface FilterCriterion { + key: string + label: string + inputType: 'date' | 'datetime' +} + +interface FilterPopoverProps { + criteria: FilterCriterion[] + resetTrigger: boolean + onApply: (filters: Record) => void +} + +const FilterPopover = ({ criteria, resetTrigger, onApply }: FilterPopoverProps) => { + const [values, setValues] = useState>({}) + + useEffect(() => { + setValues({}) + }, [resetTrigger]) + + const handleChange = (key: string, value: string) => { + setValues(prev => ({ ...prev, [key]: value })) + } + + const handleApply = () => { + onApply(values) + } + + return ( + + + Filter + + } + > + {criteria.map(c => ( +
+ + handleChange(c.key, e.target.value)} + /> +
+ ))} +
+ ) +} + +export default FilterPopover \ No newline at end of file diff --git a/src/components/common/popover/generic-popover.tsx b/src/components/common/popover/generic-popover.tsx new file mode 100644 index 0000000..2e8f624 --- /dev/null +++ b/src/components/common/popover/generic-popover.tsx @@ -0,0 +1,37 @@ +import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' +import { Button } from '@/components/ui/button' +import React, { useState } from 'react' + +interface GenericPopoverProps { + children: React.ReactNode + display: React.ReactNode + onApply: () => void +} + +const GenericPopover = ({ children, display, onApply }: GenericPopoverProps) => { + const [isOpen, setIsOpen] = useState(false) + + const handleApplyClick = () => { + onApply() + setIsOpen(false) + } + + return ( + + + + + + + {children} + + + + ) +} + +export default GenericPopover \ No newline at end of file diff --git a/src/components/common/popover/sort-popover.tsx b/src/components/common/popover/sort-popover.tsx new file mode 100644 index 0000000..7e3a141 --- /dev/null +++ b/src/components/common/popover/sort-popover.tsx @@ -0,0 +1,73 @@ +import { SortDesc } from 'lucide-react' +import { useEffect, useState } from 'react' +import GenericPopover from '@/components/common/popover/generic-popover' + +export interface SortCriterion { + value: string + label: string +} + +interface SortPopoverProps { + criteria: SortCriterion[] + resetTrigger: boolean + onApply: (sortBy : string, sortOrder : string) => void +} + +const SortPopover = ({ criteria, resetTrigger, onApply } : SortPopoverProps) => { + const [sortBy, setSortBy] = useState(''); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc') + + useEffect(() => { + setSortBy('') + setSortOrder('desc') + }, [resetTrigger]) + + const handleApply = () => { + onApply(sortBy, sortOrder) + } + + return ( + + + Sort + + } + > + {/* Sort Criteria */} +
+ + +
+ + {/* Sort Order */} +
+ + +
+
+ ) +} + +export default SortPopover \ No newline at end of file diff --git a/src/components/common/search-layout.tsx b/src/components/common/search-layout.tsx new file mode 100644 index 0000000..b77f14a --- /dev/null +++ b/src/components/common/search-layout.tsx @@ -0,0 +1,101 @@ +import { Eraser, Search } from 'lucide-react' +import FilterPopover, { FilterCriterion } from '@/components/common/popover/filter-popover' +import SortPopover, { SortCriterion } from '@/components/common/popover/sort-popover' +import { Button } from '@/components/ui/button' +import AnimatedSection from '@/components/landing/animated-section' +import { useEffect, useState } from 'react' +import useQuery from '@/hooks/use-query' + +interface SearchLayoutProps { + filterCriteria: FilterCriterion[] + sortCriteria: SortCriterion[] +} + +const SearchLayout = ({ filterCriteria, sortCriteria } : SearchLayoutProps) => { + const [search, setSearch] = useState('') + const [resetTrigger, setResetTrigger] = useState(false) + const { setQuery, removeQuery, clearQuery } = useQuery() + + useEffect(() => { + const handler = setTimeout(() => { + if (search.trim() !== "") { + setQuery("keyword", search) + } else { + removeQuery("keyword") + } + }, 500) + + return () => clearTimeout(handler) + }, [search]) + + const handleSearchInputChange = (e: React.ChangeEvent) => { + setSearch(e.target.value) + // let keyword = e.target.value + // if (keyword.trim() != '') { + // setSearch(keyword) + // setQuery('keyword', keyword) + // } else { + // setSearch('') + // removeQuery('keyword') + // } + } + + const handleFilterInputChange = (filters: Record) => { + Object.entries(filters).forEach(([key, value]) => { + setQuery(key, value) + }) + } + + const handleSortInputChange = (sortBy : string, sortOrder : string) => { + if (sortBy !== '' && sortOrder !== '') { + setQuery('sortBy', sortBy) + setQuery('sortOrder', sortOrder) + } + } + + const handleClearQuery = () => { + setSearch('') + setResetTrigger(!resetTrigger) + clearQuery() + } + + return ( + <> + +
+
+ + +
+
+ + + +
+
+
+ + ) +} + +export default SearchLayout \ No newline at end of file diff --git a/src/components/modals/collection-selection.tsx b/src/components/modals/collection-selection.tsx index 4cfbd1c..79da9fe 100644 --- a/src/components/modals/collection-selection.tsx +++ b/src/components/modals/collection-selection.tsx @@ -1,6 +1,7 @@ import { FolderPlus } from 'lucide-react' import { Button } from '@/components/ui/button' import { useState } from 'react' +import { createPortal } from 'react-dom' interface Collection { id: number @@ -12,7 +13,10 @@ interface AddToCollectionModalProps { objTitle: string // object's title orgCollectionId: number // object's origin collection's id collections: Collection[] // available choices - isAdding: boolean + pageNumber: number, + totalPages: number, + isAdding: boolean, + onPageChange: (pageNumber: number) => void, onCancel: () => void // should trigger hiding modal in parent component onConfirm: (collectionId: number) => void // should trigger next action in parent component } @@ -22,7 +26,10 @@ const AddToCollectionModal = ({ objTitle, orgCollectionId, collections, + pageNumber, + totalPages, isAdding, + onPageChange, onCancel, onConfirm }: AddToCollectionModalProps) => { @@ -34,9 +41,9 @@ const AddToCollectionModal = ({ } } - return ( + return createPortal( <> -
+
{/* Header */}
@@ -86,6 +93,24 @@ const AddToCollectionModal = ({ )) )}
+ +
+ + + +
{/* Footer */} @@ -118,7 +143,8 @@ const AddToCollectionModal = ({
- + , + document.body ) } diff --git a/src/components/modals/delete-confirmation.tsx b/src/components/modals/delete-confirmation.tsx index 3cce62e..2101f11 100644 --- a/src/components/modals/delete-confirmation.tsx +++ b/src/components/modals/delete-confirmation.tsx @@ -1,5 +1,6 @@ import { Trash } from 'lucide-react' import { Button } from '@/components/ui/button' +import { createPortal } from 'react-dom' interface ConfirmationModalProps { type: string @@ -11,9 +12,9 @@ interface ConfirmationModalProps { } const DeleteConfirmationModal = ({ type, id, title, isDeleting, onCancel, onConfirm } : ConfirmationModalProps) => { - return ( + return createPortal( <> -
+
{/* Header */}
@@ -66,7 +67,8 @@ const DeleteConfirmationModal = ({ type, id, title, isDeleting, onCancel, onConf
- + , + document.body ) } diff --git a/src/components/notes/note-card.tsx b/src/components/notes/note-card.tsx index 27a9c35..12cf937 100644 --- a/src/components/notes/note-card.tsx +++ b/src/components/notes/note-card.tsx @@ -1,27 +1,27 @@ 'use client' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { Edit, Trash, Tag, MoreVertical } from 'lucide-react' +import { Trash, Tag, MoreVertical } from 'lucide-react' import { Badge } from '@/components/ui/badge' import TimeAgo from '@/components/time-ago' import { MouseEvent, useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useRouter } from 'next/navigation' import { deleteNoteById } from '@/services/note.service' +import { ROUTES } from '@/hooks/use-nav' +import DeleteConfirmationModal from '@/components/modals/delete-confirmation' interface NoteCardProps { id: number title: string description: string createdAt: Date + updatedAt: Date tags: string[] onFinishDelete?: (id: number) => void // callback to remove deleted note } -const NoteCard = ({ id, title, description, createdAt, tags, onFinishDelete }: NoteCardProps) => { - const router = useRouter() - +const NoteCard = ({ id, title, description, createdAt, updatedAt, tags, onFinishDelete }: NoteCardProps) => { const [actionsOpen, setActionsOpen] = useState(false) const [isDeleting, setIsDeleting] = useState(false) const [confirmationOpen, setConfirmationOpen] = useState(false) @@ -32,6 +32,7 @@ const NoteCard = ({ id, title, description, createdAt, tags, onFinishDelete }: N const visibleTags = tags.slice(0, MAX_TAGS_DISPLAY) const hiddenCount = tags.length - visibleTags.length + // ------ Handle action bar on each card ------ // const handleClickOutside = (event: MouseEvent | globalThis.MouseEvent) => { if ( menuRef.current && @@ -44,25 +45,29 @@ const NoteCard = ({ id, title, description, createdAt, tags, onFinishDelete }: N } } + // Close actions menu when clicking outside + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside as EventListener) + return () => { + document.removeEventListener('mousedown', handleClickOutside as EventListener) + } + }, [actionsOpen]) + const handleActionsToggle = (event: MouseEvent) => { event.stopPropagation() setActionsOpen((prev) => !prev) } - const handleEdit = (event: MouseEvent) => { + const handleCancel = (event: MouseEvent) => { event.stopPropagation() setActionsOpen(false) - // Logic to handle edit action, e.g., redirect to edit page - router.push(`/notes/${id}`) - console.log('Edit action triggered for note:', id) } + // ------ Handle delete ------ // const handleDelete = (event: MouseEvent) => { event.stopPropagation() setActionsOpen(false) - // Logic to handle delete action, e.g., show confirmation dialog setConfirmationOpen(true) - console.log('Delete action triggered for note:', id) } const handleConfirmDelete = async () => { @@ -82,19 +87,6 @@ const NoteCard = ({ id, title, description, createdAt, tags, onFinishDelete }: N } } - const handleCancel = (event: MouseEvent) => { - event.stopPropagation() - setActionsOpen(false) - } - - // Close actions menu when clicking outside - useEffect(() => { - document.addEventListener('mousedown', handleClickOutside as EventListener) - return () => { - document.removeEventListener('mousedown', handleClickOutside as EventListener) - } - }, [actionsOpen]) - return ( <> @@ -109,16 +101,20 @@ const NoteCard = ({ id, title, description, createdAt, tags, onFinishDelete }: N > - +

{title}

- +

{description}

+
+ Created on: {new Date(createdAt).toDateString()} +
+
@@ -146,14 +142,6 @@ const NoteCard = ({ id, title, description, createdAt, tags, onFinishDelete }: N ref={menuRef} className='hidden md:block absolute right-4 top-12 z-20 bg-white border rounded shadow-lg w-24' > - - -
-
-
+ setConfirmationOpen(false)} + onConfirm={handleConfirmDelete} + > )} {/* End - Confirmation Modal */} diff --git a/src/components/notes/note-list.tsx b/src/components/notes/note-list.tsx new file mode 100644 index 0000000..6f890e0 --- /dev/null +++ b/src/components/notes/note-list.tsx @@ -0,0 +1,138 @@ +'use client' + +import { FilterCriterion } from '@/components/common/popover/filter-popover' +import { SortCriterion } from '@/components/common/popover/sort-popover' +import FadeInSection from '@/components/animations/fade-in-section' +import { Notebook } from 'lucide-react' +import AnimatedList from '@/components/animations/animated-list' +import NoteCard from '@/components/notes/note-card' +import Pagination from '@/components/pagination' +import { useEffect, useState } from 'react' +import { Note } from '@/types/note.type' +import { PageInfo } from '@/types/util.type' +import { useSearchParams } from 'next/navigation' +import useQuery from '@/hooks/use-query' +import { getAllNotes } from '@/services/note.service' +import SearchLayout from '@/components/common/search-layout' +import ErrorBlock from '@/components/common/error-block' + +const filterCriteria : FilterCriterion[] = [ + { key: 'createdFrom', label: 'Created From', inputType: 'date' }, + { key: 'createdTo', label: 'Created Before', inputType: 'date' }, + { key: 'updatedFrom', label: 'Updated From', inputType: 'date' }, + { key: 'updatedTo', label: 'Updated Before', inputType: 'date' } +] + +const sortCriteria : SortCriterion[] = [ + { value: 'createdAt', label: 'Created At' }, + { value: 'updatedAt', label: 'Updated At' }, + { value: 'title', label: 'Title' } +] + +interface NoteListProps { + pageSize?: number + reloadTrigger?: boolean +} + +const NoteList = ({ pageSize, reloadTrigger } : NoteListProps ) => { + const [notes, setNotes] = useState([]) + const [page, setPage] = useState({ + currentPage: 1, + pageSize: pageSize ? pageSize : 6, + totalPages: 0, + totalElements: 0 } + ) + + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + const searchParams = useSearchParams() + const { setQuery } = useQuery() + + // ------ Fetching data ------ // + const fetchData = async (pageNum: number) => { + setLoading(true) + setError('') + + try { + const data = await getAllNotes(pageNum, page.pageSize, searchParams.toString()) + setNotes(data.pageData) + setPage(data.pageInfo) + } catch (error: any) { + setNotes([]) + setError(error.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchData(page.currentPage) + }, [searchParams, reloadTrigger]) + + // ------ Handle AFTER deletion (update list) ------ // + const handleNoteDeleted = (deletedNoteId: number) => { + fetchData(page.currentPage) // Fetch again to reload paginated elements + } + + // ------ Pagination ------ // + const handlePageChange = (pageNumber: number) => { + setPage((prevState) => ({ ...prevState, currentPage: pageNumber })) + setQuery('page', pageNumber) + } + + return ( + <> + {/* Search & Filter */} + + + {/* Error State */} + + + {/* Notes Grid */} + + {loading ? ( +

Loading notes...

+ ) : !error && notes.length === 0 ? ( +
+ +

No notes found. Try a different search or add a new note!

+
+ ) : ( + + {notes.map((note) => ( + + ))} + + )} +
+ {/* End - Notes Grid */} + + {/* Pagination */} + {page.totalPages > 1 && ( + + + + )} + {/* End - Pagination */} + + ) +} + +export default NoteList \ No newline at end of file diff --git a/src/components/pagination.tsx b/src/components/pagination.tsx index b9f099a..0a149b9 100644 --- a/src/components/pagination.tsx +++ b/src/components/pagination.tsx @@ -4,12 +4,12 @@ import { getPagination } from '@/utils/pagination' interface PaginationProps { total: number pageSize: number + totalPages: number currentPage: number onPageChange: (page: number) => void } -const Pagination = ({ total, pageSize, currentPage, onPageChange }: PaginationProps) => { - const totalPages = Math.ceil(total / pageSize) +const Pagination = ({ total, pageSize, totalPages, currentPage, onPageChange }: PaginationProps) => { const pages = getPagination(currentPage, totalPages) return ( diff --git a/src/components/quizzes/attempt-card.tsx b/src/components/quizzes/attempt-card.tsx index 5efb3a0..1c8a72f 100644 --- a/src/components/quizzes/attempt-card.tsx +++ b/src/components/quizzes/attempt-card.tsx @@ -28,8 +28,6 @@ const AttemptCard = ({ quizId, quizTitle, id, score, totalQuestions, attemptAt, const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false) const [isDeleting, setIsDeleting] = useState(false) - const MAX_TAGS_DISPLAY = 2 - // ------ Handle action bar on each attempt cards ------ // const handleClickOutside = (event: MouseEvent | globalThis.MouseEvent) => { if ( diff --git a/src/components/quizzes/quiz-card.tsx b/src/components/quizzes/quiz-card.tsx index 107ae27..31def80 100644 --- a/src/components/quizzes/quiz-card.tsx +++ b/src/components/quizzes/quiz-card.tsx @@ -11,6 +11,7 @@ import DeleteConfirmationModal from '@/components/modals/delete-confirmation' import CollectionSelection from '@/components/modals/collection-selection' import { getAllQuizSets } from '@/services/quiz-set.service' import { QuizCollection } from '@/types/quiz-set.type' +import { PageInfo } from '@/types/util.type' interface QuizCardProps { id: number @@ -18,11 +19,12 @@ interface QuizCardProps { quizSetId: number totalQuestions?: number createdAt: Date + updatedAt: Date onFinishCollectionChange: () => void onFinishDelete: () => void // callback to remove deleted note } -const QuizCard = ({ id, title, quizSetId, totalQuestions, createdAt, onFinishCollectionChange, onFinishDelete }: QuizCardProps) => { +const QuizCard = ({ id, title, quizSetId, totalQuestions, createdAt, updatedAt, onFinishCollectionChange, onFinishDelete }: QuizCardProps) => { const [actionsOpen, setActionsOpen] = useState(false) const menuRef = useRef(null) const cancelButtonRef = useRef(null) @@ -34,8 +36,7 @@ const QuizCard = ({ id, title, quizSetId, totalQuestions, createdAt, onFinishCol const [collectionSelectionOpen, setCollectionSelectionOpen] = useState(false) const [isAdding, setIsAdding] = useState(false) const [collection, setCollection] = useState([]) - - const MAX_TAGS_DISPLAY = 2 + const [collectionPage, setCollectionPage] = useState({ currentPage: 1, pageSize: 4, totalPages: 0, totalElements: 0 }) // ------ Handle action bar on each card ------ // const handleClickOutside = (event: MouseEvent | globalThis.MouseEvent) => { @@ -73,15 +74,25 @@ const QuizCard = ({ id, title, quizSetId, totalQuestions, createdAt, onFinishCol event.stopPropagation() setActionsOpen(false) - const result = await getAllQuizSets() - const mappedCollection: QuizCollection[] = result.map((quizSet) => ({ + const result = await getAllQuizSets(collectionPage.currentPage, collectionPage.pageSize) + const mappedCollection: QuizCollection[] = result.pageData.map((quizSet) => ({ id: quizSet.id, title: quizSet.originType === "DEFAULT" ? "DEFAULT" : quizSet.title })) setCollection(mappedCollection) setCollectionSelectionOpen(true) - console.log('Add to collection action triggered for quiz:', id) + } + + const handleCollectionPageChange = async (pageNumber: number) => { + setCollectionPage((prevState) => ({ ...prevState, currentPage: pageNumber })) + + const result = await getAllQuizSets(pageNumber, collectionPage.pageSize) + const mappedCollection: QuizCollection[] = result.pageData.map((quizSet) => ({ + id: quizSet.id, + title: quizSet.originType === "DEFAULT" ? "DEFAULT" : quizSet.title + })) + setCollection(mappedCollection) } const handleConfirmSelection = async (newQuizSetId: number) => { @@ -156,7 +167,8 @@ const QuizCard = ({ id, title, quizSetId, totalQuestions, createdAt, onFinishCol

{title}

-

Total: {totalQuestions} questions

+ {totalQuestions && +

Total: {totalQuestions} questions

} @@ -220,7 +232,7 @@ const QuizCard = ({ id, title, quizSetId, totalQuestions, createdAt, onFinishCol isDeleting={isDeleting} onCancel={() => setDeleteConfirmationOpen(false)} onConfirm={handleConfirmDelete} - > + /> } {/* End - Confirmation Modal */} @@ -230,8 +242,11 @@ const QuizCard = ({ id, title, quizSetId, totalQuestions, createdAt, onFinishCol objId={id} objTitle={title} orgCollectionId={quizSetId} + pageNumber={collectionPage.currentPage} + totalPages={collectionPage.totalPages} isAdding={isAdding} collections={collection} + onPageChange={handleCollectionPageChange} onCancel={() => setCollectionSelectionOpen(false)} onConfirm={handleConfirmSelection} /> diff --git a/src/components/quizzes/quiz-list.tsx b/src/components/quizzes/quiz-list.tsx new file mode 100644 index 0000000..7a5897b --- /dev/null +++ b/src/components/quizzes/quiz-list.tsx @@ -0,0 +1,149 @@ +'use client' + +import AnimatedSection from '@/components/landing/animated-section' +import { Notebook } from 'lucide-react' +import { FilterCriterion } from '@/components/common/popover/filter-popover' +import { SortCriterion } from '@/components/common/popover/sort-popover' +import QuizCard from '@/components/quizzes/quiz-card' +import FadeInSection from '@/components/animations/fade-in-section' +import Pagination from '@/components/pagination' +import { useEffect, useState } from 'react' +import { useSearchParams } from 'next/navigation' +import useQuery from '@/hooks/use-query' +import { PageInfo } from '@/types/util.type' +import { Quiz } from '@/types/quiz.type' +import { getAllQuizzes } from '@/services/quiz.service' +import SearchLayout from '@/components/common/search-layout' +import ErrorBlock from '@/components/common/error-block' + +const filterCriteria : FilterCriterion[] = [ + { key: 'createdFrom', label: 'Created From', inputType: 'date' }, + { key: 'createdTo', label: 'Created Before', inputType: 'date' }, + { key: 'updatedFrom', label: 'Updated From', inputType: 'date' }, + { key: 'updatedTo', label: 'Updated Before', inputType: 'date' } +] + +const sortCriteria : SortCriterion[] = [ + { value: 'createdAt', label: 'Created At' }, + { value: 'updatedAt', label: 'Updated At' }, + { value: 'title', label: 'Title' } +] + +interface QuizListProps { + heading?: string + quizSetId?: number + reloadTrigger?: boolean | false +} + +const QuizList = ({ heading, quizSetId, reloadTrigger } : QuizListProps) => { + const [quizzes, setQuizzes] = useState([]) + const [page, setPage] = useState({ currentPage: 1, pageSize: 6, totalPages: 0, totalElements: 0 }) + + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + const searchParams = useSearchParams() + const { setQuery } = useQuery() + + // ------ Fetching data (quizzes) ------ // + const fetchData = async (pageNum: number) => { + if (quizSetId) setQuery('quizSetId', quizSetId); + + setLoading(true) + setError('') + + try { + const data = await getAllQuizzes( + pageNum, + page.pageSize, + quizSetId ? `quizSetId=${quizSetId}&${searchParams.toString()}` : searchParams.toString() + ) + setQuizzes(data.pageData) + setPage(data.pageInfo) + } catch (error : any) { + setQuizzes([]) + setError(error.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchData(page.currentPage) + }, [searchParams, reloadTrigger]) + + // ------ Handle AFTER deletion (update list) ------ // + const handleDeleted = (deletedId: number) => { + setQuizzes((prevQuizzes) => prevQuizzes.filter((quiz) => quiz.id !== deletedId)) + } + + // ------ Handle AFTER renamed (update list) ------ // + const handleCollectionChanged = () => { + fetchData(page.currentPage) + } + + // ------ Pagination ------ // + const handlePageChange = (pageNumber: number) => { + setPage((prevState) => ({ ...prevState, currentPage: pageNumber })) + setQuery('page', pageNumber) + } + + return ( + <> +

{heading ? heading : "Quizzes"}

+ + {/* Search & Filter */} + + + {/* Error State */} + + + {/* Quizzes Grid */} + + {loading ? ( +

Loading quizzes...

+ ) : !error && quizzes.length === 0 ? ( +
+ +

No quiz found. Try a different search or generate new quiz!

+
+ ) : ( +
+
+ {quizzes.map((quiz) => ( + handleDeleted(quiz.id)} + /> + ))} +
+
+ )} +
+ {/* End - Quizzes Grid */} + + {/* Pagination */} + {page.totalPages > 1 && ( + + + + )} + {/* End - Pagination */} + + ) +} + +export default QuizList \ No newline at end of file diff --git a/src/components/quizzes/quiz-set-card.tsx b/src/components/quizzes/quiz-set-card.tsx index afef765..8bbf57c 100644 --- a/src/components/quizzes/quiz-set-card.tsx +++ b/src/components/quizzes/quiz-set-card.tsx @@ -138,13 +138,11 @@ const QuizSetCard = ({ id, originType, title, onFinishRename, onFinishDelete }: `} >
-
-
- -

- {originType === "DEFAULT" ? "DEFAULT" : title} -

-
+
+ +

+ {originType === "DEFAULT" ? "DEFAULT" : title} +

@@ -216,7 +214,7 @@ const QuizSetCard = ({ id, originType, title, onFinishRename, onFinishDelete }: {/* Confirmation Modal */} {deleteConfirmationOpen && -
+
{/* Header */}
@@ -117,7 +118,8 @@ const QuizSetInfoModal = ({ previousTitle, isProcessing, onCancel, onConfirm }:
- + , + document.body ) } diff --git a/src/components/quizzes/quiz-set-list.tsx b/src/components/quizzes/quiz-set-list.tsx new file mode 100644 index 0000000..a5698a2 --- /dev/null +++ b/src/components/quizzes/quiz-set-list.tsx @@ -0,0 +1,134 @@ +'use client' + +import AnimatedSection from '@/components/landing/animated-section' +import { Notebook } from 'lucide-react' +import { FilterCriterion } from '@/components/common/popover/filter-popover' +import { SortCriterion } from '@/components/common/popover/sort-popover' +import QuizSetCard from '@/components/quizzes/quiz-set-card' +import FadeInSection from '@/components/animations/fade-in-section' +import Pagination from '@/components/pagination' +import { useEffect, useState } from 'react' +import { QuizSet } from '@/types/quiz-set.type' +import { PageInfo } from '@/types/util.type' +import { useSearchParams } from 'next/navigation' +import useQuery from '@/hooks/use-query' +import { getAllQuizSets } from '@/services/quiz-set.service' +import SearchLayout from '@/components/common/search-layout' +import ErrorBlock from '@/components/common/error-block' + +const filterCriteria : FilterCriterion[] = [ + { key: 'createdFrom', label: 'Created From', inputType: 'date' }, + { key: 'createdTo', label: 'Created Before', inputType: 'date' }, + { key: 'updatedFrom', label: 'Updated From', inputType: 'date' }, + { key: 'updatedTo', label: 'Updated Before', inputType: 'date' } +] + +const sortCriteria : SortCriterion[] = [ + { value: 'createdAt', label: 'Created At' }, + { value: 'updatedAt', label: 'Updated At' }, + { value: 'title', label: 'Title' } +] + +interface QuizSetListProps { + reloadTrigger?: boolean +} + +const QuizSetList = ({ reloadTrigger } : QuizSetListProps) => { + const [quizSets, setQuizSets] = useState([]) + const [page, setPage] = useState({ currentPage: 1, pageSize: 12, totalPages: 0, totalElements: 0 }) + + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + // Search & filter + const searchParams = useSearchParams() + const { setQuery } = useQuery() + + // ------ Fetching data ------ // + const fetchData = async (pageNum: number) => { + setLoading(true) + setError('') + + try { + const data = await getAllQuizSets(pageNum, page.pageSize, searchParams.toString()) + setQuizSets(data.pageData) + setPage(data.pageInfo) + } catch (error : any) { + setQuizSets([]) + setError(error.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchData(page.currentPage) + }, [searchParams, reloadTrigger]) + + // ------ Handle AFTER deletion (update list) ------ // + const handleDeleted = (deletedId: number) => { + setQuizSets((prevQuizSets) => prevQuizSets.filter((quizSet) => quizSet.id !== deletedId)) + } + + // ------ Handle AFTER rename (update list) ------ // + const handleRenamed = () => { + fetchData(page.currentPage) + } + + // ------ Pagination ------ // + const handlePageChange = (pageNumber: number) => { + setPage((prevState) => ({ ...prevState, currentPage: pageNumber })) + setQuery('page', pageNumber) + } + + return ( + <> + {/* Search & Filter */} + + + {/* Error State */} + + + {/* Quiz Sets Grid */} + + {loading ? ( +

Loading data...

+ ) : !error && quizSets.length === 0 ? ( +
+ +

No collection found. Try a different search or add a collection!

+
+ ) : ( +
+ {quizSets.map((quizSet) => ( + handleDeleted(quizSet.id)} + /> + ))} +
+ )} +
+ + {/* Pagination */} + {page.totalPages > 1 && ( + + + + )} + {/* End - Pagination */} + + ) +} + +export default QuizSetList \ No newline at end of file diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000..01e468b --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,48 @@ +"use client" + +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/src/hooks/use-nav.tsx b/src/hooks/use-nav.tsx index 487eb49..8f8a903 100644 --- a/src/hooks/use-nav.tsx +++ b/src/hooks/use-nav.tsx @@ -4,6 +4,11 @@ import { useRouter } from 'next/navigation' export const ROUTES = { HOME: '/', + NOTE: { + LIST: '/notes', + NEW: '/notes/new', + DETAIL: (id: number | undefined) => `/notes/${id}` + }, QUIZ: { LIST: '/quizzes', COLLECTION: (id: number | undefined) => `/quizzes/collections/${id}`, @@ -21,6 +26,9 @@ export const useNav = () => { return { toHome: () => router.push(ROUTES.HOME), + back: () => router.back, + refresh: () => router.refresh(), + // Quizzes toQuizList: () => router.push(ROUTES.QUIZ.LIST), toQuizCollection: (id: number) => router.push(ROUTES.QUIZ.COLLECTION(id)), toQuizCollectionList: () => router.push(ROUTES.QUIZ.COLLECTION_LIST), @@ -28,6 +36,9 @@ export const useNav = () => { toQuizGenerationFromNote: () => router.push(ROUTES.QUIZ.GENERATION_FROM_NOTE), toQuiz: (id: number | undefined) => router.push(ROUTES.QUIZ.DETAIL(id)), toNewQuizAttempt: (qid: number | undefined, aid: number | undefined) => router.push(ROUTES.QUIZ.ATTEMPT_DETAIL(qid, aid)), - refresh: () => router.refresh() + // Notes + toNoteList: () => router.push(ROUTES.NOTE.LIST), + toNoteCreation: () => router.push(ROUTES.NOTE.NEW), + toNote: (id: number | undefined) => router.push(ROUTES.NOTE.DETAIL(id)) } } diff --git a/src/hooks/use-query.tsx b/src/hooks/use-query.tsx new file mode 100644 index 0000000..50ebf80 --- /dev/null +++ b/src/hooks/use-query.tsx @@ -0,0 +1,26 @@ +'use client' +import { useRouter, useSearchParams } from 'next/navigation' + +const useQuery = () => { + const router = useRouter() + const searchParams = useSearchParams() + let params = new URLSearchParams(searchParams.toString()) + + const setQuery = (key: string, value: string | number) => { + params.set(key, String(value)) + router.push(`?${params.toString()}`) + } + + const removeQuery = (key: string) => { + params.delete(key) + router.push(`?${params.toString()}`) + } + + const clearQuery = () => { + router.push(`?`) + } + + return { setQuery, removeQuery, clearQuery } +} + +export default useQuery diff --git a/src/mapper/attempt-mapper.ts b/src/mapper/attempt-mapper.ts index ad503e6..45e31b2 100644 --- a/src/mapper/attempt-mapper.ts +++ b/src/mapper/attempt-mapper.ts @@ -1,6 +1,6 @@ -import { AttemptDetail } from '@/types/quiz-attempt' +import { QuizAttemptDetail } from '@/types/quiz-attempt.type' -export const toAttemptQuestion = (attempts: AttemptDetail[]) => { +export const toAttemptQuestion = (attempts: QuizAttemptDetail[]) => { return attempts.map((detail) => ({ id: detail.id, text: detail.questionText, diff --git a/src/services/note.service.ts b/src/services/note.service.ts index 57b022c..04b9499 100644 --- a/src/services/note.service.ts +++ b/src/services/note.service.ts @@ -1,10 +1,10 @@ -import { Note, NoteUpdateRequest } from '@/types/note.type' +import { Note, NotePage, NoteUpsertRequest } from '@/types/note.type' import apiClient from '@/apis/api-client' -import { ApiResponse, UserResponse } from '@/types/auth.type' +import { ApiResponse } from '@/types/auth.type' const NOTE_BASE_API = '/documents/notes' -export const createNote = async (request: NoteUpdateRequest): Promise => { +export const createNote = async (request: NoteUpsertRequest): Promise => { const response = await apiClient.post(`${NOTE_BASE_API}`, request) const apiRes: ApiResponse = response.data @@ -14,9 +14,10 @@ export const createNote = async (request: NoteUpdateRequest): Promise => { return apiRes.data } -export const getAllNotes = async (): Promise => { - const response = await apiClient.get(`${NOTE_BASE_API}`) - const apiRes: ApiResponse = response.data +export const getAllNotes = async (page: number, size: number, query?: string): Promise => { + const response = await apiClient.get( + `${NOTE_BASE_API}?page=${page}&size=${size}&${query}`) + const apiRes: ApiResponse = response.data if (!apiRes.data && apiRes.code != 1000) { throw new Error(`Failed to update data: ${apiRes.message}`) @@ -34,7 +35,7 @@ export const getNoteById = async (id: number): Promise => { return apiRes.data } -export const updateNote = async (id: number, request: NoteUpdateRequest): Promise => { +export const updateNote = async (id: number, request: NoteUpsertRequest): Promise => { const response = await apiClient.patch(`${NOTE_BASE_API}/${id}`, request) const apiRes: ApiResponse = response.data diff --git a/src/services/quiz-set.service.ts b/src/services/quiz-set.service.ts index 2f4e966..a4289c4 100644 --- a/src/services/quiz-set.service.ts +++ b/src/services/quiz-set.service.ts @@ -1,6 +1,6 @@ import apiClient from '@/apis/api-client' import { ApiResponse } from '@/types/auth.type' -import { QuizSet } from '@/types/quiz-set.type' +import { QuizSet, QuizSetPage } from '@/types/quiz-set.type' const QUIZ_SET_BASE_API = '/quiz-sets' @@ -53,9 +53,25 @@ export const getQuizSet = async (id: number): Promise => { return apiRes.data } -export const getAllQuizSets = async (): Promise => { - const response = await apiClient.get(`${QUIZ_SET_BASE_API}`) - const apiRes: ApiResponse = response.data +export const getRecentQuizSets = async (): Promise => { + const page = 1; + const size = 6; + const response = await apiClient.get( + `${QUIZ_SET_BASE_API}?page=${page}&size=${size}` + ) + const apiRes: ApiResponse = response.data + + if (!apiRes.data && apiRes.code != 1000) { + throw new Error(`Failed to get data: ${apiRes.message}`) + } + return apiRes.data +} + +export const getAllQuizSets = async (page: number, size: number, query?: string): Promise => { + const response = await apiClient.get( + `${QUIZ_SET_BASE_API}?page=${page}&size=${size}&${query}` + ) + const apiRes: ApiResponse = response.data if (!apiRes.data && apiRes.code != 1000) { throw new Error(`Failed to get data: ${apiRes.message}`) diff --git a/src/services/quiz.service.ts b/src/services/quiz.service.ts index f36193e..294236f 100644 --- a/src/services/quiz.service.ts +++ b/src/services/quiz.service.ts @@ -1,11 +1,9 @@ import apiClient from '@/apis/api-client' import { ApiResponse } from '@/types/auth.type' -import { Quiz } from '@/types/quiz.type' -import { QuizAttempt } from '@/types/quiz-attempt' -import { Note } from '@/types/note.type' +import { Quiz, QuizPage } from '@/types/quiz.type' +import { QuizAttempt, QuizAttemptPage } from '@/types/quiz-attempt.type' const QUIZ_BASE_API = '/quizzes' -const QUIZ_ATTEMPT_BASE_API = '/quizzes' const QUIZ_GENERATION_BASE_API = '/ai/generation/quiz-sets' export const generateSingleQuiz = async (docId : number): Promise => { @@ -22,9 +20,11 @@ export const generateSingleQuiz = async (docId : number): Promise => { return apiRes.data } -export const getAllQuizzes = async (): Promise => { - const response = await apiClient.get(QUIZ_BASE_API) - const apiRes: ApiResponse = response.data +export const getAllQuizzes = async (page: number, size: number, query?: string): Promise => { + const response = await apiClient.get( + `${QUIZ_BASE_API}?page=${page}&size=${size}&${query}` + ) + const apiRes: ApiResponse = response.data if (!apiRes.data && apiRes.code != 1000) { throw new Error(`Failed to get data: ${apiRes.message}`) @@ -64,9 +64,10 @@ export const deleteQuiz = async (quizId : number) => { } // ------ Quiz Attempts ------ // -export const getAllQuizAttempts = async (quizId : number): Promise => { - const response = await apiClient.get(`${QUIZ_BASE_API}/${quizId}/attempts`) - const apiRes: ApiResponse = response.data +export const getAllQuizAttempts = async (quizId : number, page: number, size: number): Promise => { + const response = await apiClient.get( + `${QUIZ_BASE_API}/${quizId}/attempts?page=${page}&size=${size}`) + const apiRes: ApiResponse = response.data if (!apiRes.data && apiRes.code != 1000) { throw new Error(`Failed to get data: ${apiRes.message}`) diff --git a/src/types/note.type.ts b/src/types/note.type.ts index 3be1d3d..f776250 100644 --- a/src/types/note.type.ts +++ b/src/types/note.type.ts @@ -1,25 +1,24 @@ +import { PageInfo } from '@/types/util.type' + export interface Note { id: number title: string content: string createdAt: string - updatedAt?: string + updatedAt: string tags?: string[] // isPinned?: boolean } -export interface NoteUpdateRequest { +export interface NoteUpsertRequest { title: string content: string } -export interface NoteList { - notes: Note[] - pagination: { - currentPage: number - limit: number - totalPages: number - } +// Pagination +export interface NotePage { + pageData: Note[] + pageInfo: PageInfo } export interface NoteListConfig { diff --git a/src/types/quesiton.type.ts b/src/types/quesiton.type.ts deleted file mode 100644 index 6776ef1..0000000 --- a/src/types/quesiton.type.ts +++ /dev/null @@ -1,26 +0,0 @@ -export interface Question { - id: number - questionText: string - optionA: string - optionB: string - optionC: string - optionD: string - correctAnswer: string -} - -// For easy frontend display -export interface AttemptQuestion { - id: number - text: string - options: { key: string; text: string }[] - correctOption?: string - userAnswer?: string - isCorrect?: boolean -} - -export interface AttemptResult { - score: number - total: number - percent: number - questions: AttemptQuestion[] -} \ No newline at end of file diff --git a/src/types/quiz-attempt.ts b/src/types/quiz-attempt.ts deleted file mode 100644 index 7d8b9a1..0000000 --- a/src/types/quiz-attempt.ts +++ /dev/null @@ -1,20 +0,0 @@ -export interface QuizAttempt { - id: number - quizId: number - totalQuestion: number - score: number - attemptDetails?: AttemptDetail[] - attemptAt: string -} - -export interface AttemptDetail { - id: number - questionText: string - optionA: string - optionB: string - optionC: string - optionD: string - correctAnswer?: string - userAnswer?: string - isCorrect?: boolean -} \ No newline at end of file diff --git a/src/types/quiz-attempt.type.ts b/src/types/quiz-attempt.type.ts new file mode 100644 index 0000000..04fd70f --- /dev/null +++ b/src/types/quiz-attempt.type.ts @@ -0,0 +1,44 @@ +import { PageInfo } from '@/types/util.type' + +export interface QuizAttempt { + id: number + quizId: number + totalQuestion: number + score: number + attemptDetails?: QuizAttemptDetail[] + attemptAt: string +} + +export interface QuizAttemptDetail { + id: number + questionText: string + optionA: string + optionB: string + optionC: string + optionD: string + correctAnswer?: string + userAnswer?: string + isCorrect?: boolean +} + +export interface QuizAttemptPage { + pageData: QuizAttempt[] + pageInfo: PageInfo +} + +// For easy frontend display (Map to list) +export interface AttemptQuestion { + id: number + text: string + options: { key: string; text: string }[] + correctOption?: string + userAnswer?: string + isCorrect?: boolean +} + +export interface AttemptResult { + score: number + total: number + percent: number + questions: AttemptQuestion[] +} \ No newline at end of file diff --git a/src/types/quiz-set.type.ts b/src/types/quiz-set.type.ts index 741124a..f982ef2 100644 --- a/src/types/quiz-set.type.ts +++ b/src/types/quiz-set.type.ts @@ -1,4 +1,5 @@ import { Quiz } from '@/types/quiz.type' +import { PageInfo } from '@/types/util.type' export interface QuizSet { id: number @@ -9,6 +10,11 @@ export interface QuizSet { updatedAt?: string } +export interface QuizSetPage { + pageData: QuizSet[] + pageInfo: PageInfo +} + // For frontend display export interface QuizCollection { id: number diff --git a/src/types/quiz.type.ts b/src/types/quiz.type.ts index 60d2264..390be23 100644 --- a/src/types/quiz.type.ts +++ b/src/types/quiz.type.ts @@ -1,4 +1,4 @@ -import { Question } from '@/types/quesiton.type' +import { PageInfo } from '@/types/util.type' export interface Quiz { id: number @@ -7,5 +7,20 @@ export interface Quiz { sourceDocumentId: string questions?: Question[] createdAt: string - updatedAt?: string + updatedAt: string +} + +export interface Question { + id: number + questionText: string + optionA: string + optionB: string + optionC: string + optionD: string + correctAnswer: string +} + +export interface QuizPage { + pageData: Quiz[] + pageInfo: PageInfo } \ No newline at end of file diff --git a/src/types/util.type.ts b/src/types/util.type.ts new file mode 100644 index 0000000..ff5ccc9 --- /dev/null +++ b/src/types/util.type.ts @@ -0,0 +1,6 @@ +export interface PageInfo { + currentPage: number + pageSize: number + totalPages: number + totalElements: number +} \ No newline at end of file