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/page.tsx b/src/app/(pages)/(protected)/quiz/page.tsx deleted file mode 100644 index bb09bae..0000000 --- a/src/app/(pages)/(protected)/quiz/page.tsx +++ /dev/null @@ -1,60 +0,0 @@ -'use client' - -import { Card, CardContent } from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { Sparkles, FileText, BookOpen } from 'lucide-react' -import { useRouter } from 'next/navigation' - -const QuizPage = () => { - const router = useRouter() - - const handleRedirectToCreateQuiz = () => { - router.push('/quiz/create') - } - - return ( -
- - -
- -

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 QuizPage 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]/page.tsx b/src/app/(pages)/(protected)/quizzes/[quizId]/attempts/[attemptId]/page.tsx similarity index 52% rename from src/app/(pages)/(protected)/quiz/[quizId]/page.tsx rename to src/app/(pages)/(protected)/quizzes/[quizId]/attempts/[attemptId]/page.tsx index d4c86fb..a3a8048 100644 --- a/src/app/(pages)/(protected)/quiz/[quizId]/page.tsx +++ b/src/app/(pages)/(protected)/quizzes/[quizId]/attempts/[attemptId]/page.tsx @@ -1,127 +1,70 @@ 'use client' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' - -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' +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(); // 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)[]>(Array(mockQuestions.length).fill(null)) + const [answers, setAnswers] = useState<(string | null)[]>([]) const [submitted, setSubmitted] = useState(false) + const [error, setError] = useState('') + + // ------ Fetching data ------ // + 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 : AttemptQuestion[] = toAttemptQuestion(data.attemptDetails) + 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]) + + // ------ 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] @@ -131,13 +74,53 @@ const QuizQuestionPage = () => { } const handlePrev = () => setCurrent((prev) => Math.max(0, prev - 1)) - const handleNext = () => setCurrent((prev) => Math.min(mockQuestions.length - 1, prev + 1)) - const handleSubmit = () => setSubmitted(true) + 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 : AttemptQuestion[] = toAttemptQuestion(result.attemptDetails) + setAttempt(result) + setQuestions(mappedQuestions) + + setSubmitted(true) + } catch (error : any) { + setError(error.message) + } + } // Calculate results const results = submitted - ? mockQuestions.map((q, idx) => ({ + ? questions.map((q, idx) => ({ correct: answers[idx] === q.correctOption, answered: answers[idx] !== null })) @@ -150,13 +133,13 @@ const QuizQuestionPage = () => {
      -

      Quiz Results

      +

      Quiz Ended

      You scored {score} out of{' '} - {mockQuestions.length} + {questions.length}
      - {mockQuestions.map((q, idx) => ( + {questions.map((q, idx) => (
      {
      ))}
      - + +
      + {/**/} + + +
      ) } - const q = mockQuestions[current] + if (loading) { + return ( +

      Loading quizzes...

      + ) + } + + const q = questions[current] return (
      @@ -199,10 +195,10 @@ const QuizQuestionPage = () => { {/* Progress */}
      - Question {current + 1} of {mockQuestions.length} + Question {current + 1} of {questions.length}
      - {mockQuestions.map((_, idx) => ( + {questions.map((_, idx) => ( { - {current < mockQuestions.length - 1 ? ( + {current < questions.length - 1 ? ( ) : ( - )} 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..80c473e --- /dev/null +++ b/src/app/(pages)/(protected)/quizzes/[quizId]/attempts/[attemptId]/result/page.tsx @@ -0,0 +1,126 @@ +'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 { getAttemptAnswer } from '@/services/quiz.service' +import { useNav } from '@/hooks/use-nav' +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 [loading, setLoading] = useState(true) + + const [error, setError] = useState('') + + // ------ Fetching data ------ // + const fetchData = async (qid: number, aid: number) => { + setLoading(true) + setError('') + + try { + const data = await getAttemptAnswer(qid, aid) + if (!data.attemptDetails) { + throw new Error("This attempt has no information, please try again") + } + 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 : AttemptResult = { 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.correctOption)?.text} + +
      + )} +
      +
      + ) + })} +
      + + +
      + + ) +} + +export default QuizResultPage diff --git a/src/app/(pages)/(protected)/quizzes/[quizId]/page.tsx b/src/app/(pages)/(protected)/quizzes/[quizId]/page.tsx new file mode 100644 index 0000000..3543f1b --- /dev/null +++ b/src/app/(pages)/(protected)/quizzes/[quizId]/page.tsx @@ -0,0 +1,163 @@ +'use client' + +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { FileText, BookOpen, CircleChevronLeft, Notebook } from 'lucide-react' +import { useParams } from 'next/navigation' +import { useEffect, useState } from 'react' +import { QuizAttempt } from '@/types/quiz-attempt' +import { Quiz } from '@/types/quiz.type' +import { getAllQuizAttempts, getQuiz, startQuizAttempt } from '@/services/quiz.service' +import AnimatedSection from '@/components/landing/animated-section' +import Pagination from '@/components/pagination' +import useQueryConfig from '@/hooks/use-query-config' +import useUpdateQueryParam from '@/hooks/use-update-query-param' +import AttemptCard from '@/components/quizzes/attempt-card' +import { useNav } from '@/hooks/use-nav' + +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('') + + // ------ Fetching data ------ // + const fetchData = async (id: number) => { + setLoading(true) + setError('') + + try { + const [quizData, attemptsData] = await Promise.all([ + getQuiz(id), + getAllQuizAttempts(id) + ]) + + setQuiz(quizData) + setAttempts(attemptsData) + } catch (error : any) { + setQuiz(null) + setAttempts([]) + setError(error.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchData(Number(quizId)); + }, [quizId]) + + // ------ 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('') + + if (!quiz) return + try { + const newAttempt = await startQuizAttempt(quiz.id) + nav.toNewQuizAttempt(quiz.id, newAttempt.id) + } catch (error : any) { + setError(error.message) + } + } + + // ------ 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 ( + <> + {/* Back to previous */} +
      + +
      + +
      + {/* Introduction */} + + +
      +

      Quiz: {quiz?.title}

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

      Loading attempts...

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

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

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

      + {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 new file mode 100644 index 0000000..2b6f768 --- /dev/null +++ b/src/app/(pages)/(protected)/quizzes/collections/page.tsx @@ -0,0 +1,217 @@ +'use client' +import { useEffect, useState } from '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' +import useQueryConfig from '@/hooks/use-query-config' +import useUpdateQueryParam from '@/hooks/use-update-query-param' + +import { createQuizSet, getAllQuizSets } from '@/services/quiz-set.service' +import { QuizSet } from '@/types/quiz-set.type' +import { useNav } from '@/hooks/use-nav' +import QuizSetCard from '@/components/quizzes/quiz-set-card' +import QuizSetInfoModal from '@/components/quizzes/quiz-set-info-modal' + +const QuizSetsListPage = () => { + const nav = useNav() + + const [quizSets, setQuizSets] = useState([]) + const [loading, setLoading] = useState(true) + + const [creationModalOpen, setCreationModalOpen] = useState(false) + const [isCreating, setIsCreating] = useState(false) + + // Search & filter + const [search, setSearch] = useState('') + const [error, setError] = useState('') + + const allowSearch = false; + const allowFilter = false; + + // ------ Fetching data ------ // + const fetchData = async () => { + setLoading(true) + setError('') + + try { + const data = await getAllQuizSets() + setQuizSets(data) + } catch (error : any) { + setQuizSets([]) + setError(error.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchData() + }, []) + + // ------ Handle AFTER deletion (update list) ------ // + const handleDeleted = (deletedId: number) => { + setQuizSets((prevQuizSets) => + prevQuizSets.filter((quizSet) => quizSet.id !== deletedId) + ) + } + + // ------ Handle AFTER rename (update list) ------ // + const handleRenamed = () => { + fetchData() + } + + // ------ Handle create new quiz set ------ // + const handleCreateQuizSet = async (title: string) => { + setIsCreating(true) + try { + const createdSet = await createQuizSet({ title }) + setCreationModalOpen(false) + await fetchData() + } 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()) + ) + + const pageSize = 12 + const queryConfig = useQueryConfig() + const setQueryParam = useUpdateQueryParam() + const currentPage = Number(queryConfig.page) || 1 + + const paginatedData = filteredData.slice((currentPage - 1) * pageSize, currentPage * pageSize) + + const handleSearchInputChange = (e: React.ChangeEvent) => { + let keyword = e.target.value + if (keyword.trim() === '') { + setSearch('') + } else { + setSearch(keyword) + } + } + + const handlePageChange = (page: number) => { + setQueryParam('page', String(page)) + } + + return ( +
      + {/* Header */} + +
      + {/* Back to previous */} +
      + +
      +

      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 collection found. Try a different search or add a collection!

      +
      + ) : ( +
      + {paginatedData.map((quizSet) => ( + handleDeleted(quizSet.id)} + /> + ))} +
      + )} +
      + + {/* Pagination */} + + {filteredData.length > pageSize && ( + + )} + + {/* End - Pagination */} + + {/* Create Quiz Set Modal */} + {creationModalOpen && ( + setCreationModalOpen(false)} + onConfirm={handleCreateQuizSet} + /> + )} + {/* End - Create Quiz Set Modal */} +
      + ) +} + +export default QuizSetsListPage \ No newline at end of file diff --git a/src/app/(pages)/(protected)/quizzes/generation/note/page.tsx b/src/app/(pages)/(protected)/quizzes/generation/note/page.tsx new file mode 100644 index 0000000..3d5ce9a --- /dev/null +++ b/src/app/(pages)/(protected)/quizzes/generation/note/page.tsx @@ -0,0 +1,241 @@ +'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 { Note } from '@/types/note.type' +import { generateSingleQuiz } from '@/services/quiz.service' +import AnimatedSection from '@/components/landing/animated-section' +import { getAllNotes } from '@/services/note.service' +import { useNav } from '@/hooks/use-nav' + +const NoteSelectionPage = () => { + const nav = useNav() + + 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() + setNotes(data) + } catch (error : any) { + setNotes([]) + setError(error.message) + } finally { + setError('') + setLoading(false) + } + } + + useEffect(() => { + fetchData() + }, []) + + 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 quiz = await generateSingleQuiz(selectedNoteId) + nav.toQuizList() + } 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)/quizzes/generation/page.tsx b/src/app/(pages)/(protected)/quizzes/generation/page.tsx new file mode 100644 index 0000000..8ef9550 --- /dev/null +++ b/src/app/(pages)/(protected)/quizzes/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 { useNav } from '@/hooks/use-nav' + +const QuizGenerationPage = () => { + const nav = useNav() + + const allowLearnMore = false; + + const handleBackToQuizzes = () => { + nav.toQuizList() + } + + const handleRedirectToCreateQuiz = () => { + nav.toQuizGenerationFromNote() + } + + 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)/quizzes/page.tsx b/src/app/(pages)/(protected)/quizzes/page.tsx new file mode 100644 index 0000000..7141e3a --- /dev/null +++ b/src/app/(pages)/(protected)/quizzes/page.tsx @@ -0,0 +1,273 @@ +'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 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 { getAllQuizzes } from '@/services/quiz.service' +import { QuizSet } from '@/types/quiz-set.type' +import QuizSetCard from '@/components/quizzes/quiz-set-card' + +const QuizzesListPage = () => { + const nav = useNav() + + const [quizzes, setQuizzes] = 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('') + + try { + const data = await getAllQuizzes() + setQuizzes(data) + } catch (error : any) { + setQuizzes([]) + setError(error.message) + } finally { + setLoading(false) + } + } + + // Fetch collections (quiz sets) + const fetchCollections = async () => { + setQuizSetLoading(true) + try { + const data = await getAllQuizSets() + setQuizSets(data) + } catch (error: any) { + console.error('Failed to load collections:', error) + } finally { + setQuizSetLoading(false) + } + } + + useEffect(() => { + fetchData() + fetchCollections() + }, []) + + + // ------ Handle AFTER deletion (update list) ------ // + const handleDeleted = (deletedId: number) => { + setQuizzes((prevQuizzes) => prevQuizzes.filter((quiz) => quiz.id !== deletedId)) + } + + // ------ Handle AFTER renamed (update list) ------ // + const handleQuizSetRenamed = () => { + fetchCollections() + } + + // ------ Handle AFTER deletion (update list) ------ // + const handleQuizSetDeleted = (deletedId: number) => { + // Delete a quiz set may result in deleting all quizzes in it + fetchData() + fetchCollections() + } + + // ------ Filter, search and pagination ------ // + const filteredData = quizzes.filter( + (quizSet) => + quizSet.title.toLowerCase().includes(search.toLowerCase()) + ) + + const pageSize = 6 + const queryConfig = useQueryConfig() + const setQueryParam = useUpdateQueryParam() + const currentPage = Number(queryConfig.page) || 1 + + const paginatedData = filteredData.slice((currentPage - 1) * pageSize, currentPage * pageSize) + + const handleSearchInputChange = (e: React.ChangeEvent) => { + let keyword = e.target.value + if (keyword.trim() === '') { + setSearch('') + } else { + setSearch(keyword) + } + } + + const handlePageChange = (page: number) => { + setQueryParam('page', String(page)) + } + + return ( +
      + {/* Header */} + +
      +

      + + Quizzes +

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

      Error Loading Quizzes

      +

      {error}

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

      Collections / Quiz Sets

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

      Recent Quizzes

      + + {loading ? ( +

      Loading quizzes...

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

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

      +
      + ) : ( +
      +
      + {paginatedData.map((quiz) => ( + {}} + onFinishDelete={() => handleDeleted(quiz.id)} + /> + ))} +
      +
      + )} +
      + + {/* Pagination */} + + {filteredData.length > pageSize && ( + + )} + + {/* End - Pagination */} +
      + ) +} + +export default QuizzesListPage \ No newline at end of file diff --git a/src/components/modals/collection-selection.tsx b/src/components/modals/collection-selection.tsx new file mode 100644 index 0000000..4cfbd1c --- /dev/null +++ b/src/components/modals/collection-selection.tsx @@ -0,0 +1,125 @@ +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 + orgCollectionId: number // object's origin collection's id + collections: Collection[] // available choices + isAdding: boolean + onCancel: () => void // should trigger hiding modal in parent component + onConfirm: (collectionId: number) => void // should trigger next action in parent component +} + +const AddToCollectionModal = ({ + objId, + objTitle, + orgCollectionId, + collections, + isAdding, + onCancel, + onConfirm + }: AddToCollectionModalProps) => { + const [selectedCollectionId, setSelectedCollectionId] = useState(orgCollectionId) + + 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/modals/delete-confirmation.tsx b/src/components/modals/delete-confirmation.tsx new file mode 100644 index 0000000..3cce62e --- /dev/null +++ b/src/components/modals/delete-confirmation.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 // 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) => { + 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 DeleteConfirmationModal \ No newline at end of file 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 new file mode 100644 index 0000000..5efb3a0 --- /dev/null +++ b/src/components/quizzes/attempt-card.tsx @@ -0,0 +1,165 @@ +'use client' + +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +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 { ROUTES } from '@/hooks/use-nav' +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, 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 && + !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 handleCancel = (event: MouseEvent) => { + event.stopPropagation() + setActionsOpen(false) + } + + // ------ Handle deletion ------ // + const handleDelete = (event: MouseEvent) => { + event.stopPropagation() + setActionsOpen(false) + setDeleteConfirmationOpen(true) + } + + const handleConfirmDelete = async () => { + try { + setIsDeleting(true) + + await deleteAttempt(quizId, id) + + onFinishDelete() + } catch (error : any) { + console.log(error.message) + } finally { + setIsDeleting(false) + setDeleteConfirmationOpen(false) + } + } + + return ( + <> + +
      + + + +
      +

      ID: {id}

      +
      +

      Score: {score | 0} / {totalQuestions}

      + +
      + +
      + + {/* Actions Menu */} + {actionsOpen && ( + <> +
      + +
      + {/* Mobile: Bottom sheet */} +
      + + +
      + + )} + {/* End - Actions Menu */} + + {/* Confirmation Modal */} + {deleteConfirmationOpen && + setDeleteConfirmationOpen(false)} + onConfirm={handleConfirmDelete} + > + } + {/* End - Confirmation Modal */} +
      + + ) +} + +export default AttemptCard diff --git a/src/components/quizzes/quiz-card.tsx b/src/components/quizzes/quiz-card.tsx new file mode 100644 index 0000000..107ae27 --- /dev/null +++ b/src/components/quizzes/quiz-card.tsx @@ -0,0 +1,245 @@ +'use client' +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +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' +import { ROUTES } from '@/hooks/use-nav' +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 { QuizCollection } from '@/types/quiz-set.type' + +interface QuizCardProps { + id: number + title: string + quizSetId: number + totalQuestions?: number + createdAt: Date + onFinishCollectionChange: () => void + onFinishDelete: () => void // callback to remove deleted note +} + +const QuizCard = ({ id, title, quizSetId, totalQuestions, createdAt, onFinishCollectionChange, 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 + + // ------ 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 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: QuizCollection[] = result.map((quizSet) => ({ + id: quizSet.id, + title: quizSet.originType === "DEFAULT" ? "DEFAULT" : quizSet.title + })) + setCollection(mappedCollection) + + setCollectionSelectionOpen(true) + console.log('Add to collection action triggered for quiz:', id) + } + + const handleConfirmSelection = async (newQuizSetId: number) => { + try { + setIsAdding(true) + await updateQuiz(id, { + quizSetId: newQuizSetId, + topic: title + }) + + if (quizSetId !== newQuizSetId) { + onFinishCollectionChange() + } + } catch (error : any) { + console.log(error.message) + } finally { + setIsAdding(false) + setCollectionSelectionOpen(false) + } + } + + // ------ 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) + + await deleteQuiz(id) + + onFinishDelete() + } catch (error : any) { + console.log(error.message) + } finally { + setIsDeleting(false) + setDeleteConfirmationOpen(false) + } + } + + // ------ 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 ( + <> + +
      + + + +
      +

      {title}

      +
      + +

      Total: {totalQuestions} questions

      + +
      + +
      + + {/* Actions Menu */} + {actionsOpen && ( + <> + {/* Desktop: Dropdown menu */} +
      + + +
      + {/* Mobile: Bottom sheet */} +
      + + + +
      + + )} + {/* End - Actions Menu */} + + {/* Confirmation Modal */} + {deleteConfirmationOpen && + setDeleteConfirmationOpen(false)} + onConfirm={handleConfirmDelete} + > + } + {/* End - Confirmation Modal */} + + {/* Collection Selection Modal */} + {collectionSelectionOpen && + setCollectionSelectionOpen(false)} + onConfirm={handleConfirmSelection} + /> + } + {/* End - Collection Selection Modal */} +
      + + ) +} + +export default QuizCard diff --git a/src/components/quizzes/quiz-set-card.tsx b/src/components/quizzes/quiz-set-card.tsx new file mode 100644 index 0000000..afef765 --- /dev/null +++ b/src/components/quizzes/quiz-set-card.tsx @@ -0,0 +1,233 @@ +'use client' + +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, 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' + +interface QuizSetCardProps { + id: number + originType: string + title: string + onFinishRename: () => void + onFinishDelete: () => void // callback to remove deleted item +} + +const QuizSetCard = ({ id, originType, title, onFinishRename, onFinishDelete }: QuizSetCardProps) => { + const nav = useNav() + + 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 [renameModalOpen, setRenameModalOpen] = useState(false) + const [isRenaming, setIsRenaming] = useState(false) + + // ------ 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 handleCancel = (event: MouseEvent) => { + event.stopPropagation() + setActionsOpen(false) + } + + // ------ Handle card click ------ // + const handleCollectionClick = (collectionId: number) => { + nav.toQuizCollection(collectionId) + } + + // ------ Handle rename ------ // + const handleRename = (event: MouseEvent) => { + event.stopPropagation() + setActionsOpen(false) + + setRenameModalOpen(true) + } + + const handleConfirmRename = async (newTitle: string) => { + try { + setIsRenaming(true) + + const updatedData = await updateQuizSet(id, { title: newTitle }) + + console.log(updatedData) + onFinishRename() + } catch (error : any) { + console.log(error.message) + } finally { + setIsRenaming(false) + setRenameModalOpen(false) + } + } + + // ------ Handle delete ------ // + const handleDelete = (event: MouseEvent) => { + event.stopPropagation() + setActionsOpen(false) + setDeleteConfirmationOpen(true) + } + + const handleConfirmDelete = async () => { + try { + setIsDeleting(true) + + await deleteQuizSet(id) + + onFinishDelete() + } catch (error : any) { + console.log(error.message) + } finally { + setIsDeleting(false) + setDeleteConfirmationOpen(false) + } + } + + return ( + <> + +
      + + +
      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 new file mode 100644 index 0000000..487eb49 --- /dev/null +++ b/src/hooks/use-nav.tsx @@ -0,0 +1,33 @@ +'use client' + +import { useRouter } from 'next/navigation' + +export const ROUTES = { + HOME: '/', + QUIZ: { + LIST: '/quizzes', + COLLECTION: (id: number | undefined) => `/quizzes/collections/${id}`, + COLLECTION_LIST: '/quizzes/collections', + 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`, + } +} + +export const useNav = () => { + const router = useRouter() + + return { + toHome: () => router.push(ROUTES.HOME), + toQuizList: () => router.push(ROUTES.QUIZ.LIST), + toQuizCollection: (id: number) => router.push(ROUTES.QUIZ.COLLECTION(id)), + toQuizCollectionList: () => router.push(ROUTES.QUIZ.COLLECTION_LIST), + 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/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 new file mode 100644 index 0000000..2f4e966 --- /dev/null +++ b/src/services/quiz-set.service.ts @@ -0,0 +1,64 @@ +import apiClient from '@/apis/api-client' +import { ApiResponse } from '@/types/auth.type' +import { QuizSet } from '@/types/quiz-set.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 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 +} + +export const getAllQuizSets = async (): Promise => { + const response = await apiClient.get(`${QUIZ_SET_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 +} \ No newline at end of file diff --git a/src/services/quiz.service.ts b/src/services/quiz.service.ts new file mode 100644 index 0000000..f36193e --- /dev/null +++ b/src/services/quiz.service.ts @@ -0,0 +1,134 @@ +import apiClient from '@/apis/api-client' +import { ApiResponse } from '@/types/auth.type' +import { Quiz } from '@/types/quiz.type' +import { QuizAttempt } from '@/types/quiz-attempt' +import { Note } from '@/types/note.type' + +const QUIZ_BASE_API = '/quizzes' +const QUIZ_ATTEMPT_BASE_API = '/quizzes' +const QUIZ_GENERATION_BASE_API = '/ai/generation/quiz-sets' + +export const generateSingleQuiz = async (docId : number): Promise => { + const request = { + 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 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 + + if (!apiRes.data && apiRes.code != 1000) { + throw new Error(`Failed to get data: ${apiRes.message}`) + } + return apiRes.data +} + +export const updateQuiz = async (quizId : number, request : { quizSetId : number , topic: string }): Promise => { + console.log(`${QUIZ_BASE_API}/${quizId}`) + console.log(request) + const response = await apiClient.patch(`${QUIZ_BASE_API}/${quizId}`, 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 deleteQuiz = async (quizId : number) => { + const response = await apiClient.delete(`${QUIZ_BASE_API}/${quizId}`) + const apiRes: ApiResponse = response.data + + if (!apiRes.data && apiRes.code != 1000) { + throw new Error(`Failed to delete data: ${apiRes.message}`) + } +} + +// ------ Quiz Attempts ------ // +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 deleteAttempt = async (quizId : number, attemptId : number) => { + const response = await apiClient.delete(`${QUIZ_BASE_API}/${quizId}/attempts/${attemptId}`) + const apiRes: ApiResponse = response.data + + if (!apiRes.data && apiRes.code != 1000) { + throw new Error(`Failed to update data: ${apiRes.message}`) + } +} + +export const getAttemptAnswer = 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 + + 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.post(`${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}`) + } + return apiRes.data +} \ 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..6776ef1 --- /dev/null +++ b/src/types/quesiton.type.ts @@ -0,0 +1,26 @@ +export interface Question { + id: number + questionText: string + optionA: string + optionB: string + optionC: string + optionD: string + correctAnswer: string +} + +// For easy frontend display +export interface AttemptQuestion { + id: number + text: string + options: { key: string; text: string }[] + correctOption?: string + userAnswer?: string + isCorrect?: boolean +} + +export interface AttemptResult { + score: number + total: number + percent: number + questions: AttemptQuestion[] +} \ No newline at end of file diff --git a/src/types/quiz-attempt.ts b/src/types/quiz-attempt.ts 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 diff --git a/src/types/quiz-set.type.ts b/src/types/quiz-set.type.ts new file mode 100644 index 0000000..741124a --- /dev/null +++ b/src/types/quiz-set.type.ts @@ -0,0 +1,16 @@ +import { Quiz } from '@/types/quiz.type' + +export interface QuizSet { + id: number + title: string + originType: string + quizzes?: Quiz[] + createdAt: string + updatedAt?: string +} + +// For frontend display +export interface QuizCollection { + id: number + title: 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