mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-12 17:51:53 +08:00
Compare commits
45 Commits
feat/roadm
...
feat/ai-qu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c00667f7a | ||
|
|
735ca6770c | ||
|
|
72929f4283 | ||
|
|
518e207b83 | ||
|
|
b097d5ea43 | ||
|
|
6a9070cfed | ||
|
|
f70db38d43 | ||
|
|
cb491c723e | ||
|
|
9a1d53b97d | ||
|
|
3148e4f5de | ||
|
|
18ca18bd97 | ||
|
|
892298fc6b | ||
|
|
5267c1c3bd | ||
|
|
0dae2255a7 | ||
|
|
c25cf0227c | ||
|
|
bdad50666b | ||
|
|
b8c6800a92 | ||
|
|
d504d9b444 | ||
|
|
6f51211725 | ||
|
|
52b70924c2 | ||
|
|
f3025cbe40 | ||
|
|
804ed76560 | ||
|
|
de38434170 | ||
|
|
c9418b0fa4 | ||
|
|
3948f2cec6 | ||
|
|
bf3e0e4163 | ||
|
|
e7e1c1c8d5 | ||
|
|
920d0512f6 | ||
|
|
9f15ca1e53 | ||
|
|
c370bafc53 | ||
|
|
70d0f7c82e | ||
|
|
e3bb896df2 | ||
|
|
d8d6536e31 | ||
|
|
8bb4f0a913 | ||
|
|
fff19eb566 | ||
|
|
adfbb818a6 | ||
|
|
35eba5e649 | ||
|
|
1be13b9148 | ||
|
|
bc55f466e7 | ||
|
|
40e1b44565 | ||
|
|
0f9d3af6ae | ||
|
|
38c9a67a2a | ||
|
|
9423f45586 | ||
|
|
abf58dabcd | ||
|
|
399ce72650 |
@@ -3,6 +3,6 @@
|
||||
"enabled": false
|
||||
},
|
||||
"_variables": {
|
||||
"lastUpdateCheck": 1751887359982
|
||||
"lastUpdateCheck": 1751901824723
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { LibraryTabs } from '../Library/LibraryTab';
|
||||
|
||||
type AILibraryLayoutProps = {
|
||||
activeTab: 'courses' | 'guides' | 'roadmaps';
|
||||
activeTab: 'courses' | 'guides' | 'roadmaps' | 'quizzes';
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
|
||||
302
src/components/AIQuiz/AIMCQQuestion.tsx
Normal file
302
src/components/AIQuiz/AIMCQQuestion.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import type { QuizQuestion } from '../../queries/ai-quiz';
|
||||
import { cn } from '../../lib/classname';
|
||||
import {
|
||||
CheckIcon,
|
||||
XIcon,
|
||||
InfoIcon,
|
||||
AlertTriangleIcon,
|
||||
SkipForwardIcon,
|
||||
} from 'lucide-react';
|
||||
import { markdownToHtml } from '../../lib/markdown';
|
||||
import type { QuestionState } from './AIQuizContent';
|
||||
|
||||
export const markdownClassName =
|
||||
'prose prose-base prose-p:mb-3 prose-p:text-base prose-p:font-normal prose-pre:my-5 prose-p:prose-code:whitespace-nowrap prose-p:prose-code:text-sm prose-p:prose-code:px-2 prose-p:prose-code:py-1 prose-p:prose-code:rounded-md prose-p:prose-code:border prose-p:prose-code:border-gray-300 prose-p:prose-code:bg-gray-50 text-left text-gray-800';
|
||||
|
||||
type AIMCQQuestionProps = {
|
||||
question: QuizQuestion;
|
||||
questionState: QuestionState;
|
||||
|
||||
setSelectedOptions: (options: number[]) => void;
|
||||
onSubmit: (status: QuestionState['status']) => void;
|
||||
onNext: () => void;
|
||||
onSkip: () => void;
|
||||
isLastQuestion: boolean;
|
||||
onComplete: () => void;
|
||||
};
|
||||
|
||||
export function AIMCQQuestion(props: AIMCQQuestionProps) {
|
||||
const {
|
||||
question,
|
||||
questionState,
|
||||
setSelectedOptions,
|
||||
onSubmit,
|
||||
onSkip,
|
||||
onNext,
|
||||
isLastQuestion,
|
||||
onComplete,
|
||||
} = props;
|
||||
const { title: questionText, options, answerExplanation } = question;
|
||||
|
||||
const { isSubmitted, selectedOptions = [] } = questionState;
|
||||
|
||||
const canSubmitMultipleAnswers =
|
||||
options.filter((option) => option.isCorrect).length > 1;
|
||||
|
||||
const handleSelectOption = (index: number) => {
|
||||
if (isSubmitted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canSubmitMultipleAnswers) {
|
||||
const newSelectedOptions = [index];
|
||||
setSelectedOptions(newSelectedOptions);
|
||||
return;
|
||||
}
|
||||
|
||||
const newSelectedOptions = selectedOptions.includes(index)
|
||||
? selectedOptions.filter((id) => id !== index)
|
||||
: [...selectedOptions, index];
|
||||
setSelectedOptions(newSelectedOptions);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (isSubmitted) {
|
||||
if (isLastQuestion) {
|
||||
onComplete();
|
||||
} else {
|
||||
onNext();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isCorrect =
|
||||
selectedOptions.every((index) => options[index].isCorrect) &&
|
||||
selectedOptions.length ===
|
||||
options.filter((option) => option.isCorrect).length;
|
||||
|
||||
onSubmit(isCorrect ? 'correct' : 'incorrect');
|
||||
};
|
||||
|
||||
const hasAnySelected = selectedOptions.length > 0;
|
||||
const canSubmit = hasAnySelected || questionState.status === 'skipped';
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<QuestionTitle title={questionText} />
|
||||
|
||||
<div className="mt-8">
|
||||
{options.map((option, index) => {
|
||||
const isSelected = selectedOptions.includes(index);
|
||||
const isCorrectOption = option.isCorrect;
|
||||
|
||||
const isSelectedAndCorrect =
|
||||
isSubmitted && isSelected && isCorrectOption;
|
||||
const isSelectedAndIncorrect =
|
||||
isSubmitted && isSelected && !isCorrectOption;
|
||||
const isNotSelectedAndCorrect =
|
||||
isSubmitted && !isSelected && isCorrectOption;
|
||||
|
||||
const html = markdownToHtml(option.title, false);
|
||||
|
||||
const isOptionDisabled =
|
||||
isSubmitted && !isSelected && !isCorrectOption;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
className={cn(
|
||||
'group flex w-full items-start gap-4 rounded-lg py-2 text-left',
|
||||
isSelected && !isSubmitted && '',
|
||||
isSubmitted &&
|
||||
isSelectedAndCorrect &&
|
||||
'border-green-500 text-green-700',
|
||||
isSubmitted &&
|
||||
isSelectedAndIncorrect &&
|
||||
'border-red-500 text-red-700',
|
||||
isSubmitted &&
|
||||
isNotSelectedAndCorrect &&
|
||||
'border-green-500 text-green-700',
|
||||
isOptionDisabled && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
onClick={() => handleSelectOption(index)}
|
||||
disabled={isOptionDisabled}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-6.5 shrink-0 items-center justify-center rounded-lg border-2 border-gray-300',
|
||||
isSelected &&
|
||||
!isSubmitted &&
|
||||
'border-black bg-black text-white',
|
||||
isSelectedAndCorrect &&
|
||||
'border-green-500 bg-green-500 text-white',
|
||||
isSelectedAndIncorrect &&
|
||||
'border-red-500 bg-red-500 text-white',
|
||||
isNotSelectedAndCorrect &&
|
||||
'border-green-500 bg-green-500 text-white',
|
||||
!isSelected &&
|
||||
!isSubmitted &&
|
||||
'group-hover:border-gray-300 group-hover:bg-gray-200',
|
||||
)}
|
||||
>
|
||||
{isSelected && !isSubmitted && (
|
||||
<div className="size-5 bg-black" />
|
||||
)}
|
||||
{isSelectedAndCorrect && <CheckIcon className="size-5" />}
|
||||
|
||||
{isSelectedAndIncorrect && <XIcon className="size-5" />}
|
||||
|
||||
{isNotSelectedAndCorrect && <CheckIcon className="size-5" />}
|
||||
</div>
|
||||
<div
|
||||
className={cn(markdownClassName, 'flex-1')}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isSubmitted && answerExplanation && (
|
||||
<QuestionExplanation
|
||||
explanation={answerExplanation}
|
||||
status={questionState.status}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex justify-between">
|
||||
<button
|
||||
onClick={onSkip}
|
||||
disabled={isSubmitted}
|
||||
className="rounded-xl bg-gray-100 px-8 py-3 text-base font-medium text-gray-800 hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Skip Question
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-xl bg-black px-8 py-3 text-base font-medium text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
)}
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{isSubmitted
|
||||
? isLastQuestion
|
||||
? 'Finish Quiz'
|
||||
: 'Next Question'
|
||||
: 'Check Answer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type QuestionTitleProps = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export function QuestionTitle(props: QuestionTitleProps) {
|
||||
const { title } = props;
|
||||
|
||||
const titleHtml = markdownToHtml(title, false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="prose prose-xl prose-headings:text-3xl prose-headings:font-bold prose-headings:text-black prose-headings:mb-6 prose-p:text-3xl prose-p:font-semibold prose-p:leading-normal prose-p:text-black prose-p:mb-0 prose-pre:my-5 prose-p:prose-code:whitespace-nowrap prose-p:prose-code:relative prose-p:prose-code:top-[-5px] prose-p:prose-code:text-xl prose-p:prose-code:px-3 prose-p:prose-code:py-1 prose-p:prose-code:rounded-md prose-p:prose-code:border prose-p:prose-code:border-gray-300 prose-p:prose-code:bg-gray-100 prose-p:prose-code:font-medium mb-2 text-left"
|
||||
dangerouslySetInnerHTML={{ __html: titleHtml }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type QuestionExplanationProps = {
|
||||
explanation: string;
|
||||
title?: string;
|
||||
status?: 'correct' | 'incorrect' | 'can_be_improved' | 'skipped' | 'pending';
|
||||
};
|
||||
|
||||
export function QuestionExplanation(props: QuestionExplanationProps) {
|
||||
const { explanation, title, status } = props;
|
||||
|
||||
const explanationHtml = markdownToHtml(explanation, false);
|
||||
|
||||
const getStatusConfig = () => {
|
||||
switch (status) {
|
||||
case 'correct':
|
||||
return {
|
||||
bgColor: 'bg-green-50',
|
||||
borderColor: 'border-green-200',
|
||||
iconBgColor: 'bg-green-500',
|
||||
textColor: 'text-green-800',
|
||||
icon: CheckIcon,
|
||||
defaultTitle: 'Correct Answer',
|
||||
};
|
||||
case 'incorrect':
|
||||
return {
|
||||
bgColor: 'bg-red-50',
|
||||
borderColor: 'border-red-200',
|
||||
iconBgColor: 'bg-red-500',
|
||||
textColor: 'text-red-800',
|
||||
icon: XIcon,
|
||||
defaultTitle: 'Incorrect Answer',
|
||||
};
|
||||
case 'can_be_improved':
|
||||
return {
|
||||
bgColor: 'bg-yellow-50',
|
||||
borderColor: 'border-yellow-200',
|
||||
iconBgColor: 'bg-yellow-500',
|
||||
textColor: 'text-yellow-800',
|
||||
icon: AlertTriangleIcon,
|
||||
defaultTitle: 'Can Be Improved',
|
||||
};
|
||||
case 'skipped':
|
||||
return {
|
||||
bgColor: 'bg-gray-50',
|
||||
borderColor: 'border-gray-200',
|
||||
iconBgColor: 'bg-gray-500',
|
||||
textColor: 'text-gray-800',
|
||||
icon: SkipForwardIcon,
|
||||
defaultTitle: 'Question Skipped',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bgColor: 'bg-blue-50',
|
||||
borderColor: 'border-blue-200',
|
||||
iconBgColor: 'bg-blue-500',
|
||||
textColor: 'text-blue-800',
|
||||
icon: InfoIcon,
|
||||
defaultTitle: 'Explanation',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig();
|
||||
const IconComponent = config.icon;
|
||||
const displayTitle = title || config.defaultTitle;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-6 rounded-xl border-2 p-6 transition-all duration-200',
|
||||
config.bgColor,
|
||||
config.borderColor,
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-8 items-center justify-center rounded-full text-white',
|
||||
config.iconBgColor,
|
||||
)}
|
||||
>
|
||||
<IconComponent className="size-4" strokeWidth={2.5} />
|
||||
</div>
|
||||
<h3 className={cn('text-lg font-semibold', config.textColor)}>
|
||||
{displayTitle}
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
className={cn(markdownClassName, 'leading-relaxed text-gray-700')}
|
||||
dangerouslySetInnerHTML={{ __html: explanationHtml }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
src/components/AIQuiz/AIOpenEndedQuestion.tsx
Normal file
142
src/components/AIQuiz/AIOpenEndedQuestion.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { type QuizQuestion } from '../../queries/ai-quiz';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { QuestionExplanation, QuestionTitle } from './AIMCQQuestion';
|
||||
import type { QuestionState } from './AIQuizContent';
|
||||
import { useVerifyAnswer } from '../../hooks/use-verify-answer';
|
||||
|
||||
export type VerifyQuizAnswerResponse = {
|
||||
status: 'correct' | 'incorrect' | 'can_be_improved';
|
||||
feedback: string;
|
||||
};
|
||||
|
||||
type AIOpenEndedQuestionProps = {
|
||||
quizSlug: string;
|
||||
question: QuizQuestion;
|
||||
questionState: QuestionState;
|
||||
|
||||
onSubmit: (status: QuestionState['status']) => void;
|
||||
onNext: () => void;
|
||||
|
||||
setUserAnswer: (answer: string) => void;
|
||||
setCorrectAnswer: (answer: string) => void;
|
||||
|
||||
isLastQuestion: boolean;
|
||||
onComplete: () => void;
|
||||
};
|
||||
|
||||
export function AIOpenEndedQuestion(props: AIOpenEndedQuestionProps) {
|
||||
const {
|
||||
quizSlug,
|
||||
question,
|
||||
questionState,
|
||||
onSubmit,
|
||||
onNext,
|
||||
setUserAnswer,
|
||||
setCorrectAnswer,
|
||||
isLastQuestion,
|
||||
onComplete,
|
||||
} = props;
|
||||
const { title: questionText } = question;
|
||||
|
||||
const {
|
||||
isSubmitted,
|
||||
userAnswer = '',
|
||||
correctAnswer = '',
|
||||
status,
|
||||
} = questionState;
|
||||
|
||||
const {
|
||||
verifyAnswer,
|
||||
data: verificationData,
|
||||
status: verifyStatus,
|
||||
} = useVerifyAnswer({
|
||||
quizSlug,
|
||||
question: questionText,
|
||||
userAnswer,
|
||||
onFinish: (data) => {
|
||||
if (!data || !data.status) {
|
||||
console.error('No data or status', data);
|
||||
onSubmit('incorrect');
|
||||
return;
|
||||
}
|
||||
|
||||
setCorrectAnswer(data.feedback || '');
|
||||
onSubmit(data?.status || 'incorrect');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isSubmittedAndNotSkipped) {
|
||||
if (isLastQuestion) {
|
||||
onComplete();
|
||||
} else {
|
||||
onNext();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await verifyAnswer();
|
||||
};
|
||||
|
||||
const canSubmit = userAnswer.trim().length > 0;
|
||||
const isVerifying =
|
||||
verifyStatus === 'loading' || verifyStatus === 'streaming';
|
||||
const feedback = verificationData?.feedback || correctAnswer;
|
||||
const feedbackStatus = verificationData?.status || status;
|
||||
|
||||
const isSubmittedAndNotSkipped = isSubmitted && status !== 'skipped';
|
||||
|
||||
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',
|
||||
isSubmittedAndNotSkipped && 'bg-gray-50',
|
||||
isSubmittedAndNotSkipped &&
|
||||
feedbackStatus === 'correct' &&
|
||||
'border-green-500 bg-green-50',
|
||||
isSubmittedAndNotSkipped &&
|
||||
feedbackStatus === 'incorrect' &&
|
||||
'border-red-500 bg-red-50',
|
||||
isSubmittedAndNotSkipped &&
|
||||
feedbackStatus === 'can_be_improved' &&
|
||||
'border-yellow-500 bg-yellow-50',
|
||||
)}
|
||||
placeholder="Type your answer here..."
|
||||
value={userAnswer}
|
||||
onChange={(e) => setUserAnswer(e.target.value)}
|
||||
disabled={isSubmittedAndNotSkipped || isVerifying}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{feedback && (
|
||||
<QuestionExplanation explanation={feedback} status={feedbackStatus} />
|
||||
)}
|
||||
|
||||
<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 disabled:cursor-not-allowed',
|
||||
)}
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit || isVerifying}
|
||||
>
|
||||
{isVerifying ? (
|
||||
<Loader2Icon className="size-4 animate-spin stroke-[2.5]" />
|
||||
) : isSubmittedAndNotSkipped ? (
|
||||
isLastQuestion ? (
|
||||
'Finish Quiz'
|
||||
) : (
|
||||
'Next Question'
|
||||
)
|
||||
) : (
|
||||
'Verify Answer'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/components/AIQuiz/AIQuiz.tsx
Normal file
85
src/components/AIQuiz/AIQuiz.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { AlertCircleIcon } from 'lucide-react';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { AIQuizLayout } from './AIQuizLayout';
|
||||
import { GenerateAIQuiz } from './GenerateAIQuiz';
|
||||
import { aiQuizOptions, generateAIQuiz } from '../../queries/ai-quiz';
|
||||
import { AIQuizContent } from './AIQuizContent';
|
||||
import { LoadingChip } from '../LoadingChip';
|
||||
|
||||
type AIQuizProps = {
|
||||
quizSlug?: string;
|
||||
};
|
||||
|
||||
export function AIQuiz(props: AIQuizProps) {
|
||||
const { quizSlug: defaultQuizSlug } = props;
|
||||
const [quizSlug, setQuizSlug] = useState(defaultQuizSlug);
|
||||
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
|
||||
// only fetch the guide if the guideSlug is provided
|
||||
// otherwise we are still generating the guide
|
||||
const {
|
||||
data: aiQuiz,
|
||||
isLoading: isLoadingBySlug,
|
||||
error: aiQuizError,
|
||||
} = useQuery(aiQuizOptions(quizSlug), queryClient);
|
||||
|
||||
const {
|
||||
data: tokenUsage,
|
||||
isLoading: isTokenUsageLoading,
|
||||
refetch: refetchTokenUsage,
|
||||
} = useQuery(getAiCourseLimitOptions(), queryClient);
|
||||
|
||||
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
|
||||
useQuery(billingDetailsOptions(), queryClient);
|
||||
|
||||
const isLoading =
|
||||
isLoadingBySlug ||
|
||||
isRegenerating ||
|
||||
isTokenUsageLoading ||
|
||||
isBillingDetailsLoading;
|
||||
|
||||
return (
|
||||
<AIQuizLayout>
|
||||
{showUpgradeModal && (
|
||||
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
|
||||
)}
|
||||
|
||||
<div className="relative grow">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 z-20 flex h-full flex-col items-center justify-center bg-white">
|
||||
<LoadingChip message="Loading Quiz" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && aiQuizError && (
|
||||
<div className="absolute inset-0 z-20 flex h-full flex-col items-center justify-center bg-white">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<AlertCircleIcon className="size-10 text-gray-500" />
|
||||
<p className="text-center">
|
||||
{aiQuizError?.message || 'Something went wrong'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{quizSlug && !aiQuizError && (
|
||||
<AIQuizContent
|
||||
quizSlug={quizSlug}
|
||||
questions={aiQuiz?.questions ?? []}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
{!quizSlug && !aiQuizError && (
|
||||
<GenerateAIQuiz onQuizSlugChange={setQuizSlug} />
|
||||
)}
|
||||
</div>
|
||||
</AIQuizLayout>
|
||||
);
|
||||
}
|
||||
116
src/components/AIQuiz/AIQuizActions.tsx
Normal file
116
src/components/AIQuiz/AIQuizActions.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { MoreVertical, Play, Trash2 } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { httpDelete } from '../../lib/query-http';
|
||||
|
||||
type AIQuizActionsType = {
|
||||
quizSlug: string;
|
||||
onDeleted?: () => void;
|
||||
};
|
||||
|
||||
export function AIQuizActions(props: AIQuizActionsType) {
|
||||
const { quizSlug, onDeleted } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
|
||||
const { mutate: deleteQuiz, isPending: isDeleting } = useMutation(
|
||||
{
|
||||
mutationFn: async () => {
|
||||
return httpDelete(`/v1-delete-ai-quiz/${quizSlug}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Quiz deleted');
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => query.queryKey?.[0] === 'user-ai-quizzes',
|
||||
});
|
||||
onDeleted?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error?.message || 'Failed to delete quiz');
|
||||
},
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
||||
useOutsideClick(dropdownRef, () => {
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative h-full" ref={dropdownRef}>
|
||||
<button
|
||||
className="h-full text-gray-400 hover:text-gray-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-8 right-0 z-10 w-48 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
|
||||
<a
|
||||
href={`/ai/quiz/${quizSlug}`}
|
||||
className="flex w-full items-center gap-1.5 p-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Take Quiz
|
||||
</a>
|
||||
{!isConfirming && (
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 p-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-70"
|
||||
onClick={() => setIsConfirming(true)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{!isDeleting ? (
|
||||
<>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete Quiz
|
||||
</>
|
||||
) : (
|
||||
'Deleting...'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isConfirming && (
|
||||
<span className="flex w-full items-center justify-between gap-1.5 p-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-70">
|
||||
Are you sure?
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsConfirming(false);
|
||||
deleteQuiz();
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
className="text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsConfirming(false)}
|
||||
className="text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/components/AIQuiz/AIQuizCard.tsx
Normal file
56
src/components/AIQuiz/AIQuizCard.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { CalendarIcon, ClipboardCheck } from 'lucide-react';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
import type { AIQuizDocument } from '../../queries/ai-quiz';
|
||||
import { AIQuizActions } from './AIQuizActions';
|
||||
|
||||
type AIQuizCardProps = {
|
||||
quiz: Omit<AIQuizDocument, 'content' | 'questionAndAnswers'>;
|
||||
variant?: 'row' | 'column';
|
||||
showActions?: boolean;
|
||||
};
|
||||
|
||||
export function AIQuizCard(props: AIQuizCardProps) {
|
||||
const { quiz, variant = 'row', showActions = true } = props;
|
||||
|
||||
const updatedAgo = getRelativeTimeString(quiz?.updatedAt);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-grow">
|
||||
<a
|
||||
href={`/ai/quiz/${quiz.slug}`}
|
||||
className={cn(
|
||||
'group relative flex h-full w-full gap-3 overflow-hidden rounded-lg border border-gray-200 bg-white p-4 text-left transition-all hover:border-gray-300 hover:bg-gray-50 sm:gap-4',
|
||||
variant === 'column' && 'flex-col',
|
||||
variant === 'row' && 'sm:flex-col sm:items-start',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="line-clamp-2 text-base font-semibold text-balance text-gray-900">
|
||||
{quiz.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-4 sm:gap-4">
|
||||
<div className="flex items-center text-xs text-gray-600">
|
||||
<CalendarIcon className="mr-1 h-3.5 w-3.5" />
|
||||
<span>{updatedAgo}</span>
|
||||
|
||||
<div className="ml-3 flex items-center text-xs text-gray-600">
|
||||
<ClipboardCheck className="mr-1 h-3.5 w-3.5" />
|
||||
<span className="capitalize">
|
||||
{quiz.format === 'mcq' ? 'MCQ' : quiz.format}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{showActions && quiz.slug && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<AIQuizActions quizSlug={quiz.slug} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
src/components/AIQuiz/AIQuizContent.tsx
Normal file
257
src/components/AIQuiz/AIQuizContent.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useState } from 'react';
|
||||
import type { QuizQuestion } from '../../queries/ai-quiz';
|
||||
import { AIMCQQuestion } from './AIMCQQuestion';
|
||||
import { AIOpenEndedQuestion } from './AIOpenEndedQuestion';
|
||||
import { QuizTopNavigation } from './QuizTopNavigation';
|
||||
import { getPercentage } from '../../lib/number';
|
||||
import { AIQuizResults } from './AIQuizResults';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { AIQuizResultStrip } from './AIQuizResultStrip';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
export type QuestionState = {
|
||||
isSubmitted: boolean;
|
||||
selectedOptions?: number[];
|
||||
userAnswer?: string;
|
||||
correctAnswer?: string;
|
||||
status: 'correct' | 'incorrect' | 'skipped' | 'pending' | 'can_be_improved';
|
||||
};
|
||||
|
||||
const DEFAULT_QUESTION_STATE: QuestionState = {
|
||||
isSubmitted: false,
|
||||
selectedOptions: [],
|
||||
userAnswer: '',
|
||||
correctAnswer: '',
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
type QuizStatus = 'answering' | 'submitted' | 'reviewing';
|
||||
|
||||
type AIQuizContentProps = {
|
||||
quizSlug?: string;
|
||||
questions: QuizQuestion[];
|
||||
isLoading?: boolean;
|
||||
isStreaming?: boolean;
|
||||
};
|
||||
|
||||
export function AIQuizContent(props: AIQuizContentProps) {
|
||||
const { quizSlug, questions, isLoading, isStreaming = false } = props;
|
||||
|
||||
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
|
||||
const activeQuestion = questions[activeQuestionIndex];
|
||||
|
||||
const [questionStates, setQuestionStates] = useState<
|
||||
Record<number, QuestionState>
|
||||
>({});
|
||||
const [quizStatus, setQuizStatus] = useState<QuizStatus>('answering');
|
||||
|
||||
const activeQuestionState =
|
||||
questionStates[activeQuestionIndex] ?? DEFAULT_QUESTION_STATE;
|
||||
const isLastQuestion = activeQuestionIndex === questions.length - 1;
|
||||
|
||||
const handleSubmit = (status: QuestionState['status']) => {
|
||||
setQuestionStates((prev) => {
|
||||
const oldState = prev[activeQuestionIndex] ?? DEFAULT_QUESTION_STATE;
|
||||
|
||||
const newSelectedOptions = {
|
||||
...prev,
|
||||
[activeQuestionIndex]: {
|
||||
...oldState,
|
||||
isSubmitted: true,
|
||||
status,
|
||||
},
|
||||
};
|
||||
|
||||
return newSelectedOptions;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSetUserAnswer = (userAnswer: string) => {
|
||||
setQuestionStates((prev) => {
|
||||
const oldState = prev[activeQuestionIndex] ?? DEFAULT_QUESTION_STATE;
|
||||
|
||||
const newSelectedOptions = {
|
||||
...prev,
|
||||
[activeQuestionIndex]: {
|
||||
...oldState,
|
||||
userAnswer,
|
||||
},
|
||||
};
|
||||
|
||||
return newSelectedOptions;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSetCorrectAnswer = (correctAnswer: string) => {
|
||||
flushSync(() => {
|
||||
setQuestionStates((prev) => {
|
||||
const oldState = prev[activeQuestionIndex] ?? DEFAULT_QUESTION_STATE;
|
||||
|
||||
const newSelectedOptions = {
|
||||
...prev,
|
||||
[activeQuestionIndex]: {
|
||||
...oldState,
|
||||
correctAnswer,
|
||||
},
|
||||
};
|
||||
|
||||
return newSelectedOptions;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectOptions = (options: number[]) => {
|
||||
setQuestionStates((prev) => {
|
||||
const oldState = prev[activeQuestionIndex] ?? DEFAULT_QUESTION_STATE;
|
||||
|
||||
const newSelectedOptions = {
|
||||
...prev,
|
||||
[activeQuestionIndex]: {
|
||||
...oldState,
|
||||
selectedOptions: options,
|
||||
},
|
||||
};
|
||||
|
||||
return newSelectedOptions;
|
||||
});
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setActiveQuestionIndex(0);
|
||||
setQuestionStates({});
|
||||
setQuizStatus('answering');
|
||||
};
|
||||
|
||||
const hasNextQuestion = activeQuestionIndex < questions.length - 1;
|
||||
const hasPreviousQuestion = activeQuestionIndex > 0;
|
||||
const totalQuestions = questions?.length ?? 0;
|
||||
const isAllQuestionsSubmitted =
|
||||
Object.values(questionStates).filter((state) => state.status !== 'pending')
|
||||
.length === totalQuestions;
|
||||
|
||||
const progressPercentage = isLoading
|
||||
? 0
|
||||
: getPercentage(activeQuestionIndex + 1, totalQuestions);
|
||||
|
||||
const shouldShowQuestions =
|
||||
quizStatus === 'answering' || quizStatus === 'reviewing';
|
||||
|
||||
const handleNextQuestion = () => {
|
||||
if (!hasNextQuestion) {
|
||||
setQuizStatus(isAllQuestionsSubmitted ? 'submitted' : 'reviewing');
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveQuestionIndex(activeQuestionIndex + 1);
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
handleSubmit('skipped');
|
||||
if (hasNextQuestion) {
|
||||
handleNextQuestion();
|
||||
} else {
|
||||
handleComplete();
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
setQuizStatus('submitted');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex h-full w-full flex-col', {
|
||||
'animate-pulse cursor-progress': isStreaming,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cn('relative flex h-full flex-col overflow-y-auto', {
|
||||
'pointer-events-none': isStreaming,
|
||||
})}
|
||||
>
|
||||
<div className="absolute inset-0 z-10">
|
||||
<div className="mx-auto max-w-2xl bg-white px-4 py-10">
|
||||
{shouldShowQuestions && (
|
||||
<QuizTopNavigation
|
||||
activeQuestionIndex={activeQuestionIndex}
|
||||
totalQuestions={totalQuestions}
|
||||
progressPercentage={progressPercentage}
|
||||
onSkip={handleSkip}
|
||||
isStreaming={isStreaming}
|
||||
onPrevious={() => {
|
||||
if (!hasPreviousQuestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveQuestionIndex(activeQuestionIndex - 1);
|
||||
}}
|
||||
onNext={handleNextQuestion}
|
||||
/>
|
||||
)}
|
||||
|
||||
{quizStatus === 'submitted' && (
|
||||
<AIQuizResults
|
||||
questionStates={questionStates}
|
||||
totalQuestions={totalQuestions}
|
||||
onRetry={handleRetry}
|
||||
onNewQuiz={() => {
|
||||
window.location.href = '/ai/quiz';
|
||||
}}
|
||||
onReview={(questionIndex) => {
|
||||
setActiveQuestionIndex(questionIndex);
|
||||
setQuizStatus('reviewing');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldShowQuestions && (
|
||||
<>
|
||||
{activeQuestion && activeQuestion.type === 'mcq' && (
|
||||
<AIMCQQuestion
|
||||
question={activeQuestion}
|
||||
questionState={activeQuestionState}
|
||||
setSelectedOptions={handleSelectOptions}
|
||||
onSubmit={handleSubmit}
|
||||
onNext={handleNextQuestion}
|
||||
onSkip={handleSkip}
|
||||
isLastQuestion={isLastQuestion}
|
||||
onComplete={handleComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeQuestion && activeQuestion.type === 'open-ended' && (
|
||||
<AIOpenEndedQuestion
|
||||
key={activeQuestion.id}
|
||||
quizSlug={quizSlug ?? ''}
|
||||
question={activeQuestion}
|
||||
questionState={activeQuestionState}
|
||||
onSubmit={handleSubmit}
|
||||
onNext={handleNextQuestion}
|
||||
setUserAnswer={handleSetUserAnswer}
|
||||
setCorrectAnswer={handleSetCorrectAnswer}
|
||||
isLastQuestion={isLastQuestion}
|
||||
onComplete={handleComplete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{quizStatus === 'reviewing' && (
|
||||
<AIQuizResultStrip
|
||||
activeQuestionIndex={activeQuestionIndex}
|
||||
questionStates={questionStates}
|
||||
onReview={(questionIndex) => {
|
||||
setActiveQuestionIndex(questionIndex);
|
||||
setQuizStatus('reviewing');
|
||||
}}
|
||||
onComplete={() => {
|
||||
setQuizStatus('submitted');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
265
src/components/AIQuiz/AIQuizGenerator.tsx
Normal file
265
src/components/AIQuiz/AIQuizGenerator.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
BookOpenIcon,
|
||||
FileTextIcon,
|
||||
ListCheckIcon,
|
||||
ListIcon,
|
||||
ListTodoIcon,
|
||||
MapIcon,
|
||||
SparklesIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useId, useState } from 'react';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { billingDetailsOptions, useIsPaidUser } from '../../queries/billing';
|
||||
import {
|
||||
clearQuestionAnswerChatMessages,
|
||||
storeQuestionAnswerChatMessages,
|
||||
} from '../../lib/ai-questions';
|
||||
import {
|
||||
QuestionAnswerChat,
|
||||
type QuestionAnswerChatMessage,
|
||||
} from '../ContentGenerator/QuestionAnswerChat';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import { useParams } from '../../hooks/use-params';
|
||||
import { FormatItem } from '../ContentGenerator/FormatItem';
|
||||
import { AIQuizLayout } from './AIQuizLayout';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
|
||||
const allowedFormats = ['mcq', 'open-ended', 'mixed'] as const;
|
||||
export type AllowedFormat = (typeof allowedFormats)[number];
|
||||
|
||||
export function AIQuizGenerator() {
|
||||
const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false);
|
||||
|
||||
const toast = useToast();
|
||||
const [title, setTitle] = useState('');
|
||||
const [selectedFormat, setSelectedFormat] = useState<AllowedFormat>('mcq');
|
||||
|
||||
const [showFineTuneOptions, setShowFineTuneOptions] = useState(false);
|
||||
const [questionAnswerChatMessages, setQuestionAnswerChatMessages] = useState<
|
||||
QuestionAnswerChatMessage[]
|
||||
>([]);
|
||||
|
||||
const {
|
||||
data: tokenUsage,
|
||||
isLoading: isTokenUsageLoading,
|
||||
refetch: refetchTokenUsage,
|
||||
} = useQuery(getAiCourseLimitOptions(), queryClient);
|
||||
|
||||
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
|
||||
useQuery(billingDetailsOptions(), queryClient);
|
||||
|
||||
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
|
||||
const isPaidUser = userBillingDetails?.status === 'active';
|
||||
|
||||
const titleFieldId = useId();
|
||||
const fineTuneOptionsId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
const params = getUrlParams();
|
||||
const format = params.format as AllowedFormat;
|
||||
if (format && allowedFormats.find((f) => f.value === format)) {
|
||||
setSelectedFormat(format);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const allowedFormats: {
|
||||
label: string;
|
||||
formatTitle: string;
|
||||
icon: LucideIcon;
|
||||
value: AllowedFormat;
|
||||
}[] = [
|
||||
{
|
||||
label: 'MCQ',
|
||||
formatTitle: 'Multiple Choice Question',
|
||||
icon: ListTodoIcon,
|
||||
value: 'mcq',
|
||||
},
|
||||
{
|
||||
label: 'Open-Ended',
|
||||
formatTitle: 'Open-Ended Question',
|
||||
icon: FileTextIcon,
|
||||
value: 'open-ended',
|
||||
},
|
||||
{
|
||||
label: 'Mixed',
|
||||
formatTitle: 'Mixed Question (MCQ + Open-Ended)',
|
||||
icon: ListIcon,
|
||||
value: 'mixed',
|
||||
},
|
||||
];
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPaidUser && isLimitExceeded) {
|
||||
setIsUpgradeModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let sessionId = '';
|
||||
if (showFineTuneOptions) {
|
||||
clearQuestionAnswerChatMessages();
|
||||
sessionId = storeQuestionAnswerChatMessages(questionAnswerChatMessages);
|
||||
}
|
||||
|
||||
window.location.href = `/ai/quiz/search?term=${title}&format=${selectedFormat}&id=${sessionId}`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window?.fireEvent({
|
||||
action: 'tutor_user',
|
||||
category: 'ai_tutor',
|
||||
label: 'Visited AI Quiz Generator Page',
|
||||
});
|
||||
}, []);
|
||||
|
||||
const trimmedTitle = title.trim();
|
||||
const canGenerate = trimmedTitle && trimmedTitle.length >= 3;
|
||||
const selectedFormatTitle = allowedFormats.find(
|
||||
(f) => f.value === selectedFormat,
|
||||
)?.formatTitle;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-grow flex-col pt-4 md:justify-center md:pt-10 lg:pt-28 lg:pb-24">
|
||||
<div className="relative">
|
||||
{isUpgradeModalOpen && (
|
||||
<UpgradeAccountModal onClose={() => setIsUpgradeModalOpen(false)} />
|
||||
)}
|
||||
|
||||
{!isPaidUser && !isBillingDetailsLoading && isLoggedIn() && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 -translate-y-8 text-gray-500 max-md:hidden">
|
||||
You are on the free plan
|
||||
<button
|
||||
onClick={() => setIsUpgradeModalOpen(true)}
|
||||
className="ml-2 rounded-xl bg-yellow-600 px-2 py-1 text-sm text-white hover:opacity-80"
|
||||
>
|
||||
Upgrade to Pro
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<h1 className="mb-0.5 text-center text-4xl font-semibold max-md:text-left max-md:text-xl lg:mb-3">
|
||||
Test your Knowledge
|
||||
</h1>
|
||||
<p className="text-center text-lg text-balance text-gray-600 max-md:text-left max-md:text-sm">
|
||||
Create a personalized quiz to test your understanding of any topic
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="mt-10 space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor={titleFieldId} className="inline-block text-gray-500">
|
||||
What topic would you like to quiz yourself on?
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id={titleFieldId}
|
||||
placeholder="e.g., JavaScript fundamentals, Machine Learning basics"
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value);
|
||||
setShowFineTuneOptions(false);
|
||||
}}
|
||||
className="block w-full rounded-xl border border-gray-200 bg-white p-4 outline-none placeholder:text-gray-500 focus:border-gray-500"
|
||||
required
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="inline-block text-gray-500">
|
||||
Choose the format
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{allowedFormats.map((format) => {
|
||||
const isSelected = format.value === selectedFormat;
|
||||
|
||||
return (
|
||||
<FormatItem
|
||||
key={format.value}
|
||||
label={format.label}
|
||||
onClick={() => setSelectedFormat(format.value)}
|
||||
icon={format.icon}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-xl border border-gray-200 bg-white p-4 transition-all',
|
||||
)}
|
||||
htmlFor={fineTuneOptionsId}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={fineTuneOptionsId}
|
||||
checked={showFineTuneOptions}
|
||||
onChange={(e) => {
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPaidUser && isLimitExceeded) {
|
||||
setIsUpgradeModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trimmedTitle) {
|
||||
toast.error('Please enter a topic first');
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedTitle.length < 3) {
|
||||
toast.error('Topic must be at least 3 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
setShowFineTuneOptions(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span className="max-sm:hidden">
|
||||
Answer the following questions for a better result
|
||||
</span>
|
||||
<span className="sm:hidden">Customize your quiz</span>
|
||||
</label>
|
||||
|
||||
{showFineTuneOptions && (
|
||||
<QuestionAnswerChat
|
||||
term={title}
|
||||
format={selectedFormatTitle || selectedFormat}
|
||||
questionAnswerChatMessages={questionAnswerChatMessages}
|
||||
setQuestionAnswerChatMessages={setQuestionAnswerChatMessages}
|
||||
from="quiz"
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-black p-4 text-white focus:outline-none disabled:cursor-not-allowed disabled:opacity-80"
|
||||
disabled={!canGenerate}
|
||||
>
|
||||
<SparklesIcon className="size-4" />
|
||||
Generate Quiz
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
src/components/AIQuiz/AIQuizLayout.tsx
Normal file
18
src/components/AIQuiz/AIQuizLayout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { AITutorLayout } from '../AITutor/AITutorLayout';
|
||||
|
||||
type AIQuizLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function AIQuizLayout(props: AIQuizLayoutProps) {
|
||||
const { children } = props;
|
||||
return (
|
||||
<AITutorLayout
|
||||
activeTab="quiz"
|
||||
wrapperClassName="flex-row p-0 lg:p-0 relative overflow-hidden bg-white"
|
||||
containerClassName="h-[calc(100vh-49px)] overflow-hidden relative"
|
||||
>
|
||||
{children}
|
||||
</AITutorLayout>
|
||||
);
|
||||
}
|
||||
111
src/components/AIQuiz/AIQuizResultStrip.tsx
Normal file
111
src/components/AIQuiz/AIQuizResultStrip.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { cn } from '../../lib/classname';
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CheckIcon,
|
||||
CircleAlertIcon,
|
||||
Minus, XIcon
|
||||
} from 'lucide-react';
|
||||
import type { QuestionState } from './AIQuizContent';
|
||||
|
||||
type AIQuizResultStripProps = {
|
||||
activeQuestionIndex: number;
|
||||
questionStates: Record<number, QuestionState>;
|
||||
onReview?: (questionIndex: number) => void;
|
||||
onComplete?: () => void;
|
||||
};
|
||||
|
||||
export function AIQuizResultStrip(props: AIQuizResultStripProps) {
|
||||
const { activeQuestionIndex, questionStates, onReview, onComplete } = props;
|
||||
const states = Object.values(questionStates);
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-200 bg-white p-3">
|
||||
<div className="flex flex-col items-center justify-between gap-2 md:flex-row">
|
||||
<div className="flex w-full flex-wrap gap-1">
|
||||
{states.map((state, quizIndex) => (
|
||||
<QuizStateButton
|
||||
key={quizIndex}
|
||||
state={state}
|
||||
quizIndex={quizIndex}
|
||||
isActive={quizIndex === activeQuestionIndex}
|
||||
onReview={onReview}
|
||||
variant="small"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="flex w-full shrink-0 items-center justify-center gap-2 rounded-xl bg-black px-4 py-2 text-white hover:bg-gray-900 disabled:opacity-70 md:w-auto md:justify-start"
|
||||
onClick={onComplete}
|
||||
>
|
||||
Show Results <ArrowRightIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type QuizStateButtonProps = {
|
||||
state: QuestionState;
|
||||
quizIndex: number;
|
||||
isActive: boolean;
|
||||
onReview?: (questionIndex: number) => void;
|
||||
className?: string;
|
||||
variant?: 'default' | 'small';
|
||||
};
|
||||
|
||||
export function QuizStateButton(props: QuizStateButtonProps) {
|
||||
const {
|
||||
state,
|
||||
quizIndex,
|
||||
isActive,
|
||||
onReview,
|
||||
className,
|
||||
variant = 'default',
|
||||
} = props;
|
||||
const { status } = state;
|
||||
|
||||
const isCorrect = status === 'correct';
|
||||
const isIncorrect = status === 'incorrect';
|
||||
const isSkipped = status === 'skipped';
|
||||
const isCanBeImproved = status === 'can_be_improved';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={quizIndex}
|
||||
onClick={() => onReview?.(quizIndex)}
|
||||
className={cn(
|
||||
'flex aspect-square flex-col items-center justify-center rounded-xl border p-1 hover:opacity-80',
|
||||
isCorrect && 'border-green-700 bg-green-700 text-white',
|
||||
isIncorrect && 'border-red-700 bg-red-700 text-white',
|
||||
isSkipped && 'border-gray-400 bg-gray-400 text-white',
|
||||
isCanBeImproved && 'border-yellow-700 bg-yellow-700 text-white',
|
||||
!isActive && 'opacity-50',
|
||||
variant === 'small' && 'rounded-lg',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isCorrect && (
|
||||
<CheckIcon
|
||||
className={cn('size-6.5', variant === 'small' && 'size-5')}
|
||||
/>
|
||||
)}
|
||||
{isIncorrect && (
|
||||
<XIcon className={cn('size-6.5', variant === 'small' && 'size-5')} />
|
||||
)}
|
||||
{isSkipped && (
|
||||
<Minus
|
||||
className={cn(
|
||||
'size-6.5 fill-current',
|
||||
variant === 'small' && 'size-5',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{isCanBeImproved && (
|
||||
<CircleAlertIcon
|
||||
className={cn('size-6.5', variant === 'small' && 'size-5')}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
379
src/components/AIQuiz/AIQuizResults.tsx
Normal file
379
src/components/AIQuiz/AIQuizResults.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import { RotateCcw, BarChart3, Zap, Check, X, Minus } from 'lucide-react';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { getPercentage } from '../../lib/number';
|
||||
import type { QuestionState } from './AIQuizContent';
|
||||
import { QuizStateButton } from './AIQuizResultStrip';
|
||||
import { CircularProgress } from './CircularProgress';
|
||||
|
||||
type AIQuizResultsProps = {
|
||||
questionStates: Record<number, QuestionState>;
|
||||
totalQuestions: number;
|
||||
onRetry: () => void;
|
||||
onNewQuiz: () => void;
|
||||
onReview?: (questionIndex: number) => void;
|
||||
};
|
||||
|
||||
export function AIQuizResults(props: AIQuizResultsProps) {
|
||||
const { questionStates, totalQuestions, onRetry, onNewQuiz, onReview } =
|
||||
props;
|
||||
|
||||
const states = Object.values(questionStates);
|
||||
const correctCount = states.filter(
|
||||
(state) => state.status === 'correct',
|
||||
).length;
|
||||
|
||||
const incorrectCount = states.filter(
|
||||
(state) => state.status === 'incorrect',
|
||||
).length;
|
||||
|
||||
const skippedCount = states.filter(
|
||||
(state) => state.status === 'skipped',
|
||||
).length;
|
||||
|
||||
const accuracy = getPercentage(correctCount, totalQuestions);
|
||||
|
||||
const getPerformanceLevel = (): {
|
||||
level: string;
|
||||
color: 'emerald' | 'green' | 'blue' | 'orange' | 'red';
|
||||
} => {
|
||||
if (accuracy >= 90) return { level: 'Excellent', color: 'emerald' };
|
||||
if (accuracy >= 75) return { level: 'Great', color: 'green' };
|
||||
if (accuracy >= 60) return { level: 'Good', color: 'blue' };
|
||||
if (accuracy >= 40) return { level: 'Fair', color: 'orange' };
|
||||
return { level: 'Needs Work', color: 'red' };
|
||||
};
|
||||
|
||||
const performance = getPerformanceLevel();
|
||||
|
||||
const canReview = onReview && states.some((state) => state.isSubmitted);
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-8 max-w-4xl space-y-6">
|
||||
{/* Header Card with Performance Overview */}
|
||||
<div className="relative overflow-hidden rounded-2xl border border-gray-200 bg-gradient-to-br from-gray-50 to-white p-6 md:p-8">
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-bold text-gray-900 md:text-3xl">
|
||||
Quiz Complete!
|
||||
</h2>
|
||||
<p
|
||||
className={cn(
|
||||
'text-lg font-semibold',
|
||||
performance.color === 'emerald' && 'text-emerald-600',
|
||||
performance.color === 'green' && 'text-green-600',
|
||||
performance.color === 'blue' && 'text-blue-600',
|
||||
performance.color === 'orange' && 'text-orange-600',
|
||||
performance.color === 'red' && 'text-red-600',
|
||||
)}
|
||||
>
|
||||
{performance.level}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 md:text-base">
|
||||
You scored {correctCount} out of {totalQuestions} questions
|
||||
correctly
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CircularProgress accuracy={accuracy} color={performance.color} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compact Stats */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4 md:p-6">
|
||||
<h3 className="mb-4 text-sm font-semibold text-gray-900 md:text-base">
|
||||
Results Breakdown
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<StatRow
|
||||
icon={<Check className="h-4 w-4" />}
|
||||
label="Correct"
|
||||
value={correctCount}
|
||||
total={totalQuestions}
|
||||
color="green"
|
||||
/>
|
||||
<StatRow
|
||||
icon={<X className="h-4 w-4" />}
|
||||
label="Incorrect"
|
||||
value={incorrectCount}
|
||||
total={totalQuestions}
|
||||
color="red"
|
||||
/>
|
||||
<StatRow
|
||||
icon={<Minus className="h-4 w-4" />}
|
||||
label="Skipped"
|
||||
value={skippedCount}
|
||||
total={totalQuestions}
|
||||
color="gray"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Question Review Section */}
|
||||
{canReview && totalQuestions <= 20 && (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4 md:p-6">
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5 text-gray-600" />
|
||||
<h3 className="text-sm font-semibold text-gray-900 md:text-base">
|
||||
Question Breakdown
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 sm:ml-auto">
|
||||
Click to review
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-2',
|
||||
totalQuestions <= 8
|
||||
? 'grid-cols-4 sm:grid-cols-8'
|
||||
: totalQuestions <= 12
|
||||
? 'grid-cols-4 sm:grid-cols-6'
|
||||
: 'grid-cols-5',
|
||||
)}
|
||||
>
|
||||
{states.map((state, quizIndex) => (
|
||||
<QuizStateButton
|
||||
key={quizIndex}
|
||||
state={state}
|
||||
quizIndex={quizIndex}
|
||||
isActive={true}
|
||||
onReview={onReview}
|
||||
className="p-2 transition-transform duration-200"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
icon={<RotateCcw className="h-4 w-4" />}
|
||||
onClick={onRetry}
|
||||
>
|
||||
Try Again
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
icon={<Zap className="h-4 w-4" />}
|
||||
onClick={onNewQuiz}
|
||||
>
|
||||
New Quiz
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* Performance Insights */}
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 md:p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="mb-1 flex items-center text-sm font-semibold text-gray-900 md:text-base">
|
||||
Performance Insight
|
||||
</h4>
|
||||
<p className="text-sm leading-relaxed text-balance text-gray-600">
|
||||
{accuracy >= 90 &&
|
||||
"Outstanding work! You've mastered this topic. Consider challenging yourself with more advanced questions."}
|
||||
{accuracy >= 75 &&
|
||||
accuracy < 90 &&
|
||||
'Great job! You have a solid understanding. A few more practice sessions could get you to mastery.'}
|
||||
{accuracy >= 60 &&
|
||||
accuracy < 75 &&
|
||||
"Good progress! You're on the right track. Focus on reviewing the questions you missed."}
|
||||
{accuracy >= 40 &&
|
||||
accuracy < 60 &&
|
||||
'Keep practicing! Consider reviewing the fundamentals before attempting another quiz.'}
|
||||
{accuracy < 40 &&
|
||||
"Don't give up! Learning takes time. Review the material thoroughly and try again when you're ready."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Items */}
|
||||
<div className="mt-5 border-t border-gray-200 pt-5 -mx-6 px-6">
|
||||
<h5 className="mb-3 text-sm font-medium text-gray-900">
|
||||
Here's what you can do next
|
||||
</h5>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<ActionLink
|
||||
href="/ai"
|
||||
label="Learn a Topic"
|
||||
description="Create a course or guide"
|
||||
variant="secondary"
|
||||
/>
|
||||
<ActionLink
|
||||
href="/ai/chat"
|
||||
label="Chat with AI Tutor"
|
||||
description="Learn while you chat"
|
||||
variant="secondary"
|
||||
/>
|
||||
<ActionLink
|
||||
href="/ai/quiz"
|
||||
label="Take another Quiz"
|
||||
description="Challenge yourself"
|
||||
variant="secondary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type StatRowProps = {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: number;
|
||||
total: number;
|
||||
color: 'green' | 'red' | 'gray';
|
||||
};
|
||||
|
||||
function StatRow(props: StatRowProps) {
|
||||
const { icon, label, value, total, color } = props;
|
||||
const percentage = total > 0 ? Math.round((value / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md p-1.5',
|
||||
color === 'green' && 'bg-green-100 text-green-600',
|
||||
color === 'red' && 'bg-red-100 text-red-600',
|
||||
color === 'gray' && 'bg-gray-100 text-gray-600',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">{label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||
<span className="font-semibold text-gray-900">{value}</span>
|
||||
<span>({percentage}%)</span>
|
||||
</div>
|
||||
<div className="h-2 w-16 rounded-full bg-gray-200">
|
||||
<div
|
||||
className={cn(
|
||||
'h-2 rounded-full transition-all duration-500',
|
||||
color === 'green' && 'bg-green-500',
|
||||
color === 'red' && 'bg-red-500',
|
||||
color === 'gray' && 'bg-gray-400',
|
||||
)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ActionButtonProps = {
|
||||
variant: 'primary' | 'secondary';
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function ActionButton(props: ActionButtonProps) {
|
||||
const { variant, icon, onClick, children } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-2 rounded-xl px-4 py-3 font-medium transition-all duration-200 md:px-6',
|
||||
variant === 'primary' && 'bg-black text-white hover:bg-gray-800',
|
||||
variant === 'secondary' &&
|
||||
'border-2 border-gray-300 bg-white text-gray-700 hover:border-gray-400 hover:bg-gray-50',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type ActionLinkProps = {
|
||||
href: string;
|
||||
label: string;
|
||||
description: string;
|
||||
variant: 'primary' | 'secondary';
|
||||
};
|
||||
|
||||
function ActionLink(props: ActionLinkProps) {
|
||||
const { href, label, description, variant } = props;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={cn(
|
||||
'block rounded-lg border p-3 text-left transition-all duration-200',
|
||||
variant === 'primary' &&
|
||||
'border-black bg-black text-white hover:bg-gray-800',
|
||||
variant === 'secondary' &&
|
||||
'border-gray-300 bg-white text-gray-900 hover:border-gray-400 hover:bg-gray-50',
|
||||
)}
|
||||
>
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-xs',
|
||||
variant === 'primary' && 'text-gray-300',
|
||||
variant === 'secondary' && 'text-gray-600',
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// Keep the old components for backward compatibility
|
||||
type ResultCardProps = {
|
||||
count: number;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ResultCard(props: ResultCardProps) {
|
||||
const { count, label, icon, className } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center rounded-xl bg-gray-50 px-4 py-6 text-gray-700 transition-all duration-200',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<div className="text-xl font-semibold">{count}</div>
|
||||
<div className="text-sm">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ResultActionProps = {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ResultAction(props: ResultActionProps) {
|
||||
const { label, icon, onClick, className } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex flex-grow items-center justify-center gap-2 rounded-xl bg-black px-4 py-3 text-sm font-medium text-white transition-all duration-200 hover:bg-gray-800',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
69
src/components/AIQuiz/CircularProgress.tsx
Normal file
69
src/components/AIQuiz/CircularProgress.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type CircularProgressProps = {
|
||||
accuracy: number;
|
||||
color: 'emerald' | 'green' | 'blue' | 'orange' | 'red';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
};
|
||||
|
||||
export function CircularProgress(props: CircularProgressProps) {
|
||||
const { accuracy, color, size = 'md' } = props;
|
||||
|
||||
const circumference = 2 * Math.PI * 45;
|
||||
const strokeDashoffset = circumference - (accuracy / 100) * circumference;
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-16 w-16',
|
||||
md: 'h-20 w-20 md:h-24 md:w-24',
|
||||
lg: 'h-28 w-28 md:h-32 md:w-32',
|
||||
};
|
||||
|
||||
const textSizeClasses = {
|
||||
sm: 'text-base font-bold',
|
||||
md: 'text-lg md:text-xl font-bold',
|
||||
lg: 'text-xl md:text-2xl font-bold',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex max-md:hidden flex-shrink-0 self-center">
|
||||
<svg
|
||||
className={cn(sizeClasses[size], '-rotate-90 transform')}
|
||||
viewBox="0 0 100 100"
|
||||
>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="transparent"
|
||||
className="text-gray-200"
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="transparent"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
className={cn(
|
||||
'transition-all duration-1000 ease-out',
|
||||
color === 'emerald' && 'text-emerald-500',
|
||||
color === 'green' && 'text-green-500',
|
||||
color === 'blue' && 'text-blue-500',
|
||||
color === 'orange' && 'text-orange-500',
|
||||
color === 'red' && 'text-red-500',
|
||||
)}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className={cn(textSizeClasses[size], 'text-gray-900')}>
|
||||
{accuracy}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
src/components/AIQuiz/GenerateAIQuiz.tsx
Normal file
125
src/components/AIQuiz/GenerateAIQuiz.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { LoadingChip } from '../LoadingChip';
|
||||
import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
|
||||
import { getQuestionAnswerChatMessages } from '../../lib/ai-questions';
|
||||
import {
|
||||
aiQuizOptions,
|
||||
generateAIQuiz,
|
||||
type QuizQuestion,
|
||||
} from '../../queries/ai-quiz';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { AIQuizContent } from './AIQuizContent';
|
||||
import { AlertCircleIcon } from 'lucide-react';
|
||||
|
||||
type GenerateAIQuizProps = {
|
||||
onQuizSlugChange?: (quizSlug: string) => void;
|
||||
};
|
||||
|
||||
export function GenerateAIQuiz(props: GenerateAIQuizProps) {
|
||||
const { onQuizSlugChange } = props;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [questions, setQuestions] = useState<QuizQuestion[]>([]);
|
||||
const questionsRef = useRef<QuizQuestion[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = getUrlParams();
|
||||
const paramsTerm = params?.term;
|
||||
const paramsFormat = params?.format;
|
||||
const paramsSrc = params?.src || 'search';
|
||||
if (!paramsTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
let questionAndAnswers: QuestionAnswerChatMessage[] = [];
|
||||
const sessionId = params?.id;
|
||||
if (sessionId) {
|
||||
questionAndAnswers = getQuestionAnswerChatMessages(sessionId);
|
||||
}
|
||||
|
||||
handleGenerateQuiz({
|
||||
term: paramsTerm,
|
||||
format: paramsFormat,
|
||||
src: paramsSrc,
|
||||
questionAndAnswers,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleGenerateQuiz = async (options: {
|
||||
term: string;
|
||||
format: string;
|
||||
isForce?: boolean;
|
||||
prompt?: string;
|
||||
src?: string;
|
||||
questionAndAnswers?: QuestionAnswerChatMessage[];
|
||||
}) => {
|
||||
const { term, format, isForce, prompt, src, questionAndAnswers } = options;
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
window.location.href = '/ai';
|
||||
return;
|
||||
}
|
||||
|
||||
await generateAIQuiz({
|
||||
term,
|
||||
format,
|
||||
isForce,
|
||||
prompt,
|
||||
questionAndAnswers,
|
||||
onDetailsChange: (details) => {
|
||||
const { quizId, quizSlug, title, userId } = details;
|
||||
const aiQuizData = {
|
||||
_id: quizId,
|
||||
userId,
|
||||
title,
|
||||
slug: quizSlug,
|
||||
keyword: term,
|
||||
format,
|
||||
content: '',
|
||||
questionAndAnswers: questionAndAnswers || [],
|
||||
questions: questionsRef.current || [],
|
||||
viewCount: 0,
|
||||
lastVisitedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
queryClient.setQueryData(aiQuizOptions(quizSlug).queryKey, aiQuizData);
|
||||
onQuizSlugChange?.(quizSlug);
|
||||
window.history.replaceState(null, '', `/ai/quiz/${quizSlug}`);
|
||||
},
|
||||
onLoadingChange: setIsLoading,
|
||||
onError: setError,
|
||||
onStreamingChange: setIsStreaming,
|
||||
onQuestionsChange: (questions) => {
|
||||
setQuestions(questions);
|
||||
questionsRef.current = questions;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 flex h-full flex-col items-center justify-center bg-white">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<AlertCircleIcon className="size-10 text-gray-500" />
|
||||
<p className="text-center">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoadingChip message="Please wait..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <AIQuizContent isStreaming={isStreaming} questions={questions} />;
|
||||
}
|
||||
105
src/components/AIQuiz/QuizTopNavigation.tsx
Normal file
105
src/components/AIQuiz/QuizTopNavigation.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
Loader2Icon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type QuizTopNavigationProps = {
|
||||
activeQuestionIndex: number;
|
||||
totalQuestions: number;
|
||||
progressPercentage: number;
|
||||
onSkip: () => void;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
isStreaming?: boolean;
|
||||
};
|
||||
|
||||
export function QuizTopNavigation(props: QuizTopNavigationProps) {
|
||||
const {
|
||||
activeQuestionIndex,
|
||||
totalQuestions,
|
||||
progressPercentage,
|
||||
onPrevious,
|
||||
onNext,
|
||||
onSkip,
|
||||
isStreaming = false,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="mb-8 space-y-4">
|
||||
{/* Header with question count and navigation */}
|
||||
<div className="flex items-center justify-center lg:justify-between">
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<NavigationButton
|
||||
disabled={activeQuestionIndex === 0}
|
||||
onClick={onPrevious}
|
||||
icon={ChevronLeftIcon}
|
||||
/>
|
||||
<span className="text-center text-sm font-medium text-gray-600 max-lg:w-full">
|
||||
Question{' '}
|
||||
<span className="text-black">{activeQuestionIndex + 1}</span> of{' '}
|
||||
{totalQuestions}
|
||||
</span>
|
||||
<NavigationButton
|
||||
disabled={false}
|
||||
onClick={onSkip}
|
||||
icon={ChevronRightIcon}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isStreaming && (
|
||||
<div
|
||||
className={cn(
|
||||
'hidden flex-shrink-0 text-sm font-medium text-gray-500 min-lg:flex',
|
||||
)}
|
||||
>
|
||||
{Math.round(progressPercentage)}% complete
|
||||
</div>
|
||||
)}
|
||||
{isStreaming && (
|
||||
<div className="text-sm font-medium text-gray-500">
|
||||
<Loader2Icon className="size-4 animate-spin stroke-[2.5]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Enhanced progress bar */}
|
||||
<div className="relative h-2 overflow-hidden rounded-full bg-gray-100 shadow-inner">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-black transition-all duration-300 ease-out"
|
||||
style={{
|
||||
width: `${progressPercentage}%`,
|
||||
}}
|
||||
/>
|
||||
{/* Subtle shine effect */}
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-transparent via-white/20 to-transparent transition-all duration-300 ease-out"
|
||||
style={{
|
||||
width: `${progressPercentage}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type NavigationButtonProps = {
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
function NavigationButton(props: NavigationButtonProps) {
|
||||
const { disabled, onClick, icon: Icon } = props;
|
||||
return (
|
||||
<button
|
||||
className="flex size-8 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-400 transition-all duration-150 hover:border-gray-300 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-gray-200 disabled:hover:text-gray-400 disabled:hover:shadow-sm"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
148
src/components/AIQuiz/UserQuizzesList.tsx
Normal file
148
src/components/AIQuiz/UserQuizzesList.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { BookOpen, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { AITutorTallMessage } from '../AITutor/AITutorTallMessage';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { Pagination } from '../Pagination/Pagination';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { AICourseSearch } from '../GenerateCourse/AICourseSearch';
|
||||
import {
|
||||
listUserAiQuizzesOptions,
|
||||
type ListUserAiQuizzesQuery,
|
||||
} from '../../queries/ai-quiz';
|
||||
import { AIQuizCard } from './AIQuizCard';
|
||||
|
||||
export function UserQuizzesList() {
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
|
||||
|
||||
const [pageState, setPageState] = useState<ListUserAiQuizzesQuery>({
|
||||
perPage: '21',
|
||||
currPage: '1',
|
||||
query: '',
|
||||
});
|
||||
|
||||
const { data: userAiQuizzes, isFetching: isUserAiQuizzesLoading } = useQuery(
|
||||
listUserAiQuizzesOptions(pageState),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsInitialLoading(false);
|
||||
}, [userAiQuizzes]);
|
||||
|
||||
const quizzes = userAiQuizzes?.data ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const queryParams = getUrlParams();
|
||||
|
||||
setPageState({
|
||||
...pageState,
|
||||
currPage: queryParams?.p || '1',
|
||||
query: queryParams?.q || '',
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (pageState?.currPage !== '1' || pageState?.query !== '') {
|
||||
setUrlParams({
|
||||
p: pageState?.currPage || '1',
|
||||
q: pageState?.query || '',
|
||||
});
|
||||
} else {
|
||||
deleteUrlParam('p');
|
||||
deleteUrlParam('q');
|
||||
}
|
||||
}, [pageState]);
|
||||
|
||||
const isUserAuthenticated = isLoggedIn();
|
||||
const isAnyLoading = isUserAiQuizzesLoading || isInitialLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showUpgradePopup && (
|
||||
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
|
||||
)}
|
||||
|
||||
<AICourseSearch
|
||||
value={pageState?.query || ''}
|
||||
onChange={(value) => {
|
||||
setPageState({
|
||||
...pageState,
|
||||
query: value,
|
||||
currPage: '1',
|
||||
});
|
||||
}}
|
||||
placeholder="Search Quizzes..."
|
||||
disabled={isAnyLoading}
|
||||
/>
|
||||
|
||||
{isAnyLoading && (
|
||||
<p className="mb-4 flex flex-row items-center gap-2 text-sm text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading your quizzes...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isAnyLoading && (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
{isUserAuthenticated
|
||||
? `You have generated ${userAiQuizzes?.totalCount} quizzes so far.`
|
||||
: 'Sign up or login to generate your first quiz. Takes 2s to do so.'}
|
||||
</p>
|
||||
|
||||
{isUserAuthenticated && !isAnyLoading && quizzes.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||
{quizzes.map((quiz) => (
|
||||
<AIQuizCard variant="column" key={quiz._id} quiz={quiz} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
totalCount={userAiQuizzes?.totalCount || 0}
|
||||
totalPages={userAiQuizzes?.totalPages || 0}
|
||||
currPage={Number(userAiQuizzes?.currPage || 1)}
|
||||
perPage={Number(userAiQuizzes?.perPage || 10)}
|
||||
onPageChange={(page) => {
|
||||
setPageState({ ...pageState, currPage: String(page) });
|
||||
}}
|
||||
className="rounded-lg border border-gray-200 bg-white p-4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAnyLoading && quizzes.length === 0 && (
|
||||
<AITutorTallMessage
|
||||
title={
|
||||
isUserAuthenticated ? 'No quizzes found' : 'Sign up or login'
|
||||
}
|
||||
subtitle={
|
||||
isUserAuthenticated
|
||||
? "You haven't generated any quizzes yet."
|
||||
: 'Takes 2s to sign up and generate your first quiz.'
|
||||
}
|
||||
icon={BookOpen}
|
||||
buttonText={
|
||||
isUserAuthenticated
|
||||
? 'Create your first quiz'
|
||||
: 'Sign up or login'
|
||||
}
|
||||
onButtonClick={() => {
|
||||
if (isUserAuthenticated) {
|
||||
window.location.href = '/ai/quiz';
|
||||
} else {
|
||||
showLoginPopup();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
MessageCircle,
|
||||
Plus,
|
||||
Star,
|
||||
Swords,
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
@@ -34,6 +35,12 @@ const sidebarItems = [
|
||||
href: '/ai',
|
||||
icon: Plus,
|
||||
},
|
||||
{
|
||||
key: 'quiz',
|
||||
label: 'Test my Skills',
|
||||
href: '/ai/quiz',
|
||||
icon: Swords,
|
||||
},
|
||||
{
|
||||
key: 'chat',
|
||||
label: 'Ask AI Tutor',
|
||||
|
||||
@@ -60,7 +60,7 @@ export function ContentGenerator() {
|
||||
useEffect(() => {
|
||||
const params = getUrlParams();
|
||||
const format = params.format as AllowedFormat;
|
||||
if (format && allowedFormats.includes(format)) {
|
||||
if (format && allowedFormats.find((f) => f.value === format)) {
|
||||
setSelectedFormat(format);
|
||||
}
|
||||
}, []);
|
||||
@@ -227,9 +227,6 @@ export function ContentGenerator() {
|
||||
format={selectedFormat}
|
||||
questionAnswerChatMessages={questionAnswerChatMessages}
|
||||
setQuestionAnswerChatMessages={setQuestionAnswerChatMessages}
|
||||
onGenerateNow={() => {
|
||||
handleSubmit();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,11 +5,7 @@ import {
|
||||
type AIQuestionSuggestionsResponse,
|
||||
} from '../../queries/user-ai-session';
|
||||
import type { AllowedFormat } from './ContentGenerator';
|
||||
import {
|
||||
Loader2Icon,
|
||||
RefreshCcwIcon,
|
||||
SendIcon, Trash2
|
||||
} from 'lucide-react';
|
||||
import { Loader2Icon, RefreshCcwIcon, SendIcon, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { flushSync } from 'react-dom';
|
||||
@@ -26,14 +22,16 @@ export type QuestionAnswerChatMessage =
|
||||
|
||||
type QuestionAnswerChatProps = {
|
||||
term: string;
|
||||
format: AllowedFormat;
|
||||
format: AllowedFormat | (string & {});
|
||||
questionAnswerChatMessages: QuestionAnswerChatMessage[];
|
||||
setQuestionAnswerChatMessages: (
|
||||
messages: QuestionAnswerChatMessage[],
|
||||
) => void;
|
||||
onGenerateNow: () => void;
|
||||
defaultQuestions?: AIQuestionSuggestionsResponse['questions'];
|
||||
type?: 'create' | 'update';
|
||||
|
||||
from?: 'content' | 'quiz';
|
||||
|
||||
className?: string;
|
||||
};
|
||||
|
||||
@@ -44,9 +42,9 @@ export function QuestionAnswerChat(props: QuestionAnswerChatProps) {
|
||||
defaultQuestions,
|
||||
questionAnswerChatMessages,
|
||||
setQuestionAnswerChatMessages,
|
||||
onGenerateNow,
|
||||
type = 'create',
|
||||
className = '',
|
||||
from = 'content',
|
||||
} = props;
|
||||
|
||||
const [activeMessageIndex, setActiveMessageIndex] = useState(
|
||||
@@ -62,7 +60,7 @@ export function QuestionAnswerChat(props: QuestionAnswerChatProps) {
|
||||
data: aiQuestionSuggestions,
|
||||
isLoading: isLoadingAiQuestionSuggestions,
|
||||
} = useQuery(
|
||||
aiQuestionSuggestionsOptions({ term, format }, defaultQuestions),
|
||||
aiQuestionSuggestionsOptions({ term, format, from }, defaultQuestions),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
@@ -117,11 +115,6 @@ export function QuestionAnswerChat(props: QuestionAnswerChatProps) {
|
||||
scrollToBottom();
|
||||
};
|
||||
|
||||
const canGenerateNow =
|
||||
// user can generate after answering 5 questions -> 5 * 2 messages (user and assistant)
|
||||
!isLoadingAiQuestionSuggestions && questionAnswerChatMessages.length >= 10;
|
||||
|
||||
const canReset = questionAnswerChatMessages.length >= 2;
|
||||
const handleReset = () => {
|
||||
setQuestionAnswerChatMessages([]);
|
||||
setActiveMessageIndex(0);
|
||||
@@ -259,7 +252,11 @@ export function QuestionAnswerChat(props: QuestionAnswerChatProps) {
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
className="w-full bg-transparent text-sm focus:outline-none"
|
||||
placeholder={activeMessage.possibleAnswers ? "Type your answer..." : "Or type your own answer..."}
|
||||
placeholder={
|
||||
activeMessage.possibleAnswers
|
||||
? 'Type your answer...'
|
||||
: 'Or type your own answer...'
|
||||
}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
@@ -346,7 +343,7 @@ function QuestionAnswerChatMessage(props: QuestionAnswerChatMessageProps) {
|
||||
<div className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md opacity-70 hover:bg-gray-100 hover:opacity-100 focus:outline-none text-gray-500"
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md text-gray-500 opacity-70 hover:bg-gray-100 hover:opacity-100 focus:outline-none"
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { BookOpen, FileTextIcon, MapIcon, type LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
BookOpen,
|
||||
FileTextIcon,
|
||||
MapIcon,
|
||||
ListCheckIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type LibraryTabsProps = {
|
||||
activeTab: 'guides' | 'courses' | 'roadmaps';
|
||||
activeTab: 'guides' | 'courses' | 'roadmaps' | 'quizzes';
|
||||
};
|
||||
|
||||
export function LibraryTabs(props: LibraryTabsProps) {
|
||||
@@ -28,6 +34,12 @@ export function LibraryTabs(props: LibraryTabsProps) {
|
||||
label="Roadmaps"
|
||||
href="/ai/roadmaps"
|
||||
/>
|
||||
<LibraryTabButton
|
||||
isActive={activeTab === 'quizzes'}
|
||||
icon={ListCheckIcon}
|
||||
label="Quizzes"
|
||||
href="/ai/quizzes"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { ChevronDown, Map, MessageCircle, Plus } from 'lucide-react';
|
||||
import { ChevronDown, Map, MessageCircle, Plus, Swords } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
aiDropdownOpen,
|
||||
@@ -15,6 +15,12 @@ const links = [
|
||||
description: 'Learn something new with AI',
|
||||
Icon: Plus,
|
||||
},
|
||||
{
|
||||
link: '/ai/quiz',
|
||||
label: 'Test my Skills',
|
||||
description: 'Test your skills with AI',
|
||||
Icon: Swords,
|
||||
},
|
||||
{
|
||||
link: '/ai/chat',
|
||||
label: 'Ask AI Tutor',
|
||||
|
||||
133
src/hooks/use-verify-answer.ts
Normal file
133
src/hooks/use-verify-answer.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { removeAuthToken } from '../lib/jwt';
|
||||
import { readChatStream } from '../lib/chat';
|
||||
import { flushSync } from 'react-dom';
|
||||
import type { VerifyQuizAnswerResponse } from '../components/AIQuiz/AIOpenEndedQuestion';
|
||||
|
||||
type VerifyAnswerResponse = {
|
||||
status?: VerifyQuizAnswerResponse['status'];
|
||||
feedback?: string;
|
||||
};
|
||||
|
||||
type UseVerifyAnswerOptions = {
|
||||
quizSlug: string;
|
||||
question: string;
|
||||
userAnswer: string;
|
||||
|
||||
onError?: (error: Error) => void;
|
||||
onFinish?: (data: VerifyAnswerResponse) => void;
|
||||
};
|
||||
|
||||
export function useVerifyAnswer(options: UseVerifyAnswerOptions) {
|
||||
const { quizSlug, question, userAnswer, onError, onFinish } = options;
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const contentRef = useRef<VerifyAnswerResponse | null>(null);
|
||||
const [data, setData] = useState<VerifyAnswerResponse | null>(null);
|
||||
|
||||
const [status, setStatus] = useState<
|
||||
'idle' | 'streaming' | 'loading' | 'ready' | 'error'
|
||||
>('idle');
|
||||
|
||||
const verifyAnswer = useCallback(async () => {
|
||||
try {
|
||||
setStatus('loading');
|
||||
abortControllerRef.current?.abort();
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
const response = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-verify-quiz-answer/${quizSlug}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ question, userAnswer }),
|
||||
signal: abortControllerRef.current?.signal,
|
||||
credentials: 'include',
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
setStatus('error');
|
||||
if (data.status === 401) {
|
||||
removeAuthToken();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
throw new Error(data?.message || 'Something went wrong');
|
||||
}
|
||||
|
||||
const stream = response.body;
|
||||
if (!stream) {
|
||||
setStatus('error');
|
||||
throw new Error('Something went wrong');
|
||||
}
|
||||
|
||||
await readChatStream(stream, {
|
||||
onMessage: async (content) => {
|
||||
flushSync(() => {
|
||||
setStatus('streaming');
|
||||
contentRef.current = parseVerifyAIQuizAnswerResponse(content);
|
||||
setData(contentRef.current);
|
||||
});
|
||||
},
|
||||
onMessageEnd: async () => {
|
||||
flushSync(() => {
|
||||
setStatus('ready');
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
setStatus('idle');
|
||||
abortControllerRef.current = null;
|
||||
|
||||
if (!contentRef.current) {
|
||||
setStatus('error');
|
||||
throw new Error('Something went wrong');
|
||||
}
|
||||
|
||||
onFinish?.(contentRef.current);
|
||||
} catch (error) {
|
||||
if (abortControllerRef.current?.signal.aborted) {
|
||||
// we don't want to show error if the user stops the chat
|
||||
// so we just return
|
||||
return;
|
||||
}
|
||||
|
||||
onError?.(error as Error);
|
||||
setStatus('error');
|
||||
}
|
||||
}, [quizSlug, question, userAnswer, onError]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (!abortControllerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
data,
|
||||
status,
|
||||
stop,
|
||||
verifyAnswer,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseVerifyAIQuizAnswerResponse(
|
||||
response: string,
|
||||
): VerifyQuizAnswerResponse {
|
||||
const statusRegex = /<status>(.*?)<\/status>/;
|
||||
const status = response.match(statusRegex)?.[1]?.trim();
|
||||
const responseWithoutStatus = response.replace(statusRegex, '').trim();
|
||||
|
||||
return {
|
||||
status: status as VerifyQuizAnswerResponse['status'],
|
||||
feedback: responseWithoutStatus,
|
||||
};
|
||||
}
|
||||
@@ -32,5 +32,5 @@ export function getPercentage(portion: number, total: number): number {
|
||||
}
|
||||
|
||||
const percentage = (portion / total) * 100;
|
||||
return Math.round(percentage);
|
||||
return Math.min(Math.round(percentage), 100);
|
||||
}
|
||||
|
||||
22
src/pages/ai/quiz/[slug].astro
Normal file
22
src/pages/ai/quiz/[slug].astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import { AIQuiz } from '../../../components/AIQuiz/AIQuiz';
|
||||
import SkeletonLayout from '../../../layouts/SkeletonLayout.astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
interface Params extends Record<string, string | undefined> {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
const { slug } = Astro.params as Params;
|
||||
---
|
||||
|
||||
<SkeletonLayout
|
||||
title='AI Tutor'
|
||||
briefTitle='AI Tutor'
|
||||
description='AI Tutor'
|
||||
keywords={['ai', 'tutor', 'education', 'learning']}
|
||||
canonicalUrl={`/ai/guide/${slug}`}
|
||||
>
|
||||
<AIQuiz client:load quizSlug={slug} />
|
||||
</SkeletonLayout>
|
||||
18
src/pages/ai/quiz/index.astro
Normal file
18
src/pages/ai/quiz/index.astro
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
import { AIQuizGenerator } from '../../../components/AIQuiz/AIQuizGenerator';
|
||||
import { AITutorLayout } from '../../../components/AITutor/AITutorLayout';
|
||||
import SkeletonLayout from '../../../layouts/SkeletonLayout.astro';
|
||||
---
|
||||
|
||||
<SkeletonLayout
|
||||
title='AI Quiz'
|
||||
briefTitle='AI Quiz'
|
||||
description='AI Quiz'
|
||||
keywords={['ai', 'quiz', 'education', 'learning']}
|
||||
canonicalUrl='/ai/quiz'
|
||||
noIndex={true}
|
||||
>
|
||||
<AITutorLayout activeTab='quiz' client:load>
|
||||
<AIQuizGenerator client:load />
|
||||
</AITutorLayout>
|
||||
</SkeletonLayout>
|
||||
15
src/pages/ai/quiz/search.astro
Normal file
15
src/pages/ai/quiz/search.astro
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
import { AIQuiz } from '../../../components/AIQuiz/AIQuiz';
|
||||
import SkeletonLayout from '../../../layouts/SkeletonLayout.astro';
|
||||
---
|
||||
|
||||
<SkeletonLayout
|
||||
title='AI Quiz'
|
||||
briefTitle='AI Quiz'
|
||||
description='AI Quiz'
|
||||
keywords={['ai', 'quiz', 'education', 'learning']}
|
||||
canonicalUrl='/ai/quiz'
|
||||
noIndex={true}
|
||||
>
|
||||
<AIQuiz client:load />
|
||||
</SkeletonLayout>
|
||||
18
src/pages/ai/quizzes.astro
Normal file
18
src/pages/ai/quizzes.astro
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
import { UserQuizzesList } from '../../components/AIQuiz/UserQuizzesList';
|
||||
import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
|
||||
import { AILibraryLayout } from '../../components/AIGuide/AILibraryLayout';
|
||||
const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png';
|
||||
---
|
||||
|
||||
<SkeletonLayout
|
||||
title='Quiz AI'
|
||||
noIndex={true}
|
||||
ogImageUrl={ogImage}
|
||||
description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.'
|
||||
>
|
||||
<AILibraryLayout activeTab='quizzes' client:load>
|
||||
<UserQuizzesList client:load />
|
||||
</AILibraryLayout>
|
||||
</SkeletonLayout>
|
||||
|
||||
310
src/queries/ai-quiz.ts
Normal file
310
src/queries/ai-quiz.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
|
||||
import { readChatStream } from '../lib/chat';
|
||||
import { queryClient } from '../stores/query-client';
|
||||
import { getAiCourseLimitOptions } from './ai-course';
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { httpGet } from '../lib/query-http';
|
||||
import { isLoggedIn } from '../lib/jwt';
|
||||
|
||||
type QuizDetails = {
|
||||
quizId: string;
|
||||
quizSlug: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type GenerateAIQuizOptions = {
|
||||
term: string;
|
||||
format: string;
|
||||
isForce?: boolean;
|
||||
prompt?: string;
|
||||
questionAndAnswers?: QuestionAnswerChatMessage[];
|
||||
|
||||
quizSlug?: string;
|
||||
|
||||
onQuestionsChange?: (questions: QuizQuestion[]) => void;
|
||||
onDetailsChange?: (details: QuizDetails) => void;
|
||||
onLoadingChange?: (isLoading: boolean) => void;
|
||||
onStreamingChange?: (isStreaming: boolean) => void;
|
||||
onError?: (error: string) => void;
|
||||
onFinish?: () => void;
|
||||
};
|
||||
|
||||
export async function generateAIQuiz(options: GenerateAIQuizOptions) {
|
||||
const {
|
||||
term,
|
||||
format,
|
||||
quizSlug,
|
||||
onLoadingChange,
|
||||
onError,
|
||||
isForce = false,
|
||||
prompt,
|
||||
onDetailsChange,
|
||||
onFinish,
|
||||
questionAndAnswers,
|
||||
onStreamingChange,
|
||||
onQuestionsChange,
|
||||
} = options;
|
||||
|
||||
onLoadingChange?.(true);
|
||||
onStreamingChange?.(false);
|
||||
try {
|
||||
let response = null;
|
||||
|
||||
if (quizSlug && isForce) {
|
||||
response = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-regenerate-ai-quiz/${quizSlug}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
}),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
response = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-quiz`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
keyword: term,
|
||||
format,
|
||||
isForce,
|
||||
customPrompt: prompt,
|
||||
questionAndAnswers,
|
||||
}),
|
||||
credentials: 'include',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
console.error(
|
||||
'Error generating quiz:',
|
||||
data?.message || 'Something went wrong',
|
||||
);
|
||||
onLoadingChange?.(false);
|
||||
onError?.(data?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = response.body;
|
||||
if (!stream) {
|
||||
console.error('Failed to get stream from response');
|
||||
onError?.('Something went wrong');
|
||||
onLoadingChange?.(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onLoadingChange?.(false);
|
||||
onStreamingChange?.(true);
|
||||
await readChatStream(stream, {
|
||||
onMessage: async (message) => {
|
||||
const questions = generateAiQuizQuestions(message);
|
||||
console.log(questions);
|
||||
onQuestionsChange?.(questions);
|
||||
},
|
||||
onMessageEnd: async (result) => {
|
||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
||||
onStreamingChange?.(false);
|
||||
},
|
||||
onDetails: async (details) => {
|
||||
if (!details?.quizId || !details?.quizSlug) {
|
||||
throw new Error('Invalid details');
|
||||
}
|
||||
|
||||
onDetailsChange?.(details);
|
||||
},
|
||||
});
|
||||
onFinish?.();
|
||||
} catch (error: any) {
|
||||
onError?.(error?.message || 'Something went wrong');
|
||||
console.error('Error in quiz generation:', error);
|
||||
onLoadingChange?.(false);
|
||||
onStreamingChange?.(false);
|
||||
}
|
||||
}
|
||||
|
||||
export type QuizQuestion = {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'mcq' | 'open-ended';
|
||||
options: {
|
||||
id: string;
|
||||
title: string;
|
||||
isCorrect: boolean;
|
||||
}[];
|
||||
answerExplanation?: string;
|
||||
};
|
||||
|
||||
export function generateAiQuizQuestions(questionData: string): QuizQuestion[] {
|
||||
const questions: QuizQuestion[] = [];
|
||||
const lines = questionData.split('\n');
|
||||
|
||||
let currentQuestion: QuizQuestion | null = null;
|
||||
let context: 'question' | 'explanation' | 'option' | null = null;
|
||||
|
||||
const addCurrentQuestion = () => {
|
||||
if (!currentQuestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
questions.push(currentQuestion);
|
||||
currentQuestion = null;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('###')) {
|
||||
addCurrentQuestion();
|
||||
|
||||
currentQuestion = {
|
||||
id: nanoid(),
|
||||
title: line.slice(3).trim(),
|
||||
type: 'open-ended',
|
||||
options: [],
|
||||
};
|
||||
context = 'question';
|
||||
} else if (line.startsWith('##')) {
|
||||
if (!currentQuestion) {
|
||||
continue;
|
||||
}
|
||||
|
||||
currentQuestion.answerExplanation = line.slice(2).trim();
|
||||
context = 'explanation';
|
||||
} else if (line.startsWith('#')) {
|
||||
addCurrentQuestion();
|
||||
|
||||
const title = line.slice(1).trim();
|
||||
currentQuestion = {
|
||||
id: nanoid(),
|
||||
title,
|
||||
type: 'mcq',
|
||||
options: [],
|
||||
};
|
||||
context = 'question';
|
||||
} else if (line.startsWith('-')) {
|
||||
if (!currentQuestion) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawOption = line.slice(1).trim();
|
||||
const isCorrect = rawOption.startsWith('*');
|
||||
const title = rawOption.slice(isCorrect ? 1 : 0).trim();
|
||||
currentQuestion.options.push({
|
||||
id: nanoid(),
|
||||
title,
|
||||
isCorrect,
|
||||
});
|
||||
context = 'option';
|
||||
} else {
|
||||
if (!currentQuestion) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (context === 'question') {
|
||||
currentQuestion.title += `\n${line}`;
|
||||
} else if (context === 'explanation') {
|
||||
currentQuestion.answerExplanation =
|
||||
(currentQuestion?.answerExplanation || '') + `\n${line}`;
|
||||
} else if (context === 'option') {
|
||||
const lastOption = currentQuestion.options.at(-1);
|
||||
if (lastOption) {
|
||||
lastOption.title += `\n${line}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addCurrentQuestion();
|
||||
return questions;
|
||||
}
|
||||
|
||||
export interface AIQuizDocument {
|
||||
_id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
keyword: string;
|
||||
format: string;
|
||||
content: string;
|
||||
|
||||
tokens?: {
|
||||
prompt: number;
|
||||
completion: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
questionAndAnswers: QuestionAnswerChatMessage[];
|
||||
|
||||
viewCount: number;
|
||||
lastVisitedAt: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
type GetAIQuizResponse = AIQuizDocument & {
|
||||
questions: QuizQuestion[];
|
||||
};
|
||||
|
||||
export function aiQuizOptions(quizSlug?: string) {
|
||||
return queryOptions({
|
||||
queryKey: ['ai-quiz', quizSlug],
|
||||
queryFn: async () => {
|
||||
const res = await httpGet<GetAIQuizResponse>(
|
||||
`/v1-get-ai-quiz/${quizSlug}`,
|
||||
);
|
||||
|
||||
return {
|
||||
...res,
|
||||
questions: generateAiQuizQuestions(res.content),
|
||||
};
|
||||
},
|
||||
enabled: !!quizSlug,
|
||||
});
|
||||
}
|
||||
|
||||
export type ListUserAiQuizzesQuery = {
|
||||
perPage?: string;
|
||||
currPage?: string;
|
||||
query?: string;
|
||||
};
|
||||
|
||||
export type ListUserAiQuizzesResponse = {
|
||||
data: Omit<AIQuizDocument, 'content' | 'questionAndAnswers'>[];
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
currPage: number;
|
||||
perPage: number;
|
||||
};
|
||||
|
||||
export function listUserAiQuizzesOptions(
|
||||
params: ListUserAiQuizzesQuery = {
|
||||
perPage: '21',
|
||||
currPage: '1',
|
||||
query: '',
|
||||
},
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: ['user-ai-quizzes', params],
|
||||
queryFn: () => {
|
||||
return httpGet<ListUserAiQuizzesResponse>(
|
||||
`/v1-list-user-ai-quizzes`,
|
||||
params,
|
||||
);
|
||||
},
|
||||
enabled: !!isLoggedIn(),
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import { queryOptions } from '@tanstack/react-query';
|
||||
import { httpGet } from '../lib/query-http';
|
||||
import { generateAICourseRoadmapStructure } from '../lib/ai';
|
||||
import { generateAIRoadmapFromText, renderFlowJSON } from '@roadmapsh/editor';
|
||||
import { queryClient } from '../stores/query-client';
|
||||
import { getAiCourseLimitOptions } from '../queries/ai-course';
|
||||
import { readChatStream } from '../lib/chat';
|
||||
import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
|
||||
import { isLoggedIn } from '../lib/jwt';
|
||||
|
||||
export interface AIRoadmapDocument {
|
||||
_id: string;
|
||||
@@ -47,13 +52,6 @@ export function aiRoadmapOptions(roadmapSlug?: string) {
|
||||
});
|
||||
}
|
||||
|
||||
import { queryClient } from '../stores/query-client';
|
||||
import { getAiCourseLimitOptions } from '../queries/ai-course';
|
||||
import { readChatStream } from '../lib/chat';
|
||||
import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
|
||||
import type { AIGuideDocument } from './ai-guide';
|
||||
import { isLoggedIn } from '../lib/jwt';
|
||||
|
||||
type RoadmapDetails = {
|
||||
roadmapId: string;
|
||||
roadmapSlug: string;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { httpGet } from '../lib/query-http';
|
||||
type AIQuestionSuggestionsQuery = {
|
||||
term: string;
|
||||
format: string;
|
||||
from?: 'content' | 'quiz';
|
||||
};
|
||||
|
||||
export type AIQuestionSuggestionsResponse = {
|
||||
@@ -31,7 +32,7 @@ export function aiQuestionSuggestionsOptions(
|
||||
query,
|
||||
);
|
||||
},
|
||||
enabled: !!query.term && !!query.format,
|
||||
enabled: !!query.term && !!query.format && !!query.from,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user