1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-25 16:39:02 +02:00

feat: enhance the progress rendering

This commit is contained in:
Arik Chakma
2025-05-22 03:45:29 +06:00
parent 4e53d1bf28
commit aa0833de6f
6 changed files with 231 additions and 67 deletions

1
.astro/types.d.ts vendored
View File

@@ -1,2 +1 @@
/// <reference types="astro/client" />
/// <reference path="content.d.ts" />

View File

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

View File

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

View File

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

View File

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

View File

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