mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-03 14:22:41 +02:00
feat: roadmap actions
This commit is contained in:
@@ -5,7 +5,7 @@ import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
|||||||
import { LibraryTabs } from '../Library/LibraryTab';
|
import { LibraryTabs } from '../Library/LibraryTab';
|
||||||
|
|
||||||
type AILibraryLayoutProps = {
|
type AILibraryLayoutProps = {
|
||||||
activeTab: 'courses' | 'guides';
|
activeTab: 'courses' | 'guides' | 'roadmaps';
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import './AIRoadmap.css';
|
import './AIRoadmap.css';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useRef, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
import { useToast } from '../../hooks/use-toast';
|
import { useToast } from '../../hooks/use-toast';
|
||||||
import { queryClient } from '../../stores/query-client';
|
import { queryClient } from '../../stores/query-client';
|
||||||
|
116
src/components/AIRoadmap/AIRoadmapActions.tsx
Normal file
116
src/components/AIRoadmap/AIRoadmapActions.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 AIRoadmapActionsType = {
|
||||||
|
roadmapSlug: string;
|
||||||
|
onDeleted?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AIRoadmapActions(props: AIRoadmapActionsType) {
|
||||||
|
const { roadmapSlug, onDeleted } = props;
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isConfirming, setIsConfirming] = useState(false);
|
||||||
|
|
||||||
|
const { mutate: deleteRoadmap, isPending: isDeleting } = useMutation(
|
||||||
|
{
|
||||||
|
mutationFn: async () => {
|
||||||
|
return httpDelete(`/v1-delete-ai-roadmap/${roadmapSlug}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Roadmap deleted');
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (query) => query.queryKey?.[0] === 'user-ai-roadmaps',
|
||||||
|
});
|
||||||
|
onDeleted?.();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error?.message || 'Failed to delete roadmap');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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-roadmaps/${roadmapSlug}`}
|
||||||
|
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" />
|
||||||
|
Visit Roadmap
|
||||||
|
</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 Roadmap
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'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);
|
||||||
|
deleteRoadmap();
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
49
src/components/AIRoadmap/AIRoadmapCard.tsx
Normal file
49
src/components/AIRoadmap/AIRoadmapCard.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { CalendarIcon } from 'lucide-react';
|
||||||
|
import { getRelativeTimeString } from '../../lib/date';
|
||||||
|
import { cn } from '../../lib/classname';
|
||||||
|
import type { AIRoadmapDocument } from '../../queries/ai-roadmap';
|
||||||
|
import { AIRoadmapActions } from './AIRoadmapActions';
|
||||||
|
|
||||||
|
type AIRoadmapCardProps = {
|
||||||
|
roadmap: Omit<AIRoadmapDocument, 'data' | 'questionAndAnswers'>;
|
||||||
|
variant?: 'row' | 'column';
|
||||||
|
showActions?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AIRoadmapCard(props: AIRoadmapCardProps) {
|
||||||
|
const { roadmap, variant = 'row', showActions = true } = props;
|
||||||
|
|
||||||
|
const updatedAgo = getRelativeTimeString(roadmap?.updatedAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-grow">
|
||||||
|
<a
|
||||||
|
href={`/ai-roadmaps/${roadmap.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' && 'flex-row sm:flex-row sm:items-center',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="line-clamp-2 text-base font-semibold text-balance text-gray-900">
|
||||||
|
{roadmap.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-7 flex items-center gap-4 sm:gap-4">
|
||||||
|
<div className="hidden items-center text-xs text-gray-600 sm:flex">
|
||||||
|
<CalendarIcon className="mr-1 h-3.5 w-3.5" />
|
||||||
|
<span>{updatedAgo}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{showActions && roadmap.slug && (
|
||||||
|
<div className="absolute top-2 right-2">
|
||||||
|
<AIRoadmapActions roadmapSlug={roadmap.slug} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
146
src/components/AIRoadmap/UserRoadmapsList.tsx
Normal file
146
src/components/AIRoadmap/UserRoadmapsList.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
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 {
|
||||||
|
listUserAiRoadmapsOptions,
|
||||||
|
type ListUserAiRoadmapsQuery,
|
||||||
|
} from '../../queries/ai-roadmap';
|
||||||
|
import { AIRoadmapCard } from './AIRoadmapCard';
|
||||||
|
|
||||||
|
export function UserRoadmapsList() {
|
||||||
|
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||||
|
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
|
||||||
|
|
||||||
|
const [pageState, setPageState] = useState<ListUserAiRoadmapsQuery>({
|
||||||
|
perPage: '21',
|
||||||
|
currPage: '1',
|
||||||
|
query: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: userAiRoadmaps, isFetching: isUserAiRoadmapsLoading } =
|
||||||
|
useQuery(listUserAiRoadmapsOptions(pageState), queryClient);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsInitialLoading(false);
|
||||||
|
}, [userAiRoadmaps]);
|
||||||
|
|
||||||
|
const roadmaps = userAiRoadmaps?.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 = isUserAiRoadmapsLoading || isInitialLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showUpgradePopup && (
|
||||||
|
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AICourseSearch
|
||||||
|
value={pageState?.query || ''}
|
||||||
|
onChange={(value) => {
|
||||||
|
setPageState({
|
||||||
|
...pageState,
|
||||||
|
query: value,
|
||||||
|
currPage: '1',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="Search Roadmaps..."
|
||||||
|
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 courses...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isAnyLoading && (
|
||||||
|
<>
|
||||||
|
<p className="mb-4 text-sm text-gray-500">
|
||||||
|
{isUserAuthenticated
|
||||||
|
? `You have generated ${userAiRoadmaps?.totalCount} roadmaps so far.`
|
||||||
|
: 'Sign up or login to generate your first roadmap. Takes 2s to do so.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isUserAuthenticated && !isAnyLoading && roadmaps.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">
|
||||||
|
{roadmaps.map((roadmap) => (
|
||||||
|
<AIRoadmapCard key={roadmap._id} roadmap={roadmap} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
totalCount={userAiRoadmaps?.totalCount || 0}
|
||||||
|
totalPages={userAiRoadmaps?.totalPages || 0}
|
||||||
|
currPage={Number(userAiRoadmaps?.currPage || 1)}
|
||||||
|
perPage={Number(userAiRoadmaps?.perPage || 10)}
|
||||||
|
onPageChange={(page) => {
|
||||||
|
setPageState({ ...pageState, currPage: String(page) });
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-gray-200 bg-white p-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isAnyLoading && roadmaps.length === 0 && (
|
||||||
|
<AITutorTallMessage
|
||||||
|
title={
|
||||||
|
isUserAuthenticated ? 'No roadmaps found' : 'Sign up or login'
|
||||||
|
}
|
||||||
|
subtitle={
|
||||||
|
isUserAuthenticated
|
||||||
|
? "You haven't generated any roadmaps yet."
|
||||||
|
: 'Takes 2s to sign up and generate your first roadmap.'
|
||||||
|
}
|
||||||
|
icon={BookOpen}
|
||||||
|
buttonText={
|
||||||
|
isUserAuthenticated
|
||||||
|
? 'Create your first roadmap'
|
||||||
|
: 'Sign up or login'
|
||||||
|
}
|
||||||
|
onButtonClick={() => {
|
||||||
|
if (isUserAuthenticated) {
|
||||||
|
window.location.href = '/ai';
|
||||||
|
} else {
|
||||||
|
showLoginPopup();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,8 +1,8 @@
|
|||||||
import { BookOpen, FileTextIcon, type LucideIcon } from 'lucide-react';
|
import { BookOpen, FileTextIcon, MapIcon, type LucideIcon } from 'lucide-react';
|
||||||
import { cn } from '../../lib/classname';
|
import { cn } from '../../lib/classname';
|
||||||
|
|
||||||
type LibraryTabsProps = {
|
type LibraryTabsProps = {
|
||||||
activeTab: 'guides' | 'courses';
|
activeTab: 'guides' | 'courses' | 'roadmaps';
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LibraryTabs(props: LibraryTabsProps) {
|
export function LibraryTabs(props: LibraryTabsProps) {
|
||||||
@@ -22,6 +22,12 @@ export function LibraryTabs(props: LibraryTabsProps) {
|
|||||||
label="Guides"
|
label="Guides"
|
||||||
href="/ai/guides"
|
href="/ai/guides"
|
||||||
/>
|
/>
|
||||||
|
<LibraryTabButton
|
||||||
|
isActive={activeTab === 'roadmaps'}
|
||||||
|
icon={MapIcon}
|
||||||
|
label="Roadmaps"
|
||||||
|
href="/ai/roadmaps"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
17
src/pages/ai/roadmaps.astro
Normal file
17
src/pages/ai/roadmaps.astro
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
import { UserRoadmapsList } from '../../components/AIRoadmap/UserRoadmapsList';
|
||||||
|
import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
|
||||||
|
import { AILibraryLayout } from '../../components/AIGuide/AILibraryLayout';
|
||||||
|
const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png';
|
||||||
|
---
|
||||||
|
|
||||||
|
<SkeletonLayout
|
||||||
|
title='Roadmap 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='roadmaps' client:load>
|
||||||
|
<UserRoadmapsList client:load />
|
||||||
|
</AILibraryLayout>
|
||||||
|
</SkeletonLayout>
|
@@ -51,6 +51,8 @@ import { queryClient } from '../stores/query-client';
|
|||||||
import { getAiCourseLimitOptions } from '../queries/ai-course';
|
import { getAiCourseLimitOptions } from '../queries/ai-course';
|
||||||
import { readChatStream } from '../lib/chat';
|
import { readChatStream } from '../lib/chat';
|
||||||
import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
|
import type { QuestionAnswerChatMessage } from '../components/ContentGenerator/QuestionAnswerChat';
|
||||||
|
import type { AIGuideDocument } from './ai-guide';
|
||||||
|
import { isLoggedIn } from '../lib/jwt';
|
||||||
|
|
||||||
type RoadmapDetails = {
|
type RoadmapDetails = {
|
||||||
roadmapId: string;
|
roadmapId: string;
|
||||||
@@ -176,3 +178,36 @@ export async function generateAIRoadmap(options: GenerateAIRoadmapOptions) {
|
|||||||
onStreamingChange?.(false);
|
onStreamingChange?.(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ListUserAiRoadmapsQuery = {
|
||||||
|
perPage?: string;
|
||||||
|
currPage?: string;
|
||||||
|
query?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListUserAiRoadmapsResponse = {
|
||||||
|
data: Omit<AIRoadmapDocument, 'data' | 'questionAndAnswers'>[];
|
||||||
|
totalCount: number;
|
||||||
|
totalPages: number;
|
||||||
|
currPage: number;
|
||||||
|
perPage: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function listUserAiRoadmapsOptions(
|
||||||
|
params: ListUserAiRoadmapsQuery = {
|
||||||
|
perPage: '21',
|
||||||
|
currPage: '1',
|
||||||
|
query: '',
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: ['user-ai-roadmaps', params],
|
||||||
|
queryFn: () => {
|
||||||
|
return httpGet<ListUserAiRoadmapsResponse>(
|
||||||
|
`/v1-list-user-ai-roadmaps`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enabled: !!isLoggedIn(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user