Compare commits

...

45 Commits

Author SHA1 Message Date
Kamran Ahmed
3c00667f7a Merge branch 'master' into feat/ai-quiz 2025-07-09 11:52:34 +01:00
Kamran Ahmed
735ca6770c Add ai quiz generator in top navigation 2025-07-09 11:37:42 +01:00
Kamran Ahmed
72929f4283 Update quiz UI 2025-07-09 11:35:52 +01:00
Kamran Ahmed
518e207b83 Show progress when streaming 2025-07-09 11:18:09 +01:00
Kamran Ahmed
b097d5ea43 Improve quiz UI 2025-07-09 10:55:06 +01:00
Kamran Ahmed
6a9070cfed Let user retry skipped answers 2025-07-09 10:51:06 +01:00
Kamran Ahmed
f70db38d43 Refactor circular progress 2025-07-08 19:59:25 +01:00
Kamran Ahmed
cb491c723e Improve results UI 2025-07-08 19:44:26 +01:00
Kamran Ahmed
9a1d53b97d Improve results UI 2025-07-08 18:50:26 +01:00
Kamran Ahmed
3148e4f5de Improve results UI 2025-07-08 18:27:18 +01:00
Kamran Ahmed
18ca18bd97 Improve multi-choice questions 2025-07-08 18:19:08 +01:00
Kamran Ahmed
892298fc6b Improve UI for the explanation 2025-07-08 17:22:58 +01:00
Kamran Ahmed
5267c1c3bd Improve navigation design 2025-07-08 17:15:37 +01:00
Kamran Ahmed
0dae2255a7 Refactor quiz navigation 2025-07-08 16:02:19 +01:00
Kamran Ahmed
c25cf0227c Update MCQs design 2025-07-08 15:53:59 +01:00
Arik Chakma
bdad50666b fix: wait for to finish quiz 2025-07-07 23:50:15 +06:00
Kamran Ahmed
b8c6800a92 Merge branch 'feat/ai-quiz' of github.com:kamranahmedse/developer-roadmap into feat/ai-quiz 2025-07-07 17:13:46 +01:00
Kamran Ahmed
d504d9b444 Update tutor sidebar 2025-07-07 17:13:40 +01:00
Arik Chakma
6f51211725 wip 2025-07-07 21:51:01 +06:00
Arik Chakma
52b70924c2 wip 2025-07-07 21:35:52 +06:00
Arik Chakma
f3025cbe40 feat: implement ai quizzes listing 2025-07-07 20:40:28 +06:00
Kamran Ahmed
804ed76560 Merge branch 'master' into feat/ai-quiz 2025-07-07 13:27:37 +01:00
Arik Chakma
de38434170 wip 2025-07-04 01:08:30 +06:00
Arik Chakma
c9418b0fa4 fix: responsiveness 2025-07-04 00:36:15 +06:00
Arik Chakma
3948f2cec6 fix: open ended question 2025-07-04 00:11:42 +06:00
Arik Chakma
bf3e0e4163 wip 2025-07-03 02:14:47 +06:00
Arik Chakma
e7e1c1c8d5 wip 2025-07-03 02:14:29 +06:00
Arik Chakma
920d0512f6 wip 2025-07-03 02:07:48 +06:00
Arik Chakma
9f15ca1e53 wip 2025-07-03 00:40:10 +06:00
Arik Chakma
c370bafc53 wip 2025-07-03 00:20:26 +06:00
Arik Chakma
70d0f7c82e wip 2025-07-02 23:00:05 +06:00
Arik Chakma
e3bb896df2 wip 2025-07-02 21:34:26 +06:00
Arik Chakma
d8d6536e31 wip 2025-07-02 21:18:24 +06:00
Arik Chakma
8bb4f0a913 wip 2025-07-02 21:12:19 +06:00
Arik Chakma
fff19eb566 wip 2025-07-02 20:58:25 +06:00
Arik Chakma
adfbb818a6 wip 2025-07-02 20:56:32 +06:00
Arik Chakma
35eba5e649 wip 2025-07-02 19:43:57 +06:00
Arik Chakma
1be13b9148 wip 2025-07-02 19:25:11 +06:00
Arik Chakma
bc55f466e7 wip 2025-07-02 16:21:08 +06:00
Arik Chakma
40e1b44565 wip 2025-07-01 22:47:18 +06:00
Arik Chakma
0f9d3af6ae wip 2025-07-01 21:57:29 +06:00
Arik Chakma
38c9a67a2a wip 2025-07-01 21:18:46 +06:00
Arik Chakma
9423f45586 wip: questions parser 2025-07-01 19:55:13 +06:00
Arik Chakma
abf58dabcd wip 2025-07-01 14:40:57 +06:00
Arik Chakma
399ce72650 wip 2025-07-01 04:58:59 +06:00
30 changed files with 2746 additions and 34 deletions

View File

@@ -3,6 +3,6 @@
"enabled": false
},
"_variables": {
"lastUpdateCheck": 1751887359982
"lastUpdateCheck": 1751901824723
}
}

View File

@@ -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;
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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>
);
}

View 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();
}
}}
/>
)}
</>
)}
</>
);
}

View File

@@ -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',

View File

@@ -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();
}}
/>
)}

View File

@@ -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" />

View File

@@ -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>
);
}

View File

@@ -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',

View 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,
};
}

View File

@@ -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);
}

View 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>

View 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>

View 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>

View 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
View 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(),
});
}

View File

@@ -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;

View File

@@ -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,
});
}