1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-03 14:22:41 +02:00
This commit is contained in:
Arik Chakma
2025-06-17 00:15:36 +06:00
parent b91bb254b1
commit 5da56891f1
13 changed files with 400 additions and 53 deletions

View File

@@ -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<HTMLDivElement>(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 (
<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/guides/${guideSlug}`}
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"
>
<ArrowUpRightIcon className="h-3.5 w-3.5" />
View Guide
</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 Guide
</>
) : (
'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);
deleteCourse();
}}
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,45 @@
import type { AIGuideDocument } from '../../queries/ai-guide';
import { AIGuideActions } from './AIGuideActions';
type AIGuideCardProps = {
guide: Pick<AIGuideDocument, 'slug' | 'title' | 'depth'>;
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 (
<div className="relative flex flex-grow flex-col">
<a
href={`/ai/guides/${guide.slug}`}
className="hover:border-gray-3 00 group relative flex h-full min-h-[140px] w-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white p-4 text-left transition-all hover:bg-gray-50"
>
<div className="flex items-center justify-between">
<span
className={`rounded-full text-xs font-medium capitalize opacity-80 ${guideDepthColor}`}
>
{guide.depth}
</span>
</div>
<h3 className="my-2 text-base font-semibold text-gray-900">
{guide.title}
</h3>
</a>
{showActions && guide.slug && (
<div className="absolute top-2 right-2">
<AIGuideActions guideSlug={guide.slug} />
</div>
)}
</div>
);
}

View File

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

View File

@@ -55,11 +55,6 @@ export function ContentGenerator() {
icon: FileTextIcon,
value: 'guide',
},
{
label: 'Roadmap',
icon: MapIcon,
value: 'roadmap',
},
];
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
@@ -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() {
<label className="inline-block text-sm text-gray-500">
Choose the format
</label>
<div className="grid grid-cols-3 gap-2">
<div className="grid grid-cols-2 gap-2">
{allowedFormats.map((format) => {
const isSelected = format.value === selectedFormat;

View File

@@ -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) {
</div>
<input
type="text"
className="block w-full rounded-md border border-gray-200 bg-white py-1.5 pl-10 pr-3 leading-5 placeholder-gray-500 focus:border-gray-300 focus:outline-hidden focus:ring-blue-500 disabled:opacity-70 sm:text-sm"
placeholder="Search courses..."
className="block w-full rounded-md border border-gray-200 bg-white py-1.5 pr-3 pl-10 leading-5 placeholder-gray-500 focus:border-gray-300 focus:ring-blue-500 focus:outline-hidden disabled:opacity-70 sm:text-sm"
placeholder={placeholder || 'Search courses...'}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>

View File

@@ -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) {
</h2>
<ul className="flex flex-col gap-1 p-1">
{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 (
<li key={topic} className="w-full">

View File

@@ -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,

View File

@@ -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<ListUserAIGuidesQuery>({
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 (
<AILoadingState
title="Loading your courses"
subtitle="This may take a moment..."
/>
);
}
if (!isLoggedIn()) {
return (
<AITutorTallMessage
title="Sign up or login"
subtitle="Takes 2s to sign up and generate your first course."
icon={BookOpen}
buttonText="Sign up or Login"
onButtonClick={() => {
showLoginPopup();
}}
/>
);
}
return (
<>
{showUpgradePopup && (
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
)}
<AITutorHeader
title="Your Guides"
onUpgradeClick={() => setShowUpgradePopup(true)}
>
<AICourseSearch
value={pageState?.query || ''}
onChange={(value) => {
setPageState({
...pageState,
query: value,
currPage: '1',
});
}}
placeholder="Search guides..."
/>
</AITutorHeader>
{(isUserAiGuidesLoading || isInitialLoading) && (
<AILoadingState
title="Loading your guides"
subtitle="This may take a moment..."
/>
)}
{!isUserAiGuidesLoading && !isInitialLoading && guides.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">
{guides.map((guide) => (
<AIGuideCard key={guide._id} guide={guide} />
))}
</div>
<Pagination
totalCount={userAiGuides?.totalCount || 0}
totalPages={userAiGuides?.totalPages || 0}
currPage={Number(userAiGuides?.currPage || 1)}
perPage={Number(userAiGuides?.perPage || 10)}
onPageChange={(page) => {
setPageState({ ...pageState, currPage: String(page) });
}}
className="rounded-lg border border-gray-200 bg-white p-4"
/>
</div>
)}
{!isUserAiGuidesLoading && !isInitialLoading && guides.length === 0 && (
<AITutorTallMessage
title="No guides found"
subtitle="You haven't generated any guides yet."
icon={BookOpen}
buttonText="Create your first guide"
onButtonClick={() => {
window.location.href = '/ai';
}}
/>
)}
</>
);
}

View File

@@ -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.'
>
<AITutorLayout activeTab='courses' client:load>
<AITutorLayout activeTab='library' client:load>
<UserCoursesList client:load />
</AITutorLayout>
</SkeletonLayout>

17
src/pages/ai/guides.astro Normal file
View File

@@ -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';
---
<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.'
>
<AITutorLayout activeTab='library' client:load>
<ListUserAIGuides client:load />
</AITutorLayout>
</SkeletonLayout>

View File

@@ -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<ListUserAiDocumentsResponse>(
`/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<ListUserAIGuidesResponse>(
`/v1-list-user-ai-guides`,
params,
);
},
enabled: !!isLoggedIn(),
});
}