From fd4c4aeb7af8c7bfdb4e4b1c1e74cd6c878bbbae Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Mon, 10 Nov 2025 15:21:27 +1100 Subject: [PATCH 01/10] feat(ai-quiz): add quiz generation for single note --- .gitignore | 3 + src/app/(pages)/(protected)/quiz-set/page.tsx | 178 +++++++++++++ .../(pages)/(protected)/quiz/create/page.tsx | 99 ------- .../(protected)/quiz/generation/note/page.tsx | 248 ++++++++++++++++++ .../(protected)/quiz/generation/page.tsx | 77 ++++++ src/app/(pages)/(protected)/quiz/page.tsx | 205 ++++++++++++--- src/components/quizzes/quiz-card.tsx | 149 +++++++++++ src/services/quiz-set.service.tsx | 44 ++++ src/services/quiz.service.tsx | 138 ++++++++++ src/types/quesiton.type.ts | 9 + src/types/quiz-set.type.ts | 10 + src/types/quiz.type.ts | 11 + 12 files changed, 1029 insertions(+), 142 deletions(-) create mode 100644 src/app/(pages)/(protected)/quiz-set/page.tsx delete mode 100644 src/app/(pages)/(protected)/quiz/create/page.tsx create mode 100644 src/app/(pages)/(protected)/quiz/generation/note/page.tsx create mode 100644 src/app/(pages)/(protected)/quiz/generation/page.tsx create mode 100644 src/components/quizzes/quiz-card.tsx create mode 100644 src/services/quiz-set.service.tsx create mode 100644 src/services/quiz.service.tsx create mode 100644 src/types/quesiton.type.ts create mode 100644 src/types/quiz-set.type.ts create mode 100644 src/types/quiz.type.ts diff --git a/.gitignore b/.gitignore index 5ef6a52..f9a9bf5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Ignore IDE config +.idea + # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies diff --git a/src/app/(pages)/(protected)/quiz-set/page.tsx b/src/app/(pages)/(protected)/quiz-set/page.tsx new file mode 100644 index 0000000..c67e07d --- /dev/null +++ b/src/app/(pages)/(protected)/quiz-set/page.tsx @@ -0,0 +1,178 @@ +'use client' +import { useEffect, useState } from 'react' +import { Plus, Search, Filter, 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 NoteCard from '@/components/notes/note-card' + +import { useRouter } from 'next/navigation' + +import { getAllQuizSets } from '@/services/quiz-set.service' +import { Quiz } from '@/types/quiz.type' +import { QuizSet } from '@/types/quiz-set.type' + +const QuizSetsListPage = () => { + const router = useRouter() + + const [search, setSearch] = useState('') + const [quizSets, setQuizSets] = 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 getAllQuizSets() + setQuizSets(data) + } catch (error : any) { + setQuizSets([]) + setError(error.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchData() + }, []) + + const filteredData = quizSets.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)) + } + + const handleDeleted = (deletedId: number) => { + setQuizSets((prevQuizSets) => + prevQuizSets.filter((quizSet) => quizSet.id !== deletedId) + ) + } + + return ( +
+ {/* Header */} + +
+

+ + My Quiz Collection +

+ +
+
+ {/* End - Header */} + + {/* Search & Filter */} + +
+
+ + +
+ +
+
+ + {/* Error State */} + {error != '' && ( + +
+
+
+

Error Loading Quiz Sets

+

{error}

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

Loading notes...

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

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

+
+ ) : ( +
+ {paginatedData.map((note) => ( + + ))} +
+ )} +
+ + {/* Pagination */} + + {filteredData.length > pageSize && ( + + )} + + {/* End - Pagination */} +
+ ) +} + +export default QuizSetsListPage \ No newline at end of file diff --git a/src/app/(pages)/(protected)/quiz/create/page.tsx b/src/app/(pages)/(protected)/quiz/create/page.tsx deleted file mode 100644 index 5f3fbc8..0000000 --- a/src/app/(pages)/(protected)/quiz/create/page.tsx +++ /dev/null @@ -1,99 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Card, CardContent } from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { FileText, BookOpen, Sparkles, UploadCloud } from 'lucide-react' -import { notes } from '@/data/notes' - -const mockNotes = notes.map((note) => ({ id: note.id, title: note.title })) - -const QuizCreatePage = () => { - const [selectedNotes, setSelectedNotes] = useState([]) - const [pdfFile, setPdfFile] = useState(null) - - const handleNoteToggle = (id: number) => { - setSelectedNotes((prev) => (prev.includes(id) ? prev.filter((nid) => nid !== id) : [...prev, id])) - } - - const handlePdfChange = (e: React.ChangeEvent) => { - if (e.target.files?.[0]) setPdfFile(e.target.files[0]) - } - - const canGenerate = selectedNotes.length > 0 || pdfFile - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - // TODO: Trigger quiz generation - } - - return ( -
- - -
- -

Create AI Quiz

-
-

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

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

- Select Notes -

- {mockNotes.length === 0 ? ( -
- No notes available. Please create a note first. -
- ) : ( -
- {mockNotes.map((note) => ( - - ))} -
- )} -
- {/* PDF Upload */} -
-

- Upload PDF -

- -
- {/* Generate Button */} -
- -
-
-
-
-
- ) -} - -export default QuizCreatePage diff --git a/src/app/(pages)/(protected)/quiz/generation/note/page.tsx b/src/app/(pages)/(protected)/quiz/generation/note/page.tsx new file mode 100644 index 0000000..768f2e6 --- /dev/null +++ b/src/app/(pages)/(protected)/quiz/generation/note/page.tsx @@ -0,0 +1,248 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { FileText, BookOpen, Sparkles, UploadCloud, CircleChevronLeft, Notebook, Plus, ToggleLeft } from 'lucide-react' +// import { notes } from '@/data/notes' +import { useRouter } from 'next/navigation' +import { Note } from '@/types/note.type' +import { getAllNotes, generateSingleQuiz } from '@/services/quiz.service' +import AnimatedSection from '@/components/landing/animated-section' + +// const mockNotes = notes.map((note) => ({ id: note.id, title: note.title })) + +const NoteSelectionPage = () => { + const router = useRouter() + + const [notes, setNotes] = useState([]) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const [generating, setGenerating] = useState(false) + + const allowMultipleSelection = false + const [isMultiple, setMultiple] = useState(false) + + const [selectedNoteId, setSelectedNoteId] = useState(null) + const [selectedNotes, setSelectedNotes] = useState([]) + // const [pdfFile, setPdfFile] = useState(null) + + const fetchData = async () => { + setLoading(true) + setError('') + + try { + const data = await getAllNotes() + console.log(data) + setNotes(data) + } catch (error : any) { + setNotes([]) + setError(error.message) + } finally { + setError('') + setLoading(false) + } + } + + useEffect(() => { + fetchData() + }, []) + + const handleBackToQuizGeneration = () => { + router.push('/quiz/generation') + } + + const handleMultipleToggle = () => { + setMultiple(!isMultiple) + setSelectedNotes([]) + setSelectedNoteId(null) + } + + const handleNoteToggle = (id: number) => { + if (isMultiple) { + setSelectedNotes((prev) => (prev.includes(id) ? prev.filter((nid) => nid !== id) : [...prev, id])) + } else { + setSelectedNoteId(id); + } + } + + // const handlePdfChange = (e: React.ChangeEvent) => { + // 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 + setGenerating(true) + setError('') + + if (!selectedNoteId) return + try { + const updatedNote = await generateSingleQuiz(selectedNoteId) + router.push('/quiz') + } catch (error : any) { + setError(error.message) + } finally { + setGenerating(false) + } + } + + return ( + <> + {/* Buttons */} +
+ +
+ + {/* Note Selection */} +
+ + +
+
+ +

Create AI Quiz

+
+ +
+ + + +
+
+ +

Select notes to generate a quiz using AI.

+ {/*

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

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

+ Select Notes +

+ {isMultiple ? ( +
+ {notes.length === 0 ? ( +
+ No notes available. Please create a note first. +
+ ) : ( +
+ {notes.map((note) => ( + + ))} +
+ )} +
+ ) : ( +
+ {notes.length === 0 ? ( +
+ No notes available. Please create a note first. +
+ ) : ( +
+ {notes.map((note) => ( + + ))} +
+ )} +
+ )} + + {/* PDF Upload */} + {/*
*/} + {/*

*/} + {/* Upload PDF*/} + {/*

*/} + {/* */} + {/*
*/} + + {/* Generate Button */} +
+ +
+
+ +
+ {/* Generating Message */} + {generating && ( + +
+
+
+

Generating quiz...

+
+
+
+
+ )} + + {/* Error Message */} + {error != '' && ( + +
+
+
+

Error Loading Notes

+

{error}

+
+
+
+
+ )} +
+
+
+
+ + ) +} + +export default NoteSelectionPage diff --git a/src/app/(pages)/(protected)/quiz/generation/page.tsx b/src/app/(pages)/(protected)/quiz/generation/page.tsx new file mode 100644 index 0000000..971ef92 --- /dev/null +++ b/src/app/(pages)/(protected)/quiz/generation/page.tsx @@ -0,0 +1,77 @@ +'use client' + +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Sparkles, FileText, BookOpen, CircleChevronLeft } from 'lucide-react' +import { useRouter } from 'next/navigation' + +const QuizGenerationPage = () => { + const router = useRouter() + + const allowLearnMore = false; + + const handleBackToQuizzes = () => { + router.push('/quiz') + } + + const handleRedirectToCreateQuiz = () => { + router.push('/quiz/generation/note') + } + + return ( + <> + {/* Back to previous */} +
+ +
+ + {/* Introduction */} +
+ + +
+ +

AI Quiz Generation

+
+

+ Instantly generate quizzes from your notes or uploaded PDFs using advanced AI powered by{' '} + Google Gemma2 via{' '} + Nebius on{' '} + HuggingFace. +

+
+
+ + Select notes or uploaded PDFs document +
+
+ + Let AI analyze your content and create relevant quiz questions +
+
+ + Review, edit, and save quizzes for practice or sharing +
+
+
+ + +
+
+
+
+ + ) +} + +export default QuizGenerationPage diff --git a/src/app/(pages)/(protected)/quiz/page.tsx b/src/app/(pages)/(protected)/quiz/page.tsx index bb09bae..0d32b8a 100644 --- a/src/app/(pages)/(protected)/quiz/page.tsx +++ b/src/app/(pages)/(protected)/quiz/page.tsx @@ -1,60 +1,179 @@ 'use client' - -import { Card, CardContent } from '@/components/ui/card' +import { useEffect, useState } from 'react' +import { Plus, Search, Filter, Notebook } from 'lucide-react' import { Button } from '@/components/ui/button' -import { Sparkles, FileText, BookOpen } from 'lucide-react' +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 { useRouter } from 'next/navigation' -const QuizPage = () => { +import { Quiz } from '@/types/quiz.type' +import { QuizSet } from '@/types/quiz-set.type' +import { getDefaultQuizSet } from '@/services/quiz-set.service' + +const QuizzesListPage = () => { const router = useRouter() - const handleRedirectToCreateQuiz = () => { - router.push('/quiz/create') + const [search, setSearch] = useState('') + const [quizzes, setQuizzes] = 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 getDefaultQuizSet() + if (data?.quizzes) { + setQuizzes(data?.quizzes) + } else { + setQuizzes([]) + } + } catch (error : any) { + setQuizzes([]) + setError(error.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchData() + }, []) + + 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)) + } + + const handleDeleted = (deletedId: number) => { + setQuizzes((prevQuizzes) => + prevQuizzes.filter((quizSet) => quizSet.id !== deletedId) + ) } return ( -
- - -
- -

AI Quiz Generation

+
+ {/* Header */} + +
+

+ + My Quizzes +

+ +
+
+ {/* End - Header */} + + {/* Search & Filter */} + +
+
+ +
-

- Instantly generate quizzes from your notes or uploaded PDFs using advanced AI powered by{' '} - Google Gemma2 via{' '} - Nebius on{' '} - HuggingFace. -

-
-
- - Select notes or uploaded PDFs document -
-
- - Let AI analyze your content and create relevant quiz questions -
-
- - Review, edit, and save quizzes for practice or sharing + +
+ + + {/* Error State */} + {error != '' && ( + +
+
+
+

Error Loading Quizzes

+

{error}

+
-
- - + + )} + + {/* Quizzes Grid */} + + {loading ? ( +

Loading quizzes...

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

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

+
+ ) : ( +
+ {paginatedData.map((quiz) => ( + + ))}
- - + )} +
+ + {/* Pagination */} + + {filteredData.length > pageSize && ( + + )} + + {/* End - Pagination */}
) } -export default QuizPage +export default QuizzesListPage \ No newline at end of file diff --git a/src/components/quizzes/quiz-card.tsx b/src/components/quizzes/quiz-card.tsx new file mode 100644 index 0000000..e555788 --- /dev/null +++ b/src/components/quizzes/quiz-card.tsx @@ -0,0 +1,149 @@ +'use client' +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Edit, 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' +interface QuizCardProps { + id: number + title: string + totalQuestions?: number + createdAt: Date +} + +const QuizCard = ({ id, title, totalQuestions, createdAt }: QuizCardProps) => { + const [actionsOpen, setActionsOpen] = useState(false) + const menuRef = useRef(null) + const cancelButtonRef = useRef(null) + + const MAX_TAGS_DISPLAY = 2 + + const handleClickOutside = (event: MouseEvent | globalThis.MouseEvent) => { + if ( + menuRef.current && + !menuRef.current.contains(event.target as Node) && + cancelButtonRef.current && + !cancelButtonRef.current.contains(event.target as Node) && + actionsOpen + ) { + setActionsOpen(false) + } + } + + const handleActionsToggle = (event: MouseEvent) => { + event.stopPropagation() + setActionsOpen((prev) => !prev) + } + + const handleEdit = (event: MouseEvent) => { + event.stopPropagation() + setActionsOpen(false) + // Logic to handle edit action, e.g., redirect to edit page + // useRouter().push(`/notes/${id}/edit`) + console.log('Edit action triggered for quiz:', id) + } + + const handleDelete = (event: MouseEvent) => { + event.stopPropagation() + setActionsOpen(false) + // Logic to handle delete action, e.g., show confirmation dialog + // useRouter().push(`/notes/${id}/delete`) + console.log('Delete action triggered for quiz:', id) + } + + 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 ( + <> + +
+ + + +
+

{title}

+
+ +

Total: {totalQuestions} questions

+ +
+ +
+ + {/* Actions Menu */} + {actionsOpen && ( + <> + {/* Desktop: Dropdown menu */} +
+ + +
+ {/* Mobile: Bottom sheet */} +
+ + + +
+ + )} + {/* End - Actions Menu */} +
+ + ) +} + +export default QuizCard diff --git a/src/services/quiz-set.service.tsx b/src/services/quiz-set.service.tsx new file mode 100644 index 0000000..b9575e8 --- /dev/null +++ b/src/services/quiz-set.service.tsx @@ -0,0 +1,44 @@ +import { QuizSet } from '@/types/quiz-set.type' + +const baseUrl = 'http://localhost:8080/api/quiz-sets' + +// Temporary function only +function getJwtToken() { + return localStorage.getItem('jwtToken') || '' +} + +export async function getDefaultQuizSet(): Promise { + const jwtToken = getJwtToken(); + const url = `${baseUrl}/default`; + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${jwtToken}` + } + }) + + // Handle response + const jsonResponse = await response.json() + if (!response.ok) { + throw new Error(`Failed to fetch data: ${jsonResponse.message}`) + } + return jsonResponse.data as QuizSet +} + +export async function getAllQuizSets(): Promise { + const jwtToken = getJwtToken(); + const url = baseUrl; + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${jwtToken}` + } + }) + + // Handle response + const jsonResponse = await response.json() + if (!response.ok) { + throw new Error(`Failed to fetch data: ${jsonResponse.message}`) + } + return jsonResponse.data as QuizSet[] +} \ No newline at end of file diff --git a/src/services/quiz.service.tsx b/src/services/quiz.service.tsx new file mode 100644 index 0000000..d852e25 --- /dev/null +++ b/src/services/quiz.service.tsx @@ -0,0 +1,138 @@ +import { Quiz } from '@/types/quiz.type' +import { Note } from '@/types/note.type' + +const baseQuizUrl = 'http://localhost:8080/api/quizzes' +const baseGenerateUrl = 'http://localhost:8080/api/ai/generation/quiz-sets' + +// Temporary function only +function getJwtToken() { + return localStorage.getItem('jwtToken') || '' +} + +export async function generateSingleQuiz(id : number): Promise { + const jwtToken = getJwtToken(); + const url = `${baseGenerateUrl}/default`; + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${jwtToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + docId: id + }) + }) + + // Handle response + const jsonResponse = await response.json() + if (!response.ok) { + throw new Error(`Failed to fetch data: ${jsonResponse.message}`) + } + return jsonResponse.data as Quiz +} + +// Temporary support function from other feature branch +export async function getAllNotes(): Promise { + const jwtToken = getJwtToken(); + const url = 'http://localhost:8080/api/documents/notes'; + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${jwtToken}` + } + }) + + // Handle response + const jsonResponse = await response.json() + if (!response.ok) { + throw new Error(`Failed to fetch data: ${jsonResponse.message}`) + } + return jsonResponse.data as Note[] +} + +/* +export async function createNote(data: Partial): Promise { + const jwtToken = getJwtToken(); + const url = `${baseUrl}`; + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${jwtToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title: data.title, + content: data.content + }) + }) + + // Handle response + const jsonResponse = await response.json() + if (!response.ok) { + throw new Error(`Failed to create new note: ${jsonResponse.message}`) + } + return jsonResponse.data as Note +} + +export async function getNoteById(id: number): Promise { + const jwtToken = getJwtToken(); + const url = `${baseUrl}/${id}`; + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${jwtToken}` + } + }) + + // Handle response + const jsonResponse = await response.json() + if (!response.ok) { + throw new Error(`Failed to fetch data: ${jsonResponse.message}`) + } + return jsonResponse.data as Note +} + +export async function updateNote(id: number, data: Partial): Promise { + const jwtToken = getJwtToken(); + const url = `${baseUrl}/${id}`; + + const response = await fetch(url, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${jwtToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title: data.title, + content: data.content + }) + }) + + // Handle response + const jsonResponse = await response.json() + if (!response.ok) { + throw new Error(`Failed to update data: ${jsonResponse.message}`) + } + return jsonResponse.data as Note +} + +export async function deleteNoteById(id: number) { + const jwtToken = getJwtToken(); + const url = `${baseUrl}/${id}`; + + const response = await fetch(url, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${jwtToken}` + } + }) + + // Handle response + const jsonResponse = await response.json() + if (!response.ok) { + throw new Error(`Failed to delete data: ${jsonResponse.message}`) + } +} + */ \ No newline at end of file diff --git a/src/types/quesiton.type.ts b/src/types/quesiton.type.ts new file mode 100644 index 0000000..f05dd01 --- /dev/null +++ b/src/types/quesiton.type.ts @@ -0,0 +1,9 @@ +export interface Question { + id: number + questionText: string + optionA: string + optionB: string + optionC: string + optionD: string + correctAnswer: string +} \ No newline at end of file diff --git a/src/types/quiz-set.type.ts b/src/types/quiz-set.type.ts new file mode 100644 index 0000000..013171e --- /dev/null +++ b/src/types/quiz-set.type.ts @@ -0,0 +1,10 @@ +import { Quiz } from '@/types/quiz.type' + +export interface QuizSet { + id: number + title: string + originType: number + quizzes?: Quiz[] + createdAt: string + updatedAt?: string +} \ No newline at end of file diff --git a/src/types/quiz.type.ts b/src/types/quiz.type.ts new file mode 100644 index 0000000..60d2264 --- /dev/null +++ b/src/types/quiz.type.ts @@ -0,0 +1,11 @@ +import { Question } from '@/types/quesiton.type' + +export interface Quiz { + id: number + title: string + quizSetId: number + sourceDocumentId: string + questions?: Question[] + createdAt: string + updatedAt?: string +} \ No newline at end of file From 1092d0c8c8bc47b0db47a4874a9f20406c1934a6 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Mon, 10 Nov 2025 18:36:29 +1100 Subject: [PATCH 02/10] fix(ai-quiz): rename incorrect file file extension for services --- src/services/{quiz-set.service.tsx => quiz-set.service.ts} | 0 src/services/{quiz.service.tsx => quiz.service.ts} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/services/{quiz-set.service.tsx => quiz-set.service.ts} (100%) rename src/services/{quiz.service.tsx => quiz.service.ts} (100%) diff --git a/src/services/quiz-set.service.tsx b/src/services/quiz-set.service.ts similarity index 100% rename from src/services/quiz-set.service.tsx rename to src/services/quiz-set.service.ts diff --git a/src/services/quiz.service.tsx b/src/services/quiz.service.ts similarity index 100% rename from src/services/quiz.service.tsx rename to src/services/quiz.service.ts From 03a4fa81661cb51e56ace3356dfed9c4fdb8a2d7 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Mon, 10 Nov 2025 18:58:16 +1100 Subject: [PATCH 03/10] feat(ai-quiz): update quiz-related service with new auth implementation --- .../(protected)/quiz/generation/note/page.tsx | 6 +- src/services/quiz-set.service.ts | 49 ++---- src/services/quiz.service.ts | 144 ++---------------- 3 files changed, 31 insertions(+), 168 deletions(-) diff --git a/src/app/(pages)/(protected)/quiz/generation/note/page.tsx b/src/app/(pages)/(protected)/quiz/generation/note/page.tsx index 768f2e6..540b067 100644 --- a/src/app/(pages)/(protected)/quiz/generation/note/page.tsx +++ b/src/app/(pages)/(protected)/quiz/generation/note/page.tsx @@ -7,8 +7,9 @@ import { FileText, BookOpen, Sparkles, UploadCloud, CircleChevronLeft, Notebook, // import { notes } from '@/data/notes' import { useRouter } from 'next/navigation' import { Note } from '@/types/note.type' -import { getAllNotes, generateSingleQuiz } from '@/services/quiz.service' +import { generateSingleQuiz } from '@/services/quiz.service' import AnimatedSection from '@/components/landing/animated-section' +import { getAllNotes } from '@/services/note.service' // const mockNotes = notes.map((note) => ({ id: note.id, title: note.title })) @@ -33,7 +34,6 @@ const NoteSelectionPage = () => { try { const data = await getAllNotes() - console.log(data) setNotes(data) } catch (error : any) { setNotes([]) @@ -83,7 +83,7 @@ const NoteSelectionPage = () => { if (!selectedNoteId) return try { - const updatedNote = await generateSingleQuiz(selectedNoteId) + const quiz = await generateSingleQuiz(selectedNoteId) router.push('/quiz') } catch (error : any) { setError(error.message) diff --git a/src/services/quiz-set.service.ts b/src/services/quiz-set.service.ts index b9575e8..5b15ba2 100644 --- a/src/services/quiz-set.service.ts +++ b/src/services/quiz-set.service.ts @@ -1,44 +1,25 @@ +import apiClient from '@/apis/api-client' +import { ApiResponse } from '@/types/auth.type' import { QuizSet } from '@/types/quiz-set.type' -const baseUrl = 'http://localhost:8080/api/quiz-sets' +const QUIZ_SET_BASE_API = '/quiz-sets' -// Temporary function only -function getJwtToken() { - return localStorage.getItem('jwtToken') || '' -} - -export async function getDefaultQuizSet(): Promise { - const jwtToken = getJwtToken(); - const url = `${baseUrl}/default`; +export const getDefaultQuizSet = async (): Promise => { + const response = await apiClient.get(`${QUIZ_SET_BASE_API}/default`) + const apiRes: ApiResponse = response.data - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${jwtToken}` - } - }) - - // Handle response - const jsonResponse = await response.json() - if (!response.ok) { - throw new Error(`Failed to fetch data: ${jsonResponse.message}`) + if (!apiRes.data && apiRes.code != 1000) { + throw new Error(`Failed to update data: ${apiRes.message}`) } - return jsonResponse.data as QuizSet + return apiRes.data } -export async function getAllQuizSets(): Promise { - const jwtToken = getJwtToken(); - const url = baseUrl; - - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${jwtToken}` - } - }) +export const getAllQuizSets = async (): Promise => { + const response = await apiClient.get(`${QUIZ_SET_BASE_API}`) + const apiRes: ApiResponse = response.data - // Handle response - const jsonResponse = await response.json() - if (!response.ok) { - throw new Error(`Failed to fetch data: ${jsonResponse.message}`) + if (!apiRes.data && apiRes.code != 1000) { + throw new Error(`Failed to update data: ${apiRes.message}`) } - return jsonResponse.data as QuizSet[] + return apiRes.data } \ No newline at end of file diff --git a/src/services/quiz.service.ts b/src/services/quiz.service.ts index d852e25..78a37f1 100644 --- a/src/services/quiz.service.ts +++ b/src/services/quiz.service.ts @@ -1,138 +1,20 @@ +import apiClient from '@/apis/api-client' +import { ApiResponse } from '@/types/auth.type' import { Quiz } from '@/types/quiz.type' -import { Note } from '@/types/note.type' -const baseQuizUrl = 'http://localhost:8080/api/quizzes' -const baseGenerateUrl = 'http://localhost:8080/api/ai/generation/quiz-sets' +const QUIZ_BASE_API = '/quizzes' +const QUIZ_GENERATION_BASE_API = '/ai/generation/quiz-sets' -// Temporary function only -function getJwtToken() { - return localStorage.getItem('jwtToken') || '' -} - -export async function generateSingleQuiz(id : number): Promise { - const jwtToken = getJwtToken(); - const url = `${baseGenerateUrl}/default`; - - const response = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${jwtToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - docId: id - }) - }) - - // Handle response - const jsonResponse = await response.json() - if (!response.ok) { - throw new Error(`Failed to fetch data: ${jsonResponse.message}`) - } - return jsonResponse.data as Quiz -} - -// Temporary support function from other feature branch -export async function getAllNotes(): Promise { - const jwtToken = getJwtToken(); - const url = 'http://localhost:8080/api/documents/notes'; - - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${jwtToken}` - } - }) - - // Handle response - const jsonResponse = await response.json() - if (!response.ok) { - throw new Error(`Failed to fetch data: ${jsonResponse.message}`) - } - return jsonResponse.data as Note[] -} - -/* -export async function createNote(data: Partial): Promise { - const jwtToken = getJwtToken(); - const url = `${baseUrl}`; - - const response = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${jwtToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - title: data.title, - content: data.content - }) - }) - - // Handle response - const jsonResponse = await response.json() - if (!response.ok) { - throw new Error(`Failed to create new note: ${jsonResponse.message}`) - } - return jsonResponse.data as Note -} - -export async function getNoteById(id: number): Promise { - const jwtToken = getJwtToken(); - const url = `${baseUrl}/${id}`; - - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${jwtToken}` - } - }) - - // Handle response - const jsonResponse = await response.json() - if (!response.ok) { - throw new Error(`Failed to fetch data: ${jsonResponse.message}`) +export const generateSingleQuiz = async (id : number): Promise => { + const request = { + docId: id } - return jsonResponse.data as Note -} - -export async function updateNote(id: number, data: Partial): Promise { - const jwtToken = getJwtToken(); - const url = `${baseUrl}/${id}`; - - const response = await fetch(url, { - method: 'PATCH', - headers: { - Authorization: `Bearer ${jwtToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - title: data.title, - content: data.content - }) - }) - - // Handle response - const jsonResponse = await response.json() - if (!response.ok) { - throw new Error(`Failed to update data: ${jsonResponse.message}`) - } - return jsonResponse.data as Note -} - -export async function deleteNoteById(id: number) { - const jwtToken = getJwtToken(); - const url = `${baseUrl}/${id}`; - const response = await fetch(url, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${jwtToken}` - } - }) + const response = await apiClient.post(`${QUIZ_GENERATION_BASE_API}/default`, request) + const apiRes: ApiResponse = response.data - // Handle response - const jsonResponse = await response.json() - if (!response.ok) { - throw new Error(`Failed to delete data: ${jsonResponse.message}`) + if (!apiRes.data && apiRes.code != 1000) { + throw new Error(`Failed to update data: ${apiRes.message}`) } -} - */ \ No newline at end of file + return apiRes.data +} \ No newline at end of file From b18630a06c2ed53ba4165a90a9f02f876715b709 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Mon, 10 Nov 2025 20:37:49 +1100 Subject: [PATCH 04/10] feat(ai-quiz): implement quiz attempt --- .../[quizId]/attempt/[attemptId]/page.tsx | 374 ++++++++++++++++++ .../(protected)/quiz/[quizId]/page.tsx | 315 ++++----------- src/services/quiz.service.ts | 66 +++- src/types/quiz-attempt.ts | 20 + 4 files changed, 538 insertions(+), 237 deletions(-) create mode 100644 src/app/(pages)/(protected)/quiz/[quizId]/attempt/[attemptId]/page.tsx create mode 100644 src/types/quiz-attempt.ts diff --git a/src/app/(pages)/(protected)/quiz/[quizId]/attempt/[attemptId]/page.tsx b/src/app/(pages)/(protected)/quiz/[quizId]/attempt/[attemptId]/page.tsx new file mode 100644 index 0000000..16a3004 --- /dev/null +++ b/src/app/(pages)/(protected)/quiz/[quizId]/attempt/[attemptId]/page.tsx @@ -0,0 +1,374 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { AttemptDetail, QuizAttempt } from '@/types/quiz-attempt' +import { getNoteById, updateNote } from '@/services/note.service' +import { finishAttempt, getQuizAttempt, updateAttemptProgress } from '@/services/quiz.service' +import { useParams } from 'next/navigation' + +// const mockQuestions = [ +// { +// id: 1, +// text: 'Which one is NOT a JavaScript framework?', +// options: [ +// { key: 'A', text: 'React' }, +// { key: 'B', text: 'Angular' }, +// { key: 'C', text: 'Vue' }, +// { key: 'D', text: 'Laravel' } +// ], +// correctOption: 'D' +// }, +// { +// id: 2, +// text: 'What does HTML stand for?', +// options: [ +// { key: 'A', text: 'Hyper Trainer Marking Language' }, +// { key: 'B', text: 'Hyper Text Markup Language' }, +// { key: 'C', text: 'Hyper Text Marketing Language' }, +// { key: 'D', text: 'Hyper Text Markup Leveler' } +// ], +// correctOption: 'B' +// }, +// { +// id: 3, +// text: 'Which company developed TypeScript?', +// options: [ +// { key: 'A', text: 'Facebook' }, +// { key: 'B', text: 'Microsoft' }, +// { key: 'C', text: 'Google' }, +// { key: 'D', text: 'Amazon' } +// ], +// correctOption: 'B' +// }, +// { +// id: 4, +// text: 'Which is a backend language?', +// options: [ +// { key: 'A', text: 'Python' }, +// { key: 'B', text: 'CSS' }, +// { key: 'C', text: 'HTML' }, +// { key: 'D', text: 'Sass' } +// ], +// correctOption: 'A' +// }, +// { +// id: 5, +// text: 'What is the output of 2 + "2" in JavaScript?', +// options: [ +// { key: 'A', text: '4' }, +// { key: 'B', text: '"22"' }, +// { key: 'C', text: 'NaN' }, +// { key: 'D', text: 'undefined' } +// ], +// correctOption: 'B' +// }, +// { +// id: 6, +// text: 'Which tag is used for the largest heading in HTML?', +// options: [ +// { key: 'A', text: '

' }, +// { key: 'B', text: '

' }, +// { key: 'C', text: '' }, +// { key: 'D', text: '
' } +// ], +// correctOption: 'A' +// }, +// { +// id: 7, +// text: 'Which of these is a NoSQL database?', +// options: [ +// { key: 'A', text: 'MySQL' }, +// { key: 'B', text: 'PostgreSQL' }, +// { key: 'C', text: 'MongoDB' }, +// { key: 'D', text: 'Oracle' } +// ], +// correctOption: 'C' +// }, +// { +// id: 8, +// text: 'Which CSS property changes text color?', +// options: [ +// { key: 'A', text: 'font-style' }, +// { key: 'B', text: 'color' }, +// { key: 'C', text: 'background-color' }, +// { key: 'D', text: 'text-decoration' } +// ], +// correctOption: 'B' +// }, +// { +// id: 9, +// text: 'Which is a JavaScript data type?', +// options: [ +// { key: 'A', text: 'float' }, +// { key: 'B', text: 'number' }, +// { key: 'C', text: 'decimal' }, +// { key: 'D', text: 'character' } +// ], +// correctOption: 'B' +// }, +// { +// id: 10, +// text: 'Which HTML attribute is used for an image source?', +// options: [ +// { key: 'A', text: 'src' }, +// { key: 'B', text: 'href' }, +// { key: 'C', text: 'alt' }, +// { key: 'D', text: 'link' } +// ], +// correctOption: 'A' +// } +// ] + +interface Question { + id: number + text: string + options: { key: string; text: string }[] + correctOption?: string + userAnswer?: string + isCorrect?: boolean +} + +const QuizQuestionPage = () => { + const { quizId, attemptId } = useParams() + const [attempt, setAttempt] = useState(); + const [questions, setQuestions] = useState([]); + + const [current, setCurrent] = useState(0) + const [answers, setAnswers] = useState<(string | null)[]>([]) + const [submitted, setSubmitted] = useState(false) + + const [error, setError] = useState('') + const [loading, setLoading] = useState(true) + + const fetchData = async (qid: number, aid: number) => { + setLoading(true) + setError('') + + try { + const data = await getQuizAttempt(qid, aid) + if (!data.attemptDetails) { + throw new Error("This attempt has no information, please try again") + } + + const mappedQuestions : Question[] = data.attemptDetails.map((detail) => ({ + id: detail.id, + text: detail.questionText, + options: [ + { key: 'A', text: detail.optionA }, + { key: 'B', text: detail.optionB }, + { key: 'C', text: detail.optionC }, + { key: 'D', text: detail.optionD } + ], + correctOption: detail.correctAnswer, + userAnswer: detail.userAnswer, + isCorrect: detail.isCorrect + })) + setAttempt(data) + setQuestions(mappedQuestions) + setAnswers(Array(mappedQuestions.length).fill(null)) + } catch (error : any) { + setAttempt(null) + setQuestions([]) + setError(error.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchData(Number(quizId), Number(attemptId)); + }, [quizId]) + + const handleSelect = (optionKey: string) => { + setAnswers((prev) => { + const updated = [...prev] + updated[current] = optionKey + return updated + }) + } + + const handlePrev = () => setCurrent((prev) => Math.max(0, prev - 1)) + // const handleNext = () => setCurrent((prev) => Math.min(questions.length - 1, prev + 1)) + const handleNext = async () => { + const currentAnswer = answers[current] + const currentQuestion = questions[current] + + // Only sync if user has selected an answer + if (currentAnswer && quizId && attemptId) { + try { + await updateAttemptProgress( + Number(quizId), + Number(attemptId), + { + id: currentQuestion.id, + userAnswer: currentAnswer + } + ) + } catch (error: any) { + console.error('Failed to save answer:', error) + setError(error.message) + } + } + + // Move to next question + setCurrent((prev) => Math.min(questions.length - 1, prev + 1)) + } + + const handleSubmit = async (qid: number, aid: number) => { + try { + const result = await finishAttempt(qid, aid) + + if (!result.attemptDetails) { + throw new Error("This attempt has no information, please try again") + } + + const mappedQuestions : Question[] = result.attemptDetails.map((detail) => ({ + id: detail.id, + text: detail.questionText, + options: [ + { key: 'A', text: detail.optionA }, + { key: 'B', text: detail.optionB }, + { key: 'C', text: detail.optionC }, + { key: 'D', text: detail.optionD } + ], + correctOption: detail.correctAnswer, + userAnswer: detail.userAnswer, + isCorrect: detail.isCorrect + })) + setAttempt(result) + setQuestions(mappedQuestions) + + setSubmitted(true) + } catch (error : any) { + setError(error.message) + } + } + + // Calculate results + const results = submitted + ? questions.map((q, idx) => ({ + correct: answers[idx] === q.correctOption, + answered: answers[idx] !== null + })) + : [] + + const score = results.filter((r) => r.correct).length + + if (submitted) { + return ( +
+ + +

Quiz Results

+
+ You scored {score} out of{' '} + {questions.length} +
+
+ {questions.map((q, idx) => ( +
+
{q.text}
+
+ Your answer:{' '} + + {answers[idx] ? ( + q.options.find((o) => o.key === answers[idx])?.text + ) : ( + No answer + )} + +
+
+ Correct answer:{' '} + + {q.options.find((o) => o.key === q.correctOption)?.text} + +
+
+ ))} +
+ +
+
+
+ ) + } + + if (loading) { + return ( +

Loading notes...

+ ) + } + + const q = questions[current] + + return ( +
+ + + {/* Progress */} +
+ + Question {current + 1} of {questions.length} + +
+ {questions.map((_, idx) => ( + + ))} +
+
+ {/* Question */} +
{q.text}
+
+ {q.options.map((opt) => ( + + ))} +
+ {/* Navigation */} +
+ + {current < questions.length - 1 ? ( + + ) : ( + + )} +
+
+
+
+ ) +} + +export default QuizQuestionPage diff --git a/src/app/(pages)/(protected)/quiz/[quizId]/page.tsx b/src/app/(pages)/(protected)/quiz/[quizId]/page.tsx index d4c86fb..4b05312 100644 --- a/src/app/(pages)/(protected)/quiz/[quizId]/page.tsx +++ b/src/app/(pages)/(protected)/quiz/[quizId]/page.tsx @@ -1,257 +1,102 @@ 'use client' -import { useState } from 'react' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' +import { Sparkles, FileText, BookOpen, CircleChevronLeft } from 'lucide-react' +import { useParams, useRouter } from 'next/navigation' +import { useEffect, useState } from 'react' +import { Note } from '@/types/note.type' +import { QuizAttempt } from '@/types/quiz-attempt' +import { Quiz } from '@/types/quiz.type' +import { getNoteById, updateNote } from '@/services/note.service' +import { getAllQuizAttempts, getQuiz, startQuizAttempt } from '@/services/quiz.service' -const mockQuestions = [ - { - id: 1, - text: 'Which one is NOT a JavaScript framework?', - options: [ - { key: 'A', text: 'React' }, - { key: 'B', text: 'Angular' }, - { key: 'C', text: 'Vue' }, - { key: 'D', text: 'Laravel' } - ], - correctOption: 'D' - }, - { - id: 2, - text: 'What does HTML stand for?', - options: [ - { key: 'A', text: 'Hyper Trainer Marking Language' }, - { key: 'B', text: 'Hyper Text Markup Language' }, - { key: 'C', text: 'Hyper Text Marketing Language' }, - { key: 'D', text: 'Hyper Text Markup Leveler' } - ], - correctOption: 'B' - }, - { - id: 3, - text: 'Which company developed TypeScript?', - options: [ - { key: 'A', text: 'Facebook' }, - { key: 'B', text: 'Microsoft' }, - { key: 'C', text: 'Google' }, - { key: 'D', text: 'Amazon' } - ], - correctOption: 'B' - }, - { - id: 4, - text: 'Which is a backend language?', - options: [ - { key: 'A', text: 'Python' }, - { key: 'B', text: 'CSS' }, - { key: 'C', text: 'HTML' }, - { key: 'D', text: 'Sass' } - ], - correctOption: 'A' - }, - { - id: 5, - text: 'What is the output of 2 + "2" in JavaScript?', - options: [ - { key: 'A', text: '4' }, - { key: 'B', text: '"22"' }, - { key: 'C', text: 'NaN' }, - { key: 'D', text: 'undefined' } - ], - correctOption: 'B' - }, - { - id: 6, - text: 'Which tag is used for the largest heading in HTML?', - options: [ - { key: 'A', text: '

' }, - { key: 'B', text: '

' }, - { key: 'C', text: '' }, - { key: 'D', text: '
' } - ], - correctOption: 'A' - }, - { - id: 7, - text: 'Which of these is a NoSQL database?', - options: [ - { key: 'A', text: 'MySQL' }, - { key: 'B', text: 'PostgreSQL' }, - { key: 'C', text: 'MongoDB' }, - { key: 'D', text: 'Oracle' } - ], - correctOption: 'C' - }, - { - id: 8, - text: 'Which CSS property changes text color?', - options: [ - { key: 'A', text: 'font-style' }, - { key: 'B', text: 'color' }, - { key: 'C', text: 'background-color' }, - { key: 'D', text: 'text-decoration' } - ], - correctOption: 'B' - }, - { - id: 9, - text: 'Which is a JavaScript data type?', - options: [ - { key: 'A', text: 'float' }, - { key: 'B', text: 'number' }, - { key: 'C', text: 'decimal' }, - { key: 'D', text: 'character' } - ], - correctOption: 'B' - }, - { - id: 10, - text: 'Which HTML attribute is used for an image source?', - options: [ - { key: 'A', text: 'src' }, - { key: 'B', text: 'href' }, - { key: 'C', text: 'alt' }, - { key: 'D', text: 'link' } - ], - correctOption: 'A' - } -] +const QuizPage = () => { + const router = useRouter() + + const { quizId } = useParams() + const [quiz, setQuiz] = useState(null) + const [attempts, setAttempts] = useState([]) + + const [error, setError] = useState('') + const [loading, setLoading] = useState(true) + const allowLearnMore = false; + + const fetchData = async (id: number) => { + setLoading(true) + setError('') -const QuizQuestionPage = () => { - const [current, setCurrent] = useState(0) - const [answers, setAnswers] = useState<(string | null)[]>(Array(mockQuestions.length).fill(null)) - const [submitted, setSubmitted] = useState(false) + try { + const [quizData, attemptsData] = await Promise.all([ + getQuiz(id), + getAllQuizAttempts(id) + ]) - const handleSelect = (optionKey: string) => { - setAnswers((prev) => { - const updated = [...prev] - updated[current] = optionKey - return updated - }) + setQuiz(quizData) + setAttempts(attemptsData) + } catch (error : any) { + setQuiz(null) + setAttempts([]) + setError(error.message) + } finally { + setLoading(false) + } } - const handlePrev = () => setCurrent((prev) => Math.max(0, prev - 1)) - const handleNext = () => setCurrent((prev) => Math.min(mockQuestions.length - 1, prev + 1)) + useEffect(() => { + fetchData(Number(quizId)); + }, [quizId]) - const handleSubmit = () => setSubmitted(true) + const handleBackToQuizzes = () => { + router.push('/quiz') + } - // Calculate results - const results = submitted - ? mockQuestions.map((q, idx) => ({ - correct: answers[idx] === q.correctOption, - answered: answers[idx] !== null - })) - : [] + const handleStartNewAttempt = async () => { + setError('') - const score = results.filter((r) => r.correct).length + if (!quiz) return + try { + const newAttempt = await startQuizAttempt(quiz.id) + router.push(`/quiz/${quiz.id}/attempt/${newAttempt.id}`) + } catch (error : any) { + setError(error.message) + } + } - if (submitted) { - return ( -
- + return ( + <> + {/* Back to previous */} +
+ +
+ + {/* Introduction */} +
+ -

Quiz Results

-
- You scored {score} out of{' '} - {mockQuestions.length} +
+

Quiz: {quiz?.title}

+
+
+
+ + Total questions: {quiz?.questions?.length} +
+
+ + Total attempts: {attempts.length} +
-
- {mockQuestions.map((q, idx) => ( -
-
{q.text}
-
- Your answer:{' '} - - {answers[idx] ? ( - q.options.find((o) => o.key === answers[idx])?.text - ) : ( - No answer - )} - -
-
- Correct answer:{' '} - - {q.options.find((o) => o.key === q.correctOption)?.text} - -
-
- ))} +
+
-
- ) - } - - const q = mockQuestions[current] - - return ( -
- - - {/* Progress */} -
- - Question {current + 1} of {mockQuestions.length} - -
- {mockQuestions.map((_, idx) => ( - - ))} -
-
- {/* Question */} -
{q.text}
-
- {q.options.map((opt) => ( - - ))} -
- {/* Navigation */} -
- - {current < mockQuestions.length - 1 ? ( - - ) : ( - - )} -
-
-
-
+ ) } -export default QuizQuestionPage +export default QuizPage diff --git a/src/services/quiz.service.ts b/src/services/quiz.service.ts index 78a37f1..8eaf4d8 100644 --- a/src/services/quiz.service.ts +++ b/src/services/quiz.service.ts @@ -1,18 +1,80 @@ import apiClient from '@/apis/api-client' import { ApiResponse } from '@/types/auth.type' import { Quiz } from '@/types/quiz.type' +import { QuizAttempt } from '@/types/quiz-attempt' const QUIZ_BASE_API = '/quizzes' +const QUIZ_ATTEMPT_BASE_API = '/quizzes' const QUIZ_GENERATION_BASE_API = '/ai/generation/quiz-sets' -export const generateSingleQuiz = async (id : number): Promise => { +export const generateSingleQuiz = async (docId : number): Promise => { const request = { - docId: id + docId: docId } const response = await apiClient.post(`${QUIZ_GENERATION_BASE_API}/default`, request) const apiRes: ApiResponse = response.data + if (!apiRes.data && apiRes.code != 1000) { + throw new Error(`Failed to create data: ${apiRes.message}`) + } + return apiRes.data +} + +export const getQuiz = async (quizId : number): Promise => { + const response = await apiClient.get(`${QUIZ_BASE_API}/${quizId}`) + 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 getAllQuizAttempts = async (quizId : number): Promise => { + const response = await apiClient.get(`${QUIZ_BASE_API}/${quizId}/attempts`) + 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 startQuizAttempt = async (quizId : number): Promise => { + const response = await apiClient.post(`${QUIZ_BASE_API}/${quizId}/attempts`) + const apiRes: ApiResponse = response.data + + if (!apiRes.data && apiRes.code != 1000) { + throw new Error(`Failed to create data: ${apiRes.message}`) + } + return apiRes.data +} + +export const getQuizAttempt = async (quizId : number, attemptId : number): Promise => { + const response = await apiClient.get(`${QUIZ_BASE_API}/${quizId}/attempts/${attemptId}`) + 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 updateAttemptProgress = async (quizId : number, attemptId : number, request : { id : number , userAnswer: string }): Promise => { + const response = await apiClient.patch(`${QUIZ_BASE_API}/${quizId}/attempts/${attemptId}`, request) + const apiRes: ApiResponse = response.data + + if (!apiRes.data && apiRes.code != 1000) { + throw new Error(`Failed to update data: ${apiRes.message}`) + } + return apiRes.data +} + +export const finishAttempt = async (quizId : number, attemptId : number): Promise => { + const response = await apiClient.get(`${QUIZ_BASE_API}/${quizId}/attempts/${attemptId}/answer`) + const apiRes: ApiResponse = response.data + if (!apiRes.data && apiRes.code != 1000) { throw new Error(`Failed to update data: ${apiRes.message}`) } diff --git a/src/types/quiz-attempt.ts b/src/types/quiz-attempt.ts new file mode 100644 index 0000000..7d8b9a1 --- /dev/null +++ b/src/types/quiz-attempt.ts @@ -0,0 +1,20 @@ +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 From 3077aea5fd8307016b665876e17eb003c16b922f Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Mon, 10 Nov 2025 21:00:46 +1100 Subject: [PATCH 05/10] feat(ai-quiz): display quiz attempts list --- .../[quizId]/attempt/[attemptId]/page.tsx | 19 ++- .../(protected)/quiz/[quizId]/page.tsx | 61 ++++++- src/components/quizzes/attempt-card.tsx | 151 ++++++++++++++++++ src/services/quiz.service.ts | 2 +- 4 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 src/components/quizzes/attempt-card.tsx diff --git a/src/app/(pages)/(protected)/quiz/[quizId]/attempt/[attemptId]/page.tsx b/src/app/(pages)/(protected)/quiz/[quizId]/attempt/[attemptId]/page.tsx index 16a3004..7d37f23 100644 --- a/src/app/(pages)/(protected)/quiz/[quizId]/attempt/[attemptId]/page.tsx +++ b/src/app/(pages)/(protected)/quiz/[quizId]/attempt/[attemptId]/page.tsx @@ -6,7 +6,8 @@ import { Button } from '@/components/ui/button' import { AttemptDetail, QuizAttempt } from '@/types/quiz-attempt' import { getNoteById, updateNote } from '@/services/note.service' import { finishAttempt, getQuizAttempt, updateAttemptProgress } from '@/services/quiz.service' -import { useParams } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' +import { CircleChevronLeft } from 'lucide-react' // const mockQuestions = [ // { @@ -131,6 +132,8 @@ interface Question { } const QuizQuestionPage = () => { + const router = useRouter() + const { quizId, attemptId } = useParams() const [attempt, setAttempt] = useState(); const [questions, setQuestions] = useState([]); @@ -181,6 +184,10 @@ const QuizQuestionPage = () => { fetchData(Number(quizId), Number(attemptId)); }, [quizId]) + const handleBackToQuiz = () => { + router.push(`/quiz/${quizId}`) + } + const handleSelect = (optionKey: string) => { setAnswers((prev) => { const updated = [...prev] @@ -294,7 +301,13 @@ const QuizQuestionPage = () => {
))}
- + +
+ + +
@@ -303,7 +316,7 @@ const QuizQuestionPage = () => { if (loading) { return ( -

Loading notes...

+

Loading quiz...

) } diff --git a/src/app/(pages)/(protected)/quiz/[quizId]/page.tsx b/src/app/(pages)/(protected)/quiz/[quizId]/page.tsx index 4b05312..f819526 100644 --- a/src/app/(pages)/(protected)/quiz/[quizId]/page.tsx +++ b/src/app/(pages)/(protected)/quiz/[quizId]/page.tsx @@ -2,7 +2,7 @@ import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { Sparkles, FileText, BookOpen, CircleChevronLeft } from 'lucide-react' +import { Sparkles, FileText, BookOpen, CircleChevronLeft, Notebook } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { useEffect, useState } from 'react' import { Note } from '@/types/note.type' @@ -10,6 +10,12 @@ import { QuizAttempt } from '@/types/quiz-attempt' import { Quiz } from '@/types/quiz.type' import { getNoteById, updateNote } from '@/services/note.service' import { getAllQuizAttempts, getQuiz, startQuizAttempt } from '@/services/quiz.service' +import AnimatedSection from '@/components/landing/animated-section' +import NoteCard from '@/components/notes/note-card' +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' const QuizPage = () => { const router = useRouter() @@ -47,6 +53,17 @@ const QuizPage = () => { fetchData(Number(quizId)); }, [quizId]) + 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)) + } + const handleBackToQuizzes = () => { router.push('/quiz') } @@ -72,9 +89,9 @@ const QuizPage = () => {
- {/* Introduction */}
- + {/* Introduction */} +

Quiz: {quiz?.title}

@@ -93,6 +110,44 @@ const QuizPage = () => {
+ + + {/* Attempts Grid */} + + {loading ? ( +

Loading attempts...

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

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

+
+ ) : ( +
+ {paginatedAttempts.map((attempt) => ( + + ))} +
+ )} +
+ + {/* Pagination */} + + {attempts.length > pageSize && ( + + )} + + {/* End - Pagination */}
diff --git a/src/components/quizzes/attempt-card.tsx b/src/components/quizzes/attempt-card.tsx new file mode 100644 index 0000000..9f5d0da --- /dev/null +++ b/src/components/quizzes/attempt-card.tsx @@ -0,0 +1,151 @@ +'use client' +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Edit, 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 { AttemptDetail } from '@/types/quiz-attempt' +// import { useRouter } from 'next/navigation' +interface AttemptCardProps { + id: number + score: number + totalQuestions: number + attemptAt: Date +} + +const AttemptCard = ({ id, score, totalQuestions, attemptAt }: AttemptCardProps) => { + const [actionsOpen, setActionsOpen] = useState(false) + const menuRef = useRef(null) + const cancelButtonRef = useRef(null) + + const MAX_TAGS_DISPLAY = 2 + + const handleClickOutside = (event: MouseEvent | globalThis.MouseEvent) => { + if ( + menuRef.current && + !menuRef.current.contains(event.target as Node) && + cancelButtonRef.current && + !cancelButtonRef.current.contains(event.target as Node) && + actionsOpen + ) { + setActionsOpen(false) + } + } + + const handleActionsToggle = (event: MouseEvent) => { + event.stopPropagation() + setActionsOpen((prev) => !prev) + } + + const handleEdit = (event: MouseEvent) => { + event.stopPropagation() + setActionsOpen(false) + // Logic to handle edit action, e.g., redirect to edit page + // useRouter().push(`/notes/${id}/edit`) + console.log('Edit action triggered for quiz:', id) + } + + const handleDelete = (event: MouseEvent) => { + event.stopPropagation() + setActionsOpen(false) + // Logic to handle delete action, e.g., show confirmation dialog + // useRouter().push(`/notes/${id}/delete`) + console.log('Delete action triggered for quiz:', id) + } + + 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 ( + <> + +
+ + + +
+

ID: {id}

+
+ +

Score: {score} / {totalQuestions}

+

Score: {score} / {totalQuestions}

+ +
+ +
+ + {/* Actions Menu */} + {actionsOpen && ( + <> + {/* Desktop: Dropdown menu */} + {/**/} + {/* */} + {/* */} + {/* Edit*/} + {/* */} + {/* */} + {/* */} + {/* Delete*/} + {/* */} + {/*
*/} + {/*/!* Mobile: Bottom sheet *!/*/} + {/*
*/} + {/* */} + {/* */} + {/* Delete*/} + {/* */} + {/* */} + {/* Cancel*/} + {/* */} + {/*
*/} + + )} + {/* End - Actions Menu */} + + + ) +} + +export default AttemptCard diff --git a/src/services/quiz.service.ts b/src/services/quiz.service.ts index 8eaf4d8..51b595c 100644 --- a/src/services/quiz.service.ts +++ b/src/services/quiz.service.ts @@ -72,7 +72,7 @@ export const updateAttemptProgress = async (quizId : number, attemptId : number, } export const finishAttempt = async (quizId : number, attemptId : number): Promise => { - const response = await apiClient.get(`${QUIZ_BASE_API}/${quizId}/attempts/${attemptId}/answer`) + const response = await apiClient.post(`${QUIZ_BASE_API}/${quizId}/attempts/${attemptId}/answer`) const apiRes: ApiResponse = response.data if (!apiRes.data && apiRes.code != 1000) { From 1646871d23dcd7191934949d446164ef1992cfde Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Sun, 11 Jan 2026 16:34:05 +1100 Subject: [PATCH 06/10] refactor(ai-quiz): reorganize structure and add new navigation hook --- .../(pages)/(protected)/quiz/result/page.tsx | 212 ------------------ .../[quizId]/attempts}/[attemptId]/page.tsx | 146 ++---------- .../attempts/[attemptId]/result/page.tsx | 151 +++++++++++++ .../{quiz => quizzes}/[quizId]/page.tsx | 22 +- .../collections}/page.tsx | 19 +- .../generation/note/page.tsx | 15 +- .../{quiz => quizzes}/generation/page.tsx | 4 +- .../(protected)/{quiz => quizzes}/page.tsx | 56 +++-- src/components/nav-sidebar.tsx | 4 +- src/components/quizzes/attempt-card.tsx | 82 +++---- src/components/quizzes/quiz-card.tsx | 3 +- src/hooks/use-nav.tsx | 30 +++ src/services/quiz.service.ts | 10 + 13 files changed, 307 insertions(+), 447 deletions(-) delete mode 100644 src/app/(pages)/(protected)/quiz/result/page.tsx rename src/app/(pages)/(protected)/{quiz/[quizId]/attempt => quizzes/[quizId]/attempts}/[attemptId]/page.tsx (69%) create mode 100644 src/app/(pages)/(protected)/quizzes/[quizId]/attempts/[attemptId]/result/page.tsx rename src/app/(pages)/(protected)/{quiz => quizzes}/[quizId]/page.tsx (87%) rename src/app/(pages)/(protected)/{quiz-set => quizzes/collections}/page.tsx (91%) rename src/app/(pages)/(protected)/{quiz => quizzes}/generation/note/page.tsx (95%) rename src/app/(pages)/(protected)/{quiz => quizzes}/generation/page.tsx (97%) rename src/app/(pages)/(protected)/{quiz => quizzes}/page.tsx (79%) create mode 100644 src/hooks/use-nav.tsx diff --git a/src/app/(pages)/(protected)/quiz/result/page.tsx b/src/app/(pages)/(protected)/quiz/result/page.tsx deleted file mode 100644 index 210850c..0000000 --- a/src/app/(pages)/(protected)/quiz/result/page.tsx +++ /dev/null @@ -1,212 +0,0 @@ -'use client' - -import { Card, CardContent } from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { CheckCircle, XCircle } from 'lucide-react' - -const mockResult = { - score: 8, - total: 10, - percent: 80, - questions: [ - { - text: 'Which one is NOT a JavaScript framework?', - options: [ - { key: 'A', text: 'React' }, - { key: 'B', text: 'Angular' }, - { key: 'C', text: 'Vue' }, - { key: 'D', text: 'Laravel' } - ], - userAnswer: 'D', - correctAnswer: 'D' - }, - { - text: 'What does HTML stand for?', - options: [ - { key: 'A', text: 'Hyper Trainer Marking Language' }, - { key: 'B', text: 'Hyper Text Markup Language' }, - { key: 'C', text: 'Hyper Text Marketing Language' }, - { key: 'D', text: 'Hyper Text Markup Leveler' } - ], - userAnswer: 'A', - correctAnswer: 'B' - }, - { - text: 'Which company developed TypeScript?', - options: [ - { key: 'A', text: 'Facebook' }, - { key: 'B', text: 'Microsoft' }, - { key: 'C', text: 'Google' }, - { key: 'D', text: 'Apple' } - ], - userAnswer: 'B', - correctAnswer: 'B' - }, - { - text: 'Which is a backend language?', - options: [ - { key: 'A', text: 'Python' }, - { key: 'B', text: 'CSS' }, - { key: 'C', text: 'JavaScript' }, - { key: 'D', text: 'Ruby' } - ], - userAnswer: 'A', - correctAnswer: 'A' - }, - { - text: 'Which HTML tag is used to define an unordered list?', - options: [ - { key: 'A', text: '
    ' }, - { key: 'B', text: '
      ' }, - { key: 'C', text: '
    1. ' }, - { key: 'D', text: '' } - ], - userAnswer: 'A', - correctAnswer: 'A' - }, - { - text: 'What is the output of 2 + "2" in JavaScript?', - options: [ - { key: 'A', text: '4' }, - { key: 'B', text: '"22"' }, - { key: 'C', text: 'NaN' }, - { key: 'D', text: 'undefined' } - ], - userAnswer: 'B', - correctAnswer: 'B' - }, - { - text: 'Which of the following is a CSS framework?', - options: [ - { key: 'A', text: 'Django' }, - { key: 'B', text: 'Bootstrap' }, - { key: 'C', text: 'Flask' }, - { key: 'D', text: 'Express' } - ], - userAnswer: 'B', - correctAnswer: 'B' - }, - { - text: 'Which keyword is used to declare a constant in JavaScript?', - options: [ - { key: 'A', text: 'let' }, - { key: 'B', text: 'var' }, - { key: 'C', text: 'const' }, - { key: 'D', text: 'static' } - ], - userAnswer: 'C', - correctAnswer: 'C' - }, - { - text: 'What does CSS stand for?', - options: [ - { key: 'A', text: 'Computer Style Sheets' }, - { key: 'B', text: 'Creative Style Sheets' }, - { key: 'C', text: 'Cascading Style Sheets' }, - { key: 'D', text: 'Colorful Style Sheets' } - ], - userAnswer: 'A', - correctAnswer: 'C' - }, - { - text: 'Which of the following is NOT a programming language?', - options: [ - { key: 'A', text: 'Python' }, - { key: 'B', text: 'HTML' }, - { key: 'C', text: 'Java' }, - { key: 'D', text: 'C++' } - ], - userAnswer: 'B', - correctAnswer: 'B' - } - ] -} - -const QuizResultPage = () => { - const result = mockResult - - return ( -
      - - -

      Quiz Results

      -
      -
      - {result.score} / {result.total} -
      -
      {result.percent}% correct
      -
      -
      -
      -
      - {result.percent >= 80 - ? 'Excellent work!' - : result.percent >= 60 - ? 'Good job, keep practicing!' - : 'Keep trying, you can do it!'} -
      -
      -
      - {result.questions.map((q, idx) => { - const isCorrect = q.userAnswer === q.correctAnswer - return ( -
      - {isCorrect ? ( - - ) : ( - - )} -
      -
      {q.text}
      -
      - Your answer:{' '} - - {q.userAnswer ? ( - q.options.find((o) => o.key === q.userAnswer)?.text - ) : ( - No answer - )} - -
      - {!isCorrect && ( -
      - Correct answer:{' '} - - {q.options.find((o) => o.key === q.correctAnswer)?.text} - -
      - )} -
      -
      - ) - })} -
      -
      - - -
      - - -
      - ) -} - -export default QuizResultPage diff --git a/src/app/(pages)/(protected)/quiz/[quizId]/attempt/[attemptId]/page.tsx b/src/app/(pages)/(protected)/quizzes/[quizId]/attempts/[attemptId]/page.tsx similarity index 69% rename from src/app/(pages)/(protected)/quiz/[quizId]/attempt/[attemptId]/page.tsx rename to src/app/(pages)/(protected)/quizzes/[quizId]/attempts/[attemptId]/page.tsx index 7d37f23..9cb022c 100644 --- a/src/app/(pages)/(protected)/quiz/[quizId]/attempt/[attemptId]/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/[quizId]/attempts/[attemptId]/page.tsx @@ -3,124 +3,10 @@ import { useEffect, useState } from 'react' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { AttemptDetail, QuizAttempt } from '@/types/quiz-attempt' -import { getNoteById, updateNote } from '@/services/note.service' -import { finishAttempt, getQuizAttempt, updateAttemptProgress } from '@/services/quiz.service' -import { useParams, useRouter } from 'next/navigation' -import { CircleChevronLeft } from 'lucide-react' - -// const mockQuestions = [ -// { -// id: 1, -// text: 'Which one is NOT a JavaScript framework?', -// options: [ -// { key: 'A', text: 'React' }, -// { key: 'B', text: 'Angular' }, -// { key: 'C', text: 'Vue' }, -// { key: 'D', text: 'Laravel' } -// ], -// correctOption: 'D' -// }, -// { -// id: 2, -// text: 'What does HTML stand for?', -// options: [ -// { key: 'A', text: 'Hyper Trainer Marking Language' }, -// { key: 'B', text: 'Hyper Text Markup Language' }, -// { key: 'C', text: 'Hyper Text Marketing Language' }, -// { key: 'D', text: 'Hyper Text Markup Leveler' } -// ], -// correctOption: 'B' -// }, -// { -// id: 3, -// text: 'Which company developed TypeScript?', -// options: [ -// { key: 'A', text: 'Facebook' }, -// { key: 'B', text: 'Microsoft' }, -// { key: 'C', text: 'Google' }, -// { key: 'D', text: 'Amazon' } -// ], -// correctOption: 'B' -// }, -// { -// id: 4, -// text: 'Which is a backend language?', -// options: [ -// { key: 'A', text: 'Python' }, -// { key: 'B', text: 'CSS' }, -// { key: 'C', text: 'HTML' }, -// { key: 'D', text: 'Sass' } -// ], -// correctOption: 'A' -// }, -// { -// id: 5, -// text: 'What is the output of 2 + "2" in JavaScript?', -// options: [ -// { key: 'A', text: '4' }, -// { key: 'B', text: '"22"' }, -// { key: 'C', text: 'NaN' }, -// { key: 'D', text: 'undefined' } -// ], -// correctOption: 'B' -// }, -// { -// id: 6, -// text: 'Which tag is used for the largest heading in HTML?', -// options: [ -// { key: 'A', text: '

      ' }, -// { key: 'B', text: '

      ' }, -// { key: 'C', text: '' }, -// { key: 'D', text: '
      ' } -// ], -// correctOption: 'A' -// }, -// { -// id: 7, -// text: 'Which of these is a NoSQL database?', -// options: [ -// { key: 'A', text: 'MySQL' }, -// { key: 'B', text: 'PostgreSQL' }, -// { key: 'C', text: 'MongoDB' }, -// { key: 'D', text: 'Oracle' } -// ], -// correctOption: 'C' -// }, -// { -// id: 8, -// text: 'Which CSS property changes text color?', -// options: [ -// { key: 'A', text: 'font-style' }, -// { key: 'B', text: 'color' }, -// { key: 'C', text: 'background-color' }, -// { key: 'D', text: 'text-decoration' } -// ], -// correctOption: 'B' -// }, -// { -// id: 9, -// text: 'Which is a JavaScript data type?', -// options: [ -// { key: 'A', text: 'float' }, -// { key: 'B', text: 'number' }, -// { key: 'C', text: 'decimal' }, -// { key: 'D', text: 'character' } -// ], -// correctOption: 'B' -// }, -// { -// id: 10, -// text: 'Which HTML attribute is used for an image source?', -// options: [ -// { key: 'A', text: 'src' }, -// { key: 'B', text: 'href' }, -// { key: 'C', text: 'alt' }, -// { key: 'D', text: 'link' } -// ], -// correctOption: 'A' -// } -// ] +import { QuizAttempt } from '@/types/quiz-attempt' +import { finishAttempt, getQuizAttempt, startQuizAttempt, updateAttemptProgress } from '@/services/quiz.service' +import { useParams } from 'next/navigation' +import { useNav } from '@/hooks/use-nav' interface Question { id: number @@ -132,7 +18,7 @@ interface Question { } const QuizQuestionPage = () => { - const router = useRouter() + const nav = useNav() const { quizId, attemptId } = useParams() const [attempt, setAttempt] = useState(); @@ -184,10 +70,6 @@ const QuizQuestionPage = () => { fetchData(Number(quizId), Number(attemptId)); }, [quizId]) - const handleBackToQuiz = () => { - router.push(`/quiz/${quizId}`) - } - const handleSelect = (optionKey: string) => { setAnswers((prev) => { const updated = [...prev] @@ -253,6 +135,17 @@ const QuizQuestionPage = () => { } } + const handleNewQuizAttempt = async () => { + setError('') + + try { + const newAttempt = await startQuizAttempt(Number(quizId)) + nav.toNewQuizAttempt(Number(quizId), newAttempt.id) + } catch (error : any) { + setError(error.message) + } + } + // Calculate results const results = submitted ? questions.map((q, idx) => ({ @@ -268,7 +161,7 @@ const QuizQuestionPage = () => {
      -

      Quiz Results

      +

      Quiz Ended

      You scored {score} out of{' '} {questions.length} @@ -303,8 +196,9 @@ const QuizQuestionPage = () => {
      - - */} + +
      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 new file mode 100644 index 0000000..8779d60 --- /dev/null +++ b/src/app/(pages)/(protected)/quizzes/[quizId]/attempts/[attemptId]/result/page.tsx @@ -0,0 +1,151 @@ +'use client' + +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { CheckCircle, CircleChevronLeft, XCircle } from 'lucide-react' +import { useParams } from 'next/navigation' +import { useEffect, useState } from 'react' +import { getQuizAttemptAnswer } from '@/services/quiz.service' +import { useNav } from '@/hooks/use-nav' + +interface Result { + score: number + total: number + percent: number + questions: Question[] +} + +interface Question { + id: number + text: string + options: { key: string; text: string }[] + correctAnswer?: string + userAnswer?: string + isCorrect?: boolean +} + +const QuizResultPage = () => { + const nav = useNav() + + const { quizId, attemptId } = useParams() + + const [result, setResult] = useState({score: 0, total: 0, percent: 0, questions: []}); + const [error, setError] = useState('') + const [loading, setLoading] = useState(true) + + const fetchData = async (qid: number, aid: number) => { + setLoading(true) + setError('') + + try { + const data = await getQuizAttemptAnswer(qid, aid) + if (!data.attemptDetails) { + throw new Error("This attempt has no information, please try again") + } + const mappedQuestion : Question[] = data.attemptDetails.map((detail) => ({ + id: detail.id, + text: detail.questionText, + options: [ + { key: 'A', text: detail.optionA }, + { key: 'B', text: detail.optionB }, + { key: 'C', text: detail.optionC }, + { key: 'D', text: detail.optionD } + ], + correctAnswer: detail.correctAnswer, + userAnswer: detail.userAnswer, + isCorrect: detail.isCorrect + })) + + const score = mappedQuestion.filter(q => q.isCorrect).length + const total = mappedQuestion.length + const percent = Math.round((score / total) * 100) + const result : Result = { score: score, total: total, percent: percent, questions: mappedQuestion } + setResult(result) + } catch (error : any) { + setError(error.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchData(Number(quizId), Number(attemptId)); + }, [quizId]) + + + return ( + <> +
      + +
      + +
      + + +

      Quiz Results

      +
      +
      + {result.score} / {result.total} +
      +
      {result.percent}% correct
      +
      +
      +
      +
      + {result.percent >= 80 + ? 'Excellent work!' + : result.percent >= 60 + ? 'Good job, keep practicing!' + : 'Keep trying, you can do it!'} +
      +
      +
      + {result.questions.map((q, idx) => { + const isCorrect = q.isCorrect + return ( +
      + {isCorrect ? ( + + ) : ( + + )} +
      +
      {q.text}
      +
      + Your answer:{' '} + + {q.userAnswer ? ( + q.options.find((o) => o.key === q.userAnswer)?.text + ) : ( + No answer + )} + +
      + {!isCorrect && ( +
      + Correct answer:{' '} + + {q.options.find((o) => o.key === q.correctAnswer)?.text} + +
      + )} +
      +
      + ) + })} +
      + + +
      + + ) +} + +export default QuizResultPage diff --git a/src/app/(pages)/(protected)/quiz/[quizId]/page.tsx b/src/app/(pages)/(protected)/quizzes/[quizId]/page.tsx similarity index 87% rename from src/app/(pages)/(protected)/quiz/[quizId]/page.tsx rename to src/app/(pages)/(protected)/quizzes/[quizId]/page.tsx index f819526..ef9068b 100644 --- a/src/app/(pages)/(protected)/quiz/[quizId]/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/[quizId]/page.tsx @@ -3,22 +3,20 @@ import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Sparkles, FileText, BookOpen, CircleChevronLeft, Notebook } from 'lucide-react' -import { useParams, useRouter } from 'next/navigation' +import { useParams } from 'next/navigation' import { useEffect, useState } from 'react' -import { Note } from '@/types/note.type' import { QuizAttempt } from '@/types/quiz-attempt' import { Quiz } from '@/types/quiz.type' -import { getNoteById, updateNote } from '@/services/note.service' import { getAllQuizAttempts, getQuiz, startQuizAttempt } from '@/services/quiz.service' import AnimatedSection from '@/components/landing/animated-section' -import NoteCard from '@/components/notes/note-card' 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' const QuizPage = () => { - const router = useRouter() + const nav = useNav() const { quizId } = useParams() const [quiz, setQuiz] = useState(null) @@ -26,7 +24,6 @@ const QuizPage = () => { const [error, setError] = useState('') const [loading, setLoading] = useState(true) - const allowLearnMore = false; const fetchData = async (id: number) => { setLoading(true) @@ -64,17 +61,13 @@ const QuizPage = () => { setQueryParam('page', String(page)) } - const handleBackToQuizzes = () => { - router.push('/quiz') - } - const handleStartNewAttempt = async () => { setError('') if (!quiz) return try { const newAttempt = await startQuizAttempt(quiz.id) - router.push(`/quiz/${quiz.id}/attempt/${newAttempt.id}`) + nav.toNewQuizAttempt(quiz.id, newAttempt.id) } catch (error : any) { setError(error.message) } @@ -84,7 +77,7 @@ const QuizPage = () => { <> {/* Back to previous */}
      -
      @@ -119,16 +112,17 @@ const QuizPage = () => { ) : !error && attempts.length === 0 ? (
      -

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

      +

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

      ) : (
      {paginatedAttempts.map((attempt) => ( ))} diff --git a/src/app/(pages)/(protected)/quiz-set/page.tsx b/src/app/(pages)/(protected)/quizzes/collections/page.tsx similarity index 91% rename from src/app/(pages)/(protected)/quiz-set/page.tsx rename to src/app/(pages)/(protected)/quizzes/collections/page.tsx index c67e07d..89f431d 100644 --- a/src/app/(pages)/(protected)/quiz-set/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/collections/page.tsx @@ -1,6 +1,6 @@ 'use client' import { useEffect, useState } from 'react' -import { Plus, Search, Filter, Notebook } from 'lucide-react' +import { Plus, Search, Filter, Notebook, CircleChevronLeft } from 'lucide-react' import { Button } from '@/components/ui/button' import AnimatedSection from '@/components/landing/animated-section' import Pagination from '@/components/pagination' @@ -8,14 +8,12 @@ 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 { getAllQuizSets } from '@/services/quiz-set.service' -import { Quiz } from '@/types/quiz.type' import { QuizSet } from '@/types/quiz-set.type' +import { useNav } from '@/hooks/use-nav' const QuizSetsListPage = () => { - const router = useRouter() + const nav = useNav() const [search, setSearch] = useState('') const [quizSets, setQuizSets] = useState([]) @@ -80,13 +78,18 @@ const QuizSetsListPage = () => { {/* Header */}
      + {/* Back to previous */} +
      + +

      - - My Quiz Collection + Quiz Collection

      diff --git a/src/app/(pages)/(protected)/quiz/generation/page.tsx b/src/app/(pages)/(protected)/quizzes/generation/page.tsx similarity index 97% rename from src/app/(pages)/(protected)/quiz/generation/page.tsx rename to src/app/(pages)/(protected)/quizzes/generation/page.tsx index 971ef92..00915f7 100644 --- a/src/app/(pages)/(protected)/quiz/generation/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/generation/page.tsx @@ -11,11 +11,11 @@ const QuizGenerationPage = () => { const allowLearnMore = false; const handleBackToQuizzes = () => { - router.push('/quiz') + router.push('/quizzes') } const handleRedirectToCreateQuiz = () => { - router.push('/quiz/generation/note') + router.push('/quizzes/generation/note') } return ( diff --git a/src/app/(pages)/(protected)/quiz/page.tsx b/src/app/(pages)/(protected)/quizzes/page.tsx similarity index 79% rename from src/app/(pages)/(protected)/quiz/page.tsx rename to src/app/(pages)/(protected)/quizzes/page.tsx index 0d32b8a..d41068e 100644 --- a/src/app/(pages)/(protected)/quiz/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/page.tsx @@ -1,6 +1,6 @@ 'use client' import { useEffect, useState } from 'react' -import { Plus, Search, Filter, Notebook } from 'lucide-react' +import { Plus, Search, Filter, Notebook, ChevronRight } from 'lucide-react' import { Button } from '@/components/ui/button' import AnimatedSection from '@/components/landing/animated-section' import Pagination from '@/components/pagination' @@ -8,14 +8,12 @@ import useQueryConfig from '@/hooks/use-query-config' import useUpdateQueryParam from '@/hooks/use-update-query-param' import QuizCard from '@/components/quizzes/quiz-card' -import { useRouter } from 'next/navigation' - import { Quiz } from '@/types/quiz.type' -import { QuizSet } from '@/types/quiz-set.type' import { getDefaultQuizSet } from '@/services/quiz-set.service' +import { useNav } from '@/hooks/use-nav' const QuizzesListPage = () => { - const router = useRouter() + const nav = useNav() const [search, setSearch] = useState('') const [quizzes, setQuizzes] = useState([]) @@ -86,15 +84,22 @@ const QuizzesListPage = () => {

      - My Quizzes + Quizzes

      - + +
      + + +
      +
      {/* End - Header */} @@ -146,17 +151,20 @@ const QuizzesListPage = () => {

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

      ) : ( -
      - {paginatedData.map((quiz) => ( - - ))} -
      +
      +

      Recent Quizzes

      +
      + {paginatedData.map((quiz) => ( + + ))} +
      +
      )} diff --git a/src/components/nav-sidebar.tsx b/src/components/nav-sidebar.tsx index 8d51d81..130014f 100644 --- a/src/components/nav-sidebar.tsx +++ b/src/components/nav-sidebar.tsx @@ -39,8 +39,8 @@ const navItems = [ icon: Zap }, { - title: 'Quiz', - href: '/quiz', + title: 'Quizzes', + href: '/quizzes', icon: GraduationCap }, { diff --git a/src/components/quizzes/attempt-card.tsx b/src/components/quizzes/attempt-card.tsx index 9f5d0da..3c03e2e 100644 --- a/src/components/quizzes/attempt-card.tsx +++ b/src/components/quizzes/attempt-card.tsx @@ -7,15 +7,17 @@ import TimeAgo from '@/components/time-ago' import { MouseEvent, useEffect, useRef, useState } from 'react' import Link from 'next/link' import { AttemptDetail } from '@/types/quiz-attempt' +import { ROUTES } from '@/hooks/use-nav' // import { useRouter } from 'next/navigation' interface AttemptCardProps { + quizId: number id: number score: number totalQuestions: number attemptAt: Date } -const AttemptCard = ({ id, score, totalQuestions, attemptAt }: AttemptCardProps) => { +const AttemptCard = ({ quizId, id, score, totalQuestions, attemptAt }: AttemptCardProps) => { const [actionsOpen, setActionsOpen] = useState(false) const menuRef = useRef(null) const cancelButtonRef = useRef(null) @@ -82,14 +84,12 @@ const AttemptCard = ({ id, score, totalQuestions, attemptAt }: AttemptCardProps) > - +

      ID: {id}

      - -

      Score: {score} / {totalQuestions}

      -

      Score: {score} / {totalQuestions}

      +

      Score: {score | 0} / {totalQuestions}

      @@ -98,48 +98,36 @@ const AttemptCard = ({ id, score, totalQuestions, attemptAt }: AttemptCardProps) {/* Actions Menu */} {actionsOpen && ( <> - {/* Desktop: Dropdown menu */} - {/**/} - {/* */} - {/* */} - {/* Edit*/} - {/* */} - {/* */} - {/* */} - {/* Delete*/} - {/* */} - {/*
      */} - {/*/!* Mobile: Bottom sheet *!/*/} - {/*
      */} - {/* */} - {/* */} - {/* Delete*/} - {/* */} - {/* */} - {/* Cancel*/} - {/* */} - {/*
      */} +
      + +
      + {/* Mobile: Bottom sheet */} +
      + + +
      )} {/* End - Actions Menu */} diff --git a/src/components/quizzes/quiz-card.tsx b/src/components/quizzes/quiz-card.tsx index e555788..880aa93 100644 --- a/src/components/quizzes/quiz-card.tsx +++ b/src/components/quizzes/quiz-card.tsx @@ -6,6 +6,7 @@ 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 { ROUTES } from '@/hooks/use-nav' // import { useRouter } from 'next/navigation' interface QuizCardProps { id: number @@ -81,7 +82,7 @@ const QuizCard = ({ id, title, totalQuestions, createdAt }: QuizCardProps) => { > - +

      {title}

      diff --git a/src/hooks/use-nav.tsx b/src/hooks/use-nav.tsx new file mode 100644 index 0000000..48323e3 --- /dev/null +++ b/src/hooks/use-nav.tsx @@ -0,0 +1,30 @@ +'use client' + +import { useRouter } from 'next/navigation' + +export const ROUTES = { + HOME: '/', + QUIZ: { + LIST: '/quizzes', + COLLECTION: '/quizzes/collections', + COLLECTION_CREATION: '/quizzes/collections/new', + GENERATION: '/quizzes/generation', + DETAIL: (id: number | undefined) => `/quizzes/${id}`, + ATTEMPT_DETAIL: (qid: number | undefined, aid: number | undefined) => `/quizzes/${qid}/attempts/${aid}`, + ATTEMPT_RESULT: (qid: number, aid: number) => `/quizzes/${qid}/attempts/${aid}/result`, + } +} + +export const useNav = () => { + const router = useRouter() + + return { + toHome: () => router.push(ROUTES.HOME), + toQuizList: () => router.push(ROUTES.QUIZ.LIST), + toQuizCollection: () => router.push(ROUTES.QUIZ.COLLECTION), + toQuizCollectionCreation: () => router.push(ROUTES.QUIZ.COLLECTION_CREATION), + toQuizGeneration: () => router.push(ROUTES.QUIZ.GENERATION), + 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)) + } +} diff --git a/src/services/quiz.service.ts b/src/services/quiz.service.ts index 51b595c..c6a9f5a 100644 --- a/src/services/quiz.service.ts +++ b/src/services/quiz.service.ts @@ -61,6 +61,16 @@ export const getQuizAttempt = async (quizId : number, attemptId : number): Promi return apiRes.data } +export const getQuizAttemptAnswer = async (quizId : number, attemptId : number): Promise => { + const response = await apiClient.get(`${QUIZ_BASE_API}/${quizId}/attempts/${attemptId}/answer`) + 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 updateAttemptProgress = async (quizId : number, attemptId : number, request : { id : number , userAnswer: string }): Promise => { const response = await apiClient.patch(`${QUIZ_BASE_API}/${quizId}/attempts/${attemptId}`, request) const apiRes: ApiResponse = response.data From f0b0515b16a3aee684102f24a98eaa09597aef66 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Thu, 15 Jan 2026 10:46:07 +1100 Subject: [PATCH 07/10] feat(ai-quiz): allow add a quiz to collection/quiz set --- .../attempts/[attemptId]/result/page.tsx | 4 +- src/app/(pages)/(protected)/quizzes/page.tsx | 15 ++- .../common/collection-selection.tsx | 123 ++++++++++++++++++ src/components/common/confirmation-modal.tsx | 73 +++++++++++ src/components/quizzes/quiz-card.tsx | 111 ++++++++++++++-- src/services/quiz.service.ts | 34 ++++- 6 files changed, 342 insertions(+), 18 deletions(-) create mode 100644 src/components/common/collection-selection.tsx create mode 100644 src/components/common/confirmation-modal.tsx 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 8779d60..af4f05f 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 @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button' import { CheckCircle, CircleChevronLeft, XCircle } from 'lucide-react' import { useParams } from 'next/navigation' import { useEffect, useState } from 'react' -import { getQuizAttemptAnswer } from '@/services/quiz.service' +import { getAttemptAnswer } from '@/services/quiz.service' import { useNav } from '@/hooks/use-nav' interface Result { @@ -38,7 +38,7 @@ const QuizResultPage = () => { setError('') try { - const data = await getQuizAttemptAnswer(qid, aid) + const data = await getAttemptAnswer(qid, aid) if (!data.attemptDetails) { throw new Error("This attempt has no information, please try again") } diff --git a/src/app/(pages)/(protected)/quizzes/page.tsx b/src/app/(pages)/(protected)/quizzes/page.tsx index d41068e..430be83 100644 --- a/src/app/(pages)/(protected)/quizzes/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/page.tsx @@ -1,6 +1,6 @@ 'use client' import { useEffect, useState } from 'react' -import { Plus, Search, Filter, Notebook, ChevronRight } from 'lucide-react' +import { Plus, Search, Filter, Notebook, ChevronRight, Folder } from 'lucide-react' import { Button } from '@/components/ui/button' import AnimatedSection from '@/components/landing/animated-section' import Pagination from '@/components/pagination' @@ -11,8 +11,10 @@ import QuizCard from '@/components/quizzes/quiz-card' import { Quiz } from '@/types/quiz.type' import { getDefaultQuizSet } from '@/services/quiz-set.service' import { useNav } from '@/hooks/use-nav' +import { useRouter } from 'next/navigation' const QuizzesListPage = () => { + const router = useRouter() const nav = useNav() const [search, setSearch] = useState('') @@ -72,9 +74,8 @@ const QuizzesListPage = () => { } const handleDeleted = (deletedId: number) => { - setQuizzes((prevQuizzes) => - prevQuizzes.filter((quizSet) => quizSet.id !== deletedId) - ) + setQuizzes((prevQuizzes) => prevQuizzes.filter((quizSet) => quizSet.id !== deletedId)) + router.refresh() } return ( @@ -89,6 +90,7 @@ const QuizzesListPage = () => {
      ) : (
      -

      Recent Quizzes

      {paginatedData.map((quiz) => ( { title={quiz.title} totalQuestions={quiz.questions?.length} createdAt={new Date(quiz.createdAt)} + onFinishDelete={() => handleDeleted(quiz.id)} /> ))}
      diff --git a/src/components/common/collection-selection.tsx b/src/components/common/collection-selection.tsx new file mode 100644 index 0000000..8b5da9b --- /dev/null +++ b/src/components/common/collection-selection.tsx @@ -0,0 +1,123 @@ +import { FolderPlus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useState } from 'react' + +interface Collection { + id: number + title: string +} + +interface AddToCollectionModalProps { + objId: number // object's id + objTitle: string // object's title + collections: Collection[] // Available choices + isAdding: boolean + onCancel: () => void + onConfirm: (collectionId: number) => void +} + +const AddToCollectionModal = ({ + objId, + objTitle, + collections, + isAdding, + onCancel, + onConfirm + }: AddToCollectionModalProps) => { + const [selectedCollectionId, setSelectedCollectionId] = useState(null) + + const handleConfirm = () => { + if (selectedCollectionId !== null) { + onConfirm(selectedCollectionId) + } + } + + return ( + <> +
      +
      + {/* Header */} +
      +
      +
      + +
      +
      +

      Add Quiz to Collection

      +

      Select a collection for this quiz

      +
      +
      +
      + + {/* Content */} +
      +

      + Add quiz "{objTitle}" to: +

      + +
      + {collections.length === 0 ? ( +

      No collections available

      + ) : ( + collections.map((collection) => ( + + )) + )} +
      +
      + + {/* Footer */} +
      + + +
      +
      +
      + + ) +} + +export default AddToCollectionModal \ No newline at end of file diff --git a/src/components/common/confirmation-modal.tsx b/src/components/common/confirmation-modal.tsx new file mode 100644 index 0000000..cf064fc --- /dev/null +++ b/src/components/common/confirmation-modal.tsx @@ -0,0 +1,73 @@ +import { Trash } from 'lucide-react' +import { Button } from '@/components/ui/button' + +interface ConfirmationModalProps { + type: string + id: number + title: string + isDeleting: boolean + onCancel: () => void + onConfirm: () => void +} + +const ConfirmationModal = ({ type, id, title, isDeleting, onCancel, onConfirm } : ConfirmationModalProps) => { + return ( + <> +
      +
      + {/* Header */} +
      +
      +
      + +
      +
      +

      Delete {type}: {title}

      +

      This action cannot be undone

      +
      +
      +
      + + {/* Content */} +
      +

      + Are you sure you want to delete this {type} with id {id}? + This will permanently remove {type} from your account. +

      +
      + + {/* Footer */} +
      + + +
      +
      +
      + + ) +} + +export default ConfirmationModal \ No newline at end of file diff --git a/src/components/quizzes/quiz-card.tsx b/src/components/quizzes/quiz-card.tsx index 880aa93..fd27e47 100644 --- a/src/components/quizzes/quiz-card.tsx +++ b/src/components/quizzes/quiz-card.tsx @@ -1,25 +1,44 @@ 'use client' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { Edit, Trash, Tag, MoreVertical } from 'lucide-react' +import { Edit, Trash, Tag, MoreVertical, FolderPlus } 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 { ROUTES } from '@/hooks/use-nav' -// import { useRouter } from 'next/navigation' +import { deleteQuizBy, updateQuiz } from '@/services/quiz.service' +import ConfirmationModal from '@/components/common/confirmation-modal' +import CollectionSelection from '@/components/common/collection-selection' +import { getAllQuizSets } from '@/services/quiz-set.service' +import { QuizSet } from '@/types/quiz-set.type' + interface QuizCardProps { id: number title: string totalQuestions?: number createdAt: Date + onFinishDelete: (id: number) => void // callback to remove deleted note +} + +interface Collection { + id: number + title: string } -const QuizCard = ({ id, title, totalQuestions, createdAt }: QuizCardProps) => { +const QuizCard = ({ id, title, totalQuestions, createdAt, onFinishDelete }: QuizCardProps) => { const [actionsOpen, setActionsOpen] = useState(false) const menuRef = useRef(null) const cancelButtonRef = useRef(null) + // Modals + const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + + const [collectionSelectionOpen, setCollectionSelectionOpen] = useState(false) + const [isAdding, setIsAdding] = useState(false) + const [collection, setCollection] = useState([]) + const MAX_TAGS_DISPLAY = 2 const handleClickOutside = (event: MouseEvent | globalThis.MouseEvent) => { @@ -39,6 +58,21 @@ const QuizCard = ({ id, title, totalQuestions, createdAt }: QuizCardProps) => { setActionsOpen((prev) => !prev) } + const handleAddToCollection = async (event: MouseEvent) => { + event.stopPropagation() + setActionsOpen(false) + + const result = await getAllQuizSets() + const mappedCollection: Collection[] = result.map((quizSet) => ({ + id: quizSet.id, + title: quizSet.title + })) + setCollection(mappedCollection) + + setCollectionSelectionOpen(true) + console.log('Add to collection action triggered for quiz:', id) + } + const handleEdit = (event: MouseEvent) => { event.stopPropagation() setActionsOpen(false) @@ -51,10 +85,43 @@ const QuizCard = ({ id, title, totalQuestions, createdAt }: QuizCardProps) => { event.stopPropagation() setActionsOpen(false) // Logic to handle delete action, e.g., show confirmation dialog - // useRouter().push(`/notes/${id}/delete`) + setDeleteConfirmationOpen(true) console.log('Delete action triggered for quiz:', id) } + const handleConfirmSelection = async (newQuizSetId: number) => { + try { + setIsAdding(true) + await updateQuiz(id, { + quizSetId: newQuizSetId, + topic: title + }) + + } catch (error : any) { + console.log(error.message) + } finally { + setIsAdding(false) + setCollectionSelectionOpen(false) + } + } + + const handleConfirmDelete = async () => { + try { + setIsDeleting(true) + + await deleteQuizBy(id) + + if (onFinishDelete) { + onFinishDelete(id) + } + } catch (error : any) { + console.log(error.message) + } finally { + setIsDeleting(false) + setDeleteConfirmationOpen(false) + } + } + const handleCancel = (event: MouseEvent) => { event.stopPropagation() setActionsOpen(false) @@ -105,10 +172,10 @@ const QuizCard = ({ id, title, totalQuestions, createdAt }: QuizCardProps) => {
      {/* Mobile: Bottom sheet */}
      - +
      +

      + {quizSet?.originType === "DEFAULT" ? "DEFAULT" : (quizSet?.title || "Quiz Collection")} +

      +
      + + +
      +
      + + {/* 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 */} +
      + ) +} + +export default QuizSetPage \ No newline at end of file diff --git a/src/app/(pages)/(protected)/quizzes/collections/page.tsx b/src/app/(pages)/(protected)/quizzes/collections/page.tsx index 89f431d..40a8677 100644 --- a/src/app/(pages)/(protected)/quizzes/collections/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/collections/page.tsx @@ -8,22 +8,30 @@ import useQueryConfig from '@/hooks/use-query-config' import useUpdateQueryParam from '@/hooks/use-update-query-param' import NoteCard from '@/components/notes/note-card' -import { getAllQuizSets } from '@/services/quiz-set.service' +import { createQuizSet, getAllQuizSets, 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 QuizSetCard from '@/components/quizzes/quiz-set-card' +import QuizSetInfoModal from '@/components/quizzes/quiz-set-info-modal' const QuizSetsListPage = () => { const nav = useNav() + const { setId } = useParams() const [search, setSearch] = useState('') const [quizSets, setQuizSets] = useState([]) const [error, setError] = useState('') const [loading, setLoading] = useState(true) + // Create new collection + const [creationModalOpen, setCreationModalOpen] = useState(false) + const [isCreating, setIsCreating] = useState(false) + const allowSearch = false; const allowFilter = false; - const fetchData = async () => { + const fetchData = async (id: number) => { setLoading(true) setError('') @@ -39,7 +47,7 @@ const QuizSetsListPage = () => { } useEffect(() => { - fetchData() + fetchData(Number(setId)) }, []) const filteredData = quizSets.filter( @@ -47,7 +55,7 @@ const QuizSetsListPage = () => { quizSet.title.toLowerCase().includes(search.toLowerCase()) ) - const pageSize = 6 + const pageSize = 12 const queryConfig = useQueryConfig() const setQueryParam = useUpdateQueryParam() const currentPage = Number(queryConfig.page) || 1 @@ -67,12 +75,29 @@ const QuizSetsListPage = () => { setQueryParam('page', String(page)) } + const handleRenamed = () => { + fetchData(Number(setId)) + } + const handleDeleted = (deletedId: number) => { setQuizSets((prevQuizSets) => prevQuizSets.filter((quizSet) => quizSet.id !== deletedId) ) } + const handleCreateQuizSet = async (title: string) => { + setIsCreating(true) + try { + const createdSet = await createQuizSet({ title }) + setCreationModalOpen(false) + await fetchData(Number(setId)) + } catch (error) { + console.error('Failed to create quiz set:', error) + } finally { + setIsCreating(false) + } + } + return (
      {/* Header */} @@ -84,12 +109,12 @@ const QuizSetsListPage = () => { Back to Quizzes
      -

      - Quiz Collection -

      +

      Quiz Collection

-
{/* End - Header */} @@ -143,11 +163,65 @@ const QuizzesListPage = () => { )} + {/* Collection Grid */} + {/* ADD: Collections Section */} + +
+
+

Collections / Quiz Sets

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

Uncategorized Quizzes

+

Recent Quizzes

{loading ? ( -

Loading quizzes...

+

Loading quizzes...

) : !error && filteredData.length === 0 ? (
@@ -161,8 +235,10 @@ const QuizzesListPage = () => { key={quiz.id} id={quiz.id} title={quiz.title} + quizSetId={quiz.quizSetId} totalQuestions={quiz.questions?.length} createdAt={new Date(quiz.createdAt)} + onFinishCollectionChange={() => {}} onFinishDelete={() => handleDeleted(quiz.id)} /> ))} diff --git a/src/components/common/collection-selection.tsx b/src/components/modals/collection-selection.tsx similarity index 95% rename from src/components/common/collection-selection.tsx rename to src/components/modals/collection-selection.tsx index 8b5da9b..644c251 100644 --- a/src/components/common/collection-selection.tsx +++ b/src/components/modals/collection-selection.tsx @@ -10,6 +10,7 @@ interface Collection { interface AddToCollectionModalProps { objId: number // object's id objTitle: string // object's title + orgCollectionId: number // object's origin collection's id collections: Collection[] // Available choices isAdding: boolean onCancel: () => void @@ -19,12 +20,13 @@ interface AddToCollectionModalProps { const AddToCollectionModal = ({ objId, objTitle, + orgCollectionId, collections, isAdding, onCancel, onConfirm }: AddToCollectionModalProps) => { - const [selectedCollectionId, setSelectedCollectionId] = useState(null) + const [selectedCollectionId, setSelectedCollectionId] = useState(orgCollectionId) const handleConfirm = () => { if (selectedCollectionId !== null) { @@ -99,7 +101,7 @@ const AddToCollectionModal = ({ + +
handleCollectionClick(id)} + className={` + relative flex-shrink-0 min-w-[160px] p-4 rounded-lg cursor-pointer + transition-all duration-200 group + bg-white hover:border-gray-300 hover:shadow-md + `} + > +
+
+
+ +

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

+
+
+
+ +
+
+ + + {/* Actions Menu */} + {actionsOpen && ( + <> + {/* Desktop: Dropdown menu */} +
+ + +
+ {/* Mobile: Bottom sheet */} +
+ + + +
+ + )} + {/* End - Actions Menu */} + + {/* Update Quiz Set Modal */} + {renameModalOpen && ( + setRenameModalOpen(false)} + onConfirm={handleConfirmRename} + /> + )} + {/* End - Create Quiz Set Modal */} + + {/* Confirmation Modal */} + {deleteConfirmationOpen && + setDeleteConfirmationOpen(false)} + onConfirm={handleConfirmDelete} + > + } + {/* End - Confirmation Modal */} + + + ) +} + +export default QuizSetCard \ No newline at end of file diff --git a/src/components/quizzes/quiz-set-info-modal.tsx b/src/components/quizzes/quiz-set-info-modal.tsx new file mode 100644 index 0000000..750edc7 --- /dev/null +++ b/src/components/quizzes/quiz-set-info-modal.tsx @@ -0,0 +1,124 @@ +import { FolderPlus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useState } from 'react' + +interface QuizSetModalProps { + previousTitle?: string + isProcessing: boolean + onCancel: () => void + onConfirm: (title: string) => void +} + +const QuizSetInfoModal = ({ previousTitle, isProcessing, onCancel, onConfirm }: QuizSetModalProps) => { + const actionType = previousTitle ? "Update" : "Create" + const [title, setTitle] = useState(previousTitle || '') + const [error, setError] = useState('') + + const handleSubmit = () => { + // Validation + if (!title.trim()) { + setError('Title is required') + return + } + if (title.trim().length < 3) { + setError('Title must be at least 3 characters') + return + } + + // Clear error and submit + setError('') + onConfirm(title.trim()) + } + + const handleCancel = () => { + setTitle('') + setError('') + onCancel() + } + + return ( + <> +
+
+ {/* Header */} +
+
+
+ +
+
+

{actionType} collection

+

{actionType} collection for your quizzes

+
+
+
+ + {/* Content */} +
+
+ + { + setTitle(e.target.value) + setError('') // Clear error on input + }} + placeholder="Enter quiz set title..." + className={`w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 + ${error + ? 'border-red-300 focus:ring-red-400' + : 'border-gray-300 focus:ring-blue-400' + }`} + disabled={isProcessing} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter' && !isProcessing) { + handleSubmit() + } + }} + /> + {error && ( +

{error}

+ )} +
+
+ + {/* Footer */} +
+ + +
+
+
+ + ) +} + +export default QuizSetInfoModal \ No newline at end of file diff --git a/src/hooks/use-nav.tsx b/src/hooks/use-nav.tsx index 48323e3..3deb60b 100644 --- a/src/hooks/use-nav.tsx +++ b/src/hooks/use-nav.tsx @@ -6,7 +6,8 @@ export const ROUTES = { HOME: '/', QUIZ: { LIST: '/quizzes', - COLLECTION: '/quizzes/collections', + COLLECTION: (id: number | undefined) => `/quizzes/collections/${id}`, + COLLECTION_LIST: '/quizzes/collections', COLLECTION_CREATION: '/quizzes/collections/new', GENERATION: '/quizzes/generation', DETAIL: (id: number | undefined) => `/quizzes/${id}`, @@ -21,10 +22,12 @@ export const useNav = () => { return { toHome: () => router.push(ROUTES.HOME), toQuizList: () => router.push(ROUTES.QUIZ.LIST), - toQuizCollection: () => router.push(ROUTES.QUIZ.COLLECTION), + toQuizCollection: (id: number) => router.push(ROUTES.QUIZ.COLLECTION(id)), + toQuizCollectionList: () => router.push(ROUTES.QUIZ.COLLECTION_LIST), toQuizCollectionCreation: () => router.push(ROUTES.QUIZ.COLLECTION_CREATION), toQuizGeneration: () => router.push(ROUTES.QUIZ.GENERATION), 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)) + toNewQuizAttempt: (qid: number | undefined, aid: number | undefined) => router.push(ROUTES.QUIZ.ATTEMPT_DETAIL(qid, aid)), + refresh: () => router.refresh() } } diff --git a/src/services/quiz-set.service.ts b/src/services/quiz-set.service.ts index 5b15ba2..404c40d 100644 --- a/src/services/quiz-set.service.ts +++ b/src/services/quiz-set.service.ts @@ -1,15 +1,56 @@ import apiClient from '@/apis/api-client' import { ApiResponse } from '@/types/auth.type' import { QuizSet } from '@/types/quiz-set.type' +import { QuizAttempt } from '@/types/quiz-attempt' +import { Quiz } from '@/types/quiz.type' const QUIZ_SET_BASE_API = '/quiz-sets' +export const createQuizSet = async (request: { title : string }): Promise => { + const response = await apiClient.post(`${QUIZ_SET_BASE_API}`, request) + const apiRes: ApiResponse = response.data + + if (!apiRes.data && apiRes.code != 1000) { + throw new Error(`Failed to create data: ${apiRes.message}`) + } + return apiRes.data +} + +export const updateQuizSet = async (id: number, request: { title : string }): Promise => { + const response = await apiClient.patch(`${QUIZ_SET_BASE_API}/${id}`, request) + const apiRes: ApiResponse = response.data + + if (!apiRes.data && apiRes.code != 1000) { + throw new Error(`Failed to update data: ${apiRes.message}`) + } + return apiRes.data +} + +export const deleteQuizSet = async (id : number) => { + const response = await apiClient.delete(`${QUIZ_SET_BASE_API}/${id}`) + const apiRes: ApiResponse = response.data + + if (!apiRes.data && apiRes.code != 1000) { + throw new Error(`Failed to delete data: ${apiRes.message}`) + } +} + export const getDefaultQuizSet = async (): Promise => { const response = await apiClient.get(`${QUIZ_SET_BASE_API}/default`) const apiRes: ApiResponse = response.data if (!apiRes.data && apiRes.code != 1000) { - throw new Error(`Failed to update data: ${apiRes.message}`) + throw new Error(`Failed to get data: ${apiRes.message}`) + } + return apiRes.data +} + +export const getQuizSet = async (id: number): Promise => { + const response = await apiClient.get(`${QUIZ_SET_BASE_API}/${id}`) + const apiRes: ApiResponse = response.data + + if (!apiRes.data && apiRes.code != 1000) { + throw new Error(`Failed to get data: ${apiRes.message}`) } return apiRes.data } @@ -19,7 +60,7 @@ export const getAllQuizSets = async (): Promise => { const apiRes: ApiResponse = response.data if (!apiRes.data && apiRes.code != 1000) { - throw new Error(`Failed to update data: ${apiRes.message}`) + throw new Error(`Failed to get data: ${apiRes.message}`) } return apiRes.data } \ No newline at end of file diff --git a/src/services/quiz.service.ts b/src/services/quiz.service.ts index 698cbb7..edced48 100644 --- a/src/services/quiz.service.ts +++ b/src/services/quiz.service.ts @@ -22,6 +22,16 @@ 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 + + if (!apiRes.data && apiRes.code != 1000) { + throw new Error(`Failed to get data: ${apiRes.message}`) + } + return apiRes.data +} + export const getQuiz = async (quizId : number): Promise => { const response = await apiClient.get(`${QUIZ_BASE_API}/${quizId}`) const apiRes: ApiResponse = response.data @@ -44,7 +54,7 @@ export const updateQuiz = async (quizId : number, request : { quizSetId : number return apiRes.data } -export const deleteQuizBy = async (quizId : number) => { +export const deleteQuiz = async (quizId : number) => { const response = await apiClient.delete(`${QUIZ_BASE_API}/${quizId}`) const apiRes: ApiResponse = response.data diff --git a/src/types/quiz-set.type.ts b/src/types/quiz-set.type.ts index 013171e..5cacdcf 100644 --- a/src/types/quiz-set.type.ts +++ b/src/types/quiz-set.type.ts @@ -3,7 +3,7 @@ import { Quiz } from '@/types/quiz.type' export interface QuizSet { id: number title: string - originType: number + originType: string quizzes?: Quiz[] createdAt: string updatedAt?: string From b4374b06f537279a2ee23f139feb7411b2564321 Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Thu, 15 Jan 2026 17:49:29 +1100 Subject: [PATCH 09/10] refactor(ai-quiz): remove unused imports and add reusable mapper for quiz attempt --- .../[quizId]/attempts/[attemptId]/page.tsx | 47 +++---------- .../attempts/[attemptId]/result/page.tsx | 38 ++-------- .../(protected)/quizzes/[quizId]/page.tsx | 10 ++- .../modals/collection-selection.tsx | 6 +- src/components/modals/delete-confirmation.tsx | 4 +- src/components/quizzes/attempt-card.tsx | 70 +++++++++++++------ src/mapper/attempt-mapper.ts | 17 +++++ src/services/quiz-set.service.ts | 2 - src/services/quiz.service.ts | 2 +- src/types/quesiton.type.ts | 17 +++++ 10 files changed, 111 insertions(+), 102 deletions(-) create mode 100644 src/mapper/attempt-mapper.ts 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 9cb022c..106379d 100644 --- a/src/app/(pages)/(protected)/quizzes/[quizId]/attempts/[attemptId]/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/[quizId]/attempts/[attemptId]/page.tsx @@ -7,22 +7,15 @@ import { QuizAttempt } from '@/types/quiz-attempt' import { finishAttempt, getQuizAttempt, startQuizAttempt, updateAttemptProgress } from '@/services/quiz.service' import { useParams } from 'next/navigation' import { useNav } from '@/hooks/use-nav' - -interface Question { - id: number - text: string - options: { key: string; text: string }[] - correctOption?: string - userAnswer?: string - isCorrect?: boolean -} +import { AttemptQuestion } from '@/types/quesiton.type' +import { toAttemptQuestion } from '@/mapper/attempt-mapper' const QuizQuestionPage = () => { const nav = useNav() const { quizId, attemptId } = useParams() - const [attempt, setAttempt] = useState(); - const [questions, setQuestions] = useState([]); + const [attempt, setAttempt] = useState(); // Original attempts returned from backend + const [questions, setQuestions] = useState([]); // Map to lists const [current, setCurrent] = useState(0) const [answers, setAnswers] = useState<(string | null)[]>([]) @@ -41,19 +34,7 @@ const QuizQuestionPage = () => { throw new Error("This attempt has no information, please try again") } - const mappedQuestions : Question[] = data.attemptDetails.map((detail) => ({ - id: detail.id, - text: detail.questionText, - options: [ - { key: 'A', text: detail.optionA }, - { key: 'B', text: detail.optionB }, - { key: 'C', text: detail.optionC }, - { key: 'D', text: detail.optionD } - ], - correctOption: detail.correctAnswer, - userAnswer: detail.userAnswer, - isCorrect: detail.isCorrect - })) + const mappedQuestions : AttemptQuestion[] = toAttemptQuestion(data.attemptDetails) setAttempt(data) setQuestions(mappedQuestions) setAnswers(Array(mappedQuestions.length).fill(null)) @@ -79,7 +60,7 @@ const QuizQuestionPage = () => { } const handlePrev = () => setCurrent((prev) => Math.max(0, prev - 1)) - // const handleNext = () => setCurrent((prev) => Math.min(questions.length - 1, prev + 1)) + const handleNext = async () => { const currentAnswer = answers[current] const currentQuestion = questions[current] @@ -113,19 +94,7 @@ const QuizQuestionPage = () => { throw new Error("This attempt has no information, please try again") } - const mappedQuestions : Question[] = result.attemptDetails.map((detail) => ({ - id: detail.id, - text: detail.questionText, - options: [ - { key: 'A', text: detail.optionA }, - { key: 'B', text: detail.optionB }, - { key: 'C', text: detail.optionC }, - { key: 'D', text: detail.optionD } - ], - correctOption: detail.correctAnswer, - userAnswer: detail.userAnswer, - isCorrect: detail.isCorrect - })) + const mappedQuestions : AttemptQuestion[] = toAttemptQuestion(result.attemptDetails) setAttempt(result) setQuestions(mappedQuestions) @@ -210,7 +179,7 @@ const QuizQuestionPage = () => { if (loading) { return ( -

Loading quiz...

+

Loading quizzes...

) } 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 af4f05f..73659e7 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,29 +7,15 @@ import { useParams } from 'next/navigation' import { useEffect, useState } from 'react' import { getAttemptAnswer } from '@/services/quiz.service' import { useNav } from '@/hooks/use-nav' - -interface Result { - score: number - total: number - percent: number - questions: Question[] -} - -interface Question { - id: number - text: string - options: { key: string; text: string }[] - correctAnswer?: string - userAnswer?: string - isCorrect?: boolean -} +import { AttemptQuestion, AttemptResult } from '@/types/quesiton.type' +import { toAttemptQuestion } from '@/mapper/attempt-mapper' const QuizResultPage = () => { const nav = useNav() const { quizId, attemptId } = useParams() - const [result, setResult] = useState({score: 0, total: 0, percent: 0, questions: []}); + const [result, setResult] = useState({score: 0, total: 0, percent: 0, questions: []}); const [error, setError] = useState('') const [loading, setLoading] = useState(true) @@ -42,24 +28,12 @@ const QuizResultPage = () => { if (!data.attemptDetails) { throw new Error("This attempt has no information, please try again") } - const mappedQuestion : Question[] = data.attemptDetails.map((detail) => ({ - id: detail.id, - text: detail.questionText, - options: [ - { key: 'A', text: detail.optionA }, - { key: 'B', text: detail.optionB }, - { key: 'C', text: detail.optionC }, - { key: 'D', text: detail.optionD } - ], - correctAnswer: detail.correctAnswer, - userAnswer: detail.userAnswer, - isCorrect: detail.isCorrect - })) + const mappedQuestion : AttemptQuestion[] = toAttemptQuestion(data.attemptDetails) const score = mappedQuestion.filter(q => q.isCorrect).length const total = mappedQuestion.length const percent = Math.round((score / total) * 100) - const result : Result = { score: score, total: total, percent: percent, questions: mappedQuestion } + const result : AttemptResult = { score: score, total: total, percent: percent, questions: mappedQuestion } setResult(result) } catch (error : any) { setError(error.message) @@ -132,7 +106,7 @@ const QuizResultPage = () => {
Correct answer:{' '} - {q.options.find((o) => o.key === q.correctAnswer)?.text} + {q.options.find((o) => o.key === q.correctOption)?.text}
)} diff --git a/src/app/(pages)/(protected)/quizzes/[quizId]/page.tsx b/src/app/(pages)/(protected)/quizzes/[quizId]/page.tsx index ef9068b..f49ae3a 100644 --- a/src/app/(pages)/(protected)/quizzes/[quizId]/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/[quizId]/page.tsx @@ -2,7 +2,7 @@ import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { Sparkles, FileText, BookOpen, CircleChevronLeft, Notebook } from 'lucide-react' +import { FileText, BookOpen, CircleChevronLeft, Notebook } from 'lucide-react' import { useParams } from 'next/navigation' import { useEffect, useState } from 'react' import { QuizAttempt } from '@/types/quiz-attempt' @@ -73,6 +73,12 @@ const QuizPage = () => { } } + const handleDeleted = (deletedId: number) => { + setAttempts((prevAttempts) => + prevAttempts.filter((attempt) => attempt.id !== deletedId) + ) + } + return ( <> {/* Back to previous */} @@ -119,11 +125,13 @@ const QuizPage = () => { {paginatedAttempts.map((attempt) => ( handleDeleted(attempt.id)} /> ))} diff --git a/src/components/modals/collection-selection.tsx b/src/components/modals/collection-selection.tsx index 644c251..4cfbd1c 100644 --- a/src/components/modals/collection-selection.tsx +++ b/src/components/modals/collection-selection.tsx @@ -11,10 +11,10 @@ interface AddToCollectionModalProps { objId: number // object's id objTitle: string // object's title orgCollectionId: number // object's origin collection's id - collections: Collection[] // Available choices + collections: Collection[] // available choices isAdding: boolean - onCancel: () => void - onConfirm: (collectionId: number) => void + onCancel: () => void // should trigger hiding modal in parent component + onConfirm: (collectionId: number) => void // should trigger next action in parent component } const AddToCollectionModal = ({ diff --git a/src/components/modals/delete-confirmation.tsx b/src/components/modals/delete-confirmation.tsx index cb935a9..3cce62e 100644 --- a/src/components/modals/delete-confirmation.tsx +++ b/src/components/modals/delete-confirmation.tsx @@ -6,8 +6,8 @@ interface ConfirmationModalProps { id: number title: string isDeleting: boolean - onCancel: () => void - onConfirm: () => void + onCancel: () => void // should trigger hiding modal in parent component + onConfirm: () => void // should trigger next action in parent component } const DeleteConfirmationModal = ({ type, id, title, isDeleting, onCancel, onConfirm } : ConfirmationModalProps) => { diff --git a/src/components/quizzes/attempt-card.tsx b/src/components/quizzes/attempt-card.tsx index 3c03e2e..5efb3a0 100644 --- a/src/components/quizzes/attempt-card.tsx +++ b/src/components/quizzes/attempt-card.tsx @@ -1,29 +1,36 @@ 'use client' + import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { Edit, Trash, Tag, MoreVertical } from 'lucide-react' -import { Badge } from '@/components/ui/badge' +import { Trash, MoreVertical } from 'lucide-react' import TimeAgo from '@/components/time-ago' import { MouseEvent, useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { AttemptDetail } from '@/types/quiz-attempt' import { ROUTES } from '@/hooks/use-nav' -// import { useRouter } from 'next/navigation' +import { deleteAttempt } from '@/services/quiz.service' +import DeleteConfirmationModal from '@/components/modals/delete-confirmation' + interface AttemptCardProps { quizId: number + quizTitle: string id: number score: number totalQuestions: number attemptAt: Date + onFinishDelete: () => void // callback to remove deleted note } -const AttemptCard = ({ quizId, id, score, totalQuestions, attemptAt }: AttemptCardProps) => { +const AttemptCard = ({ quizId, quizTitle, id, score, totalQuestions, attemptAt, onFinishDelete }: AttemptCardProps) => { const [actionsOpen, setActionsOpen] = useState(false) const menuRef = useRef(null) const cancelButtonRef = useRef(null) + 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 ( menuRef.current && @@ -36,39 +43,45 @@ const AttemptCard = ({ quizId, id, score, totalQuestions, attemptAt }: AttemptCa } } + // 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 - // useRouter().push(`/notes/${id}/edit`) - console.log('Edit action triggered for quiz:', id) } + // ------ Handle deletion ------ // const handleDelete = (event: MouseEvent) => { event.stopPropagation() setActionsOpen(false) - // Logic to handle delete action, e.g., show confirmation dialog - // useRouter().push(`/notes/${id}/delete`) - console.log('Delete action triggered for quiz:', id) + setDeleteConfirmationOpen(true) } - const handleCancel = (event: MouseEvent) => { - event.stopPropagation() - setActionsOpen(false) - } + const handleConfirmDelete = async () => { + try { + setIsDeleting(true) - // Close actions menu when clicking outside - useEffect(() => { - document.addEventListener('mousedown', handleClickOutside as EventListener) - return () => { - document.removeEventListener('mousedown', handleClickOutside as EventListener) + await deleteAttempt(quizId, id) + + onFinishDelete() + } catch (error : any) { + console.log(error.message) + } finally { + setIsDeleting(false) + setDeleteConfirmationOpen(false) } - }, [actionsOpen]) + } return ( <> @@ -131,6 +144,19 @@ const AttemptCard = ({ quizId, id, score, totalQuestions, attemptAt }: AttemptCa )} {/* End - Actions Menu */} + + {/* Confirmation Modal */} + {deleteConfirmationOpen && + setDeleteConfirmationOpen(false)} + onConfirm={handleConfirmDelete} + > + } + {/* End - Confirmation Modal */} ) diff --git a/src/mapper/attempt-mapper.ts b/src/mapper/attempt-mapper.ts new file mode 100644 index 0000000..ad503e6 --- /dev/null +++ b/src/mapper/attempt-mapper.ts @@ -0,0 +1,17 @@ +import { AttemptDetail } from '@/types/quiz-attempt' + +export const toAttemptQuestion = (attempts: AttemptDetail[]) => { + return attempts.map((detail) => ({ + id: detail.id, + text: detail.questionText, + options: [ + { key: 'A', text: detail.optionA }, + { key: 'B', text: detail.optionB }, + { key: 'C', text: detail.optionC }, + { key: 'D', text: detail.optionD } + ], + correctOption: detail.correctAnswer, + userAnswer: detail.userAnswer, + isCorrect: detail.isCorrect + })) +} \ No newline at end of file diff --git a/src/services/quiz-set.service.ts b/src/services/quiz-set.service.ts index 404c40d..2f4e966 100644 --- a/src/services/quiz-set.service.ts +++ b/src/services/quiz-set.service.ts @@ -1,8 +1,6 @@ import apiClient from '@/apis/api-client' import { ApiResponse } from '@/types/auth.type' import { QuizSet } from '@/types/quiz-set.type' -import { QuizAttempt } from '@/types/quiz-attempt' -import { Quiz } from '@/types/quiz.type' const QUIZ_SET_BASE_API = '/quiz-sets' diff --git a/src/services/quiz.service.ts b/src/services/quiz.service.ts index edced48..f36193e 100644 --- a/src/services/quiz.service.ts +++ b/src/services/quiz.service.ts @@ -94,7 +94,7 @@ export const getQuizAttempt = async (quizId : number, attemptId : number): Promi return apiRes.data } -export const deleteAttemptBy = async (quizId : number, attemptId : number) => { +export const deleteAttempt = async (quizId : number, attemptId : number) => { const response = await apiClient.delete(`${QUIZ_BASE_API}/${quizId}/attempts/${attemptId}`) const apiRes: ApiResponse = response.data diff --git a/src/types/quesiton.type.ts b/src/types/quesiton.type.ts index f05dd01..6776ef1 100644 --- a/src/types/quesiton.type.ts +++ b/src/types/quesiton.type.ts @@ -6,4 +6,21 @@ export interface Question { 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 From 5d9172c9a81514d8281e03ed680536670d9d284c Mon Sep 17 00:00:00 2001 From: Tat Uyen Tam Date: Thu, 15 Jan 2026 18:21:07 +1100 Subject: [PATCH 10/10] chore(ai-quiz): clean up import and reorganise code --- .../[quizId]/attempts/[attemptId]/page.tsx | 29 ++++---- .../attempts/[attemptId]/result/page.tsx | 5 +- .../(protected)/quizzes/[quizId]/page.tsx | 34 +++++---- .../(protected)/quizzes/collections/page.tsx | 67 +++++++++-------- .../(protected)/quizzes/generation/page.tsx | 8 +- src/app/(pages)/(protected)/quizzes/page.tsx | 45 ++++++----- src/components/quizzes/quiz-card.tsx | 74 +++++++++---------- src/components/quizzes/quiz-set-card.tsx | 52 +++++++++---- src/hooks/use-nav.tsx | 4 +- src/types/quiz-set.type.ts | 6 ++ 10 files changed, 183 insertions(+), 141 deletions(-) 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 106379d..a3a8048 100644 --- a/src/app/(pages)/(protected)/quizzes/[quizId]/attempts/[attemptId]/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/[quizId]/attempts/[attemptId]/page.tsx @@ -12,18 +12,19 @@ import { toAttemptQuestion } from '@/mapper/attempt-mapper' const QuizQuestionPage = () => { const nav = useNav() - const { quizId, attemptId } = useParams() + const [attempt, setAttempt] = useState(); // Original attempts returned from backend const [questions, setQuestions] = useState([]); // Map to lists + const [loading, setLoading] = useState(true) const [current, setCurrent] = useState(0) const [answers, setAnswers] = useState<(string | null)[]>([]) const [submitted, setSubmitted] = useState(false) const [error, setError] = useState('') - const [loading, setLoading] = useState(true) + // ------ Fetching data ------ // const fetchData = async (qid: number, aid: number) => { setLoading(true) setError('') @@ -51,6 +52,19 @@ const QuizQuestionPage = () => { fetchData(Number(quizId), Number(attemptId)); }, [quizId]) + // ------ Handle start new attempt (after completion) ------ // + const handleNewQuizAttempt = async () => { + setError('') + + try { + const newAttempt = await startQuizAttempt(Number(quizId)) + nav.toNewQuizAttempt(Number(quizId), newAttempt.id) + } catch (error : any) { + setError(error.message) + } + } + + // ------ Handle attempt's progress ------ // const handleSelect = (optionKey: string) => { setAnswers((prev) => { const updated = [...prev] @@ -104,17 +118,6 @@ const QuizQuestionPage = () => { } } - const handleNewQuizAttempt = async () => { - setError('') - - try { - const newAttempt = await startQuizAttempt(Number(quizId)) - nav.toNewQuizAttempt(Number(quizId), newAttempt.id) - } catch (error : any) { - setError(error.message) - } - } - // Calculate results const results = submitted ? questions.map((q, idx) => ({ 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 73659e7..80c473e 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 @@ -12,13 +12,14 @@ import { toAttemptQuestion } from '@/mapper/attempt-mapper' const QuizResultPage = () => { const nav = useNav() - const { quizId, attemptId } = useParams() const [result, setResult] = useState({score: 0, total: 0, percent: 0, questions: []}); - const [error, setError] = useState('') const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + // ------ Fetching data ------ // const fetchData = async (qid: number, aid: number) => { setLoading(true) setError('') diff --git a/src/app/(pages)/(protected)/quizzes/[quizId]/page.tsx b/src/app/(pages)/(protected)/quizzes/[quizId]/page.tsx index f49ae3a..3543f1b 100644 --- a/src/app/(pages)/(protected)/quizzes/[quizId]/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/[quizId]/page.tsx @@ -17,14 +17,15 @@ import { useNav } from '@/hooks/use-nav' const QuizPage = () => { const nav = useNav() - const { quizId } = useParams() + const [quiz, setQuiz] = useState(null) const [attempts, setAttempts] = useState([]) + const [loading, setLoading] = useState(true) const [error, setError] = useState('') - const [loading, setLoading] = useState(true) + // ------ Fetching data ------ // const fetchData = async (id: number) => { setLoading(true) setError('') @@ -50,17 +51,14 @@ const QuizPage = () => { fetchData(Number(quizId)); }, [quizId]) - 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)) + // ------ Handle AFTER deletion (update list) ------ // + const handleDeleted = (deletedId: number) => { + setAttempts((prevAttempts) => + prevAttempts.filter((attempt) => attempt.id !== deletedId) + ) } + // ------ Handle start new attempt ------ // const handleStartNewAttempt = async () => { setError('') @@ -73,10 +71,16 @@ const QuizPage = () => { } } - const handleDeleted = (deletedId: number) => { - setAttempts((prevAttempts) => - prevAttempts.filter((attempt) => attempt.id !== deletedId) - ) + // ------ 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)) } return ( diff --git a/src/app/(pages)/(protected)/quizzes/collections/page.tsx b/src/app/(pages)/(protected)/quizzes/collections/page.tsx index 40a8677..2b6f768 100644 --- a/src/app/(pages)/(protected)/quizzes/collections/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/collections/page.tsx @@ -6,32 +6,31 @@ 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 { createQuizSet, getAllQuizSets, getQuizSet } from '@/services/quiz-set.service' +import { createQuizSet, getAllQuizSets } from '@/services/quiz-set.service' import { QuizSet } from '@/types/quiz-set.type' import { useNav } from '@/hooks/use-nav' -import { useParams } from 'next/navigation' import QuizSetCard from '@/components/quizzes/quiz-set-card' import QuizSetInfoModal from '@/components/quizzes/quiz-set-info-modal' const QuizSetsListPage = () => { const nav = useNav() - const { setId } = useParams() - const [search, setSearch] = useState('') const [quizSets, setQuizSets] = useState([]) - const [error, setError] = useState('') const [loading, setLoading] = useState(true) - // Create new collection 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; - const fetchData = async (id: number) => { + // ------ Fetching data ------ // + const fetchData = async () => { setLoading(true) setError('') @@ -47,9 +46,36 @@ const QuizSetsListPage = () => { } useEffect(() => { - fetchData(Number(setId)) + 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() + } catch (error) { + console.error('Failed to create quiz set:', error) + } finally { + setIsCreating(false) + } + } + + // ------ Filter, search and pagination ------ // const filteredData = quizSets.filter( (quizSet) => quizSet.title.toLowerCase().includes(search.toLowerCase()) @@ -75,29 +101,6 @@ const QuizSetsListPage = () => { setQueryParam('page', String(page)) } - const handleRenamed = () => { - fetchData(Number(setId)) - } - - const handleDeleted = (deletedId: number) => { - setQuizSets((prevQuizSets) => - prevQuizSets.filter((quizSet) => quizSet.id !== deletedId) - ) - } - - const handleCreateQuizSet = async (title: string) => { - setIsCreating(true) - try { - const createdSet = await createQuizSet({ title }) - setCreationModalOpen(false) - await fetchData(Number(setId)) - } catch (error) { - console.error('Failed to create quiz set:', error) - } finally { - setIsCreating(false) - } - } - return (
{/* Header */} diff --git a/src/app/(pages)/(protected)/quizzes/generation/page.tsx b/src/app/(pages)/(protected)/quizzes/generation/page.tsx index 00915f7..8ef9550 100644 --- a/src/app/(pages)/(protected)/quizzes/generation/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/generation/page.tsx @@ -3,19 +3,19 @@ import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Sparkles, FileText, BookOpen, CircleChevronLeft } from 'lucide-react' -import { useRouter } from 'next/navigation' +import { useNav } from '@/hooks/use-nav' const QuizGenerationPage = () => { - const router = useRouter() + const nav = useNav() const allowLearnMore = false; const handleBackToQuizzes = () => { - router.push('/quizzes') + nav.toQuizList() } const handleRedirectToCreateQuiz = () => { - router.push('/quizzes/generation/note') + nav.toQuizGenerationFromNote() } return ( diff --git a/src/app/(pages)/(protected)/quizzes/page.tsx b/src/app/(pages)/(protected)/quizzes/page.tsx index 7724157..7141e3a 100644 --- a/src/app/(pages)/(protected)/quizzes/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/page.tsx @@ -1,6 +1,6 @@ 'use client' import { useEffect, useState } from 'react' -import { Plus, Search, Filter, Notebook, ChevronRight, Folder, MoreVertical } from 'lucide-react' +import { Plus, Search, Filter, Notebook } from 'lucide-react' import { Button } from '@/components/ui/button' import AnimatedSection from '@/components/landing/animated-section' import Pagination from '@/components/pagination' @@ -11,26 +11,28 @@ import QuizCard from '@/components/quizzes/quiz-card' import { Quiz } from '@/types/quiz.type' import { getAllQuizSets } from '@/services/quiz-set.service' import { useNav } from '@/hooks/use-nav' -import { useRouter } from 'next/navigation' import { getAllQuizzes } from '@/services/quiz.service' import { QuizSet } from '@/types/quiz-set.type' import QuizSetCard from '@/components/quizzes/quiz-set-card' const QuizzesListPage = () => { - const router = useRouter() const nav = useNav() - const [search, setSearch] = useState('') const [quizzes, setQuizzes] = useState([]) - const [error, setError] = useState('') const [loading, setLoading] = useState(true) const [quizSets, setQuizSets] = useState([]) const [quizSetLoading, setQuizSetLoading] = useState(true) + // 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('') @@ -64,6 +66,25 @@ const QuizzesListPage = () => { 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() + } + + // ------ 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()) @@ -89,20 +110,6 @@ const QuizzesListPage = () => { setQueryParam('page', String(page)) } - const handleDeleted = (deletedId: number) => { - setQuizzes((prevQuizzes) => prevQuizzes.filter((quiz) => quiz.id !== deletedId)) - } - - const handleQuizSetRenamed = () => { - fetchCollections() - } - - const handleQuizSetDeleted = (deletedId: number) => { - // Delete a quiz set may result in deleting all quizzes in it - fetchData() - fetchCollections() - } - return (
{/* Header */} diff --git a/src/components/quizzes/quiz-card.tsx b/src/components/quizzes/quiz-card.tsx index 540028a..107ae27 100644 --- a/src/components/quizzes/quiz-card.tsx +++ b/src/components/quizzes/quiz-card.tsx @@ -1,8 +1,7 @@ 'use client' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { Edit, Trash, Tag, MoreVertical, FolderPlus } from 'lucide-react' -import { Badge } from '@/components/ui/badge' +import { Trash, MoreVertical, FolderPlus } from 'lucide-react' import TimeAgo from '@/components/time-ago' import { MouseEvent, useEffect, useRef, useState } from 'react' import Link from 'next/link' @@ -11,7 +10,7 @@ import { deleteQuiz, updateQuiz } from '@/services/quiz.service' import DeleteConfirmationModal from '@/components/modals/delete-confirmation' import CollectionSelection from '@/components/modals/collection-selection' import { getAllQuizSets } from '@/services/quiz-set.service' -import { QuizSet } from '@/types/quiz-set.type' +import { QuizCollection } from '@/types/quiz-set.type' interface QuizCardProps { id: number @@ -23,11 +22,6 @@ interface QuizCardProps { onFinishDelete: () => void // callback to remove deleted note } -interface Collection { - id: number - title: string -} - const QuizCard = ({ id, title, quizSetId, totalQuestions, createdAt, onFinishCollectionChange, onFinishDelete }: QuizCardProps) => { const [actionsOpen, setActionsOpen] = useState(false) const menuRef = useRef(null) @@ -39,10 +33,11 @@ const QuizCard = ({ id, title, quizSetId, totalQuestions, createdAt, onFinishCol const [collectionSelectionOpen, setCollectionSelectionOpen] = useState(false) const [isAdding, setIsAdding] = useState(false) - const [collection, setCollection] = useState([]) + const [collection, setCollection] = useState([]) const MAX_TAGS_DISPLAY = 2 + // ------ Handle action bar on each card ------ // const handleClickOutside = (event: MouseEvent | globalThis.MouseEvent) => { if ( menuRef.current && @@ -55,17 +50,31 @@ const QuizCard = ({ id, title, quizSetId, totalQuestions, createdAt, onFinishCol } } + // 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 handleCancel = (event: MouseEvent) => { + event.stopPropagation() + setActionsOpen(false) + } + + // ------ Handle add quiz to collection ------ // const handleAddToCollection = async (event: MouseEvent) => { event.stopPropagation() setActionsOpen(false) const result = await getAllQuizSets() - const mappedCollection: Collection[] = result.map((quizSet) => ({ + const mappedCollection: QuizCollection[] = result.map((quizSet) => ({ id: quizSet.id, title: quizSet.originType === "DEFAULT" ? "DEFAULT" : quizSet.title })) @@ -75,22 +84,6 @@ const QuizCard = ({ id, title, quizSetId, totalQuestions, createdAt, onFinishCol console.log('Add to collection action triggered for quiz:', id) } - const handleEdit = (event: MouseEvent) => { - event.stopPropagation() - setActionsOpen(false) - // Logic to handle edit action, e.g., redirect to edit page - // useRouter().push(`/notes/${id}/edit`) - console.log('Edit action triggered for quiz:', id) - } - - const handleDelete = (event: MouseEvent) => { - event.stopPropagation() - setActionsOpen(false) - // Logic to handle delete action, e.g., show confirmation dialog - setDeleteConfirmationOpen(true) - console.log('Delete action triggered for quiz:', id) - } - const handleConfirmSelection = async (newQuizSetId: number) => { try { setIsAdding(true) @@ -110,6 +103,15 @@ const QuizCard = ({ id, title, quizSetId, totalQuestions, createdAt, onFinishCol } } + // ------ Handle delete ------ // + const handleDelete = (event: MouseEvent) => { + event.stopPropagation() + setActionsOpen(false) + // Logic to handle delete action, e.g., show confirmation dialog + setDeleteConfirmationOpen(true) + console.log('Delete action triggered for quiz:', id) + } + const handleConfirmDelete = async () => { try { setIsDeleting(true) @@ -125,18 +127,14 @@ const QuizCard = ({ id, title, quizSetId, totalQuestions, createdAt, onFinishCol } } - 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]) + // ------ Handle quiz edit (to be implemented) ------ // + // const handleEdit = (event: MouseEvent) => { + // event.stopPropagation() + // setActionsOpen(false) + // // Logic to handle edit action, e.g., redirect to edit page + // // useRouter().push(`/notes/${id}/edit`) + // console.log('Edit action triggered for quiz:', id) + // } return ( <> diff --git a/src/components/quizzes/quiz-set-card.tsx b/src/components/quizzes/quiz-set-card.tsx index d9f47b3..afef765 100644 --- a/src/components/quizzes/quiz-set-card.tsx +++ b/src/components/quizzes/quiz-set-card.tsx @@ -4,8 +4,7 @@ import { Folder, FolderPlus, MoreVertical, Pencil, Trash } from 'lucide-react' import { useNav } from '@/hooks/use-nav' import { Button } from '@/components/ui/button' import DeleteConfirmationModal from '@/components/modals/delete-confirmation' -import { MouseEvent, useRef, useState } from 'react' -import { deleteQuiz } from '@/services/quiz.service' +import { MouseEvent, useEffect, useRef, useState } from 'react' import { deleteQuizSet, updateQuizSet } from '@/services/quiz-set.service' import { Card } from '@/components/ui/card' import QuizSetInfoModal from '@/components/quizzes/quiz-set-info-modal' @@ -32,29 +31,48 @@ const QuizSetCard = ({ id, originType, title, onFinishRename, onFinishDelete }: const [renameModalOpen, setRenameModalOpen] = useState(false) const [isRenaming, setIsRenaming] = useState(false) - const handleCollectionClick = (collectionId: number) => { - nav.toQuizCollection(collectionId) + // ------ Handle action bar on each card ------ // + const handleClickOutside = (event: MouseEvent | globalThis.MouseEvent) => { + if ( + menuRef.current && + !menuRef.current.contains(event.target as Node) && + cancelButtonRef.current && + !cancelButtonRef.current.contains(event.target as Node) && + actionsOpen + ) { + setActionsOpen(false) + } } + // 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 handleRename = (event: MouseEvent) => { + const handleCancel = (event: MouseEvent) => { event.stopPropagation() setActionsOpen(false) + } - setRenameModalOpen(true) - console.log('Rename action triggered for quiz-set:', id) + // ------ Handle card click ------ // + const handleCollectionClick = (collectionId: number) => { + nav.toQuizCollection(collectionId) } - const handleDelete = (event: MouseEvent) => { + // ------ Handle rename ------ // + const handleRename = (event: MouseEvent) => { event.stopPropagation() setActionsOpen(false) - // Logic to handle delete action, e.g., show confirmation dialog - setDeleteConfirmationOpen(true) - console.log('Delete action triggered for quiz-set:', id) + + setRenameModalOpen(true) } const handleConfirmRename = async (newTitle: string) => { @@ -73,6 +91,13 @@ const QuizSetCard = ({ id, originType, title, onFinishRename, onFinishDelete }: } } + // ------ Handle delete ------ // + const handleDelete = (event: MouseEvent) => { + event.stopPropagation() + setActionsOpen(false) + setDeleteConfirmationOpen(true) + } + const handleConfirmDelete = async () => { try { setIsDeleting(true) @@ -88,11 +113,6 @@ const QuizSetCard = ({ id, originType, title, onFinishRename, onFinishDelete }: } } - const handleCancel = (event: MouseEvent) => { - event.stopPropagation() - setActionsOpen(false) - } - return ( <> diff --git a/src/hooks/use-nav.tsx b/src/hooks/use-nav.tsx index 3deb60b..487eb49 100644 --- a/src/hooks/use-nav.tsx +++ b/src/hooks/use-nav.tsx @@ -8,8 +8,8 @@ export const ROUTES = { LIST: '/quizzes', COLLECTION: (id: number | undefined) => `/quizzes/collections/${id}`, COLLECTION_LIST: '/quizzes/collections', - COLLECTION_CREATION: '/quizzes/collections/new', GENERATION: '/quizzes/generation', + GENERATION_FROM_NOTE: '/quizzes/generation/note', DETAIL: (id: number | undefined) => `/quizzes/${id}`, ATTEMPT_DETAIL: (qid: number | undefined, aid: number | undefined) => `/quizzes/${qid}/attempts/${aid}`, ATTEMPT_RESULT: (qid: number, aid: number) => `/quizzes/${qid}/attempts/${aid}/result`, @@ -24,8 +24,8 @@ export const useNav = () => { toQuizList: () => router.push(ROUTES.QUIZ.LIST), toQuizCollection: (id: number) => router.push(ROUTES.QUIZ.COLLECTION(id)), toQuizCollectionList: () => router.push(ROUTES.QUIZ.COLLECTION_LIST), - toQuizCollectionCreation: () => router.push(ROUTES.QUIZ.COLLECTION_CREATION), toQuizGeneration: () => router.push(ROUTES.QUIZ.GENERATION), + 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() diff --git a/src/types/quiz-set.type.ts b/src/types/quiz-set.type.ts index 5cacdcf..741124a 100644 --- a/src/types/quiz-set.type.ts +++ b/src/types/quiz-set.type.ts @@ -7,4 +7,10 @@ export interface QuizSet { quizzes?: Quiz[] createdAt: string updatedAt?: string +} + +// For frontend display +export interface QuizCollection { + id: number + title: string } \ No newline at end of file