feat: Implement quiz runner functionality with dedicated pages, composable logic, and i18n support.
This commit is contained in:
parent
7e8c8e2532
commit
67f10c4287
5 changed files with 759 additions and 11 deletions
255
Frontend-Learner/composables/useQuizRunner.ts
Normal file
255
Frontend-Learner/composables/useQuizRunner.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
export type QuestionStatus = 'not_started' | 'in_progress' | 'completed' | 'skipped';
|
||||
|
||||
export interface QuizQuestion {
|
||||
id: number;
|
||||
title: string;
|
||||
is_skippable: boolean;
|
||||
type: 'single' | 'multiple' | 'text';
|
||||
options?: { id: string; label: string }[];
|
||||
}
|
||||
|
||||
export interface AnswerState {
|
||||
questionId: number;
|
||||
value: string | string[] | null;
|
||||
is_saved: boolean;
|
||||
status: QuestionStatus;
|
||||
touched: boolean;
|
||||
last_saved_at?: string;
|
||||
}
|
||||
|
||||
// Mock Data
|
||||
const MOCK_QUESTIONS: QuizQuestion[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'What is the capital of France?',
|
||||
is_skippable: true,
|
||||
type: 'single',
|
||||
options: [
|
||||
{ id: 'london', label: 'London' },
|
||||
{ id: 'paris', label: 'Paris' },
|
||||
{ id: 'berlin', label: 'Berlin' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Explain the concept of closure in JavaScript.',
|
||||
is_skippable: false,
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Which of the following are Vue lifecycle hooks? (Select all that apply)',
|
||||
is_skippable: true,
|
||||
type: 'multiple',
|
||||
options: [
|
||||
{ id: 'created', label: 'created' },
|
||||
{ id: 'mounted', label: 'mounted' },
|
||||
{ id: 'render', label: 'render' },
|
||||
{ id: 'compute', label: 'compute' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'What is 2 + 2?',
|
||||
is_skippable: false,
|
||||
type: 'single',
|
||||
options: [
|
||||
{ id: '3', label: '3' },
|
||||
{ id: '4', label: '4' },
|
||||
{ id: '5', label: '5' },
|
||||
],
|
||||
}
|
||||
];
|
||||
|
||||
export const useQuizRunner = () => {
|
||||
// State (using useState for Nuxt SSR safety and persistence across component reloads if needed)
|
||||
const questions = useState<QuizQuestion[]>('quiz-questions', () => []);
|
||||
const answers = useState<Record<number, AnswerState>>('quiz-answers', () => ({}));
|
||||
const currentQuestionIndex = useState<number>('quiz-current-index', () => 0);
|
||||
const loading = useState<boolean>('quiz-loading', () => false);
|
||||
const lastError = useState<string | null>('quiz-error', () => null);
|
||||
|
||||
// Getters
|
||||
const currentQuestion = computed(() => questions.value[currentQuestionIndex.value]);
|
||||
|
||||
const currentAnswer = computed(() => {
|
||||
if (!currentQuestion.value) return null;
|
||||
return answers.value[currentQuestion.value.id];
|
||||
});
|
||||
|
||||
const totalQuestions = computed(() => questions.value.length);
|
||||
const isLastQuestion = computed(() => currentQuestionIndex.value === questions.value.length - 1);
|
||||
const isFirstQuestion = computed(() => currentQuestionIndex.value === 0);
|
||||
|
||||
// Actions
|
||||
function initQuiz(quizId: string) {
|
||||
questions.value = [...MOCK_QUESTIONS];
|
||||
currentQuestionIndex.value = 0;
|
||||
answers.value = {};
|
||||
lastError.value = null;
|
||||
|
||||
questions.value.forEach(q => {
|
||||
answers.value[q.id] = {
|
||||
questionId: q.id,
|
||||
value: q.type === 'multiple' ? [] : null,
|
||||
is_saved: false,
|
||||
status: 'not_started',
|
||||
touched: false,
|
||||
};
|
||||
});
|
||||
|
||||
if (questions.value.length > 0) {
|
||||
enterQuestion(questions.value[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
function enterQuestion(qId: number) {
|
||||
const ans = answers.value[qId];
|
||||
if (ans) {
|
||||
ans.touched = true;
|
||||
// Mark as in_progress if not final state
|
||||
if (ans.status === 'not_started' || ans.status === 'skipped') {
|
||||
ans.status = 'in_progress';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canLeaveCurrent(): { allowed: boolean; reason?: string } {
|
||||
if (!currentQuestion.value) return { allowed: true };
|
||||
const q = currentQuestion.value;
|
||||
const a = answers.value[q.id];
|
||||
|
||||
if (a.status === 'completed' || a.is_saved) return { allowed: true };
|
||||
if (q.is_skippable) return { allowed: true };
|
||||
|
||||
// Required and unsaved
|
||||
if (!a.is_saved) {
|
||||
return { allowed: false, reason: 'This question is required and must be saved.' };
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
function updateAnswer(val: string | string[] | null) {
|
||||
if (!currentQuestion.value) return;
|
||||
const qId = currentQuestion.value.id;
|
||||
answers.value[qId].value = val;
|
||||
|
||||
// If modifying a completed answer, revert to in_progress until saved again?
|
||||
// Yes, to enforce "Green = Saved Successfully" implies current state matches saved state.
|
||||
if (answers.value[qId].is_saved) {
|
||||
answers.value[qId].is_saved = false;
|
||||
answers.value[qId].status = 'in_progress';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCurrentAnswer() {
|
||||
if (!currentQuestion.value) return;
|
||||
const qId = currentQuestion.value.id;
|
||||
const ans = answers.value[qId];
|
||||
|
||||
// Validation
|
||||
if (currentQuestion.value.type === 'multiple') {
|
||||
if (!ans.value || (ans.value as string[]).length === 0) {
|
||||
lastError.value = "Please select at least one option.";
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (!ans.value || (ans.value as string).trim() === '') {
|
||||
lastError.value = "Please provide an answer.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
lastError.value = null;
|
||||
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
ans.is_saved = true;
|
||||
ans.status = 'completed';
|
||||
ans.last_saved_at = new Date().toISOString();
|
||||
return true;
|
||||
} catch (e) {
|
||||
lastError.value = "Failed to save answer.";
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleLeaveLogic(targetIndex: number) {
|
||||
if (targetIndex === currentQuestionIndex.value) return;
|
||||
|
||||
const check = canLeaveCurrent();
|
||||
if (!check.allowed) {
|
||||
lastError.value = check.reason || "Required question.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mark current as skipped if leaving without completion
|
||||
const currQ = currentQuestion.value;
|
||||
const currAns = answers.value[currQ.id];
|
||||
// If we leave and it is NOT completed (and implicit skippable check passed), set SKIPPED
|
||||
if (currAns.status !== 'completed' && !currAns.is_saved) {
|
||||
currAns.status = 'skipped';
|
||||
}
|
||||
|
||||
currentQuestionIndex.value = targetIndex;
|
||||
lastError.value = null;
|
||||
|
||||
if (questions.value[targetIndex]) {
|
||||
enterQuestion(questions.value[targetIndex].id);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function nextQuestion() {
|
||||
if (isLastQuestion.value) return;
|
||||
handleLeaveLogic(currentQuestionIndex.value + 1);
|
||||
}
|
||||
|
||||
async function prevQuestion() {
|
||||
if (isFirstQuestion.value) return;
|
||||
handleLeaveLogic(currentQuestionIndex.value - 1);
|
||||
}
|
||||
|
||||
async function goToQuestion(index: number) {
|
||||
if (index < 0 || index >= questions.value.length) return;
|
||||
handleLeaveLogic(index);
|
||||
}
|
||||
|
||||
async function skipQuestion() {
|
||||
if (isLastQuestion.value) {
|
||||
// If last question and skip... maybe just mark skipped?
|
||||
const currQ = currentQuestion.value;
|
||||
const currAns = answers.value[currQ.id];
|
||||
currAns.status = 'skipped';
|
||||
return;
|
||||
}
|
||||
handleLeaveLogic(currentQuestionIndex.value + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
questions,
|
||||
answers,
|
||||
currentQuestionIndex,
|
||||
loading,
|
||||
lastError,
|
||||
|
||||
currentQuestion,
|
||||
currentAnswer,
|
||||
totalQuestions,
|
||||
isFirstQuestion,
|
||||
isLastQuestion,
|
||||
|
||||
initQuiz,
|
||||
updateAnswer,
|
||||
saveCurrentAnswer,
|
||||
nextQuestion,
|
||||
prevQuestion,
|
||||
goToQuestion,
|
||||
skipQuestion
|
||||
};
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue