1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-02 13:52:46 +02:00
This commit is contained in:
Arik Chakma
2025-07-02 19:25:11 +06:00
parent bc55f466e7
commit 1be13b9148
5 changed files with 279 additions and 40 deletions

View File

@@ -2,13 +2,17 @@ import type { QuizQuestion } from '../../queries/ai-quiz';
import { cn } from '../../lib/classname';
import { CheckIcon, XIcon, InfoIcon } from 'lucide-react';
import { markdownToHtml } from '../../lib/markdown';
import type { QuestionState } from './AIQuizContent';
export const markdownClassName =
'prose prose-lg prose-p:text-lg prose-p:font-normal prose-p:my-0 prose-pre:my-0 prose-p:prose-code:text-base! prose-p:prose-code:px-2 prose-p:prose-code:py-0.5 prose-p:prose-code:rounded-lg prose-p:prose-code:border prose-p:prose-code:border-black text-left text-black';
type AIMCQQuestionProps = {
question: QuizQuestion;
selectedOptions: number[];
setSelectedOptions: (options: number[]) => void;
isSubmitted: boolean;
onSubmit: () => void;
onSubmit: (status: QuestionState['status']) => void;
onNext: () => void;
};
@@ -51,21 +55,19 @@ export function AIMCQQuestion(props: AIMCQQuestionProps) {
return;
}
onSubmit();
const isCorrect =
selectedOptions.every((index) => options[index].isCorrect) &&
selectedOptions.length ===
options.filter((option) => option.isCorrect).length;
onSubmit(isCorrect ? 'correct' : 'incorrect');
};
const canSubmit = selectedOptions.length > 0;
const titleHtml = markdownToHtml(questionText, false);
const markdownClassName =
'prose prose-lg prose-p:text-lg prose-p:font-normal prose-p:my-0 prose-pre:my-0 prose-p:prose-code:text-base! prose-p:prose-code:px-2 prose-p:prose-code:py-0.5 prose-p:prose-code:rounded-lg prose-p:prose-code:border prose-p:prose-code:border-black text-left text-black';
return (
<div>
<div
className="prose prose-lg prose-p:text-4xl prose-p:font-medium prose-p:my-4 prose-pre:my-0 prose-p:prose-code:text-3xl! prose-p:prose-code:px-3 prose-p:prose-code:rounded-lg prose-p:prose-code:border prose-p:prose-code:border-black text-black"
dangerouslySetInnerHTML={{ __html: titleHtml }}
/>
<QuestionTitle title={questionText} />
<div className="mt-6 space-y-3">
{options.map((option, index) => {
@@ -137,18 +139,7 @@ export function AIMCQQuestion(props: AIMCQQuestionProps) {
</div>
{isSubmitted && answerExplanation && (
<div className="mt-4 rounded-xl bg-gray-100 p-4">
<p className="flex items-center gap-2 text-lg text-gray-600">
<InfoIcon className="size-4" />
Explanation
</p>
<p
className={cn(markdownClassName, 'mt-0.5')}
dangerouslySetInnerHTML={{
__html: markdownToHtml(answerExplanation, false),
}}
/>
</div>
<QuestionExplanation explanation={answerExplanation} />
)}
<button
@@ -163,3 +154,43 @@ export function AIMCQQuestion(props: AIMCQQuestionProps) {
</div>
);
}
type QuestionTitleProps = {
title: string;
};
export function QuestionTitle(props: QuestionTitleProps) {
const { title } = props;
const titleHtml = markdownToHtml(title, false);
return (
<div
className="prose prose-lg prose-p:text-4xl prose-p:font-medium prose-p:my-4 prose-pre:my-0 prose-p:prose-code:text-3xl! prose-p:prose-code:px-3 prose-p:prose-code:rounded-lg prose-p:prose-code:border prose-p:prose-code:border-black text-black"
dangerouslySetInnerHTML={{ __html: titleHtml }}
/>
);
}
type QuestionExplanationProps = {
explanation: string;
};
export function QuestionExplanation(props: QuestionExplanationProps) {
const { explanation } = props;
const explanationHtml = markdownToHtml(explanation, false);
return (
<div className="mt-4 rounded-xl bg-gray-100 p-4">
<p className="flex items-center gap-2 text-lg text-gray-600">
<InfoIcon className="size-4" />
Explanation
</p>
<div
className={cn(markdownClassName, 'mt-0.5')}
dangerouslySetInnerHTML={{ __html: explanationHtml }}
/>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import type { QuizQuestion } from '../../queries/ai-quiz';
import { cn } from '../../lib/classname';
import { InfoIcon, Loader2Icon } from 'lucide-react';
import { markdownToHtml } from '../../lib/markdown';
import { QuestionExplanation, QuestionTitle } from './AIMCQQuestion';
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { httpPost } from '../../lib/query-http';
import { queryClient } from '../../stores/query-client';
import type { QuestionState } from './AIQuizContent';
type VerifyQuizAnswerResponse = {
isCorrect?: boolean;
correctAnswer?: string;
};
type AIOpenEndedQuestionProps = {
quizSlug: string;
question: QuizQuestion;
isSubmitted: boolean;
onSubmit: (status: QuestionState['status']) => void;
onNext: () => void;
userAnswer: string;
setUserAnswer: (answer: string) => void;
correctAnswer: string;
setCorrectAnswer: (answer: string) => void;
status: QuestionState['status'];
};
export function AIOpenEndedQuestion(props: AIOpenEndedQuestionProps) {
const {
quizSlug,
question,
isSubmitted,
onSubmit,
onNext,
userAnswer,
setUserAnswer,
correctAnswer,
setCorrectAnswer,
status,
} = props;
const { title: questionText } = question;
const {
mutate: verifyAnswer,
isPending: isVerifying,
data: verifyAnswerData,
} = useMutation(
{
mutationFn: (answer: string) => {
return httpPost<VerifyQuizAnswerResponse>(
`/v1-verify-quiz-answer/${quizSlug}`,
{
question: question.title,
userAnswer,
},
);
},
onSuccess: (data) => {
setCorrectAnswer(data.correctAnswer ?? '');
onSubmit?.(data.isCorrect ? 'correct' : 'incorrect');
},
},
queryClient,
);
const handleSubmit = () => {
if (isSubmitted) {
onNext?.();
return;
}
verifyAnswer(userAnswer);
};
const canSubmit = userAnswer.trim().length > 0;
const markdownClassName =
'prose prose-lg prose-p:text-lg prose-p:font-normal prose-p:my-0 prose-pre:my-0 prose-p:prose-code:text-base! prose-p:prose-code:px-2 prose-p:prose-code:py-0.5 prose-p:prose-code:rounded-lg prose-p:prose-code:border prose-p:prose-code:border-black text-left text-black';
return (
<div>
<QuestionTitle title={questionText} />
<div className="mt-6">
<textarea
className={cn(
'min-h-[200px] w-full resize-none rounded-xl border border-gray-200 p-4 text-lg',
'focus:border-gray-400 focus:ring-0 focus:outline-none',
isSubmitted && 'bg-gray-50',
isSubmitted &&
status === 'correct' &&
'border-green-500 bg-green-50',
isSubmitted && status === 'incorrect' && 'border-red-500 bg-red-50',
)}
placeholder="Type your answer here..."
value={userAnswer}
onChange={(e) => setUserAnswer(e.target.value)}
disabled={isSubmitted || isVerifying}
/>
</div>
{!isVerifying && correctAnswer && (
<QuestionExplanation explanation={correctAnswer} />
)}
<button
className={cn(
'mt-4 flex h-10 min-w-[142px] items-center justify-center rounded-xl bg-black px-4 py-2 text-white hover:bg-gray-900 disabled:opacity-70',
)}
onClick={handleSubmit}
disabled={!canSubmit || isVerifying}
>
{isVerifying ? (
<Loader2Icon className="size-4 animate-spin stroke-[2.5]" />
) : isSubmitted ? (
'Next Question'
) : (
'Submit Answer'
)}
</button>
</div>
);
}

View File

@@ -118,6 +118,7 @@ export function AIQuiz(props: AIQuizProps) {
<div className="grow overflow-y-auto p-4 pt-0">
{quizSlug && !aiQuizError && (
<AIQuizContent
quizSlug={quizSlug}
questions={aiQuiz?.questions ?? []}
isLoading={isLoading}
/>

View File

@@ -6,43 +6,98 @@ import {
type LucideIcon,
} from 'lucide-react';
import { AIMCQQuestion } from './AIMCQQuestion';
import { AIOpenEndedQuestion } from './AIOpenEndedQuestion';
export type QuestionState = {
isSubmitted: boolean;
selectedOptions?: number[];
userAnswer?: string;
correctAnswer?: string;
status: 'correct' | 'incorrect' | 'skipped' | 'pending';
};
const DEFAULT_QUESTION_STATE: QuestionState = {
isSubmitted: false,
selectedOptions: [],
userAnswer: '',
correctAnswer: '',
status: 'pending',
};
type AIQuizContentProps = {
quizSlug?: string;
questions: QuizQuestion[];
isLoading?: boolean;
};
export function AIQuizContent(props: AIQuizContentProps) {
const { questions, isLoading } = props;
const { quizSlug, questions, isLoading } = props;
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
const activeQuestion = questions[activeQuestionIndex];
const [selectedOptions, setSelectedOptions] = useState<
Record<
number,
{
selectedOptions: number[];
isSubmitted: boolean;
}
>
Record<number, QuestionState>
>({});
const hasMoreQuestions = activeQuestionIndex < questions.length - 1;
const hasPreviousQuestions = activeQuestionIndex > 0;
const activeQuestionSelectedOptions =
selectedOptions[activeQuestionIndex]?.selectedOptions ?? [];
const activeQuestionIsSubmitted =
selectedOptions[activeQuestionIndex]?.isSubmitted ?? false;
const activeQuestionState =
selectedOptions[activeQuestionIndex] ?? DEFAULT_QUESTION_STATE;
const handleSubmit = (questionIndex: number) => {
const activeQuestionSelectedOptions =
activeQuestionState.selectedOptions ?? [];
const activeQuestionIsSubmitted = activeQuestionState.isSubmitted ?? false;
const handleSubmit = (
questionIndex: number,
status: QuestionState['status'],
) => {
setSelectedOptions((prev) => {
const oldState = prev[questionIndex] ?? DEFAULT_QUESTION_STATE;
const newSelectedOptions = {
...prev,
[questionIndex]: {
selectedOptions: prev[questionIndex].selectedOptions,
...oldState,
isSubmitted: true,
status,
},
};
return newSelectedOptions;
});
};
const handleSetUserAnswer = (questionIndex: number, userAnswer: string) => {
setSelectedOptions((prev) => {
const oldState = prev[questionIndex] ?? DEFAULT_QUESTION_STATE;
const newSelectedOptions = {
...prev,
[questionIndex]: {
...oldState,
userAnswer,
},
};
return newSelectedOptions;
});
};
const handleSetCorrectAnswer = (
questionIndex: number,
correctAnswer: string,
) => {
setSelectedOptions((prev) => {
const oldState = prev[questionIndex] ?? DEFAULT_QUESTION_STATE;
const newSelectedOptions = {
...prev,
[questionIndex]: {
...oldState,
correctAnswer,
},
};
@@ -52,9 +107,15 @@ export function AIQuizContent(props: AIQuizContentProps) {
const handleSelectOptions = (questionIndex: number, options: number[]) => {
setSelectedOptions((prev) => {
const oldState = prev[questionIndex] ?? DEFAULT_QUESTION_STATE;
const newSelectedOptions = {
...prev,
[questionIndex]: { selectedOptions: options, isSubmitted: false },
[questionIndex]: {
...oldState,
selectedOptions: options,
isSubmitted: true,
},
};
return newSelectedOptions;
@@ -72,7 +133,7 @@ export function AIQuizContent(props: AIQuizContentProps) {
Question {activeQuestionIndex + 1} of {questions.length}
</span>
<div className="relative h-1.5 mx-2 grow rounded-full bg-gray-200">
<div className="relative mx-2 h-1.5 grow rounded-full bg-gray-200">
<div
className="absolute inset-0 rounded-full bg-black"
style={{
@@ -101,10 +162,29 @@ export function AIQuizContent(props: AIQuizContentProps) {
setSelectedOptions={(options) =>
handleSelectOptions(activeQuestionIndex, options)
}
onSubmit={() => handleSubmit(activeQuestionIndex)}
onSubmit={(status) => handleSubmit(activeQuestionIndex, status)}
onNext={() => setActiveQuestionIndex(activeQuestionIndex + 1)}
/>
)}
{activeQuestion && activeQuestion.type === 'open-ended' && (
<AIOpenEndedQuestion
quizSlug={quizSlug ?? ''}
question={activeQuestion}
isSubmitted={activeQuestionIsSubmitted}
onSubmit={(status) => handleSubmit(activeQuestionIndex, status)}
onNext={() => setActiveQuestionIndex(activeQuestionIndex + 1)}
userAnswer={activeQuestionState.userAnswer ?? ''}
setUserAnswer={(userAnswer) =>
handleSetUserAnswer(activeQuestionIndex, userAnswer)
}
correctAnswer={activeQuestionState.correctAnswer ?? ''}
setCorrectAnswer={(correctAnswer) =>
handleSetCorrectAnswer(activeQuestionIndex, correctAnswer)
}
status={activeQuestionState.status}
/>
)}
</div>
);
}