mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-31 21:11:44 +02:00
feat: implement ai quizzes listing
This commit is contained in:
1
.astro/types.d.ts
vendored
1
.astro/types.d.ts
vendored
@@ -1 +1,2 @@
|
||||
/// <reference types="astro/client" />
|
||||
/// <reference path="content.d.ts" />
|
@@ -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;
|
||||
};
|
||||
|
||||
|
116
src/components/AIQuiz/AIQuizActions.tsx
Normal file
116
src/components/AIQuiz/AIQuizActions.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { MoreVertical, Play, Trash2 } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { httpDelete } from '../../lib/query-http';
|
||||
|
||||
type AIQuizActionsType = {
|
||||
quizSlug: string;
|
||||
onDeleted?: () => void;
|
||||
};
|
||||
|
||||
export function AIQuizActions(props: AIQuizActionsType) {
|
||||
const { quizSlug, onDeleted } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
|
||||
const { mutate: deleteQuiz, isPending: isDeleting } = useMutation(
|
||||
{
|
||||
mutationFn: async () => {
|
||||
return httpDelete(`/v1-delete-ai-quiz/${quizSlug}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Quiz deleted');
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => query.queryKey?.[0] === 'user-ai-quizzes',
|
||||
});
|
||||
onDeleted?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error?.message || 'Failed to delete quiz');
|
||||
},
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
||||
useOutsideClick(dropdownRef, () => {
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative h-full" ref={dropdownRef}>
|
||||
<button
|
||||
className="h-full text-gray-400 hover:text-gray-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-8 right-0 z-10 w-48 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
|
||||
<a
|
||||
href={`/ai/quiz/${quizSlug}`}
|
||||
className="flex w-full items-center gap-1.5 p-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Take Quiz
|
||||
</a>
|
||||
{!isConfirming && (
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 p-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-70"
|
||||
onClick={() => setIsConfirming(true)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{!isDeleting ? (
|
||||
<>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete Quiz
|
||||
</>
|
||||
) : (
|
||||
'Deleting...'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isConfirming && (
|
||||
<span className="flex w-full items-center justify-between gap-1.5 p-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-70">
|
||||
Are you sure?
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsConfirming(false);
|
||||
deleteQuiz();
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
className="text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsConfirming(false)}
|
||||
className="text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
52
src/components/AIQuiz/AIQuizCard.tsx
Normal file
52
src/components/AIQuiz/AIQuizCard.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
import { cn } from '../../lib/classname';
|
||||
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>
|
||||
<p className="mt-1 text-sm text-gray-600 capitalize">
|
||||
{quiz.format} • {quiz.keyword}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="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>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{showActions && quiz.slug && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<AIQuizActions quizSlug={quiz.slug} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
148
src/components/AIQuiz/UserQuizzesList.tsx
Normal file
148
src/components/AIQuiz/UserQuizzesList.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { BookOpen, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { AITutorTallMessage } from '../AITutor/AITutorTallMessage';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { Pagination } from '../Pagination/Pagination';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { AICourseSearch } from '../GenerateCourse/AICourseSearch';
|
||||
import {
|
||||
listUserAiQuizzesOptions,
|
||||
type ListUserAiQuizzesQuery,
|
||||
} from '../../queries/ai-quiz';
|
||||
import { AIQuizCard } from './AIQuizCard';
|
||||
|
||||
export function UserQuizzesList() {
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
|
||||
|
||||
const [pageState, setPageState] = useState<ListUserAiQuizzesQuery>({
|
||||
perPage: '21',
|
||||
currPage: '1',
|
||||
query: '',
|
||||
});
|
||||
|
||||
const { data: userAiQuizzes, isFetching: isUserAiQuizzesLoading } = useQuery(
|
||||
listUserAiQuizzesOptions(pageState),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsInitialLoading(false);
|
||||
}, [userAiQuizzes]);
|
||||
|
||||
const quizzes = userAiQuizzes?.data ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const queryParams = getUrlParams();
|
||||
|
||||
setPageState({
|
||||
...pageState,
|
||||
currPage: queryParams?.p || '1',
|
||||
query: queryParams?.q || '',
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (pageState?.currPage !== '1' || pageState?.query !== '') {
|
||||
setUrlParams({
|
||||
p: pageState?.currPage || '1',
|
||||
q: pageState?.query || '',
|
||||
});
|
||||
} else {
|
||||
deleteUrlParam('p');
|
||||
deleteUrlParam('q');
|
||||
}
|
||||
}, [pageState]);
|
||||
|
||||
const isUserAuthenticated = isLoggedIn();
|
||||
const isAnyLoading = isUserAiQuizzesLoading || isInitialLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showUpgradePopup && (
|
||||
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
|
||||
)}
|
||||
|
||||
<AICourseSearch
|
||||
value={pageState?.query || ''}
|
||||
onChange={(value) => {
|
||||
setPageState({
|
||||
...pageState,
|
||||
query: value,
|
||||
currPage: '1',
|
||||
});
|
||||
}}
|
||||
placeholder="Search Quizzes..."
|
||||
disabled={isAnyLoading}
|
||||
/>
|
||||
|
||||
{isAnyLoading && (
|
||||
<p className="mb-4 flex flex-row items-center gap-2 text-sm text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading your quizzes...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isAnyLoading && (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
{isUserAuthenticated
|
||||
? `You have generated ${userAiQuizzes?.totalCount} quizzes so far.`
|
||||
: 'Sign up or login to generate your first quiz. Takes 2s to do so.'}
|
||||
</p>
|
||||
|
||||
{isUserAuthenticated && !isAnyLoading && quizzes.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||
{quizzes.map((quiz) => (
|
||||
<AIQuizCard variant="column" key={quiz._id} quiz={quiz} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
totalCount={userAiQuizzes?.totalCount || 0}
|
||||
totalPages={userAiQuizzes?.totalPages || 0}
|
||||
currPage={Number(userAiQuizzes?.currPage || 1)}
|
||||
perPage={Number(userAiQuizzes?.perPage || 10)}
|
||||
onPageChange={(page) => {
|
||||
setPageState({ ...pageState, currPage: String(page) });
|
||||
}}
|
||||
className="rounded-lg border border-gray-200 bg-white p-4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAnyLoading && quizzes.length === 0 && (
|
||||
<AITutorTallMessage
|
||||
title={
|
||||
isUserAuthenticated ? 'No quizzes found' : 'Sign up or login'
|
||||
}
|
||||
subtitle={
|
||||
isUserAuthenticated
|
||||
? "You haven't generated any quizzes yet."
|
||||
: 'Takes 2s to sign up and generate your first quiz.'
|
||||
}
|
||||
icon={BookOpen}
|
||||
buttonText={
|
||||
isUserAuthenticated
|
||||
? 'Create your first quiz'
|
||||
: 'Sign up or login'
|
||||
}
|
||||
onButtonClick={() => {
|
||||
if (isUserAuthenticated) {
|
||||
window.location.href = '/ai/quiz';
|
||||
} else {
|
||||
showLoginPopup();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
18
src/pages/ai/quizzes.astro
Normal file
18
src/pages/ai/quizzes.astro
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
import { UserQuizzesList } from '../../components/AIQuiz/UserQuizzesList';
|
||||
import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
|
||||
import { AILibraryLayout } from '../../components/AIGuide/AILibraryLayout';
|
||||
const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png';
|
||||
---
|
||||
|
||||
<SkeletonLayout
|
||||
title='Quiz AI'
|
||||
noIndex={true}
|
||||
ogImageUrl={ogImage}
|
||||
description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.'
|
||||
>
|
||||
<AILibraryLayout activeTab='quizzes' client:load>
|
||||
<UserQuizzesList client:load />
|
||||
</AILibraryLayout>
|
||||
</SkeletonLayout>
|
||||
|
@@ -5,7 +5,7 @@ import { queryClient } from '../stores/query-client';
|
||||
import { getAiCourseLimitOptions } from './ai-course';
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { httpGet } from '../lib/query-http';
|
||||
import type { VerifyQuizAnswerResponse } from '../components/AIQuiz/AIOpenEndedQuestion';
|
||||
import { isLoggedIn } from '../lib/jwt';
|
||||
|
||||
type QuizDetails = {
|
||||
quizId: string;
|
||||
@@ -275,3 +275,36 @@ export function aiQuizOptions(quizSlug?: string) {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
Reference in New Issue
Block a user