mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-03 06:12:53 +02:00
wip
This commit is contained in:
21
src/components/AIQuiz/AIMCQQuestion.tsx
Normal file
21
src/components/AIQuiz/AIMCQQuestion.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@@ -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 { AIQuizLayout } from './AIQuizLayout';
|
||||||
import { GenerateAIQuiz } from './GenerateAIQuiz';
|
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 (
|
return (
|
||||||
<AIQuizLayout>
|
<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>
|
</AIQuizLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
66
src/components/AIQuiz/AIQuizContent.tsx
Normal file
66
src/components/AIQuiz/AIQuizContent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,12 +1,16 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { getUrlParams } from '../../lib/browser';
|
import { getUrlParams } from '../../lib/browser';
|
||||||
import { isLoggedIn } from '../../lib/jwt';
|
import { isLoggedIn } from '../../lib/jwt';
|
||||||
import { queryClient } from '../../stores/query-client';
|
|
||||||
import { LoadingChip } from '../LoadingChip';
|
import { LoadingChip } from '../LoadingChip';
|
||||||
import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
|
import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
|
||||||
import { getQuestionAnswerChatMessages } from '../../lib/ai-questions';
|
import { getQuestionAnswerChatMessages } from '../../lib/ai-questions';
|
||||||
import { aiRoadmapOptions, generateAIRoadmap } from '../../queries/ai-roadmap';
|
import {
|
||||||
import { generateAIQuiz, type QuizQuestion } from '../../queries/ai-quiz';
|
aiQuizOptions,
|
||||||
|
generateAIQuiz,
|
||||||
|
type QuizQuestion,
|
||||||
|
} from '../../queries/ai-quiz';
|
||||||
|
import { queryClient } from '../../stores/query-client';
|
||||||
|
import { AIQuizContent } from './AIQuizContent';
|
||||||
|
|
||||||
type GenerateAIQuizProps = {
|
type GenerateAIQuizProps = {
|
||||||
onQuizSlugChange?: (quizSlug: string) => void;
|
onQuizSlugChange?: (quizSlug: string) => void;
|
||||||
@@ -20,6 +24,7 @@ export function GenerateAIQuiz(props: GenerateAIQuizProps) {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const [questions, setQuestions] = useState<QuizQuestion[]>([]);
|
const [questions, setQuestions] = useState<QuizQuestion[]>([]);
|
||||||
|
const questionsRef = useRef<QuizQuestion[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = getUrlParams();
|
const params = getUrlParams();
|
||||||
@@ -66,31 +71,33 @@ export function GenerateAIQuiz(props: GenerateAIQuizProps) {
|
|||||||
prompt,
|
prompt,
|
||||||
questionAndAnswers,
|
questionAndAnswers,
|
||||||
onDetailsChange: (details) => {
|
onDetailsChange: (details) => {
|
||||||
// const { quizId, quizSlug, title, userId } = details;
|
const { quizId, quizSlug, title, userId } = details;
|
||||||
// const aiRoadmapData = {
|
const aiQuizData = {
|
||||||
// _id: quizId,
|
_id: quizId,
|
||||||
// userId,
|
userId,
|
||||||
// title,
|
title,
|
||||||
// term,
|
slug: quizSlug,
|
||||||
// data: content,
|
keyword: term,
|
||||||
// questionAndAnswers,
|
format,
|
||||||
// viewCount: 0,
|
content: '',
|
||||||
// svgHtml: svgRef.current || '',
|
questionAndAnswers: questionAndAnswers || [],
|
||||||
// lastVisitedAt: new Date(),
|
questions: questionsRef.current || [],
|
||||||
// createdAt: new Date(),
|
viewCount: 0,
|
||||||
// updatedAt: new Date(),
|
lastVisitedAt: new Date(),
|
||||||
// };
|
createdAt: new Date(),
|
||||||
// queryClient.setQueryData(
|
updatedAt: new Date(),
|
||||||
// aiRoadmapOptions(roadmapSlug).queryKey,
|
};
|
||||||
// aiRoadmapData,
|
queryClient.setQueryData(aiQuizOptions(quizSlug).queryKey, aiQuizData);
|
||||||
// );
|
onQuizSlugChange?.(quizSlug);
|
||||||
// onQuizSlugChange?.(roadmapSlug);
|
window.history.replaceState(null, '', `/ai/quiz/${quizSlug}`);
|
||||||
// window.history.replaceState(null, '', `/ai-roadmaps/${roadmapSlug}`);
|
|
||||||
},
|
},
|
||||||
onLoadingChange: setIsLoading,
|
onLoadingChange: setIsLoading,
|
||||||
onError: setError,
|
onError: setError,
|
||||||
onStreamingChange: setIsStreaming,
|
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} />;
|
||||||
}
|
}
|
||||||
|
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>
|
@@ -3,6 +3,8 @@ import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/Q
|
|||||||
import { readChatStream } from '../lib/chat';
|
import { readChatStream } from '../lib/chat';
|
||||||
import { queryClient } from '../stores/query-client';
|
import { queryClient } from '../stores/query-client';
|
||||||
import { getAiCourseLimitOptions } from './ai-course';
|
import { getAiCourseLimitOptions } from './ai-course';
|
||||||
|
import { queryOptions } from '@tanstack/react-query';
|
||||||
|
import { httpGet } from '../lib/query-http';
|
||||||
|
|
||||||
type QuizDetails = {
|
type QuizDetails = {
|
||||||
quizId: string;
|
quizId: string;
|
||||||
@@ -229,3 +231,47 @@ export function generateAiQuizQuestions(questionData: string): QuizQuestion[] {
|
|||||||
addCurrentQuestion();
|
addCurrentQuestion();
|
||||||
return questions;
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user