mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-02 13:52:46 +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 { 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>
|
||||
);
|
||||
}
|
||||
|
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 { 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} />;
|
||||
}
|
||||
|
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 { 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,
|
||||
});
|
||||
}
|
||||
|
Reference in New Issue
Block a user