diff --git a/src/components/GenerateCourse/AICourseContent.tsx b/src/components/GenerateCourse/AICourseContent.tsx index 21bce4eee..9ff4a393d 100644 --- a/src/components/GenerateCourse/AICourseContent.tsx +++ b/src/components/GenerateCourse/AICourseContent.tsx @@ -17,6 +17,7 @@ import { AICourseModuleList } from './AICourseModuleList'; import { AICourseModuleView } from './AICourseModuleView'; import { slugify } from '../../lib/slugger'; import { CheckIcon } from '../ReactIcons/CheckIcon'; +import { AICourseLimit } from './AICourseLimit'; type AICourseContentProps = { courseSlug?: string; @@ -137,6 +138,8 @@ export function AICourseContent(props: AICourseContentProps) { </h1> </div> <div className="flex items-center gap-2"> + <AICourseLimit /> + {viewMode === 'module' && ( <button onClick={() => { diff --git a/src/components/GenerateCourse/AICourseLimit.tsx b/src/components/GenerateCourse/AICourseLimit.tsx new file mode 100644 index 000000000..f3e666773 --- /dev/null +++ b/src/components/GenerateCourse/AICourseLimit.tsx @@ -0,0 +1,102 @@ +import { useQuery } from '@tanstack/react-query'; +import { + BookIcon, + BookOpenIcon, + MessageCircleQuestionIcon, + ChevronDownIcon, + ClockIcon, +} from 'lucide-react'; +import { useState, useRef } from 'react'; +import { getAiCourseLimitOptions } from '../../queries/ai-course'; +import { queryClient } from '../../stores/query-client'; +import { useOutsideClick } from '../../hooks/use-outside-click'; + +export function AICourseLimit() { + const containerRef = useRef<HTMLDivElement>(null); + const [isOpen, setIsOpen] = useState(false); + + const { data: limits, isLoading } = useQuery( + getAiCourseLimitOptions(), + queryClient, + ); + + useOutsideClick(containerRef, () => { + setIsOpen(false); + }); + + if (isLoading || !limits) { + return ( + <div className="h-[34px] w-[243px] animate-pulse rounded-lg border border-gray-200 bg-gray-200"></div> + ); + } + + const { + used: courseUsed, + limit: courseLimit, + lessonUsed, + lessonLimit, + } = limits; + + const coursePercentage = Math.round((courseUsed / courseLimit) * 100); + const lessonPercentage = Math.round((lessonUsed / lessonLimit) * 100); + + return ( + <div className="relative" ref={containerRef}> + <button + className="flex cursor-pointer items-center rounded-lg border border-gray-200 px-2 py-1.5 text-sm hover:bg-gray-50" + onClick={() => setIsOpen(!isOpen)} + > + <div className="mr-3 flex items-center gap-1.5"> + <BookIcon className="h-4 w-4" /> + {coursePercentage}% + </div> + <div className="mr-3 flex items-center gap-1.5"> + <BookOpenIcon className="h-4 w-4" /> + {lessonPercentage}% + </div> + + <span className="mr-1">of daily limits</span> + <ChevronDownIcon className="h-4 w-4" /> + </button> + + {isOpen && ( + <div className="absolute right-0 top-full w-full translate-y-1 overflow-hidden rounded-lg border border-gray-200 bg-white p-2 pt-0 text-sm shadow-lg"> + <div className="-mx-2"> + <div className="relative overflow-hidden"> + <div className="relative z-10 flex items-center gap-2 border-b border-b-gray-200 px-2 py-1"> + <BookIcon className="size-3.5" /> + {courseUsed} of {courseLimit} courses used + </div> + + <div + className="absolute inset-0 bg-gray-100" + style={{ + width: `${coursePercentage}%`, + }} + /> + </div> + + <div className="relative overflow-hidden"> + <div className="relative z-10 flex items-center gap-2 border-b border-b-gray-200 px-2 py-1"> + <BookOpenIcon className="size-3.5" /> + {lessonUsed} of {lessonLimit} lessons used + </div> + + <div + className="absolute inset-0 bg-gray-100" + style={{ + width: `${lessonPercentage}%`, + }} + /> + </div> + </div> + + <div className="mt-2 flex items-center justify-center gap-2 text-gray-500"> + <ClockIcon className="size-3.5" /> + Limit resets every 24 hours + </div> + </div> + )} + </div> + ); +} diff --git a/src/components/GenerateCourse/AICourseModuleView.tsx b/src/components/GenerateCourse/AICourseModuleView.tsx index dc144aba5..8f670d76c 100644 --- a/src/components/GenerateCourse/AICourseModuleView.tsx +++ b/src/components/GenerateCourse/AICourseModuleView.tsx @@ -11,7 +11,10 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { queryClient } from '../../stores/query-client'; import { httpPost } from '../../lib/query-http'; import { slugify } from '../../lib/slugger'; -import { getAiCourseProgressOptions } from '../../queries/ai-course'; +import { + getAiCourseLimitOptions, + getAiCourseProgressOptions, +} from '../../queries/ai-course'; type AICourseModuleViewProps = { courseSlug: string; @@ -133,6 +136,7 @@ export function AICourseModuleView(props: AICourseModuleViewProps) { } setLessonHtml(await markdownToHtmlWithHighlighting(result)); + queryClient.invalidateQueries(getAiCourseLimitOptions()); setIsGenerating(false); }, }); diff --git a/src/components/GenerateCourse/GenerateAICourse.tsx b/src/components/GenerateCourse/GenerateAICourse.tsx index 310dc8ad1..3afa6a30f 100644 --- a/src/components/GenerateCourse/GenerateAICourse.tsx +++ b/src/components/GenerateCourse/GenerateAICourse.tsx @@ -5,6 +5,8 @@ import { showLoginPopup } from '../../lib/popup'; import { generateAiCourseStructure, type AiCourse } from '../../lib/ai'; import { readAICourseStream } from '../../helper/read-stream'; import { AICourseContent } from './AICourseContent'; +import { queryClient } from '../../stores/query-client'; +import { getAiCourseLimitOptions } from '../../queries/ai-course'; type GenerateAICourseProps = {}; @@ -144,6 +146,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) { .replace(COURSE_ID_REGEX, '') .replace(COURSE_SLUG_REGEX, ''); setIsLoading(false); + queryClient.invalidateQueries(getAiCourseLimitOptions()); }, }); } catch (error: any) { diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 2858d8c56..721cb6492 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -91,7 +91,6 @@ export async function markdownToHtmlWithHighlighting(markdown: string) { return self.renderToken(tokens, idx, options); }; - // @ts-ignore markdownItAsync.renderer.rules.link_open = function ( tokens, idx, diff --git a/src/queries/ai-course.ts b/src/queries/ai-course.ts index b3c789fbd..0c8067fcf 100644 --- a/src/queries/ai-course.ts +++ b/src/queries/ai-course.ts @@ -65,3 +65,22 @@ export function getAiCourseOptions(params: GetAICourseParams) { }, }; } + +type GetAICourseLimitParams = {}; + +type GetAICourseLimitResponse = { + used: number; + limit: number; + lessonUsed: number; + lessonLimit: number; +}; + +export function getAiCourseLimitOptions() { + return { + queryKey: ['ai-course-limit'], + queryFn: () => { + return httpGet<GetAICourseLimitResponse>(`/v1-get-ai-course-limit`); + }, + enabled: !!isLoggedIn(), + }; +}