diff --git a/.env.example b/.env.example index e1037e9..b810680 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,4 @@ DATABASE_URL="mysql://username:password@host:port/database" NEXTAUTH_SECRET="your-secret-key" +NEXT_PUBLIC_API_BASE_URL="http://example.com" +NEXT_PUBLIC_QUIZ_SECRET="your-secret-key" \ No newline at end of file diff --git a/app/(user)/quiz/[id]/QuizClient.tsx b/app/(user)/quiz/[id]/QuizClient.tsx new file mode 100644 index 0000000..768556c --- /dev/null +++ b/app/(user)/quiz/[id]/QuizClient.tsx @@ -0,0 +1,151 @@ +"use client"; + +import React from "react"; +import { toast } from "react-hot-toast"; + +interface Option { + answer: string; + isCorrect: boolean; +} + +interface Question { + id: string; + question: string; + answer: { + options: Option[]; + }; +} + +interface Quiz { + id: string; + title: string; + questions: Question[]; +} + +interface QuizClientProps { + quizData: Quiz; +} + +const QuizClient: React.FC = ({ quizData }) => { + const [currentQuestion, setCurrentQuestion] = React.useState(0); + const [score, setScore] = React.useState(0); + const [showScore, setShowScore] = React.useState(false); + const [startTime, setStartTime] = React.useState(null); + const [endTime, setEndTime] = React.useState(null); + + React.useEffect(() => { + if (!startTime) { + setStartTime(new Date()); + } + }, [startTime]); + + const handleQuestion = (selectedOption: Option) => { + const correct = selectedOption.isCorrect; + const newScore = correct ? score + 1 : score; + setScore(newScore); + + if (currentQuestion + 1 === quizData.questions.length) { + setEndTime(new Date()); + setShowScore(true); + saveResult(newScore); + } else { + setCurrentQuestion((prev) => prev + 1); + } + }; + + const saveResult = async (currentScore: number) => { + let adjustedScore = currentScore / quizData.questions.length; + adjustedScore = Math.round(adjustedScore * 100); + const result = { + score: adjustedScore, + timestamp: new Date(), + }; + + try { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/user/quizzes/${quizData.id}/submit`, { + method: "POST", + body: JSON.stringify(result), + headers: { + "Content-Type": "application/json", + }, + }); + + if (response.ok) { + toast.success("Quiz result submitted successfully!"); + } else { + const err = await response.json(); + toast.error(err.error || "Failed to submit quiz result."); + } + } catch (error: any) { + console.log("Error submitting quiz result:", error); + toast.error("An unexpected error occurred while submitting the quiz result."); + } + }; + + return ( +
+ {showScore ? ( +
+

+ Your Score: {score} / {quizData.questions.length} +

+

+ {score === quizData.questions.length + ? "Perfect score! Great job!" + : `You scored ${score} out of ${quizData.questions.length}`} +

+

+ Time Taken:{" "} + {endTime && startTime + ? ((endTime.getTime() - startTime.getTime()) / 1000).toFixed(2) + : "N/A"}{" "} + seconds +

+ + + + +
+ ) : ( +
+

{quizData.title}

+
+

+ {quizData.questions[currentQuestion].question} +

+
+ {quizData.questions[currentQuestion].answer.options.map( + (option, index) => ( + + ) + )} +
+
+
+ + Question {currentQuestion + 1} of {quizData.questions.length} + +
+
+ )} +
+ ); +}; + +export default QuizClient; diff --git a/app/(user)/quiz/[id]/page.tsx b/app/(user)/quiz/[id]/page.tsx new file mode 100644 index 0000000..4129d0b --- /dev/null +++ b/app/(user)/quiz/[id]/page.tsx @@ -0,0 +1,51 @@ +"use client"; +import { useParams } from 'next/navigation'; +import React from 'react'; +import CryptoJS from 'crypto-js'; + + +const decryptData = (encryptedData: string) => { + const secret = process.env.NEXT_PUBLIC_QUIZ_SECRET; + if (!secret) { + throw new Error('QUIZ_SECRET is not defined'); + } + const bytes = CryptoJS.AES.decrypt(encryptedData, secret); + const decryptedData = bytes.toString(CryptoJS.enc.Utf8); + return JSON.parse(decryptedData); +}; + +export default function QuizPage() { + const [quizData, setQuizData] = React.useState(null); + const params = useParams(); + const id = params?.id as string; + + React.useEffect(() => { + async function fetchQuizData() { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/user/quizzes/${id}`); + const { data } = await response.json(); + if (data) { + const decryptedQuiz = decryptData(data); + setQuizData(decryptedQuiz); + } else { + console.error('Failed to fetch quiz data'); + } + } + + fetchQuizData(); + }, [id]); + + if (!quizData) { + return
Loading...
; // TODO add loading spinner + } + + // Load client-side component dynamically + const QuizClient = React.lazy(() => import('./QuizClient')); + + return ( +
+ Loading...
}> + + + + ); +} diff --git a/app/(user)/quiz/[slug]/QuizClient.tsx b/app/(user)/quiz/[slug]/QuizClient.tsx deleted file mode 100644 index 2211694..0000000 --- a/app/(user)/quiz/[slug]/QuizClient.tsx +++ /dev/null @@ -1,96 +0,0 @@ -'use client'; - -import React from 'react'; - -interface Answer { - answer: string; - isCorrect: boolean; -} - -interface Question { - question: string; - options: Answer[]; -} - -interface QuizData { - metadata: { - title: string; - }; - questions: Question[]; -} - -interface QuizClientProps { - quizData: QuizData; -} - -const QuizClient: React.FC = ({ quizData }) => { - const [currentQuestion, setCurrentQuestion] = React.useState(0); - const [score, setScore] = React.useState(0); - const [showScore, setShowScore] = React.useState(false); - const [startTime, setStartTime] = React.useState(null); - - React.useEffect(() => { - if (!startTime) { - setStartTime(new Date()); - } - }, [startTime]); - - const handleQuestion = (answer: Answer) => { - const correct = answer.isCorrect; - setScore(prevScore => (correct ? prevScore + 1 : prevScore)); - - if (currentQuestion + 1 === quizData.questions.length) { - setShowScore(true); - } else { - setCurrentQuestion(prev => prev + 1); - } - }; - - return ( -
- {showScore ? ( -
-

- Your Score: {score} / {quizData.questions.length} -

-

- {score === quizData.questions.length - ? 'Perfect score! Great job!' - : `You scored ${score} out of ${quizData.questions.length}`} -

- -
- ) : ( -
-

{quizData.metadata.title}

-
-

{quizData.questions[currentQuestion].question}

-
- {quizData.questions[currentQuestion].options.map((option, index) => ( - - ))} -
-
-
- - Question {currentQuestion + 1} of {quizData.questions.length} - -
-
- )} -
- ); -}; - -export default QuizClient; diff --git a/app/(user)/quiz/[slug]/page.tsx b/app/(user)/quiz/[slug]/page.tsx deleted file mode 100644 index 7d4adc6..0000000 --- a/app/(user)/quiz/[slug]/page.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import fs from 'fs/promises'; -import React from 'react'; - -// Fungsi generateStaticParams hanya digunakan di Server Component -export async function generateStaticParams() { - const files = await fs.readdir('quizzes'); - return files.map(fileName => ({ - slug: fileName.replace('.json', ''), - })); -} - -interface QuizParams { - params: { - slug: string; - }; -} - -interface QuizPageProps { - params: Promise<{ slug: string }>; -} - -export default async function QuizPage({ params }: QuizPageProps) { - const resolvedParams = await params; // Resolve the promise - const { slug } = resolvedParams; - - // Mengambil data quiz dari file - const fileContent = await fs.readFile(`quizzes/${slug}.json`, 'utf8'); - const quizData = JSON.parse(fileContent); - - // Memuat komponen klien secara dinamis - const QuizClient = React.lazy(() => import('./QuizClient')); - - return ( -
- Loading...
}> - - - - ); -} diff --git a/app/(user)/quiz/history/page.tsx b/app/(user)/quiz/history/page.tsx new file mode 100644 index 0000000..28c2d9b --- /dev/null +++ b/app/(user)/quiz/history/page.tsx @@ -0,0 +1,109 @@ +'use client' +import { useSessionContext } from '@/app/context/sessionContext'; +import { Quiz } from '@prisma/client'; +import { Check, Clock } from 'lucide-react'; +import React, { useEffect, useState } from 'react' +import toast from 'react-hot-toast'; + +interface History { + id: string; + quiz: Quiz; + attempts: number; + averageScore: number; +} + +export default function page() { + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(false); + const { session } = useSessionContext(); + + const fetchhistory = async () => { + try { + setLoading(true); + + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/user/quizzes/history`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + } + ); + + if (!response.ok) { + throw new Error("Failed to fetch history"); + } + + const { success, data } = await response.json(); + if (success) { + setHistory(data); + } else { + throw new Error("Invalid response"); + } + } catch (err: any) { + console.log(err); + toast.error("Failed to load history."); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchhistory(); + }, []); + return ( +
+
+
+

+ Quiz & Latihan +

+
+
+ + {/* Loader */} + {loading && ( +
+
+
+ )} + + {!loading && ( +
+ {history.length > 0 ? ( + history.map(({ id, quiz, attempts, averageScore }: History) => ( +
+

+ {quiz.title} +

+

+ {quiz.content} +

+
+

+ + Jumlah percobaan: {attempts} +

+

+ + Rata-rata skor: {averageScore} +

+
+
+ )) + ) : ( +

+ Tidak ada quiz yang ditemukan. +

+ )} +
+ )} +
+
+
+ ) +} diff --git a/app/(user)/quiz/page.tsx b/app/(user)/quiz/page.tsx index df0009e..2337414 100644 --- a/app/(user)/quiz/page.tsx +++ b/app/(user)/quiz/page.tsx @@ -1,40 +1,56 @@ -import fs from "fs/promises"; +"use client"; +import { Quiz } from "@prisma/client"; import ArticleCard from "@/components/ArticleCard"; +import { useEffect, useState } from "react"; +import { toast } from "react-hot-toast"; // Import toast +import { useSessionContext } from "@/app/context/sessionContext"; -// Define a more specific type for metadata -interface Metadata { - title: string; - description?: string; - date: string; // Change to non-optional string - tags: string[]; // Ensure tags is always a string array, no longer optional -} +export default function QuizPage() { + const [quizzes, setQuizzes] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [loading, setLoading] = useState(false); + const { isLoading, session } = useSessionContext() + const user = session?.user; -interface Quiz { - slug: string; - metadata: Metadata; // Use the specific type for metadata -} + const fetchQuizzes = async (query: string = "") => { + try { + setLoading(true); -export default async function QuizPage() { - // Read quizzes directory asynchronously - const files = await fs.readdir("quizzes"); + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/user/quizzes?search=${query}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + } + ); - // Read each file and parse the metadata with type safety - const quizzes: Quiz[] = await Promise.all( - files.map(async (fileName) => { - const slug = fileName.replace(".json", ""); - const fileContent = await fs.readFile(`quizzes/${fileName}`, "utf-8"); - const { metadata } = JSON.parse(fileContent); + if (!response.ok) { + throw new Error("Failed to fetch quizzes"); + } - // Ensure tags is always an array (empty array if not provided) - const tags = metadata.tags ?? []; // Use empty array if undefined + const { success, data } = await response.json(); + if (success) { + setQuizzes(data); + } else { + throw new Error("Invalid response"); + } + } catch (err: any) { + console.log(err); + toast.error("Failed to load quizzes."); // Show error toast + } finally { + setLoading(false); + } + }; - // Provide a default value for date if it's undefined - const date = metadata.date ?? "Tanggal tidak tersedia"; // Default value for date + useEffect(() => { + fetchQuizzes(); + }, []); - // Return the quiz object with tags and date ensured - return { slug, metadata: { ...metadata, tags, date } }; - }) - ); + const handleSearch = () => { + fetchQuizzes(searchQuery); + }; return (
@@ -42,21 +58,61 @@ export default async function QuizPage() {

Quiz & Latihan

-
- {quizzes.map(({ slug, metadata }) => ( - - ))} +
+ setSearchQuery(e.target.value)} + className="w-full px-4 py-2 border rounded-md" + /> + + +
+ + {/* Loader */} + {loading && ( +
+
+
+ )} + + {/* Quiz List or No Data Message */} + {!loading && ( +
+ {quizzes.length > 0 ? ( + quizzes.map(({ id, title, content, quizType, image, createdAt }: Quiz) => ( + + )) + ) : ( +

+ Tidak ada quiz yang ditemukan. +

+ )} +
+ )}
); } - -export const metadata = { - title: "Daftar Quiz | GDSC Universitas Negeri Malang", - description: "Daftar quiz yang disediakan oleh GDSC UM", -}; diff --git a/app/admin/dashboard/quiz/components/quizColumns.tsx b/app/admin/dashboard/quiz/components/quizColumns.tsx index 84498ff..f48c0a6 100644 --- a/app/admin/dashboard/quiz/components/quizColumns.tsx +++ b/app/admin/dashboard/quiz/components/quizColumns.tsx @@ -43,6 +43,16 @@ export const columns: ColumnDef[] = [ } }, + { + accessorKey: "isPublished", + header: "Published", + cell: ({ row }) => { + const quiz = row.original; + return
+ {quiz.isPublished ? "Yes" : "No"} +
+ } + }, { id: "total", header: () => { diff --git a/app/admin/dashboard/quiz/components/quizForm.tsx b/app/admin/dashboard/quiz/components/quizForm.tsx index 2dc896a..5dca976 100644 --- a/app/admin/dashboard/quiz/components/quizForm.tsx +++ b/app/admin/dashboard/quiz/components/quizForm.tsx @@ -10,156 +10,171 @@ import { z } from 'zod'; import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, } from "@/components/ui/form" import { QuizWithAllRelations } from '../lib/definition'; +import { Checkbox } from '@/components/ui/checkbox'; // Import Checkbox component interface QuizFormProps { - quiz?: QuizWithAllRelations, - type: "ADD" | "EDIT" + quiz?: QuizWithAllRelations, + type: "ADD" | "EDIT" } const schema = z.object({ - title: z.string().min(3, { message: "Title must be at least 3 characters" }).nonempty({ message: "Title cannot be empty" }), - content: z.string().min(10, { message: "Content must be at least 10 characters" }).nonempty({ message: "Content cannot be empty" }), - image: z.string().url({ message: "Invalid URL" }), - quizType: z.string().nonempty({ message: "Quiz type is required" }) + title: z.string().min(3, { message: "Title must be at least 3 characters" }).nonempty({ message: "Title cannot be empty" }), + content: z.string().min(10, { message: "Content must be at least 10 characters" }).nonempty({ message: "Content cannot be empty" }), + image: z.string().url({ message: "Invalid URL" }), + quizType: z.string().nonempty({ message: "Quiz type is required" }), + isPublished: z.boolean() }); const QuizForm: FC = ({ quiz, type }) => { - const form = useForm>({ - resolver: zodResolver(schema), - defaultValues: { - title: type === "ADD" ? '' : quiz?.title, - content: type === "ADD" ? '' : quiz?.content, - image: type === "ADD" ? '' : quiz?.image ?? '', - quizType: type === "ADD" ? '' : quiz?.quizType - } - }) - - async function onSubmit(values: z.infer) { - let url = `/api/quizzes`; - if (type === "EDIT") url += `/${quiz?.id}`; - const response = await fetch(url, { - method: type === "ADD" ? "POST" : "PUT", - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(values) - }); - await response.json(); - if (response.ok) { - if (response.ok) { - router.push('/admin/dashboard/quiz'); - } else { - console.error('Failed to save the quiz:', response.statusText); - // Handle the error appropriately here, e.g., show a notification to the user - } - } - } - const router = useRouter(); - - return ( - <> -
- - -
- { - return - Title - - - - - - - }} /> -
-
- { - return - Content - -