1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-02 13:52:46 +02:00
This commit is contained in:
Arik Chakma
2025-07-01 21:18:46 +06:00
parent 9423f45586
commit 38c9a67a2a
6 changed files with 310 additions and 27 deletions

View File

@@ -0,0 +1,21 @@
import type { QuizQuestion } from '../../queries/ai-quiz';
type AIMCQQuestionProps = {
question: QuizQuestion;
};
export function AIMCQQuestion(props: AIMCQQuestionProps) {
const { question } = props;
const { title: questionText, options } = question;
return (
<div>
<h3 className="text-3xl font-medium">{questionText}</h3>
<div className="mt-4">
{options.map((option) => (
<div key={option.id}>{option.title}</div>
))}
</div>
</div>
);
}

View File

@@ -1,10 +1,131 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { flushSync } from 'react-dom';
import { useToast } from '../../hooks/use-toast';
import { queryClient } from '../../stores/query-client';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { AlertCircleIcon } from 'lucide-react';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
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';
type AIQuizProps = {
quizSlug?: string;
};
export function AIQuiz(props: AIQuizProps) {
const { quizSlug: defaultQuizSlug } = props;
const [quizSlug, setQuizSlug] = useState(defaultQuizSlug);
const toast = useToast();
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 isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const isPaidUser = userBillingDetails?.status === 'active';
const handleRegenerate = async (prompt?: string) => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
if (!isPaidUser && isLimitExceeded) {
setShowUpgradeModal(true);
return;
}
flushSync(() => {
setIsRegenerating(true);
});
queryClient.cancelQueries(aiQuizOptions(quizSlug));
queryClient.setQueryData(aiQuizOptions(quizSlug).queryKey, (old) => {
if (!old) {
return old;
}
return {
...old,
data: '',
svgHtml: '',
};
});
await generateAIQuiz({
quizSlug: aiQuiz?.slug || '',
term: aiQuiz?.keyword || '',
format: aiQuiz?.format || '',
prompt,
isForce: true,
onStreamingChange: setIsRegenerating,
onError: (error) => {
toast.error(error);
},
onFinish: () => {
setIsRegenerating(false);
refetchTokenUsage();
queryClient.invalidateQueries(aiQuizOptions(quizSlug));
},
});
};
const isLoading =
isLoadingBySlug ||
isRegenerating ||
isTokenUsageLoading ||
isBillingDetailsLoading;
export function AIQuiz() {
return (
<AIQuizLayout>
<GenerateAIQuiz />
{showUpgradeModal && (
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
)}
{!isLoading && aiQuizError && (
<div className="absolute inset-0 z-10 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>
)}
<div className="grow overflow-y-auto p-4 pt-0">
{quizSlug && !aiQuizError && (
<AIQuizContent
questions={aiQuiz?.questions ?? []}
isLoading={isLoading}
/>
)}
{!quizSlug && !aiQuizError && (
<GenerateAIQuiz onQuizSlugChange={setQuizSlug} />
)}
</div>
</AIQuizLayout>
);
}

View File

@@ -0,0 +1,66 @@
import { useState } from 'react';
import type { QuizQuestion } from '../../queries/ai-quiz';
import {
ChevronLeftIcon,
ChevronRightIcon,
type LucideIcon,
} from 'lucide-react';
import { AIMCQQuestion } from './AIMCQQuestion';
type AIQuizContentProps = {
questions: QuizQuestion[];
isLoading?: boolean;
};
export function AIQuizContent(props: AIQuizContentProps) {
const { questions, isLoading } = props;
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
const activeQuestion = questions[activeQuestionIndex];
const hasMoreQuestions = activeQuestionIndex < questions.length - 1;
const hasPreviousQuestions = activeQuestionIndex > 0;
return (
<div className="py-10">
<div className="mb-4 flex items-center gap-3">
<NavigationButton
disabled={!hasPreviousQuestions}
onClick={() => setActiveQuestionIndex(activeQuestionIndex - 1)}
icon={ChevronLeftIcon}
/>
<span className="text-sm text-gray-500">
Question {activeQuestionIndex + 1} of {questions.length}
</span>
<NavigationButton
disabled={!hasMoreQuestions}
onClick={() => setActiveQuestionIndex(activeQuestionIndex + 1)}
icon={ChevronRightIcon}
/>
</div>
{activeQuestion && activeQuestion.type === 'mcq' && (
<AIMCQQuestion question={activeQuestion} />
)}
</div>
);
}
type NavigationButtonProps = {
disabled: boolean;
onClick: () => void;
icon: LucideIcon;
};
function NavigationButton(props: NavigationButtonProps) {
const { disabled, onClick, icon: Icon } = props;
return (
<button
className="flex size-7 items-center justify-center rounded-lg border border-gray-200 text-gray-500 hover:text-black disabled:opacity-50"
disabled={disabled}
onClick={onClick}
>
<Icon className="size-4" />
</button>
);
}

View File

@@ -1,12 +1,16 @@
import { useEffect, useRef, useState } from 'react';
import { getUrlParams } from '../../lib/browser';
import { isLoggedIn } from '../../lib/jwt';
import { queryClient } from '../../stores/query-client';
import { LoadingChip } from '../LoadingChip';
import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
import { getQuestionAnswerChatMessages } from '../../lib/ai-questions';
import { aiRoadmapOptions, generateAIRoadmap } from '../../queries/ai-roadmap';
import { generateAIQuiz, type QuizQuestion } from '../../queries/ai-quiz';
import {
aiQuizOptions,
generateAIQuiz,
type QuizQuestion,
} from '../../queries/ai-quiz';
import { queryClient } from '../../stores/query-client';
import { AIQuizContent } from './AIQuizContent';
type GenerateAIQuizProps = {
onQuizSlugChange?: (quizSlug: string) => void;
@@ -20,6 +24,7 @@ export function GenerateAIQuiz(props: GenerateAIQuizProps) {
const [error, setError] = useState('');
const [questions, setQuestions] = useState<QuizQuestion[]>([]);
const questionsRef = useRef<QuizQuestion[]>([]);
useEffect(() => {
const params = getUrlParams();
@@ -66,31 +71,33 @@ export function GenerateAIQuiz(props: GenerateAIQuizProps) {
prompt,
questionAndAnswers,
onDetailsChange: (details) => {
// const { quizId, quizSlug, title, userId } = details;
// const aiRoadmapData = {
// _id: quizId,
// userId,
// title,
// term,
// data: content,
// questionAndAnswers,
// viewCount: 0,
// svgHtml: svgRef.current || '',
// lastVisitedAt: new Date(),
// createdAt: new Date(),
// updatedAt: new Date(),
// };
// queryClient.setQueryData(
// aiRoadmapOptions(roadmapSlug).queryKey,
// aiRoadmapData,
// );
// onQuizSlugChange?.(roadmapSlug);
// window.history.replaceState(null, '', `/ai-roadmaps/${roadmapSlug}`);
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: setQuestions,
onQuestionsChange: (questions) => {
setQuestions(questions);
questionsRef.current = questions;
},
});
};
@@ -106,5 +113,5 @@ export function GenerateAIQuiz(props: GenerateAIQuizProps) {
);
}
return <div>GenerateAIQuiz</div>;
return <AIQuizContent questions={questions} />;
}

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

@@ -3,6 +3,8 @@ import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/Q
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';
type QuizDetails = {
quizId: string;
@@ -229,3 +231,47 @@ export function generateAiQuizQuestions(questionData: string): QuizQuestion[] {
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,
});
}