1
0
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:
Arik Chakma
2025-06-25 00:28:52 +06:00
parent 83720b387c
commit 423cc80e57
8 changed files with 373 additions and 4 deletions

View File

@@ -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;
}; };

View File

@@ -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';

View 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>
);
}

View 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>
);
}

View 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();
}
}}
/>
)}
</>
)}
</>
);
}

View File

@@ -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>
); );
} }

View 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>

View File

@@ -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(),
});
}