mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-02 13:52:46 +02:00
wip
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
127
src/components/AIQuiz/AIOpenEndedQuestion.tsx
Normal file
127
src/components/AIQuiz/AIOpenEndedQuestion.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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}
|
||||
/>
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -113,5 +113,5 @@ export function GenerateAIQuiz(props: GenerateAIQuizProps) {
|
||||
);
|
||||
}
|
||||
|
||||
return <AIQuizContent questions={questions} />;
|
||||
return <AIQuizContent questions={questions} />;
|
||||
}
|
||||
|
Reference in New Issue
Block a user