mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-25 00:21:28 +02:00
feat: enhance the progress rendering
This commit is contained in:
1
.astro/types.d.ts
vendored
1
.astro/types.d.ts
vendored
@@ -1,2 +1 @@
|
||||
/// <reference types="astro/client" />
|
||||
/// <reference path="content.d.ts" />
|
@@ -1,6 +1,6 @@
|
||||
import './ChatRoadmapRenderer.css';
|
||||
|
||||
import { lazy, useCallback, useEffect, useRef } from 'react';
|
||||
import { lazy, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
renderResourceProgress,
|
||||
updateResourceProgress,
|
||||
@@ -17,6 +17,7 @@ import { showLoginPopup } from '../../lib/popup';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { userResourceProgressOptions } from '../../queries/resource-progress';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { TopicResourcesModal } from './TopicResourcesModal';
|
||||
|
||||
const Renderer = lazy(() =>
|
||||
import('@roadmapsh/editor').then((mod) => ({
|
||||
@@ -68,6 +69,7 @@ export function ChatRoadmapRenderer(props: ChatRoadmapRendererProps) {
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [selectedTopicId, setSelectedTopicId] = useState<string | null>(null);
|
||||
const { data: userResourceProgressData } = useQuery(
|
||||
userResourceProgressOptions('roadmap', roadmapId),
|
||||
queryClient,
|
||||
@@ -190,6 +192,8 @@ export function ChatRoadmapRenderer(props: ChatRoadmapRendererProps) {
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedTopicId(nodeId);
|
||||
}, []);
|
||||
|
||||
const handleSvgRightClick = useCallback((e: MouseEvent) => {
|
||||
@@ -230,29 +234,39 @@ export function ChatRoadmapRenderer(props: ChatRoadmapRendererProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Renderer
|
||||
ref={roadmapRef}
|
||||
roadmap={{ nodes, edges }}
|
||||
onRendered={() => {
|
||||
roadmapRef.current?.setAttribute('data-renderer', 'editor');
|
||||
<>
|
||||
{selectedTopicId && (
|
||||
<TopicResourcesModal
|
||||
roadmapId={roadmapId}
|
||||
topicId={selectedTopicId}
|
||||
onClose={() => setSelectedTopicId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
if (!userResourceProgressData) {
|
||||
return;
|
||||
}
|
||||
<Renderer
|
||||
ref={roadmapRef}
|
||||
roadmap={{ nodes, edges }}
|
||||
onRendered={() => {
|
||||
roadmapRef.current?.setAttribute('data-renderer', 'editor');
|
||||
|
||||
const { done, learning, skipped } = userResourceProgressData;
|
||||
done.forEach((topicId) => {
|
||||
renderTopicProgress(topicId, 'done');
|
||||
});
|
||||
if (!userResourceProgressData) {
|
||||
return;
|
||||
}
|
||||
|
||||
learning.forEach((topicId) => {
|
||||
renderTopicProgress(topicId, 'learning');
|
||||
});
|
||||
const { done, learning, skipped } = userResourceProgressData;
|
||||
done.forEach((topicId) => {
|
||||
renderTopicProgress(topicId, 'done');
|
||||
});
|
||||
|
||||
skipped.forEach((topicId) => {
|
||||
renderTopicProgress(topicId, 'skipped');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
learning.forEach((topicId) => {
|
||||
renderTopicProgress(topicId, 'learning');
|
||||
});
|
||||
|
||||
skipped.forEach((topicId) => {
|
||||
renderTopicProgress(topicId, 'skipped');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -5,6 +5,9 @@ import { queryClient } from '../../stores/query-client';
|
||||
import { roadmapContentOptions } from '../../queries/roadmap';
|
||||
import { ModalLoader } from '../UserProgress/ModalLoader';
|
||||
import { TopicDetailLink } from '../TopicDetail/TopicDetailLink';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import { ErrorIcon } from '../ReactIcons/ErrorIcon';
|
||||
import { markdownToHtml } from '../../lib/markdown';
|
||||
|
||||
type TopicResourcesModalProps = {
|
||||
roadmapId: string;
|
||||
@@ -24,34 +27,62 @@ export function TopicResourcesModal(props: TopicResourcesModalProps) {
|
||||
const topicContent = roadmapContentData?.[topicId];
|
||||
const links = topicContent?.links || [];
|
||||
|
||||
if (isLoadingRoadmapContent || error) {
|
||||
return (
|
||||
<ModalLoader
|
||||
text="Loading Topic Resources..."
|
||||
isLoading={isLoadingRoadmapContent}
|
||||
error={error?.message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<div className="p-4">
|
||||
<h2 className="text-xl font-bold">{topicContent?.title}</h2>
|
||||
<ul className="mt-4 space-y-1">
|
||||
{links.map((link, index) => {
|
||||
return (
|
||||
<li key={`${link.url}-${index}`}>
|
||||
<TopicDetailLink
|
||||
url={link.url}
|
||||
type={link.type}
|
||||
title={link.title}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<Modal onClose={onClose} wrapperClassName="max-w-lg">
|
||||
{!isLoadingRoadmapContent && !error && topicContent && (
|
||||
<div className="p-4">
|
||||
<h2 className="text-xl font-bold">{topicContent?.title}</h2>
|
||||
|
||||
<div
|
||||
className="course-content course-ai-content prose prose-sm mt-1 max-w-full overflow-hidden text-sm"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: markdownToHtml(topicContent.description, true),
|
||||
}}
|
||||
/>
|
||||
|
||||
{links.length > 0 && (
|
||||
<ul className="mt-4 space-y-1">
|
||||
{links.map((link, index) => {
|
||||
return (
|
||||
<li key={`${link.url}-${index}`}>
|
||||
<TopicDetailLink
|
||||
url={link.url}
|
||||
type={link.type}
|
||||
title={link.title}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isLoadingRoadmapContent || error || !topicContent) && (
|
||||
<div className="rounded-lg bg-white p-5">
|
||||
<div className="flex items-center">
|
||||
{isLoadingRoadmapContent && (
|
||||
<>
|
||||
<Spinner className="h-6 w-6" isDualRing={false} />
|
||||
<span className="ml-3 text-lg font-semibold">
|
||||
Loading Topic Resources...
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(error || !topicContent) && !isLoadingRoadmapContent && (
|
||||
<>
|
||||
<ErrorIcon additionalClasses="h-6 w-6 text-red-500" />
|
||||
<span className="ml-3 text-lg font-semibold">
|
||||
{!topicContent
|
||||
? 'No resources found'
|
||||
: (error?.message ?? 'Something went wrong')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
@@ -1,7 +1,12 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
useIsMutating,
|
||||
useMutation,
|
||||
useMutationState,
|
||||
useQuery,
|
||||
} from '@tanstack/react-query';
|
||||
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { renderTopicProgress } from '../../lib/resource-progress';
|
||||
import { updateResourceProgress } from '../../lib/resource-progress';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
@@ -10,6 +15,7 @@ import { userResourceProgressOptions } from '../../queries/resource-progress';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||
import { httpPost } from '../../lib/query-http';
|
||||
|
||||
type UpdateUserProgress = {
|
||||
id: string;
|
||||
@@ -47,6 +53,19 @@ function parseUserProgress(content: string): UpdateUserProgress[] {
|
||||
return items;
|
||||
}
|
||||
|
||||
type BulkUpdateResourceProgressBody = {
|
||||
done: string[];
|
||||
learning: string[];
|
||||
skipped: string[];
|
||||
pending: string[];
|
||||
};
|
||||
|
||||
type BulkUpdateResourceProgressResponse = {
|
||||
done: string[];
|
||||
learning: string[];
|
||||
skipped: string[];
|
||||
};
|
||||
|
||||
type UserProgressActionListProps = {
|
||||
roadmapId: string;
|
||||
content: string;
|
||||
@@ -55,6 +74,7 @@ type UserProgressActionListProps = {
|
||||
export function UserProgressActionList(props: UserProgressActionListProps) {
|
||||
const { roadmapId, content } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const updateUserProgress = parseUserProgress(content);
|
||||
|
||||
const { data: roadmapTreeData } = useQuery(
|
||||
@@ -62,6 +82,38 @@ export function UserProgressActionList(props: UserProgressActionListProps) {
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const {
|
||||
mutate: bulkUpdateResourceProgress,
|
||||
isPending: isBulkUpdating,
|
||||
isSuccess: isBulkUpdateSuccess,
|
||||
} = useMutation(
|
||||
{
|
||||
mutationFn: (body: BulkUpdateResourceProgressBody) => {
|
||||
return httpPost<BulkUpdateResourceProgressResponse>(
|
||||
`/v1-bulk-update-resource-progress/${roadmapId}`,
|
||||
body,
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries(
|
||||
userResourceProgressOptions('roadmap', roadmapId),
|
||||
);
|
||||
},
|
||||
onMutate: () => {
|
||||
pageProgressMessage.set('Updating progress');
|
||||
},
|
||||
onSettled: () => {
|
||||
pageProgressMessage.set('');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error?.message ?? 'Something went wrong, please try again.',
|
||||
);
|
||||
},
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const progressItemWithText = useMemo(() => {
|
||||
return updateUserProgress.map((item) => {
|
||||
const roadmapTreeItem = roadmapTreeData?.find(
|
||||
@@ -78,17 +130,74 @@ export function UserProgressActionList(props: UserProgressActionListProps) {
|
||||
});
|
||||
}, [updateUserProgress, roadmapTreeData]);
|
||||
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const itemCountToShow = 3;
|
||||
const itemsToShow = showAll
|
||||
? progressItemWithText
|
||||
: progressItemWithText.slice(0, itemCountToShow);
|
||||
|
||||
return (
|
||||
<div className="relative my-6 flex flex-col gap-2 rounded-lg border border-gray-200 bg-white p-2 first:mt-0 last:mb-0">
|
||||
{progressItemWithText.map((item) => (
|
||||
<ProgressItem
|
||||
key={item.id}
|
||||
roadmapId={roadmapId}
|
||||
topicId={item.id}
|
||||
text={item.text}
|
||||
action={item.action}
|
||||
/>
|
||||
))}
|
||||
<div className="relative my-6 overflow-hidden rounded-lg border border-gray-200 bg-white p-2 first:mt-0 last:mb-0">
|
||||
<div className="relative flex flex-col gap-2">
|
||||
{itemsToShow.map((item) => (
|
||||
<ProgressItem
|
||||
key={item.id}
|
||||
roadmapId={roadmapId}
|
||||
topicId={item.id}
|
||||
text={item.text}
|
||||
action={item.action}
|
||||
isBulkUpdating={isBulkUpdating}
|
||||
isBulkUpdateSuccess={isBulkUpdateSuccess}
|
||||
/>
|
||||
))}
|
||||
|
||||
{progressItemWithText.length > itemCountToShow && (
|
||||
<div className="absolute inset-x-0 right-0 bottom-0.5 translate-y-1/2">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
className="rounded-md bg-gray-100 px-2 py-1 text-[10px] leading-none font-medium"
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
>
|
||||
{showAll
|
||||
? '- Show Less'
|
||||
: `+${progressItemWithText.length - itemCountToShow} more`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute top-0 right-0">
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-b-md bg-green-100 px-2 py-1 text-[10px] leading-none font-medium text-green-600"
|
||||
disabled={isBulkUpdating}
|
||||
onClick={() => {
|
||||
const done = updateUserProgress
|
||||
.filter((item) => item.action === 'done')
|
||||
.map((item) => item.id);
|
||||
const learning = updateUserProgress
|
||||
.filter((item) => item.action === 'learning')
|
||||
.map((item) => item.id);
|
||||
const skipped = updateUserProgress
|
||||
.filter((item) => item.action === 'skipped')
|
||||
.map((item) => item.id);
|
||||
const pending = updateUserProgress
|
||||
.filter((item) => item.action === 'pending')
|
||||
.map((item) => item.id);
|
||||
|
||||
bulkUpdateResourceProgress({
|
||||
done,
|
||||
learning,
|
||||
skipped,
|
||||
pending,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isBulkUpdating && <Loader2Icon className="size-2.5 animate-spin" />}
|
||||
{!isBulkUpdating && <CheckIcon additionalClasses="size-2.5" />}
|
||||
Apply All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -98,12 +207,22 @@ type ProgressItemProps = {
|
||||
topicId: string;
|
||||
text: string;
|
||||
action: UpdateUserProgress['action'];
|
||||
isBulkUpdating: boolean;
|
||||
isBulkUpdateSuccess: boolean;
|
||||
};
|
||||
|
||||
function ProgressItem(props: ProgressItemProps) {
|
||||
const { roadmapId, topicId, text, action } = props;
|
||||
const {
|
||||
roadmapId,
|
||||
topicId,
|
||||
text,
|
||||
action,
|
||||
isBulkUpdating,
|
||||
isBulkUpdateSuccess,
|
||||
} = props;
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const {
|
||||
mutate: updateTopicStatus,
|
||||
isSuccess,
|
||||
@@ -142,11 +261,11 @@ function ProgressItem(props: ProgressItemProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-gray-200 p-2">
|
||||
<span className="truncate text-sm text-gray-500">{text}</span>
|
||||
{!isSuccess && (
|
||||
{!isSuccess && !isBulkUpdateSuccess && (
|
||||
<button
|
||||
className="min-h-[30px] shrink-0 rounded-md border border-gray-200 bg-gray-100 px-2 py-1 text-sm"
|
||||
onClick={() => updateTopicStatus(action)}
|
||||
disabled={isUpdating}
|
||||
disabled={isUpdating || isBulkUpdating}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
@@ -155,7 +274,7 @@ function ProgressItem(props: ProgressItemProps) {
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{isSuccess && (
|
||||
{(isSuccess || isBulkUpdateSuccess) && (
|
||||
<span className="flex size-[30px] items-center justify-center text-green-500">
|
||||
<CheckIcon additionalClasses="size-4" />
|
||||
</span>
|
||||
|
@@ -11,7 +11,7 @@ export function ModalLoader(props: ModalLoaderProps) {
|
||||
const { isLoading, text, error } = props;
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 right-0 top-0 z-100 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||
<div className="fixed top-0 right-0 left-0 z-100 h-full items-center justify-center overflow-x-hidden overflow-y-auto overscroll-contain bg-black/50">
|
||||
<div className="relative mx-auto flex h-full w-full items-center justify-center">
|
||||
<div className="popup-body relative rounded-lg bg-white p-5 shadow-sm">
|
||||
<div className="flex items-center">
|
||||
|
@@ -21,7 +21,8 @@ const { roadmapId } = Astro.params as Props;
|
||||
wrapperClassName='flex-row p-0 lg:p-0 overflow-hidden'
|
||||
client:load
|
||||
>
|
||||
<RoadmapAIChat roadmapId={roadmapId} client:load />
|
||||
<!-- Make it client:load please -->
|
||||
<RoadmapAIChat roadmapId={roadmapId} client:only='react' />
|
||||
<CheckSubscriptionVerification client:load />
|
||||
</AITutorLayout>
|
||||
</SkeletonLayout>
|
||||
|
Reference in New Issue
Block a user