mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-03 14:22:41 +02:00
wip
This commit is contained in:
116
src/components/AIGuide/AIGuideActions.tsx
Normal file
116
src/components/AIGuide/AIGuideActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
45
src/components/AIGuide/AIGuideCard.tsx
Normal file
45
src/components/AIGuide/AIGuideCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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,
|
||||
},
|
||||
|
@@ -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;
|
||||
|
||||
|
@@ -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)}
|
||||
/>
|
||||
|
@@ -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">
|
||||
|
@@ -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,
|
||||
|
150
src/components/GenerateGuide/ListUserAIGuides.tsx
Normal file
150
src/components/GenerateGuide/ListUserAIGuides.tsx
Normal 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';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@@ -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
17
src/pages/ai/guides.astro
Normal 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>
|
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
Reference in New Issue
Block a user