diff --git a/src/components/AIGuide/AIGuideActions.tsx b/src/components/AIGuide/AIGuideActions.tsx new file mode 100644 index 000000000..faa23e2b7 --- /dev/null +++ b/src/components/AIGuide/AIGuideActions.tsx @@ -0,0 +1,116 @@ +import { ArrowUpRightIcon, 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 AIGuideActionsType = { + guideSlug: string; + onDeleted?: () => void; +}; + +export function AIGuideActions(props: AIGuideActionsType) { + const { guideSlug, onDeleted } = props; + + const toast = useToast(); + const dropdownRef = useRef(null); + + const [isOpen, setIsOpen] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); + + const { mutate: deleteCourse, isPending: isDeleting } = useMutation( + { + mutationFn: async () => { + return httpDelete(`/v1-delete-ai-guide/${guideSlug}`); + }, + onSuccess: () => { + toast.success('Guide deleted'); + queryClient.invalidateQueries({ + predicate: (query) => query.queryKey?.[0] === 'user-ai-guides', + }); + onDeleted?.(); + }, + onError: (error) => { + toast.error(error?.message || 'Failed to delete guide'); + }, + }, + queryClient, + ); + + useOutsideClick(dropdownRef, () => { + setIsOpen(false); + }); + + useKeydown('Escape', () => { + setIsOpen(false); + }); + + return ( +
+ + + {isOpen && ( +
+ + + View Guide + + {!isConfirming && ( + + )} + + {isConfirming && ( + + Are you sure? +
+ + +
+
+ )} +
+ )} +
+ ); +} diff --git a/src/components/AIGuide/AIGuideCard.tsx b/src/components/AIGuide/AIGuideCard.tsx new file mode 100644 index 000000000..8b5b8ae32 --- /dev/null +++ b/src/components/AIGuide/AIGuideCard.tsx @@ -0,0 +1,45 @@ +import type { AIGuideDocument } from '../../queries/ai-guide'; +import { AIGuideActions } from './AIGuideActions'; + +type AIGuideCardProps = { + guide: Pick; + showActions?: boolean; +}; + +export function AIGuideCard(props: AIGuideCardProps) { + const { guide, showActions = true } = props; + + const guideDepthColor = + { + essentials: 'text-green-700', + detailed: 'text-blue-700', + complete: 'text-purple-700', + }[guide.depth] || 'text-gray-700'; + + return ( +
+ +
+ + {guide.depth} + +
+ +

+ {guide.title} +

+
+ + {showActions && guide.slug && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/AITutor/AITutorSidebar.tsx b/src/components/AITutor/AITutorSidebar.tsx index cf0400e09..a06187d95 100644 --- a/src/components/AITutor/AITutorSidebar.tsx +++ b/src/components/AITutor/AITutorSidebar.tsx @@ -21,13 +21,13 @@ type AITutorSidebarProps = { const sidebarItems = [ { key: 'new', - label: 'New Course', + label: 'New', href: '/ai', icon: Plus, }, { - key: 'courses', - label: 'My Courses', + key: 'library', + label: 'Library', href: '/ai/courses', icon: BookOpen, }, diff --git a/src/components/ContentGenerator/ContentGenerator.tsx b/src/components/ContentGenerator/ContentGenerator.tsx index 25c5c1c28..219f7aef1 100644 --- a/src/components/ContentGenerator/ContentGenerator.tsx +++ b/src/components/ContentGenerator/ContentGenerator.tsx @@ -55,11 +55,6 @@ export function ContentGenerator() { icon: FileTextIcon, value: 'guide', }, - { - label: 'Roadmap', - icon: MapIcon, - value: 'roadmap', - }, ]; const handleSubmit = (e: FormEvent) => { @@ -82,7 +77,7 @@ export function ContentGenerator() { if (selectedFormat === 'course') { window.location.href = `/ai/course?term=${encodeURIComponent(title)}&difficulty=${difficulty}&id=${sessionId}&format=${selectedFormat}`; } else if (selectedFormat === 'guide') { - window.location.href = `/ai/guide?term=${encodeURIComponent(title)}&depth=${depth}&id=${sessionId}&format=${selectedFormat}`; + window.location.href = `/ai/guides?term=${encodeURIComponent(title)}&depth=${depth}&id=${sessionId}&format=${selectedFormat}`; } }; @@ -137,7 +132,7 @@ export function ContentGenerator() { -
+
{allowedFormats.map((format) => { const isSelected = format.value === selectedFormat; diff --git a/src/components/GenerateCourse/AICourseSearch.tsx b/src/components/GenerateCourse/AICourseSearch.tsx index 33be82a18..b2f22a6aa 100644 --- a/src/components/GenerateCourse/AICourseSearch.tsx +++ b/src/components/GenerateCourse/AICourseSearch.tsx @@ -5,10 +5,11 @@ import { useDebounceValue } from '../../hooks/use-debounce'; type AICourseSearchProps = { value: string; onChange: (value: string) => void; + placeholder?: string; }; export function AICourseSearch(props: AICourseSearchProps) { - const { value: defaultValue, onChange } = props; + const { value: defaultValue, onChange, placeholder } = props; const [searchTerm, setSearchTerm] = useState(defaultValue); const debouncedSearchTerm = useDebounceValue(searchTerm, 500); @@ -36,8 +37,8 @@ export function AICourseSearch(props: AICourseSearchProps) {
setSearchTerm(e.target.value)} /> diff --git a/src/components/GenerateGuide/AIGuide.tsx b/src/components/GenerateGuide/AIGuide.tsx index 71ae9e121..2a6f774dc 100644 --- a/src/components/GenerateGuide/AIGuide.tsx +++ b/src/components/GenerateGuide/AIGuide.tsx @@ -73,7 +73,7 @@ export function AIGuide(props: AIGuideProps) { await generateGuide({ slug: aiGuide?.slug || '', term: aiGuide?.keyword || '', - depth: aiGuide?.difficulty || '', + depth: aiGuide?.depth || '', prompt, onStreamingChange: setIsRegenerating, onHtmlChange: setRegeneratedHtml, @@ -149,7 +149,7 @@ export function ListSuggestions(props: ListSuggestionsProps) {
    {suggestions?.map((topic) => { - const url = `/ai/guide?term=${encodeURIComponent(topic)}&depth=${depth}&id=&format=guide`; + const url = `/ai/guides?term=${encodeURIComponent(topic)}&depth=${depth}&id=&format=guide`; return (
  • diff --git a/src/components/GenerateGuide/GenerateAIGuide.tsx b/src/components/GenerateGuide/GenerateAIGuide.tsx index f9efb6883..e8adab97e 100644 --- a/src/components/GenerateGuide/GenerateAIGuide.tsx +++ b/src/components/GenerateGuide/GenerateAIGuide.tsx @@ -10,7 +10,7 @@ import { getAiGuideOptions } from '../../queries/ai-guide'; type GenerateAIGuideProps = { onGuideSlugChange?: (guideSlug: string) => void; -}; +}; export function GenerateAIGuide(props: GenerateAIGuideProps) { const { onGuideSlugChange } = props; @@ -86,9 +86,18 @@ export function GenerateAIGuide(props: GenerateAIGuideProps) { title, html: htmlRef.current, keyword: term, - difficulty: depth, + depth, content, + tokens: { + prompt: 0, + completion: 0, + total: 0, + }, + relatedTopics: [], + deepDiveTopics: [], + questions: [], viewCount: 0, + lastVisitedAt: new Date(), createdAt: new Date(), updatedAt: new Date(), }; @@ -99,7 +108,7 @@ export function GenerateAIGuide(props: GenerateAIGuideProps) { ); onGuideSlugChange?.(guideSlug); - window.history.replaceState(null, '', `/ai/guide/${guideSlug}`); + window.history.replaceState(null, '', `/ai/guides/${guideSlug}`); }, onLoadingChange: setIsLoading, onError: setError, diff --git a/src/components/GenerateGuide/ListUserAIGuides.tsx b/src/components/GenerateGuide/ListUserAIGuides.tsx new file mode 100644 index 000000000..ce975f327 --- /dev/null +++ b/src/components/GenerateGuide/ListUserAIGuides.tsx @@ -0,0 +1,150 @@ +import { useQuery } from '@tanstack/react-query'; +import { BookOpen } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser'; +import { isLoggedIn } from '../../lib/jwt'; +import { showLoginPopup } from '../../lib/popup'; +import { + listUserAIGuidesOptions, + type ListUserAIGuidesQuery, +} from '../../queries/ai-guide'; +import { queryClient } from '../../stores/query-client'; +import { AITutorHeader } from '../AITutor/AITutorHeader'; +import { AITutorTallMessage } from '../AITutor/AITutorTallMessage'; +import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal'; +import { Pagination } from '../Pagination/Pagination'; +import { AILoadingState } from '../AITutor/AILoadingState'; +import { AICourseSearch } from '../GenerateCourse/AICourseSearch'; +import { AIGuideCard } from '../AIGuide/AIGuideCard'; + +export function ListUserAIGuides() { + const [isInitialLoading, setIsInitialLoading] = useState(true); + const [showUpgradePopup, setShowUpgradePopup] = useState(false); + + const [pageState, setPageState] = useState({ + perPage: '2', + currPage: '1', + query: '', + }); + + const { data: userAiGuides, isFetching: isUserAiGuidesLoading } = useQuery( + listUserAIGuidesOptions(pageState), + queryClient, + ); + + useEffect(() => { + setIsInitialLoading(false); + }, [userAiGuides]); + + const guides = userAiGuides?.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]); + + if (isUserAiGuidesLoading || isInitialLoading) { + return ( + + ); + } + + if (!isLoggedIn()) { + return ( + { + showLoginPopup(); + }} + /> + ); + } + + return ( + <> + {showUpgradePopup && ( + setShowUpgradePopup(false)} /> + )} + + setShowUpgradePopup(true)} + > + { + setPageState({ + ...pageState, + query: value, + currPage: '1', + }); + }} + placeholder="Search guides..." + /> + + + {(isUserAiGuidesLoading || isInitialLoading) && ( + + )} + + {!isUserAiGuidesLoading && !isInitialLoading && guides.length > 0 && ( +
    +
    + {guides.map((guide) => ( + + ))} +
    + + { + setPageState({ ...pageState, currPage: String(page) }); + }} + className="rounded-lg border border-gray-200 bg-white p-4" + /> +
    + )} + + {!isUserAiGuidesLoading && !isInitialLoading && guides.length === 0 && ( + { + window.location.href = '/ai'; + }} + /> + )} + + ); +} diff --git a/src/pages/ai/courses.astro b/src/pages/ai/courses.astro index 25a37a443..cbff0612c 100644 --- a/src/pages/ai/courses.astro +++ b/src/pages/ai/courses.astro @@ -11,7 +11,7 @@ const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png'; 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.' > - + diff --git a/src/pages/ai/guides.astro b/src/pages/ai/guides.astro new file mode 100644 index 000000000..762f31b79 --- /dev/null +++ b/src/pages/ai/guides.astro @@ -0,0 +1,17 @@ +--- +import { ListUserAIGuides } from '../../components/GenerateGuide/ListUserAIGuides'; +import SkeletonLayout from '../../layouts/SkeletonLayout.astro'; +import { AITutorLayout } from '../../components/AITutor/AITutorLayout'; +const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png'; +--- + + + + + + diff --git a/src/pages/ai/guide/[slug].astro b/src/pages/ai/guides/[slug].astro similarity index 100% rename from src/pages/ai/guide/[slug].astro rename to src/pages/ai/guides/[slug].astro diff --git a/src/pages/ai/guide/index.astro b/src/pages/ai/guides/index.astro similarity index 100% rename from src/pages/ai/guide/index.astro rename to src/pages/ai/guides/index.astro diff --git a/src/queries/ai-guide.ts b/src/queries/ai-guide.ts index f885ad286..bc15c40a6 100644 --- a/src/queries/ai-guide.ts +++ b/src/queries/ai-guide.ts @@ -9,9 +9,20 @@ export interface AIGuideDocument { title: string; slug?: string; keyword: string; - difficulty: string; + depth: string; content: string; + tokens: { + prompt: number; + completion: number; + total: number; + }; + + relatedTopics: string[]; + deepDiveTopics: string[]; + questions: string[]; + viewCount: number; + lastVisitedAt: Date; createdAt: Date; updatedAt: Date; } @@ -35,39 +46,6 @@ export function getAiGuideOptions(guideSlug?: string) { }); } -export type ListUserAiDocumentsQuery = { - perPage?: string; - currPage?: string; - query?: string; -}; - -type ListUserAiDocumentsResponse = { - data: AIGuideDocument[]; - totalCount: number; - totalPages: number; - currPage: number; - perPage: number; -}; - -export function listUserAiDocumentsOptions( - params: ListUserAiDocumentsQuery = { - perPage: '21', - currPage: '1', - query: '', - }, -) { - return { - queryKey: ['user-ai-documents', params], - queryFn: () => { - return httpGet( - `/v1-list-user-ai-documents`, - params, - ); - }, - enabled: !!isLoggedIn(), - }; -} - type AIGuideSuggestionsResponse = { relatedTopics: string[]; deepDiveTopics: string[]; @@ -85,3 +63,39 @@ export function aiGuideSuggestionsOptions(guideSlug?: string) { enabled: !!guideSlug && !!isLoggedIn(), }); } + +export type ListUserAIGuidesQuery = { + perPage?: string; + currPage?: string; + query?: string; +}; + +type ListUserAIGuidesResponse = { + data: Omit< + AIGuideDocument, + 'content' | 'tokens' | 'relatedTopics' | 'deepDiveTopics' | 'questions' + >[]; + totalCount: number; + totalPages: number; + currPage: number; + perPage: number; +}; + +export function listUserAIGuidesOptions( + params: ListUserAIGuidesQuery = { + perPage: '21', + currPage: '1', + query: '', + }, +) { + return queryOptions({ + queryKey: ['ai-guides', params], + queryFn: () => { + return httpGet( + `/v1-list-user-ai-guides`, + params, + ); + }, + enabled: !!isLoggedIn(), + }); +}