diff --git a/apps/upskii/messages/en.json b/apps/upskii/messages/en.json
index 1d80dd92f6..858e5d67a7 100644
--- a/apps/upskii/messages/en.json
+++ b/apps/upskii/messages/en.json
@@ -3832,26 +3832,169 @@
"ws-quiz-sets": {
"plural": "Quiz Sets",
"singular": "Quiz Set",
+ "name": "Quiz set name",
"description": "Quiz sets are used to group quizzes together for learning purposes.",
- "create": "Create quiz set",
"create_description": "Create a new quiz set",
- "name": "Set name",
- "edit": "Edit set",
"link-quizzes": "Link Quizzes",
- "link-modules": "Link modules"
+ "link-modules": "Link modules",
+ "editing": "Updating...",
+ "creating": "Creating...",
+ "create": "Create Quiz Set",
+ "edit": "Edit Quiz Set",
+ "form-sections": {
+ "basic": {
+ "title": "Basic Information",
+ "subtitle": "Set up the fundamental details of your quiz"
+ },
+ "timing-limit": {
+ "title": "Basic Information",
+ "subtitle": "Set up the fundamental details of your quiz"
+ },
+ "schedule": {
+ "title": "Schedule",
+ "subtitle": "Set availability and due dates for the quiz"
+ },
+ "settings": {
+ "title": "Quiz Settings",
+ "subtitle": "Configure quiz behavior and student permissions"
+ }
+ },
+ "form-description": "Configure your quiz settings, timing, and permissions to create an engaging learning experience for your students.",
+ "required-badge": "Required",
+ "form-fields": {
+ "name": {
+ "title": "Set Name",
+ "description": "Choose a clear, descriptive name for your quiz",
+ "placeholder": "e.g., Chapter 5 Assessment"
+ },
+ "attempt_limit": {
+ "title": "Attempt Limit",
+ "description": "Maximum number of times a user can attempt the quiz. Leave 0 for unlimited.",
+ "placeholder": "e.g., 3"
+ },
+ "time_limit_minutes": {
+ "title": "Time Limit (minutes)",
+ "description": "The time allowed to complete the quiz in minutes. Leave 0 for no time limit.",
+ "placeholder": "e.g, 60"
+ },
+ "available_date": {
+ "title": "Available From",
+ "description": "Date and time when the quiz becomes available to participants."
+ },
+ "due_date": {
+ "title": "Due Date",
+ "description": "Deadline for submitting the quiz."
+ },
+ "explanation_mode": {
+ "title": "Explanation Mode",
+ "description": "Choose when and how students can see answer explanations",
+ "placeholder": "Select explanation timing",
+ "select_never": "Never show answer explanations",
+ "select_correct_answer": "Only show explanation of correct answers",
+ "select_all_answer": "Show explanation of all answers"
+ },
+ "instruction": {
+ "title": "Instructions",
+ "description": "Provide detailed instructions for students using the rich text editor, displayed before starting the quiz.",
+ "save": "Save Instruction",
+ "saving": "Saving..."
+ },
+ "allow_view_results": {
+ "title": "Allow Immediate Results",
+ "description": "Enable users to see their score immediately upon submission."
+ },
+ "results_released": {
+ "title": "Results Released",
+ "description": "Mark quiz results as released, allowing detailed feedback and explanations."
+ },
+ "allow_view_old_attempts": {
+ "title": "Allow Review Old Attempts",
+ "description": "Revisit users' previous quiz attempts. Before results are released, users can view only their submitted answers. Once results are released, they'll see their answers along with detailed results and feedback."
+ }
+ }
},
"ws-quizzes": {
"plural": "Quizzes",
"singular": "Quiz",
"create": "Create quiz",
+ "create_multiple": "Add questions",
"create_description": "Create a new quiz",
"description": "Manage quizzes in your workspace that have multiple choice questions for learning purposes.",
+ "edit_description": "Edit a quiz",
"edit": "Edit quiz",
+ "edit_all": "Edit all quizzes",
"question": "Question",
"answer": "Answer",
"points": "Points",
"submitting": "Submitting...",
"submit": "Submit",
+ "form": {
+ "edit-title": "Edit Quiz Questions",
+ "create-title": "Create Quiz Questions",
+ "create-description": "Create multiple quiz questions at once. Each question can have multiple options with explanations. The user can also copy and paste quiz questions",
+ "edit-description": "Edit or add more multiple quiz questions at once. Each question can have multiple options with explanations. The user can also copy and paste quiz questions",
+ "question-no": "Question {no}",
+ "option-no": "Option {no}",
+ "untitled-question": "Untitled Question",
+ "content": {
+ "options": {
+ "label": "Answer Options",
+ "add-button": "Add Option",
+ "placeholder": "Option {no}",
+ "total-options-text": "Total Options",
+ "correct-label": "Correct",
+ "explanation-label": "Explanation",
+ "generate-explanation-button": "Generate",
+ "explanation-placeholder": "Explain why this option is correct or incorrect..."
+ },
+ "questions": {
+ "label": "Question",
+ "placeholder": "Enter your question here...",
+ "count-many": "{count} Questions",
+ "count-one": "{count} Question"
+ },
+ "correct-answers": {
+ "no-selected": "No correct answer selected",
+ "single-selected": "Single correct answer",
+ "multiple-selected": "Multiple correct answers"
+ }
+ },
+ "paste-confirm-modal": {
+ "title": "Confirm Paste",
+ "description-plural": "You are about to paste {length} quiz questions. Review the content below and choose an action.",
+ "description-single": "You are about to paste 1 quiz question. Review the content below and choose an action.",
+ "discard-button": "Discard",
+ "confirm-button": "Confirm Paste"
+ },
+ "add-another-button": "Add Another Question",
+ "paste-question-button": "Paste Question",
+ "create-button": "Create All Questions",
+ "edit-button": "Edit All Questions",
+ "creating": "Creating...",
+ "updating": "Updating...",
+ "error": {
+ "title": "Error",
+ "fail-update": "Failed to update quiz",
+ "fail-create": "Failed to create quiz",
+ "unexpected": "An unexpected error occurred",
+ "invalid-clipboard-title": "Error",
+ "invalid-clipboard": "Invalid quiz format",
+ "invalid-clipboard-message": "Failed to paste quiz data. Please ensure you have valid quiz data in your clipboard.",
+ "explanation-generation-failed": "Failed to generate explanation",
+ "quiz-copy-failed": "Failed to copy quiz to clipboard"
+ },
+ "success": {
+ "title": "Success",
+ "edit-quizzes": "Successfully updated {length} quizzes",
+ "edit-quiz": "Successfully updated {length} quiz",
+ "create-quizzes": "Successfully created {length} quizzes",
+ "create-quiz": "Successfully created {length} quiz",
+ "quiz-copied": "Quiz copied to clipboard",
+ "paste-success-many": "Pasted {length} quizzes succesfully",
+ "paste-success-one": "Pasted 1 quiz succesfully",
+ "explanation-generated": "Explanation generated successfully"
+ }
+ },
"quiz-status": {
"sidebar_aria": "Quiz progress sidebar",
"question_status_title": "Question Progress",
@@ -3860,7 +4003,11 @@
"question_navigation_label": "Jump to question",
"answered_state": "Answered",
"unanswered_state": "Unanswered",
- "jump_to_question": "Jump to question"
+ "jump_to_question": "Jump to question",
+ "upcoming": "Upcoming",
+ "active": "Active",
+ "expired": "Expired",
+ "draft": "Draft"
},
"time": {
"remaining": "Time Remaining",
diff --git a/apps/upskii/messages/vi.json b/apps/upskii/messages/vi.json
index d0b947f047..6900055995 100644
--- a/apps/upskii/messages/vi.json
+++ b/apps/upskii/messages/vi.json
@@ -3836,28 +3836,168 @@
"create_description": "Tạo một hàng chờ mới"
},
"ws-quiz-sets": {
- "plural": "Bộ trắc nghiệm",
- "singular": "Bộ trắc nghiệm",
- "description": "Quản lý các bộ trắc nghiệm trong không gian làm việc của bạn bao gồm các câu hỏi nhiều lựa chọn dành cho mục đích học tập.",
- "create": "Tạo bộ trắc nghiệm",
- "create_description": "Tạo bộ trắc nghiệm mới",
- "name": "Tên bộ trắc nghiệm",
- "edit": "Chỉnh sửa bộ",
- "link-quizzes": "Liên kết câu hỏi",
- "link-modules": "Liên kết mô-đun"
+ "plural": "Bộ câu hỏi",
+ "singular": "Bộ câu hỏi",
+ "name": "Tên bộ câu hỏi",
+ "description": "Bộ câu hỏi được sử dụng để nhóm các câu quiz lại với nhau cho mục đích học tập.",
+ "create_description": "Tạo một bộ câu hỏi mới",
+ "link-quizzes": "Liên kết bài kiểm tra",
+ "link-modules": "Liên kết học phần",
+ "editing": "Đang cập nhật...",
+ "creating": "Đang tạo...",
+ "create": "Tạo bộ câu hỏi",
+ "edit": "Chỉnh sửa bộ câu hỏi",
+ "form-sections": {
+ "basic": {
+ "title": "Thông tin cơ bản",
+ "subtitle": "Thiết lập các chi tiết cơ bản cho bài kiểm tra của bạn"
+ },
+ "timing-limit": {
+ "title": "Thông tin cơ bản",
+ "subtitle": "Thiết lập các chi tiết cơ bản cho bài kiểm tra của bạn"
+ },
+ "schedule": {
+ "title": "Lịch trình",
+ "subtitle": "Đặt thời gian khả dụng và thời hạn cho bài kiểm tra"
+ },
+ "settings": {
+ "title": "Cài đặt bài kiểm tra",
+ "subtitle": "Cấu hình hành vi bài kiểm tra và quyền của sinh viên"
+ }
+ },
+ "form-description": "Cấu hình cài đặt, thời gian và quyền của bài kiểm tra để tạo trải nghiệm học tập hấp dẫn cho sinh viên của bạn.",
+ "required-badge": "Bắt buộc",
+ "form-fields": {
+ "name": {
+ "title": "Tên bộ câu hỏi",
+ "description": "Chọn một tên rõ ràng, mô tả cho bài kiểm tra của bạn",
+ "placeholder": "Ví dụ: Kiểm tra Chương 5"
+ },
+ "attempt_limit": {
+ "title": "Giới hạn số lần làm bài",
+ "description": "Số lần tối đa người dùng có thể làm bài kiểm tra. Để 0 nếu không giới hạn.",
+ "placeholder": "Ví dụ: 3"
+ },
+ "time_limit_minutes": {
+ "title": "Giới hạn thời gian (phút)",
+ "description": "Thời gian cho phép hoàn thành bài kiểm tra tính bằng phút. Để 0 nếu không giới hạn thời gian.",
+ "placeholder": "Ví dụ: 60"
+ },
+ "available_date": {
+ "title": "Có hiệu lực từ",
+ "description": "Ngày và giờ bài kiểm tra có sẵn cho người tham gia."
+ },
+ "due_date": {
+ "title": "Ngày hết hạn",
+ "description": "Thời hạn nộp bài kiểm tra."
+ },
+ "explanation_mode": {
+ "title": "Chế độ giải thích",
+ "description": "Chọn thời điểm và cách học sinh có thể xem giải thích câu trả lời",
+ "placeholder": "Chọn thời điểm giải thích",
+ "select_never": "Không bao giờ hiển thị giải thích câu trả lời",
+ "select_correct_answer": "Chỉ hiển thị giải thích các câu trả lời đúng",
+ "select_all_answer": "Hiển thị giải thích của tất cả các câu trả lời"
+ },
+ "instruction": {
+ "title": "Hướng dẫn",
+ "description": "Cung cấp hướng dẫn chi tiết cho học sinh bằng trình soạn thảo văn bản đa dạng thức, hiển thị trước khi bắt đầu bài kiểm tra."
+ },
+ "allow_view_results": {
+ "title": "Cho phép xem kết quả ngay lập tức",
+ "description": "Cho phép người dùng xem điểm của họ ngay sau khi nộp bài."
+ },
+ "results_released": {
+ "title": "Đã công bố kết quả",
+ "description": "Đánh dấu kết quả bài kiểm tra đã công bố, cho phép xem phản hồi chi tiết và giải thích."
+ },
+ "allow_view_old_attempts": {
+ "title": "Cho phép xem lại các lần làm bài cũ",
+ "description": "Cho phép người dùng xem lại các lần làm bài kiểm tra trước đó của họ. Trước khi kết quả được công bố, người dùng chỉ có thể xem các câu trả lời đã nộp của mình. Khi kết quả được công bố, họ sẽ thấy các câu trả lời cùng với kết quả chi tiết và phản hồi."
+ }
+ }
},
"ws-quizzes": {
"plural": "Câu hỏi trắc nghiệm",
"singular": "Bộ trắc nghiệm",
"create": "Tạo câu hỏi",
+ "edit_description": "Chỉnh sửa bộ trắc nghiệm",
"create_description": "Tạo một bộ trắc nghiệm mới",
"description": "Quản lý bộ trắc nghiệm trong không gian làm việc của bạn có các câu hỏi nhiều lựa chọn dành cho mục đích học tập.",
"edit": "Chỉnh sửa bộ trắc nghiệm",
+ "edit-all": "Chỉnh sửa toàn bộ",
"question": "Câu hỏi",
"answer": "Câu trả lời",
- "points": "Points",
- "submitting": "Submitting...",
- "submit": "Submit",
+ "points": "Điểm",
+ "submitting": "Đang nộp...",
+ "submit": "Nộp",
+ "form": {
+ "edit-title": "Chỉnh sửa câu hỏi đố vui",
+ "create-title": "Tạo câu hỏi đố vui",
+ "create-description": "Tạo nhiều câu hỏi đố vui cùng lúc. Mỗi câu hỏi có thể có nhiều lựa chọn với giải thích. Người dùng cũng có thể sao chép và dán câu hỏi đố vui",
+ "edit-description": "Chỉnh sửa hoặc thêm nhiều câu hỏi đố vui cùng lúc. Mỗi câu hỏi có thể có nhiều lựa chọn với giải thích. Người dùng cũng có thể sao chép và dán câu hỏi đố vui",
+ "question-no": "Câu hỏi {no}",
+ "option-no": "Tùy chọn {no}",
+ "untitled-question": "Câu hỏi không có tiêu đề",
+ "content": {
+ "options": {
+ "label": "Tùy chọn trả lời",
+ "add-button": "Thêm tùy chọn",
+ "placeholder": "Tùy chọn {no}",
+ "total-options-text": "Tổng số tùy chọn",
+ "correct-label": "Đúng",
+ "explanation-label": "Giải thích",
+ "generate-explanation-button": "Tạo",
+ "explanation-placeholder": "Giải thích tại sao tùy chọn này đúng hoặc sai..."
+ },
+ "questions": {
+ "label": "Câu hỏi",
+ "placeholder": "Nhập câu hỏi của bạn vào đây...",
+ "count-many": "{count} Câu hỏi",
+ "count-one": "{count} Câu hỏi"
+ },
+ "correct-answers": {
+ "no-selected": "Chưa chọn câu trả lời đúng",
+ "single-selected": "Một câu trả lời đúng",
+ "multiple-selected": "Nhiều câu trả lời đúng"
+ }
+ },
+ "paste-confirm-modal": {
+ "title": "Xác nhận dán",
+ "description-plural": "Bạn sắp dán {length} câu hỏi đố vui. Xem lại nội dung bên dưới và chọn hành động.",
+ "description-single": "Bạn sắp dán 1 câu hỏi đố vui. Xem lại nội dung bên dưới và chọn hành động.",
+ "discard-button": "Hủy bỏ",
+ "confirm-button": "Xác nhận dán"
+ },
+ "add-another-button": "Thêm câu hỏi khác",
+ "paste-question-button": "Dán câu hỏi",
+ "create-button": "Tạo tất cả câu hỏi",
+ "edit-button": "Chỉnh sửa tất cả câu hỏi",
+ "creating": "Đang tạo...",
+ "updating": "Đang cập nhật...",
+ "error": {
+ "title": "Lỗi",
+ "fail-update": "Cập nhật câu đố thất bại",
+ "fail-create": "Tạo câu đố thất bại",
+ "unexpected": "Đã xảy ra lỗi không mong muốn",
+ "invalid-clipboard-title": "Lỗi",
+ "invalid-clipboard": "Định dạng câu đố không hợp lệ",
+ "invalid-clipboard-message": "Không thể dán dữ liệu câu đố. Vui lòng đảm bảo bạn có dữ liệu câu đố hợp lệ trong khay nhớ tạm.",
+ "explanation-generation-failed": "Không tạo được giải thích",
+ "quiz-copy-failed": "Sao chép câu đố vào khay nhớ tạm thất bại"
+ },
+ "success": {
+ "title": "Thành công",
+ "edit-quizzes": "Đã cập nhật thành công {length} câu đố",
+ "edit-quiz": "Đã cập nhật thành công {length} câu đố",
+ "create-quizzes": "Đã tạo thành công {length} câu đố",
+ "create-quiz": "Đã tạo thành công {length} câu đố",
+ "quiz-copied": "Đã sao chép câu đố vào khay nhớ tạm",
+ "paste-success-many": "Đã dán thành công {length} câu đố",
+ "paste-success-one": "Đã dán thành công 1 câu đố",
+ "explanation-generated": "Giải thích đã được tạo thành công"
+ }
+ },
"quiz-status": {
"sidebar_aria": "Thanh bên tiến độ bài kiểm tra",
"question_status_title": "Tiến độ câu hỏi",
@@ -3866,7 +4006,11 @@
"question_navigation_label": "Chuyển đến câu hỏi",
"answered_state": "Đã trả lời",
"unanswered_state": "Chưa trả lời",
- "jump_to_question": "Chuyển đến câu hỏi"
+ "jump_to_question": "Chuyển đến câu hỏi",
+ "upcoming": "Sắp diễn ra",
+ "active": "Đang hoạt động",
+ "expired": "Đã hết hạn",
+ "draft": "Nháp"
},
"time": {
"remaining": "Thời gian còn lại",
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/page.tsx
index a83d931859..5667ab7c48 100644
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/page.tsx
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/page.tsx
@@ -1,5 +1,5 @@
+import ClientQuizzes from '../../../../../../../../components/quiz/client-quizzes';
import ClientFlashcards from './flashcards/client-flashcards';
-import ClientQuizzes from './quizzes/client-quizzes';
import { extractYoutubeId } from '@/utils/url-helper';
import {
createClient,
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx
index 9ca924fcc7..f82c8341fd 100644
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx
@@ -118,8 +118,6 @@ export default function BeforeTakingQuizWhole({
const canViewTotalPointsOnly = quizData.resultsReleased;
- console.log('Test', quizData.attempts[0]);
-
// const canViewOldAttemptsResults = quizData.resultsReleased;
// can view attempts with points in detailed explanation
const canViewOldAttemptsResults =
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/columns.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/columns.tsx
index ab5264bfa4..becc572208 100644
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/columns.tsx
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/columns.tsx
@@ -86,6 +86,7 @@ export const getQuizSetColumns = (
),
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/create/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/create/page.tsx
new file mode 100644
index 0000000000..8a2d610c8b
--- /dev/null
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/create/page.tsx
@@ -0,0 +1,17 @@
+import QuizSetForm from '@/components/quiz/quiz-set-form';
+
+interface Props {
+ params: Promise<{
+ wsId: string;
+ moduleId: string;
+ }>;
+}
+
+export default async function Page({ params }: Props) {
+ const { wsId, moduleId } = await params;
+ return (
+
+
+
+ );
+}
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/form.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/form.tsx
deleted file mode 100644
index bdc2554fc8..0000000000
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/form.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-'use client';
-
-import { type WorkspaceQuizSet } from '@tuturuuu/types/db';
-import { Button } from '@tuturuuu/ui/button';
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from '@tuturuuu/ui/form';
-import { useForm } from '@tuturuuu/ui/hooks/use-form';
-import { toast } from '@tuturuuu/ui/hooks/use-toast';
-import { Input } from '@tuturuuu/ui/input';
-import { zodResolver } from '@tuturuuu/ui/resolvers';
-import { useTranslations } from 'next-intl';
-import { useRouter } from 'next/navigation';
-import * as z from 'zod';
-
-interface Props {
- wsId: string;
- moduleId: string;
- data?: WorkspaceQuizSet;
- // eslint-disable-next-line no-unused-vars
- onFinish?: (data: z.infer) => void;
-}
-
-const FormSchema = z.object({
- id: z.string().optional(),
- name: z.string().min(1),
- moduleId: z.string(),
-});
-
-export default function CourseModuleForm({
- wsId,
- moduleId,
- data,
- onFinish,
-}: Props) {
- const t = useTranslations();
- const router = useRouter();
-
- const form = useForm({
- resolver: zodResolver(FormSchema),
- values: {
- id: data?.id,
- name: data?.name || '',
- moduleId,
- },
- });
-
- const isDirty = form.formState.isDirty;
- const isValid = form.formState.isValid;
- const isSubmitting = form.formState.isSubmitting;
-
- const disabled = !isDirty || !isValid || isSubmitting;
-
- const onSubmit = async (data: z.infer) => {
- try {
- const res = await fetch(
- data.id
- ? `/api/v1/workspaces/${wsId}/quiz-sets/${data.id}`
- : `/api/v1/workspaces/${wsId}/quiz-sets`,
- {
- method: data.id ? 'PUT' : 'POST',
- body: JSON.stringify(data),
- }
- );
-
- if (res.ok) {
- onFinish?.(data);
- router.refresh();
- } else {
- const data = await res.json();
- toast({
- title: `Failed to ${data.id ? 'edit' : 'create'} course module`,
- description: data.message,
- });
- }
- } catch (error) {
- toast({
- title: `Failed to ${data.id ? 'edit' : 'create'} course module`,
- description: error instanceof Error ? error.message : String(error),
- });
- }
- };
-
- return (
-
-
- );
-}
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/instruction-editor.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/instruction-editor.tsx
new file mode 100644
index 0000000000..acc0c9a1a0
--- /dev/null
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/instruction-editor.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+import type { JSONContent } from '@tiptap/react';
+import { Button } from '@tuturuuu/ui/button';
+import { RichTextEditor } from '@tuturuuu/ui/text-editor/editor';
+import { useTranslations } from 'next-intl';
+import { useState } from 'react';
+
+interface InstructionEditorProps {
+ quizSetId: string;
+ instruction: JSONContent | null;
+ setInstruction: (instruction: JSONContent) => void;
+}
+
+export default function InstructionEditor({
+ quizSetId,
+ instruction,
+ setInstruction,
+}: InstructionEditorProps) {
+ const t = useTranslations();
+ const [saving, setSaving] = useState(false);
+
+ const INSTRUCTION_EDITOR_KEY = `instruction-quiz-set-${quizSetId}`;
+
+ const handleSave = async () => {
+ setSaving(true);
+ if (instruction) {
+ localStorage.setItem(INSTRUCTION_EDITOR_KEY, JSON.stringify(instruction));
+ }
+ setSaving(false);
+ };
+
+ return (
+
+
+
+
+
+
+
+ {saving
+ ? t('ws-quiz-sets.form-fields.instruction.saving') || 'Saving...'
+ : t('ws-quiz-sets.form-fields.instruction.save')}
+
+
+
+ );
+}
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/page.tsx
index 4ea80f71fe..350f9ba1cb 100644
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/page.tsx
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/page.tsx
@@ -1,5 +1,4 @@
import { getQuizSetColumns } from './columns';
-import CourseModuleForm from './form';
import { CustomDataTable } from '@/components/custom-data-table';
import { createClient } from '@tuturuuu/supabase/next/server';
import { type WorkspaceQuizSet } from '@tuturuuu/types/db';
@@ -46,7 +45,8 @@ export default async function WorkspaceCoursesPage({
singularTitle={t('ws-quiz-sets.singular')}
createTitle={t('ws-quiz-sets.create')}
createDescription={t('ws-quiz-sets.create_description')}
- form={ }
+ // form={ }
+ href={`/${wsId}/courses/${courseId}/modules/${moduleId}/quiz-sets/create`}
/>
;
}
export function QuizSetRowActions({
wsId,
+ courseId,
moduleId,
row,
}: QuizSetRowActionsProps) {
@@ -76,7 +78,14 @@ export function QuizSetRowActions({
- setShowEditDialog(true)}>
+ {/* setShowEditDialog(true)}> */}
+ {
+ router.push(
+ `/${wsId}/courses/${courseId}/modules/${moduleId}/quiz-sets/${data.id}/edit`
+ );
+ }}
+ >
{t('common.edit')}
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/page.tsx
index bd1193d852..775642666d 100644
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/page.tsx
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/page.tsx
@@ -1,6 +1,6 @@
+import AIQuizzes from '../../../../../../../../../components/quiz/client-ai';
+import ClientQuizzes from '../../../../../../../../../components/quiz/client-quizzes';
import QuizForm from '../../../../../quizzes/form';
-import AIQuizzes from './client-ai';
-import ClientQuizzes from './client-quizzes';
import { createClient } from '@tuturuuu/supabase/next/server';
import FeatureSummary from '@tuturuuu/ui/custom/feature-summary';
import { ListTodo } from '@tuturuuu/ui/icons';
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/edit-set/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/edit-set/page.tsx
new file mode 100644
index 0000000000..32372995e9
--- /dev/null
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/edit-set/page.tsx
@@ -0,0 +1,64 @@
+import QuizSetForm from '@/components/quiz/quiz-set-form';
+import { createClient } from '@tuturuuu/supabase/next/server';
+
+interface Props {
+ params: Promise<{
+ wsId: string;
+ setId: string;
+ }>;
+}
+
+export default async function Page({ params }: Props) {
+ const { wsId, setId } = await params;
+ const setDetails = await getQuizSetDetails(setId);
+
+ console.log('Quiz Set Details:', setDetails);
+
+ if (!setDetails) {
+ return Error: Quiz set not found
;
+ }
+
+ return (
+
+
+
+ );
+}
+
+async function getQuizSetDetails(setId: string) {
+ const supabase = await createClient();
+
+ const queryBuilder = supabase
+ .from('workspace_quiz_sets')
+ .select('*, course_module_quiz_sets(module_id)')
+ .eq('id', setId)
+ .single();
+ const { data, error } = await queryBuilder;
+
+ if (error) {
+ console.error('Error fetching quiz set details:', error);
+ return null;
+ }
+
+ return {
+ ...data,
+ id: data?.id,
+ name: data?.name ?? '',
+ moduleId: data?.course_module_quiz_sets?.[0]?.module_id ?? undefined,
+ attemptLimit: data?.attempt_limit ?? null,
+ timeLimitMinutes: data?.time_limit_minutes ?? null,
+ allowViewResults: data?.allow_view_results ?? true,
+ availableDate: data?.available_date
+ ? data.available_date.toString().slice(0, 16)
+ : '',
+ dueDate: data?.due_date ? data.due_date.toString().slice(0, 16) : '',
+ explanationMode: data?.explanation_mode ?? 0,
+ instruction: data?.instruction ?? null,
+ resultsReleased: data?.results_released ?? false,
+ allowViewOldAttempts: data?.allow_view_old_attempts ?? true,
+ };
+}
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/page.tsx
index 7bed4f825a..f4704040e6 100644
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/page.tsx
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/page.tsx
@@ -1,11 +1,30 @@
import { getWorkspaceQuizColumns } from './columns';
-import QuizForm from './form';
import { CustomDataTable } from '@/components/custom-data-table';
import { createClient } from '@tuturuuu/supabase/next/server';
-import type { WorkspaceQuiz } from '@tuturuuu/types/db';
-import FeatureSummary from '@tuturuuu/ui/custom/feature-summary';
-import { Separator } from '@tuturuuu/ui/separator';
+import type { WorkspaceQuiz, WorkspaceQuizSet } from '@tuturuuu/types/db';
+import { Badge } from '@tuturuuu/ui/badge';
+import { Button } from '@tuturuuu/ui/button';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@tuturuuu/ui/card';
+import {
+ BarChart3,
+ Calendar,
+ CheckCircle,
+ Edit,
+ FileText,
+ Plus,
+ Settings,
+ Target,
+ Timer,
+ XCircle,
+} from 'lucide-react';
import { getTranslations } from 'next-intl/server';
+import Link from 'next/link';
interface SearchParams {
q?: string;
@@ -30,34 +49,377 @@ export default async function WorkspaceQuizzesPage({
const t = await getTranslations();
const { wsId, setId } = await params;
- const { data, count } = await getData(setId, await searchParams);
+ const [quizData, quizSetData] = await Promise.all([
+ getQuizData(setId, await searchParams),
+ getQuizSetData(setId),
+ ]);
+
+ const { data: quizzes, count: quizCount } = quizData;
+ const quizSet = quizSetData;
+ console.log('Quiz Set Data:', quizSet);
+
+ if (!quizSet) {
+ return (
+
+
+
+ Quiz Set Not Found
+
+
+ The requested quiz set could not be found.
+
+
+
+ );
+ }
+
+ const isActive =
+ new Date() >= new Date(quizSet.available_date) &&
+ new Date() <= new Date(quizSet.due_date);
+ const isUpcoming = new Date() < new Date(quizSet.available_date);
+ const isExpired = new Date() > new Date(quizSet.due_date);
+
+ const getStatusInfo = () => {
+ if (isUpcoming)
+ return {
+ status: 'ws-quizzes.quiz-status.upcoming',
+ color: 'bg-dynamic-blue',
+ textColor: 'text-dynamic-light-blue',
+ bgColor: 'bg-dynamic-blue/20',
+ };
+ if (isActive)
+ return {
+ status: 'ws-quizzes.quiz-status.active',
+ color: 'bg-dynamic-green',
+ textColor: 'text-dynamic-light-green',
+ bgColor: 'bg-dynamic-green/20',
+ };
+ if (isExpired)
+ return {
+ status: 'ws-quizzes.quiz-status.expired',
+ color: 'bg-dynamic-light-pink',
+ textColor: 'text-dynamic-light-pink',
+ bgColor: 'bg-dynamic-pink/20',
+ };
+ return {
+ status: 'ws-quizzes.quiz-status.draft',
+ color: 'bg-muted',
+ textColor: 'text-muted-foreground',
+ bgColor: 'bg-muted/20',
+ };
+ };
+
+ const statusInfo = getStatusInfo();
+
+ const getExplanationModeText = (mode: number) => {
+ switch (mode) {
+ case 0:
+ return 'ws-quiz-sets.form-fields.explanation_mode.select_never';
+ case 1:
+ return 'ws-quiz-sets.form-fields.explanation_mode.select_correct_answer';
+ case 2:
+ return 'ws-quiz-sets.form-fields.explanation_mode.select_all_answer';
+ default:
+ return 'Unknown';
+ }
+ };
return (
- <>
- }
- />
-
-
- >
+
+
+ {/* Header Section */}
+
+
+
+
+ {quizSet.name}
+
+
+
+ {t(statusInfo.status)}
+
+
+
+ Manage and monitor your quiz set with {quizCount} question
+ {quizCount !== 1 ? 's' : ''}
+
+
+
+
+
+
+ {t('ws-quiz-sets.edit')}
+
+
+
+
+
+ {t('ws-quizzes.create_multiple')}
+
+
+
+
+
+ {t('ws-quizzes.edit_all')}
+
+
+
+
+
+ {/* Stats Overview */}
+
+
+
+
+
+
+ Total Questions
+
+
+ {quizCount}
+
+
+
+
+
+
+
+
+
+
+
+
+ Attempt Limit
+
+
+ {quizSet.attempt_limit || '∞'}
+
+
+
+
+
+
+
+
+
+
+
+
+ Time Limit
+
+
+ {quizSet.time_limit_minutes
+ ? `${quizSet.time_limit_minutes}m`
+ : '∞'}
+
+
+
+
+
+
+
+
+
+
+
+
+ Status
+
+
+ {t(statusInfo.status)}
+
+
+
+
+
+
+
+
+ {/* Quiz Set Details */}
+
+ {/* Schedule Information */}
+
+
+
+
+ Schedule
+
+ Quiz availability and deadlines
+
+
+
+
+ Available From
+
+
+ {new Date(quizSet.available_date).toLocaleDateString()} at{' '}
+ {new Date(quizSet.available_date).toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+
+
+
+ Due Date
+
+
+ {new Date(quizSet.due_date).toLocaleDateString()} at{' '}
+ {new Date(quizSet.due_date).toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+
+
+
+
+ {/* Settings Overview */}
+
+
+
+
+ Settings
+
+ Quiz configuration and rules
+
+
+
+
+ Explanation Mode
+
+
+ {t(getExplanationModeText(quizSet.explanation_mode))}
+
+
+
+
+ View Results
+
+ {quizSet.allow_view_results ? (
+
+ ) : (
+
+ )}
+
+
+
+ Results Released
+
+ {quizSet.results_released ? (
+
+ ) : (
+
+ )}
+
+
+
+ View Old Attempts
+
+ {quizSet.allow_view_old_attempts ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* Instructions */}
+
+
+
+
+ Instructions
+
+
+ Student guidelines and information
+
+
+
+ {quizSet.instruction ? (
+
+
+ {typeof quizSet.instruction === 'string'
+ ? quizSet.instruction
+ : JSON.stringify(quizSet.instruction, null, 2)}
+
+
+ ) : (
+
+
+
+ No instructions provided
+
+
+ Add Instructions
+
+
+ )}
+
+
+
+
+ {/* Questions List */}
+
+
+
+
+ Quiz Questions
+
+
+ Manage individual questions and their settings
+
+
+
+ {quizCount > 0 ? (
+
+ ) : (
+
+
+
+ No Questions Yet
+
+
+ Get started by adding your first quiz question.
+
+
+
+
+ Create First Question
+
+
+
+ )}
+
+
+
+
);
}
-async function getData(
+async function getQuizData(
setId: string,
{
q,
@@ -89,8 +451,26 @@ async function getData(
const { data, error, count } = await queryBuilder;
if (error) {
if (!retry) throw error;
- return getData(setId, { q, pageSize, retry: false });
+ return getQuizData(setId, { q, pageSize, retry: false });
}
return { data, count } as { data: WorkspaceQuiz[]; count: number };
}
+
+async function getQuizSetData(setId: string) {
+ const supabase = await createClient();
+
+ // Try to get moduleId via join if possible
+ const { data, error } = await supabase
+ .from('workspace_quiz_sets')
+ .select('*')
+ .eq('id', setId)
+ .single();
+
+ if (error) {
+ console.error('Error fetching quiz set:', error);
+ return null;
+ }
+
+ return data as WorkspaceQuizSet;
+}
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/paste-confirm-modal.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/paste-confirm-modal.tsx
new file mode 100644
index 0000000000..33c15085a2
--- /dev/null
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/paste-confirm-modal.tsx
@@ -0,0 +1,130 @@
+import { Button } from '@tuturuuu/ui/button';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@tuturuuu/ui/card';
+import { toast } from '@tuturuuu/ui/hooks/use-toast';
+import { CheckCircle, X, XCircle } from '@tuturuuu/ui/icons';
+import { ScrollArea } from '@tuturuuu/ui/scroll-area';
+import { useTranslations } from 'next-intl';
+import React from 'react';
+
+export default function PasteConfirmModal({
+ pastedQuizzes,
+ appendQuiz,
+ setShowPasteConfirm,
+ setPastedQuizzes,
+}: {
+ pastedQuizzes: any[];
+ appendQuiz: (quiz: any) => void;
+ setShowPasteConfirm: (show: boolean) => void;
+ setPastedQuizzes: (quizzes: any[]) => void;
+}) {
+ const t = useTranslations('ws-quizzes.form');
+ const confirmPaste = () => {
+ pastedQuizzes.forEach((quiz) => {
+ const cleanQuiz = {
+ ...quiz,
+ id: undefined, // Remove ID to create new quiz
+ quiz_options: quiz.quiz_options.map((option: any) => ({
+ ...option,
+ id: undefined, // Remove ID to create new options
+ })),
+ };
+ appendQuiz(cleanQuiz);
+ });
+
+ setShowPasteConfirm(false);
+ setPastedQuizzes([]);
+
+ toast({
+ title: t('success.title'),
+ description:
+ pastedQuizzes.length > 1
+ ? t('success.paste-success-many', {
+ length: pastedQuizzes.length,
+ })
+ : t('success.paste-success-one'),
+ });
+ };
+
+ const discardPaste = () => {
+ setShowPasteConfirm(false);
+ setPastedQuizzes([]);
+ };
+ return (
+
+
+
+
+ {t('paste-confirm-modal.title')}
+
+
+ {pastedQuizzes.length > 1
+ ? t('paste-confirm-modal.description-plural', {
+ length: pastedQuizzes.length,
+ })
+ : t('paste-confirm-modal.description-single')}
+
+
+
+
+
+ {pastedQuizzes.map((quiz, index) => (
+
+
+ {t('question-no', {
+ no: index + 1,
+ })}
+ : {quiz.question || t('untitled-question')}
+
+
+ {quiz.quiz_options?.map((option: any, optIndex: number) => (
+
+ {option.is_correct ? (
+
+ ) : (
+
+ )}
+
+ {option.value ||
+ t('option-no', {
+ no: optIndex + 1,
+ })}
+
+
+ ))}
+
+
+ ))}
+
+
+
+
+
+
+ {t('paste-confirm-modal.discard-button')}
+
+
+
+ {t('paste-confirm-modal.confirm-button')}
+
+
+
+
+
+ );
+}
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/quiz-add-card.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/quiz-add-card.tsx
new file mode 100644
index 0000000000..e8e4d743d3
--- /dev/null
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/quiz-add-card.tsx
@@ -0,0 +1,428 @@
+import { Button } from '@tuturuuu/ui/button';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@tuturuuu/ui/card';
+import { Checkbox } from '@tuturuuu/ui/checkbox';
+import {
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@tuturuuu/ui/form';
+import { UseFormReturn } from '@tuturuuu/ui/hooks/use-form';
+import { toast } from '@tuturuuu/ui/hooks/use-toast';
+import {
+ Check,
+ CheckCheck,
+ Copy,
+ HelpCircle,
+ Loader2,
+ PlusCircle,
+ Trash2,
+ Wand2,
+ X,
+} from '@tuturuuu/ui/icons';
+import { Input } from '@tuturuuu/ui/input';
+import { ScrollArea } from '@tuturuuu/ui/scroll-area';
+import { Textarea } from '@tuturuuu/ui/textarea';
+import { useTranslations } from 'next-intl';
+import React, { Fragment, useState } from 'react';
+import type { FieldArrayWithId } from 'react-hook-form';
+
+type FormReturnType = UseFormReturn<
+ {
+ quizzes: {
+ quiz_options: {
+ value: string;
+ is_correct: boolean;
+ id?: string | undefined;
+ explanation?: string | undefined;
+ }[];
+ question: string;
+ id?: string | undefined;
+ }[];
+ moduleId?: string | undefined;
+ setId?: string | undefined;
+ },
+ unknown,
+ {
+ quizzes: {
+ quiz_options: {
+ value: string;
+ is_correct: boolean;
+ id?: string | undefined;
+ explanation?: string | undefined;
+ }[];
+ question: string;
+ id?: string | undefined;
+ }[];
+ moduleId?: string | undefined;
+ setId?: string | undefined;
+ }
+>;
+
+type FieldType = FieldArrayWithId<
+ {
+ quizzes: {
+ quiz_options: {
+ value: string;
+ is_correct: boolean;
+ id?: string | undefined;
+ explanation?: string | undefined;
+ }[];
+ question: string;
+ id?: string | undefined;
+ }[];
+ moduleId?: string | undefined;
+ setId?: string | undefined;
+ },
+ 'quizzes',
+ 'id'
+>;
+
+export default function QuizAddCard({
+ wsId,
+ field,
+ quizIndex,
+ form,
+ removeQuiz,
+ quizFields,
+}: {
+ wsId: string;
+ form: FormReturnType;
+ quizIndex: number;
+ field: FieldType;
+ removeQuiz: (index: number) => void;
+ quizFields: FieldType[];
+}) {
+ const t = useTranslations('ws-quizzes.form');
+ const [loadingStates, setLoadingStates] = useState>(
+ {}
+ );
+ const generateExplanation = async (
+ quizIndex: number,
+ optionIndex: number
+ ) => {
+ const question = form.getValues(`quizzes.${quizIndex}.question`);
+ const option = form.getValues(
+ `quizzes.${quizIndex}.quiz_options.${optionIndex}`
+ );
+
+ const loadingKey = `${quizIndex}-${optionIndex}`;
+ setLoadingStates((prev) => ({ ...prev, [loadingKey]: true }));
+
+ try {
+ const res = await fetch('/api/ai/objects/quizzes/explanation', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ wsId, question, option }),
+ });
+
+ if (res.ok) {
+ const { explanation } = await res.json();
+ form.setValue(
+ `quizzes.${quizIndex}.quiz_options.${optionIndex}.explanation`,
+ explanation
+ );
+ toast({
+ title: t('success.title'),
+ description: t('success.explanation-generated'),
+ });
+ } else {
+ throw new Error(t('error.explanation-generation-failed'));
+ }
+ } catch (error) {
+ toast({
+ title: t('error.title'),
+ description:
+ error instanceof Error
+ ? error.message
+ : t('error.explanation-generation-failed'),
+ variant: 'destructive',
+ });
+ } finally {
+ setLoadingStates((prev) => ({ ...prev, [loadingKey]: false }));
+ }
+ };
+
+ const addQuizOption = (quizIndex: number) => {
+ const currentOptions = form.getValues(`quizzes.${quizIndex}.quiz_options`);
+ form.setValue(`quizzes.${quizIndex}.quiz_options`, [
+ ...currentOptions,
+ { value: '', is_correct: false, explanation: '' },
+ ]);
+ };
+
+ const removeQuizOption = (quizIndex: number, optionIndex: number) => {
+ const currentOptions = form.getValues(`quizzes.${quizIndex}.quiz_options`);
+ if (currentOptions.length > 2) {
+ const newOptions = currentOptions.filter(
+ (_, index) => index !== optionIndex
+ );
+ form.setValue(`quizzes.${quizIndex}.quiz_options`, newOptions);
+ }
+ };
+
+ const getCorrectAnswersCount = (quizIndex: number) => {
+ const options = form.watch(`quizzes.${quizIndex}.quiz_options`);
+ return options.filter((option) => option.is_correct).length;
+ };
+
+ const copyQuizToClipboard = async (quizIndex: number) => {
+ try {
+ const quizToCopy = form.getValues(`quizzes.${quizIndex}`);
+ const clipboardText = JSON.stringify(quizToCopy, null, 2);
+ await navigator.clipboard.writeText(clipboardText);
+ toast({
+ title: t('success.title'),
+ description: t('success.quiz-copied'),
+ });
+ } catch (error) {
+ console.error('Failed to copy quiz:', error);
+ toast({
+ title: t('error.title'),
+ description: t('error.quiz-copy-failed'),
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const correctAnswers = getCorrectAnswersCount(quizIndex);
+ const options = form.watch(`quizzes.${quizIndex}.quiz_options`) || [];
+
+ return (
+
+
+
+
+
+
+
+ {t('question-no', {
+ no: quizIndex + 1,
+ })}
+
+
+ {correctAnswers === 0 && (
+
+
+ {t('content.correct-answers.no-selected')}
+
+ )}
+ {correctAnswers === 1 && (
+
+
+ {t('content.correct-answers.single-selected')}
+
+ )}
+ {correctAnswers > 1 && (
+
+
+ {t('content.correct-answers.multiple-selected')} (
+ {correctAnswers})
+
+ )}
+
+
+
+
+ copyQuizToClipboard(quizIndex)}
+ className="text-secondary-foreground hover:bg-primary/20"
+ >
+
+
+ {quizFields.length > 1 && (
+ removeQuiz(quizIndex)}
+ className="text-secondary-foreground hover:bg-red-500/20"
+ >
+
+
+ )}
+
+
+
+
+
+ {/* Question Input */}
+
(
+
+
+ {t('content.questions.label')}
+
+
+
+
+
+
+ )}
+ />
+
+ {/* Options */}
+
+
+
+ {t('content.options.label')}
+
+
addQuizOption(quizIndex)}
+ className="border-dynamic-purple bg-dynamic-purple/20 text-dynamic-light-purple hover:bg-dynamic-purple/50"
+ >
+
+ {t('content.options.add-button')}
+
+
+
+
+
+ {options.map((_, optionIndex) => (
+
+
+
+ {/* Correct Answer Checkbox */}
+ (
+
+
+
+
+
+ {t('content.options.correct-label')}
+
+
+ )}
+ />
+
+ {/* Option Value */}
+ (
+
+
+
+
+
+
+ )}
+ />
+
+ {/* Remove Option Button */}
+ {options.length > 2 && (
+
+ removeQuizOption(quizIndex, optionIndex)
+ }
+ className="mt-1 text-red-500 hover:bg-dynamic-light-pink/70 hover:text-red-700"
+ >
+
+
+ )}
+
+
+ {/* Explanation */}
+
(
+
+
+
+ {t('content.options.explanation-label')}
+
+
+ generateExplanation(quizIndex, optionIndex)
+ }
+ disabled={
+ !form.getValues(
+ `quizzes.${quizIndex}.quiz_options.${optionIndex}.value`
+ ) ||
+ !!field.value ||
+ loadingStates[`${quizIndex}-${optionIndex}`]
+ }
+ className="border border-dynamic-purple/70 bg-dynamic-purple/20 text-dynamic-purple hover:bg-dynamic-purple/50"
+ >
+ {loadingStates[
+ `${quizIndex}-${optionIndex}`
+ ] ? (
+
+ ) : (
+ <>
+
+ {t(
+ 'content.options.generate-explanation-button'
+ )}
+ >
+ )}
+
+
+
+
+
+
+
+ )}
+ />
+
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/quiz-create/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/quiz-create/page.tsx
new file mode 100644
index 0000000000..da42e717d7
--- /dev/null
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/quiz-create/page.tsx
@@ -0,0 +1,17 @@
+import MultiQuizzesForm from '@/components/quiz/multi-quizzes-form';
+
+interface Props {
+ params: Promise<{
+ wsId: string;
+ setId: string;
+ }>;
+}
+
+export default async function Page({ params }: Props) {
+ const { wsId, setId } = await params;
+ return (
+
+
+
+ );
+}
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/quiz-edit/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/quiz-edit/page.tsx
new file mode 100644
index 0000000000..250aa9622b
--- /dev/null
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/quiz-edit/page.tsx
@@ -0,0 +1,49 @@
+import MultiQuizzesForm from '@/components/quiz/multi-quizzes-form';
+import { createClient } from '@tuturuuu/supabase/next/server';
+
+interface Props {
+ params: Promise<{
+ wsId: string;
+ setId: string;
+ }>;
+}
+
+export default async function Page({ params }: Props) {
+ const { wsId, setId } = await params;
+ const quizData = await getData(setId);
+
+ if (!quizData) {
+
+
Error: Failed to load quiz data.
+
Please try again later.
+
;
+ }
+ return (
+
+
+
+ );
+}
+async function getData(setId: string) {
+ const supabase = await createClient();
+
+ // Fetch all quizzes for the given setId, including their quiz options
+ const { data, error } = await supabase
+ .from('quiz_set_quizzes')
+ .select('...workspace_quizzes(*, quiz_options(*))', {
+ count: 'exact',
+ })
+ .eq('set_id', setId)
+ .order('created_at', { ascending: false });
+
+ if (error || !data) {
+ return null;
+ }
+
+ return data;
+}
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/row-actions.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/row-actions.tsx
index e8d18f575f..a724224faa 100644
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/row-actions.tsx
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/row-actions.tsx
@@ -1,6 +1,6 @@
'use client';
-import WorkspaceQuizForm from './form';
+import WorkspaceQuizForm from './single-form';
import { Row } from '@tanstack/react-table';
import { WorkspaceQuiz } from '@tuturuuu/types/db';
import { Button } from '@tuturuuu/ui/button';
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/form.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/single-form.tsx
similarity index 99%
rename from apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/form.tsx
rename to apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/single-form.tsx
index c8f68b31f0..14b411a11f 100644
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/form.tsx
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/single-form.tsx
@@ -103,7 +103,7 @@ export default function QuizForm({
const res = await fetch(
data.id
? `/api/v1/workspaces/${wsId}/quizzes/${data.id}`
- : `/api/v1/workspaces/${wsId}/quizzes`,
+ : `/api/v1/workspaces/${wsId}/quizzes/single`,
{
method: data.id ? 'PUT' : 'POST',
body: JSON.stringify(data),
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/create/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/create/page.tsx
new file mode 100644
index 0000000000..9c1e56accf
--- /dev/null
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/create/page.tsx
@@ -0,0 +1,16 @@
+import QuizSetForm from '@/components/quiz/quiz-set-form';
+
+interface Props {
+ params: Promise<{
+ wsId: string;
+ }>;
+}
+
+export default async function Page({ params }: Props) {
+ const { wsId } = await params;
+ return (
+
+
+
+ );
+}
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/page.tsx
index 970bd8044a..bf9df7f469 100644
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/page.tsx
+++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/page.tsx
@@ -1,5 +1,4 @@
import { getQuizSetColumns } from './columns';
-import QuizForm from './form';
import { CustomDataTable } from '@/components/custom-data-table';
import { createClient } from '@tuturuuu/supabase/next/server';
import { type WorkspaceQuizSet } from '@tuturuuu/types/db';
@@ -44,7 +43,8 @@ export default async function WorkspaceQuizzesPage({
description={t('ws-quiz-sets.description')}
createTitle={t('ws-quiz-sets.create')}
createDescription={t('ws-quiz-sets.create_description')}
- form={ }
+ href={`/${wsId}/quiz-sets/create`}
+ // form={ }
/>
d.name);
const baseName = formattedName;
- let suffix = 2;
- let newName = `${baseName} ${suffix}`;
+ let suffix = 1;
+ let newName = baseName;
+
+ // Keep incrementing suffix until we find a unique name
while (existingNames.includes(newName)) {
suffix++;
newName = `${baseName} ${suffix}`;
diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quizzes/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quizzes/route.ts
index 0b6ebee132..eac1df644e 100644
--- a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quizzes/route.ts
+++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quizzes/route.ts
@@ -28,48 +28,190 @@ export async function GET(_: Request, { params }: Params) {
return NextResponse.json(data);
}
-export async function POST(req: Request, { params }: Params) {
- const supabase = await createClient();
- const { wsId: id } = await params;
+type OptionPayload = {
+ id?: string;
+ value: string;
+ is_correct: boolean;
+ explanation?: string | null;
+};
+type QuizPayload = {
+ id?: string;
+ question: string;
+ quiz_options: OptionPayload[];
+};
+type BulkBody = {
+ moduleId?: string;
+ setId?: string;
+ quizzes: QuizPayload[];
+};
- const { moduleId, setId, quiz_options, ...rest } = await req.json();
+export async function POST(request: Request, { params }: Params) {
+ const { wsId } = await params;
+ const sb = await createClient();
- const { data, error } = await supabase
- .from('workspace_quizzes')
- .insert({
- ...rest,
- ws_id: id,
- })
- .select('id')
- .single();
-
- if (error) {
- console.log(error);
+ let body: BulkBody;
+ try {
+ body = await request.json();
+ } catch {
+ return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
+ }
+ const { moduleId, setId, quizzes } = body;
+ if (!Array.isArray(quizzes)) {
return NextResponse.json(
- { message: 'Error creating workspace quiz' },
- { status: 500 }
+ { error: '`quizzes` must be an array' },
+ { status: 400 }
);
}
- if (moduleId) {
- await supabase.from('course_module_quizzes').insert({
- module_id: moduleId,
- quiz_id: data.id,
- });
- }
+ try {
+ const created: string[] = [];
+
+ for (const quiz of quizzes) {
+ if (quiz.id) continue; // skip those that already have an id
- if (setId) {
- await supabase.from('quiz_set_quizzes').insert({
- set_id: setId,
- quiz_id: data.id,
+ // 1) insert workspace_quizzes
+ const { data: insQ, error: insQErr } = await sb
+ .from('workspace_quizzes')
+ .insert({ question: quiz.question, ws_id: wsId })
+ .select('id')
+ .single();
+ if (insQErr || !insQ) throw insQErr || new Error('Could not create quiz');
+ const quizId = insQ.id;
+ created.push(quizId);
+
+ // 2) link to module & set
+ if (moduleId) {
+ await sb
+ .from('course_module_quizzes')
+ .insert({ module_id: moduleId, quiz_id: quizId });
+ }
+ if (setId) {
+ await sb
+ .from('quiz_set_quizzes')
+ .insert({ set_id: setId, quiz_id: quizId });
+ }
+
+ // 3) insert quiz_options
+ if (Array.isArray(quiz.quiz_options) && quiz.quiz_options.length) {
+ await sb.from('quiz_options').insert(
+ quiz.quiz_options.map((opt) => ({
+ quiz_id: quizId,
+ value: opt.value,
+ is_correct: opt.is_correct,
+ explanation: opt.explanation ?? null,
+ }))
+ );
+ }
+ }
+
+ return NextResponse.json({
+ message: `Created ${created.length} new quiz${created.length === 1 ? '' : 'zes'}`,
+ created,
});
+ } catch (err: any) {
+ console.error('Bulk-create error:', err);
+ return NextResponse.json(
+ { error: err.message || 'Unknown error creating quizzes' },
+ { status: 500 }
+ );
}
+}
- if (quiz_options) {
- await supabase
- .from('quiz_options')
- .insert(quiz_options.map((o: any) => ({ ...o, quiz_id: data.id })));
+export async function PUT(request: Request, { params }: Params) {
+ const { wsId } = await params;
+ const sb = await createClient();
+
+ let body: BulkBody;
+ try {
+ body = await request.json();
+ } catch {
+ return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
+ const { moduleId, setId, quizzes } = body;
+ if (!Array.isArray(quizzes)) {
+ return NextResponse.json(
+ { error: '`quizzes` must be an array' },
+ { status: 400 }
+ );
+ }
+
+ try {
+ for (const quiz of quizzes) {
+ let quizId: string;
+
+ // ── 1) Upsert workspace_quizzes ───────────────────────────
+ if (quiz.id) {
+ // existing → update
+ await sb
+ .from('workspace_quizzes')
+ .update({ question: quiz.question })
+ .eq('id', quiz.id);
+ quizId = quiz.id;
+ } else {
+ // new → insert
+ const { data: ins, error: insErr } = await sb
+ .from('workspace_quizzes')
+ .insert({ question: quiz.question, ws_id: wsId })
+ .select('id')
+ .single();
+ if (insErr || !ins) throw insErr || new Error('Could not create quiz');
+ quizId = ins.id;
+ }
- return NextResponse.json({ message: 'success' });
+ // ── 2) Link to module & set ─────────────────────────────
+ if (moduleId) {
+ await sb
+ .from('course_module_quizzes')
+ .upsert({ module_id: moduleId, quiz_id: quizId });
+ }
+ if (setId) {
+ await sb
+ .from('quiz_set_quizzes')
+ .upsert({ set_id: setId, quiz_id: quizId });
+ }
+
+ // ── 3) Sync quiz_options ────────────────────────────────
+ const incomingIds = quiz.quiz_options
+ .filter((o) => o.id)
+ .map((o) => o.id!);
+
+ // delete any missing
+ await sb
+ .from('quiz_options')
+ .delete()
+ .eq('quiz_id', quizId)
+ .not('id', 'in', `(${incomingIds.map((id) => `'${id}'`).join(',')})`);
+
+ // upsert each
+ for (const opt of quiz.quiz_options) {
+ if (opt.id) {
+ // update
+ await sb
+ .from('quiz_options')
+ .update({
+ value: opt.value,
+ is_correct: opt.is_correct,
+ explanation: opt.explanation ?? null,
+ })
+ .eq('id', opt.id);
+ } else {
+ // insert
+ await sb.from('quiz_options').insert({
+ quiz_id: quizId,
+ value: opt.value,
+ is_correct: opt.is_correct,
+ explanation: opt.explanation ?? null,
+ });
+ }
+ }
+ }
+
+ return NextResponse.json({ message: 'All quizzes upserted successfully' });
+ } catch (err: any) {
+ console.error('Bulk-upsert error:', err);
+ return NextResponse.json(
+ { error: err.message || 'Unknown error updating quizzes' },
+ { status: 500 }
+ );
+ }
}
diff --git a/apps/upskii/src/app/api/v1/workspaces/[wsId]/quizzes/single/route.ts b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quizzes/single/route.ts
new file mode 100644
index 0000000000..b4547ed028
--- /dev/null
+++ b/apps/upskii/src/app/api/v1/workspaces/[wsId]/quizzes/single/route.ts
@@ -0,0 +1,54 @@
+import { createClient } from '@tuturuuu/supabase/next/server';
+import { NextResponse } from 'next/server';
+
+interface Params {
+ params: Promise<{
+ wsId: string;
+ }>;
+}
+
+export async function POST(req: Request, { params }: Params) {
+ const supabase = await createClient();
+ const { wsId: id } = await params;
+
+ const { moduleId, setId, quiz_options, ...rest } = await req.json();
+
+ const { data, error } = await supabase
+ .from('workspace_quizzes')
+ .insert({
+ ...rest,
+ ws_id: id,
+ })
+ .select('id')
+ .single();
+
+ if (error) {
+ console.log(error);
+ return NextResponse.json(
+ { message: 'Error creating workspace quiz' },
+ { status: 500 }
+ );
+ }
+
+ if (moduleId) {
+ await supabase.from('course_module_quizzes').insert({
+ module_id: moduleId,
+ quiz_id: data.id,
+ });
+ }
+
+ if (setId) {
+ await supabase.from('quiz_set_quizzes').insert({
+ set_id: setId,
+ quiz_id: data.id,
+ });
+ }
+
+ if (quiz_options) {
+ await supabase
+ .from('quiz_options')
+ .insert(quiz_options.map((o: any) => ({ ...o, quiz_id: data.id })));
+ }
+
+ return NextResponse.json({ message: 'success' });
+}
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-ai.tsx b/apps/upskii/src/components/quiz/client-ai.tsx
similarity index 88%
rename from apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-ai.tsx
rename to apps/upskii/src/components/quiz/client-ai.tsx
index 349cfbdf34..7130edf9a4 100644
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-ai.tsx
+++ b/apps/upskii/src/components/quiz/client-ai.tsx
@@ -47,19 +47,14 @@ export default function AIQuizzes({
if (!quizSetRes.ok) throw new Error('Failed to create quiz set');
const quizSet = await quizSetRes.json();
- const promises = object.quizzes.map((quiz) =>
- fetch(`/api/v1/workspaces/${wsId}/quizzes`, {
- method: 'POST',
- body: JSON.stringify({
- setId: quizSet.setId,
- moduleId,
- question: quiz?.question,
- quiz_options: quiz?.quiz_options,
- }),
- })
- );
-
- await Promise.all(promises);
+ await fetch(`/api/v1/workspaces/${wsId}/quizzes`, {
+ method: 'POST',
+ body: JSON.stringify({
+ setId: quizSet.setId,
+ moduleId,
+ quizzes: object.quizzes,
+ }),
+ });
toast({
title: t('common.success'),
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-quizzes.tsx b/apps/upskii/src/components/quiz/client-quizzes.tsx
similarity index 99%
rename from apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-quizzes.tsx
rename to apps/upskii/src/components/quiz/client-quizzes.tsx
index 26131c43d2..c8111866e5 100644
--- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/client-quizzes.tsx
+++ b/apps/upskii/src/components/quiz/client-quizzes.tsx
@@ -1,6 +1,6 @@
'use client';
-import QuizForm from '../../../../../quizzes/form';
+import QuizForm from '../../app/[locale]/(dashboard)/[wsId]/quizzes/form';
import { RenderedQuizzesSets } from '@/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quizzes/page';
import { createClient } from '@tuturuuu/supabase/next/client';
import {
diff --git a/apps/upskii/src/components/quiz/multi-quizzes-form.tsx b/apps/upskii/src/components/quiz/multi-quizzes-form.tsx
new file mode 100644
index 0000000000..8a01fd9989
--- /dev/null
+++ b/apps/upskii/src/components/quiz/multi-quizzes-form.tsx
@@ -0,0 +1,384 @@
+'use client';
+
+import PasteConfirmModal from '../../app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/paste-confirm-modal';
+import QuizAddCard from '../../app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/quiz-add-card';
+import type { WorkspaceQuiz } from '@tuturuuu/types/db';
+import { Badge } from '@tuturuuu/ui/badge';
+import { Button } from '@tuturuuu/ui/button';
+import { Form } from '@tuturuuu/ui/form';
+import { useFieldArray, useForm } from '@tuturuuu/ui/hooks/use-form';
+import { toast } from '@tuturuuu/ui/hooks/use-toast';
+import { zodResolver } from '@tuturuuu/ui/resolvers';
+import { Separator } from '@tuturuuu/ui/separator';
+import { Copy, Loader2, Plus, Save } from 'lucide-react';
+import { useTranslations } from 'next-intl';
+import { useRouter } from 'next/navigation';
+import { useState } from 'react';
+import * as z from 'zod';
+
+interface Props {
+ wsId: string;
+ moduleId?: string;
+ setId?: string;
+ isEdit?: boolean;
+ data?: Array<
+ Partial<
+ WorkspaceQuiz & {
+ quiz_options: Array<{
+ id?: string;
+ value?: string;
+ is_correct?: boolean;
+ explanation?: string | null;
+ }>;
+ }
+ >
+ >;
+ onFinish?: (data: z.infer) => void;
+}
+
+const QuizOptionSchema = z.object({
+ id: z.string().optional(),
+ value: z.string().min(1, 'Option value is required'),
+ is_correct: z.boolean(),
+ explanation: z.string().optional(),
+});
+
+const QuizSchema = z.object({
+ id: z.string().optional(),
+ question: z.string().min(1, 'Question is required'),
+ quiz_options: z
+ .array(QuizOptionSchema)
+ .min(2, 'At least 2 options are required'),
+});
+
+const FormSchema = z.object({
+ moduleId: z.string().optional(),
+ setId: z.string().optional(),
+ quizzes: z.array(QuizSchema).min(1, 'At least one quiz is required'),
+});
+
+export default function MultiQuizzesForm({
+ wsId,
+ moduleId,
+ setId,
+ data,
+ onFinish,
+ isEdit = false,
+}: Props) {
+ const t = useTranslations('ws-quizzes');
+ const router = useRouter();
+
+ const [showPasteConfirm, setShowPasteConfirm] = useState(false);
+ const [pastedQuizzes, setPastedQuizzes] = useState([]);
+
+ const form = useForm({
+ resolver: zodResolver(FormSchema),
+ defaultValues: {
+ moduleId,
+ setId,
+ quizzes:
+ data && data.length > 0
+ ? data.map((quiz) => ({
+ id: quiz.id,
+ question: quiz.question || '',
+ quiz_options: quiz.quiz_options?.map((option) => ({
+ id: option.id,
+ value: option.value || '',
+ is_correct: option.is_correct || false,
+ explanation: option.explanation || '',
+ })) || [
+ { value: '', is_correct: false, explanation: '' },
+ { value: '', is_correct: false, explanation: '' },
+ ],
+ }))
+ : [
+ {
+ question: '',
+ quiz_options: [
+ { value: '', is_correct: false, explanation: '' },
+ { value: '', is_correct: false, explanation: '' },
+ ],
+ },
+ ],
+ },
+ });
+
+ const {
+ fields: quizFields,
+ append: appendQuiz,
+ remove: removeQuiz,
+ } = useFieldArray({
+ control: form.control,
+ name: 'quizzes',
+ });
+
+ // Check if any quiz has 0 correct answers
+ const hasQuizzesWithNoCorrectAnswers = () => {
+ const quizzes = form.watch('quizzes');
+ return quizzes.some((quiz) => {
+ const correctAnswersCount = quiz.quiz_options.filter(
+ (option) => option.is_correct
+ ).length;
+ return correctAnswersCount === 0;
+ });
+ };
+
+ const { isDirty, isValid, isSubmitting } = form.formState;
+ const disabled =
+ !isDirty || !isValid || isSubmitting || hasQuizzesWithNoCorrectAnswers();
+
+ const onSubmit = async (formData: z.infer) => {
+ try {
+ // const promises = formData.quizzes.map(async (quiz) => {
+ // // const res = await fetch(quiz.id ? `/api/v1/workspaces/${wsId}/quizzes/update-all` : `/api/v1/workspaces/${wsId}/quizzes`, {
+ // const res = await fetch(`/api/v1/workspaces/${wsId}/quizzes`, {
+ // method: quiz.id ? 'PUT' : 'POST',
+ // headers: { 'Content-Type': 'application/json' },
+ // body: JSON.stringify({
+ // ...quiz,
+ // moduleId: formData.moduleId,
+ // setId: formData.setId,
+ // }),
+ // });
+
+ // if (!res.ok) {
+ // const errorData = await res.json();
+ // console.log('Error response:', errorData);
+ // throw new Error(
+ // errorData.message || quiz.id
+ // ? t('form.error.fail-update')
+ // : t('form.error.fail-create')
+ // );
+ // }
+
+ // return res.json();
+ // });
+
+ // await Promise.all(promises);
+
+ const res = await fetch(`/api/v1/workspaces/${wsId}/quizzes`, {
+ method: isEdit ? 'PUT' : 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ moduleId, setId, quizzes: formData.quizzes }),
+ });
+
+ if (!res.ok) {
+ const errorData = await res.json();
+ console.log('Error response:', errorData);
+ throw new Error(
+ errorData.message
+ ? t('form.error.fail-update')
+ : t('form.error.fail-create')
+ );
+ }
+ // const json = await res.json();
+
+ let description = '';
+ if (formData.quizzes.length > 1) {
+ if (data && data.length > 0) {
+ description = t('form.success.edit-quizzes', {
+ length: formData.quizzes.length,
+ });
+ } else {
+ description = t('form.success.create-quizzes', {
+ length: formData.quizzes.length,
+ });
+ }
+ } else {
+ if (data && data.length > 0) {
+ description = t('form.success.edit-quiz', {
+ length: formData.quizzes.length,
+ });
+ } else {
+ description = t('form.success.create-quiz', {
+ length: formData.quizzes.length,
+ });
+ }
+ }
+
+ toast({
+ title: t('form.success.title'),
+ // description: `Successfully ${data && data.length > 0 ? 'updated' : 'created'} ${formData.quizzes.length} quiz${
+ // formData.quizzes.length > 1 ? 'es' : ''
+ // }`,
+ description: description,
+ });
+
+ onFinish?.(formData);
+ router.push(`/${wsId}/quiz-sets/${setId}`);
+ router.refresh();
+ } catch (error) {
+ toast({
+ title: t('form.error.title'),
+ description:
+ error instanceof Error ? error.message : t('form.error.unexpected'),
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const pasteFromClipboard = async () => {
+ try {
+ const clipboardText = await navigator.clipboard.readText();
+ const parsedData = JSON.parse(clipboardText);
+
+ // Validate if it's a valid quiz structure
+ if (
+ parsedData &&
+ typeof parsedData === 'object' &&
+ parsedData.question &&
+ parsedData.quiz_options
+ ) {
+ setPastedQuizzes([parsedData]);
+ setShowPasteConfirm(true);
+ } else if (
+ Array.isArray(parsedData) &&
+ parsedData.every((item) => item.question && item.quiz_options)
+ ) {
+ setPastedQuizzes(parsedData);
+ setShowPasteConfirm(true);
+ } else {
+ throw new Error(t('form.error.invalid-clipboard'));
+ }
+ } catch (error) {
+ console.log('Failed to parse clipboard data:', error);
+ toast({
+ title: t('form.error.invalid-clipboard-title'),
+ description: t('form.error.invalid-clipboard-message'),
+ variant: 'destructive',
+ });
+ }
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+ {data && data.length > 0
+ ? t('form.edit-title')
+ : t('form.create-title')}
+
+
+ {data && data.length > 0
+ ? t('form.edit-description')
+ : t('form.create-description')}
+
+
+
+ {quizFields.length !== 1
+ ? t('form.content.questions.count-many', {
+ count: quizFields.length,
+ })
+ : t('form.content.questions.count-one', {
+ count: quizFields.length,
+ })}
+
+
+ {quizFields.reduce((total, _, index) => {
+ const options = form.watch(`quizzes.${index}.quiz_options`);
+ return total + (options?.length || 0);
+ }, 0)}{' '}
+ {t('form.content.options.total-options-text')}
+
+
+
+
+
+
+
+ {/* Paste Confirmation Dialog */}
+ {showPasteConfirm && (
+
+ )}
+
+ );
+}
diff --git a/apps/upskii/src/components/quiz/quiz-set-form.tsx b/apps/upskii/src/components/quiz/quiz-set-form.tsx
new file mode 100644
index 0000000000..01dc2a0b4a
--- /dev/null
+++ b/apps/upskii/src/components/quiz/quiz-set-form.tsx
@@ -0,0 +1,606 @@
+'use client';
+
+import InstructionEditor from '../../app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/instruction-editor';
+import type { WorkspaceQuizSet } from '@tuturuuu/types/db';
+import { Badge } from '@tuturuuu/ui/badge';
+import { Button } from '@tuturuuu/ui/button';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@tuturuuu/ui/card';
+import { Checkbox } from '@tuturuuu/ui/checkbox';
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@tuturuuu/ui/form';
+import { useForm } from '@tuturuuu/ui/hooks/use-form';
+import { toast } from '@tuturuuu/ui/hooks/use-toast';
+import { Input } from '@tuturuuu/ui/input';
+import { zodResolver } from '@tuturuuu/ui/resolvers';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@tuturuuu/ui/select';
+import {
+ Calendar,
+ Clock,
+ Eye,
+ FileText,
+ RotateCcw,
+ Settings,
+} from 'lucide-react';
+import { useTranslations } from 'next-intl';
+import { useRouter } from 'next/navigation';
+import * as z from 'zod';
+
+interface Props {
+ wsId: string;
+ moduleId?: string;
+ data?: WorkspaceQuizSet;
+ onFinish?: (data: z.infer) => void;
+}
+
+const FormSchema = z.object({
+ id: z.string().optional(),
+ name: z.string().nonempty(),
+ moduleId: z.string().optional(),
+ attemptLimit: z.number().nullable(),
+ timeLimitMinutes: z.number().nullable(),
+ allowViewResults: z.boolean(),
+ dueDate: z.string(),
+ availableDate: z.string(),
+ explanationMode: z.number().int(),
+ instruction: z.any().optional(), // Changed to z.any() to handle JSONContent
+ resultsReleased: z.boolean(),
+ allowViewOldAttempts: z.boolean(),
+});
+
+export default function QuizSetForm({ wsId, moduleId, data, onFinish }: Props) {
+ const t = useTranslations();
+ const router = useRouter();
+ const form = useForm({
+ resolver: zodResolver(FormSchema),
+ values: {
+ id: data?.id,
+ name: data?.name ?? '',
+ moduleId,
+ attemptLimit: data?.attempt_limit ?? null,
+ timeLimitMinutes: data?.time_limit_minutes ?? null,
+ allowViewResults: data?.allow_view_results ?? true,
+ availableDate: data?.available_date
+ ? data.available_date.toString().slice(0, 16)
+ : '',
+ dueDate: data?.due_date ? data.due_date.toString().slice(0, 16) : '',
+ explanationMode: data?.explanation_mode ?? 0,
+ instruction: data?.instruction ?? null, // Changed to handle JSONContent directly
+ resultsReleased: data?.results_released ?? false,
+ allowViewOldAttempts: data?.allow_view_old_attempts ?? true,
+ },
+ });
+
+ const { isDirty, isValid, isSubmitting } = form.formState;
+ const disabled = !isDirty || !isValid || isSubmitting;
+
+ const onSubmit = async (values: z.infer) => {
+ try {
+ const { timeLimitMinutes, attemptLimit, instruction } = values;
+ const payload = {
+ name: values.name.trim(),
+ moduleId: moduleId,
+ allow_view_results: values.allowViewResults,
+ due_date: values.dueDate,
+ available_date: values.availableDate,
+ explanation_mode: values.explanationMode,
+ results_released: values.resultsReleased,
+ allow_view_old_attempts: values.allowViewOldAttempts,
+
+ time_limit_minutes: timeLimitMinutes
+ ? timeLimitMinutes <= 0
+ ? null
+ : timeLimitMinutes
+ : null,
+ attempt_limit: attemptLimit
+ ? attemptLimit <= 0
+ ? null
+ : attemptLimit
+ : null,
+ instruction: instruction, // No need to JSON.parse since it's already JSONContent
+ };
+ // console.log('Payload', payload, values.timeLimitMinutes != null && values.timeLimitMinutes <=0, "hello");
+ const res = await fetch(
+ values.id
+ ? `/api/v1/workspaces/${wsId}/quiz-sets/${values.id}`
+ : `/api/v1/workspaces/${wsId}/quiz-sets`,
+ {
+ method: values.id ? 'PUT' : 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ }
+ );
+ if (res.ok) {
+ onFinish?.(values);
+ router.refresh();
+
+ // navigate to set detail page if created or edited successfully
+ if (!values.id) {
+ const newSet = await res.json();
+ router.push(`/${wsId}/quiz-sets/${newSet.setId}`);
+ } else {
+ router.push(`/${wsId}/quiz-sets/${values.id}`);
+ }
+ } else {
+ const err = await res.json();
+ toast({ title: t('error_saving'), description: err.message });
+ }
+ } catch (error) {
+ toast({
+ title: t('error_saving'),
+ description: error instanceof Error ? error.message : String(error),
+ });
+ }
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+ {data?.id ? t('ws-quiz-sets.edit') : t('ws-quiz-sets.create')}
+
+
+ {t('ws-quiz-sets.form-description')}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/api/v1/workspaces/[wsId]/quizzes/route.ts b/apps/web/src/app/api/v1/workspaces/[wsId]/quizzes/route.ts
index 0b6ebee132..c1f652d97f 100644
--- a/apps/web/src/app/api/v1/workspaces/[wsId]/quizzes/route.ts
+++ b/apps/web/src/app/api/v1/workspaces/[wsId]/quizzes/route.ts
@@ -30,46 +30,84 @@ export async function GET(_: Request, { params }: Params) {
export async function POST(req: Request, { params }: Params) {
const supabase = await createClient();
- const { wsId: id } = await params;
+ const { wsId } = await params;
+ const body = await req.json();
+ const { moduleId, setId, quizzes } = body as {
+ moduleId?: string;
+ setId?: string;
+ quizzes: Array<{
+ id?: string;
+ question: string;
+ quiz_options: Array<{
+ id?: string;
+ value: string;
+ is_correct: boolean;
+ explanation?: string | null;
+ }>;
+ }>;
+ };
- const { moduleId, setId, quiz_options, ...rest } = await req.json();
+ try {
+ // Process each quiz sequentially (you could also do Promise.all for concurrency)
+ for (const quiz of quizzes) {
+ let quizId: string;
- const { data, error } = await supabase
- .from('workspace_quizzes')
- .insert({
- ...rest,
- ws_id: id,
- })
- .select('id')
- .single();
+ if (quiz.id) {
+ // existing quiz → update
+ const { error: updateErr } = await supabase
+ .from('workspace_quizzes')
+ .update({ question: quiz.question })
+ .eq('id', quiz.id);
+ if (updateErr) throw updateErr;
+ quizId = quiz.id;
+ } else {
+ // new quiz → insert
+ const { data: inserted, error: insertErr } = await supabase
+ .from('workspace_quizzes')
+ .insert({ question: quiz.question, ws_id: wsId })
+ .select('id')
+ .single();
+ if (insertErr) throw insertErr;
+ quizId = inserted.id;
+ }
+ // Link to module if provided
+ if (moduleId) {
+ await supabase.from('course_module_quizzes').insert({
+ module_id: moduleId,
+ quiz_id: quizId,
+ });
+ }
- if (error) {
- console.log(error);
+ // Link to set if provided
+ if (setId) {
+ await supabase.from('quiz_set_quizzes').insert({
+ set_id: setId,
+ quiz_id: quizId,
+ });
+ }
+
+ // Sync options: simplest is to delete old and re-insert
+ await supabase.from('quiz_options').delete().eq('quiz_id', quizId);
+ if (quiz.quiz_options.length) {
+ const optionsPayload = quiz.quiz_options.map((o) => ({
+ quiz_id: quizId,
+ value: o.value,
+ is_correct: o.is_correct,
+ explanation: o.explanation ?? null,
+ }));
+ const { error: optsErr } = await supabase
+ .from('quiz_options')
+ .insert(optionsPayload);
+ if (optsErr) throw optsErr;
+ }
+ }
+
+ return NextResponse.json({ message: 'All quizzes processed successfully' });
+ } catch (error: any) {
+ console.error('Bulk quiz error:', error);
return NextResponse.json(
- { message: 'Error creating workspace quiz' },
+ { message: error.message || 'An error occurred processing quizzes' },
{ status: 500 }
);
}
-
- if (moduleId) {
- await supabase.from('course_module_quizzes').insert({
- module_id: moduleId,
- quiz_id: data.id,
- });
- }
-
- if (setId) {
- await supabase.from('quiz_set_quizzes').insert({
- set_id: setId,
- quiz_id: data.id,
- });
- }
-
- if (quiz_options) {
- await supabase
- .from('quiz_options')
- .insert(quiz_options.map((o: any) => ({ ...o, quiz_id: data.id })));
- }
-
- return NextResponse.json({ message: 'success' });
}
diff --git a/packages/ui/src/components/ui/custom/feature-summary.tsx b/packages/ui/src/components/ui/custom/feature-summary.tsx
index f5dc661f40..3c7ca4f963 100644
--- a/packages/ui/src/components/ui/custom/feature-summary.tsx
+++ b/packages/ui/src/components/ui/custom/feature-summary.tsx
@@ -2,6 +2,7 @@ import { Button } from '../button';
import ModifiableDialogTrigger from './modifiable-dialog-trigger';
import { cn } from '@tuturuuu/utils/format';
import { Cog, Plus } from 'lucide-react';
+import Link from 'next/link';
import { type ReactElement, ReactNode } from 'react';
interface FormProps {
@@ -17,6 +18,7 @@ interface Props {
trigger?: ReactNode;
form?: ReactElement>;
href?: string;
+ secondaryHref?: string;
title?: ReactNode;
pluralTitle?: string;
singularTitle?: string;
@@ -46,6 +48,7 @@ export default function FeatureSummary({
defaultData,
form,
href,
+ secondaryHref,
title,
pluralTitle,
singularTitle,
@@ -94,6 +97,10 @@ export default function FeatureSummary({
)}
+ {href && !form && {primaryTrigger}}
+ {secondaryHref && !form && (
+ {secondaryTrigger}
+ )}
{(form ||
action ||
showDefaultFormAsSecondary ||