diff --git a/.astro/types.d.ts b/.astro/types.d.ts index f964fe0cf..03d7cc43f 100644 --- a/.astro/types.d.ts +++ b/.astro/types.d.ts @@ -1 +1,2 @@ /// +/// \ No newline at end of file diff --git a/src/components/AIGuide/AILibraryLayout.tsx b/src/components/AIGuide/AILibraryLayout.tsx index 8e8e50ed1..99ac825ad 100644 --- a/src/components/AIGuide/AILibraryLayout.tsx +++ b/src/components/AIGuide/AILibraryLayout.tsx @@ -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; }; diff --git a/src/components/AIQuiz/AIQuizActions.tsx b/src/components/AIQuiz/AIQuizActions.tsx new file mode 100644 index 000000000..497f3110b --- /dev/null +++ b/src/components/AIQuiz/AIQuizActions.tsx @@ -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(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 ( +
+ + + {isOpen && ( +
+ + + Take Quiz + + {!isConfirming && ( + + )} + + {isConfirming && ( + + Are you sure? +
+ + +
+
+ )} +
+ )} +
+ ); +} diff --git a/src/components/AIQuiz/AIQuizCard.tsx b/src/components/AIQuiz/AIQuizCard.tsx new file mode 100644 index 000000000..28fd622e3 --- /dev/null +++ b/src/components/AIQuiz/AIQuizCard.tsx @@ -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; + variant?: 'row' | 'column'; + showActions?: boolean; +}; + +export function AIQuizCard(props: AIQuizCardProps) { + const { quiz, variant = 'row', showActions = true } = props; + + const updatedAgo = getRelativeTimeString(quiz?.updatedAt); + + return ( +
+ +
+

+ {quiz.title} +

+

+ {quiz.format} • {quiz.keyword} +

+
+ +
+
+ + {updatedAgo} +
+
+
+ + {showActions && quiz.slug && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/AIQuiz/UserQuizzesList.tsx b/src/components/AIQuiz/UserQuizzesList.tsx new file mode 100644 index 000000000..9f5610d5b --- /dev/null +++ b/src/components/AIQuiz/UserQuizzesList.tsx @@ -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({ + 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 && ( + setShowUpgradePopup(false)} /> + )} + + { + setPageState({ + ...pageState, + query: value, + currPage: '1', + }); + }} + placeholder="Search Quizzes..." + disabled={isAnyLoading} + /> + + {isAnyLoading && ( +

+ + Loading your quizzes... +

+ )} + + {!isAnyLoading && ( + <> +

+ {isUserAuthenticated + ? `You have generated ${userAiQuizzes?.totalCount} quizzes so far.` + : 'Sign up or login to generate your first quiz. Takes 2s to do so.'} +

+ + {isUserAuthenticated && !isAnyLoading && quizzes.length > 0 && ( +
+
+ {quizzes.map((quiz) => ( + + ))} +
+ + { + setPageState({ ...pageState, currPage: String(page) }); + }} + className="rounded-lg border border-gray-200 bg-white p-4" + /> +
+ )} + + {!isAnyLoading && quizzes.length === 0 && ( + { + if (isUserAuthenticated) { + window.location.href = '/ai/quiz'; + } else { + showLoginPopup(); + } + }} + /> + )} + + )} + + ); +} diff --git a/src/components/Library/LibraryTab.tsx b/src/components/Library/LibraryTab.tsx index ceb0aa06b..9ee6e9b7c 100644 --- a/src/components/Library/LibraryTab.tsx +++ b/src/components/Library/LibraryTab.tsx @@ -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" /> + ); } diff --git a/src/pages/ai/quizzes.astro b/src/pages/ai/quizzes.astro new file mode 100644 index 000000000..e45a23e61 --- /dev/null +++ b/src/pages/ai/quizzes.astro @@ -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'; +--- + + + + + + + diff --git a/src/queries/ai-quiz.ts b/src/queries/ai-quiz.ts index 38c9acc24..bee684f56 100644 --- a/src/queries/ai-quiz.ts +++ b/src/queries/ai-quiz.ts @@ -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[]; + 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( + `/v1-list-user-ai-quizzes`, + params, + ); + }, + enabled: !!isLoggedIn(), + }); +}