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 { cn } from '../../lib/classname';
|
||||||
import { CheckIcon, XIcon, InfoIcon } from 'lucide-react';
|
import { CheckIcon, XIcon, InfoIcon } from 'lucide-react';
|
||||||
import { markdownToHtml } from '../../lib/markdown';
|
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 = {
|
type AIMCQQuestionProps = {
|
||||||
question: QuizQuestion;
|
question: QuizQuestion;
|
||||||
selectedOptions: number[];
|
selectedOptions: number[];
|
||||||
setSelectedOptions: (options: number[]) => void;
|
setSelectedOptions: (options: number[]) => void;
|
||||||
isSubmitted: boolean;
|
isSubmitted: boolean;
|
||||||
onSubmit: () => void;
|
onSubmit: (status: QuestionState['status']) => void;
|
||||||
onNext: () => void;
|
onNext: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,21 +55,19 @@ export function AIMCQQuestion(props: AIMCQQuestionProps) {
|
|||||||
return;
|
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 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<QuestionTitle title={questionText} />
|
||||||
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 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-6 space-y-3">
|
<div className="mt-6 space-y-3">
|
||||||
{options.map((option, index) => {
|
{options.map((option, index) => {
|
||||||
@@ -137,18 +139,7 @@ export function AIMCQQuestion(props: AIMCQQuestionProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSubmitted && answerExplanation && (
|
{isSubmitted && answerExplanation && (
|
||||||
<div className="mt-4 rounded-xl bg-gray-100 p-4">
|
<QuestionExplanation explanation={answerExplanation} />
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -163,3 +154,43 @@ export function AIMCQQuestion(props: AIMCQQuestionProps) {
|
|||||||
</div>
|
</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">
|
<div className="grow overflow-y-auto p-4 pt-0">
|
||||||
{quizSlug && !aiQuizError && (
|
{quizSlug && !aiQuizError && (
|
||||||
<AIQuizContent
|
<AIQuizContent
|
||||||
|
quizSlug={quizSlug}
|
||||||
questions={aiQuiz?.questions ?? []}
|
questions={aiQuiz?.questions ?? []}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
|
@@ -6,43 +6,98 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AIMCQQuestion } from './AIMCQQuestion';
|
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 = {
|
type AIQuizContentProps = {
|
||||||
|
quizSlug?: string;
|
||||||
questions: QuizQuestion[];
|
questions: QuizQuestion[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AIQuizContent(props: AIQuizContentProps) {
|
export function AIQuizContent(props: AIQuizContentProps) {
|
||||||
const { questions, isLoading } = props;
|
const { quizSlug, questions, isLoading } = props;
|
||||||
|
|
||||||
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
|
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
|
||||||
const activeQuestion = questions[activeQuestionIndex];
|
const activeQuestion = questions[activeQuestionIndex];
|
||||||
|
|
||||||
const [selectedOptions, setSelectedOptions] = useState<
|
const [selectedOptions, setSelectedOptions] = useState<
|
||||||
Record<
|
Record<number, QuestionState>
|
||||||
number,
|
|
||||||
{
|
|
||||||
selectedOptions: number[];
|
|
||||||
isSubmitted: boolean;
|
|
||||||
}
|
|
||||||
>
|
|
||||||
>({});
|
>({});
|
||||||
|
|
||||||
const hasMoreQuestions = activeQuestionIndex < questions.length - 1;
|
const hasMoreQuestions = activeQuestionIndex < questions.length - 1;
|
||||||
const hasPreviousQuestions = activeQuestionIndex > 0;
|
const hasPreviousQuestions = activeQuestionIndex > 0;
|
||||||
|
|
||||||
const activeQuestionSelectedOptions =
|
const activeQuestionState =
|
||||||
selectedOptions[activeQuestionIndex]?.selectedOptions ?? [];
|
selectedOptions[activeQuestionIndex] ?? DEFAULT_QUESTION_STATE;
|
||||||
const activeQuestionIsSubmitted =
|
|
||||||
selectedOptions[activeQuestionIndex]?.isSubmitted ?? false;
|
|
||||||
|
|
||||||
const handleSubmit = (questionIndex: number) => {
|
const activeQuestionSelectedOptions =
|
||||||
|
activeQuestionState.selectedOptions ?? [];
|
||||||
|
const activeQuestionIsSubmitted = activeQuestionState.isSubmitted ?? false;
|
||||||
|
|
||||||
|
const handleSubmit = (
|
||||||
|
questionIndex: number,
|
||||||
|
status: QuestionState['status'],
|
||||||
|
) => {
|
||||||
setSelectedOptions((prev) => {
|
setSelectedOptions((prev) => {
|
||||||
|
const oldState = prev[questionIndex] ?? DEFAULT_QUESTION_STATE;
|
||||||
|
|
||||||
const newSelectedOptions = {
|
const newSelectedOptions = {
|
||||||
...prev,
|
...prev,
|
||||||
[questionIndex]: {
|
[questionIndex]: {
|
||||||
selectedOptions: prev[questionIndex].selectedOptions,
|
...oldState,
|
||||||
isSubmitted: true,
|
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[]) => {
|
const handleSelectOptions = (questionIndex: number, options: number[]) => {
|
||||||
setSelectedOptions((prev) => {
|
setSelectedOptions((prev) => {
|
||||||
|
const oldState = prev[questionIndex] ?? DEFAULT_QUESTION_STATE;
|
||||||
|
|
||||||
const newSelectedOptions = {
|
const newSelectedOptions = {
|
||||||
...prev,
|
...prev,
|
||||||
[questionIndex]: { selectedOptions: options, isSubmitted: false },
|
[questionIndex]: {
|
||||||
|
...oldState,
|
||||||
|
selectedOptions: options,
|
||||||
|
isSubmitted: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return newSelectedOptions;
|
return newSelectedOptions;
|
||||||
@@ -72,7 +133,7 @@ export function AIQuizContent(props: AIQuizContentProps) {
|
|||||||
Question {activeQuestionIndex + 1} of {questions.length}
|
Question {activeQuestionIndex + 1} of {questions.length}
|
||||||
</span>
|
</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
|
<div
|
||||||
className="absolute inset-0 rounded-full bg-black"
|
className="absolute inset-0 rounded-full bg-black"
|
||||||
style={{
|
style={{
|
||||||
@@ -101,10 +162,29 @@ export function AIQuizContent(props: AIQuizContentProps) {
|
|||||||
setSelectedOptions={(options) =>
|
setSelectedOptions={(options) =>
|
||||||
handleSelectOptions(activeQuestionIndex, options)
|
handleSelectOptions(activeQuestionIndex, options)
|
||||||
}
|
}
|
||||||
onSubmit={() => handleSubmit(activeQuestionIndex)}
|
onSubmit={(status) => handleSubmit(activeQuestionIndex, status)}
|
||||||
onNext={() => setActiveQuestionIndex(activeQuestionIndex + 1)}
|
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>
|
</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