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:
1
.astro/types.d.ts
vendored
1
.astro/types.d.ts
vendored
@@ -1,2 +1 @@
|
|||||||
/// <reference types="astro/client" />
|
/// <reference types="astro/client" />
|
||||||
/// <reference path="content.d.ts" />
|
|
@@ -1,6 +1,6 @@
|
|||||||
import './ChatRoadmapRenderer.css';
|
import './ChatRoadmapRenderer.css';
|
||||||
|
|
||||||
import { lazy, useCallback, useEffect, useRef } from 'react';
|
import { lazy, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
renderResourceProgress,
|
renderResourceProgress,
|
||||||
updateResourceProgress,
|
updateResourceProgress,
|
||||||
@@ -17,6 +17,7 @@ import { showLoginPopup } from '../../lib/popup';
|
|||||||
import { queryClient } from '../../stores/query-client';
|
import { queryClient } from '../../stores/query-client';
|
||||||
import { userResourceProgressOptions } from '../../queries/resource-progress';
|
import { userResourceProgressOptions } from '../../queries/resource-progress';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { TopicResourcesModal } from './TopicResourcesModal';
|
||||||
|
|
||||||
const Renderer = lazy(() =>
|
const Renderer = lazy(() =>
|
||||||
import('@roadmapsh/editor').then((mod) => ({
|
import('@roadmapsh/editor').then((mod) => ({
|
||||||
@@ -68,6 +69,7 @@ export function ChatRoadmapRenderer(props: ChatRoadmapRendererProps) {
|
|||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
|
const [selectedTopicId, setSelectedTopicId] = useState<string | null>(null);
|
||||||
const { data: userResourceProgressData } = useQuery(
|
const { data: userResourceProgressData } = useQuery(
|
||||||
userResourceProgressOptions('roadmap', roadmapId),
|
userResourceProgressOptions('roadmap', roadmapId),
|
||||||
queryClient,
|
queryClient,
|
||||||
@@ -190,6 +192,8 @@ export function ChatRoadmapRenderer(props: ChatRoadmapRendererProps) {
|
|||||||
if (!title) {
|
if (!title) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSelectedTopicId(nodeId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSvgRightClick = useCallback((e: MouseEvent) => {
|
const handleSvgRightClick = useCallback((e: MouseEvent) => {
|
||||||
@@ -230,6 +234,15 @@ export function ChatRoadmapRenderer(props: ChatRoadmapRendererProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{selectedTopicId && (
|
||||||
|
<TopicResourcesModal
|
||||||
|
roadmapId={roadmapId}
|
||||||
|
topicId={selectedTopicId}
|
||||||
|
onClose={() => setSelectedTopicId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Renderer
|
<Renderer
|
||||||
ref={roadmapRef}
|
ref={roadmapRef}
|
||||||
roadmap={{ nodes, edges }}
|
roadmap={{ nodes, edges }}
|
||||||
@@ -254,5 +267,6 @@ export function ChatRoadmapRenderer(props: ChatRoadmapRendererProps) {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -5,6 +5,9 @@ import { queryClient } from '../../stores/query-client';
|
|||||||
import { roadmapContentOptions } from '../../queries/roadmap';
|
import { roadmapContentOptions } from '../../queries/roadmap';
|
||||||
import { ModalLoader } from '../UserProgress/ModalLoader';
|
import { ModalLoader } from '../UserProgress/ModalLoader';
|
||||||
import { TopicDetailLink } from '../TopicDetail/TopicDetailLink';
|
import { TopicDetailLink } from '../TopicDetail/TopicDetailLink';
|
||||||
|
import { Spinner } from '../ReactIcons/Spinner';
|
||||||
|
import { ErrorIcon } from '../ReactIcons/ErrorIcon';
|
||||||
|
import { markdownToHtml } from '../../lib/markdown';
|
||||||
|
|
||||||
type TopicResourcesModalProps = {
|
type TopicResourcesModalProps = {
|
||||||
roadmapId: string;
|
roadmapId: string;
|
||||||
@@ -24,20 +27,20 @@ export function TopicResourcesModal(props: TopicResourcesModalProps) {
|
|||||||
const topicContent = roadmapContentData?.[topicId];
|
const topicContent = roadmapContentData?.[topicId];
|
||||||
const links = topicContent?.links || [];
|
const links = topicContent?.links || [];
|
||||||
|
|
||||||
if (isLoadingRoadmapContent || error) {
|
|
||||||
return (
|
return (
|
||||||
<ModalLoader
|
<Modal onClose={onClose} wrapperClassName="max-w-lg">
|
||||||
text="Loading Topic Resources..."
|
{!isLoadingRoadmapContent && !error && topicContent && (
|
||||||
isLoading={isLoadingRoadmapContent}
|
|
||||||
error={error?.message}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal onClose={onClose}>
|
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h2 className="text-xl font-bold">{topicContent?.title}</h2>
|
<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">
|
<ul className="mt-4 space-y-1">
|
||||||
{links.map((link, index) => {
|
{links.map((link, index) => {
|
||||||
return (
|
return (
|
||||||
@@ -51,7 +54,35 @@ export function TopicResourcesModal(props: TopicResourcesModalProps) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
</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 { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
|
||||||
import { queryClient } from '../../stores/query-client';
|
import { queryClient } from '../../stores/query-client';
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { renderTopicProgress } from '../../lib/resource-progress';
|
import { renderTopicProgress } from '../../lib/resource-progress';
|
||||||
import { updateResourceProgress } from '../../lib/resource-progress';
|
import { updateResourceProgress } from '../../lib/resource-progress';
|
||||||
import { pageProgressMessage } from '../../stores/page';
|
import { pageProgressMessage } from '../../stores/page';
|
||||||
@@ -10,6 +15,7 @@ import { userResourceProgressOptions } from '../../queries/resource-progress';
|
|||||||
import { useToast } from '../../hooks/use-toast';
|
import { useToast } from '../../hooks/use-toast';
|
||||||
import { Loader2Icon } from 'lucide-react';
|
import { Loader2Icon } from 'lucide-react';
|
||||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||||
|
import { httpPost } from '../../lib/query-http';
|
||||||
|
|
||||||
type UpdateUserProgress = {
|
type UpdateUserProgress = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -47,6 +53,19 @@ function parseUserProgress(content: string): UpdateUserProgress[] {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BulkUpdateResourceProgressBody = {
|
||||||
|
done: string[];
|
||||||
|
learning: string[];
|
||||||
|
skipped: string[];
|
||||||
|
pending: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type BulkUpdateResourceProgressResponse = {
|
||||||
|
done: string[];
|
||||||
|
learning: string[];
|
||||||
|
skipped: string[];
|
||||||
|
};
|
||||||
|
|
||||||
type UserProgressActionListProps = {
|
type UserProgressActionListProps = {
|
||||||
roadmapId: string;
|
roadmapId: string;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -55,6 +74,7 @@ type UserProgressActionListProps = {
|
|||||||
export function UserProgressActionList(props: UserProgressActionListProps) {
|
export function UserProgressActionList(props: UserProgressActionListProps) {
|
||||||
const { roadmapId, content } = props;
|
const { roadmapId, content } = props;
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
const updateUserProgress = parseUserProgress(content);
|
const updateUserProgress = parseUserProgress(content);
|
||||||
|
|
||||||
const { data: roadmapTreeData } = useQuery(
|
const { data: roadmapTreeData } = useQuery(
|
||||||
@@ -62,6 +82,38 @@ export function UserProgressActionList(props: UserProgressActionListProps) {
|
|||||||
queryClient,
|
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(() => {
|
const progressItemWithText = useMemo(() => {
|
||||||
return updateUserProgress.map((item) => {
|
return updateUserProgress.map((item) => {
|
||||||
const roadmapTreeItem = roadmapTreeData?.find(
|
const roadmapTreeItem = roadmapTreeData?.find(
|
||||||
@@ -78,17 +130,74 @@ export function UserProgressActionList(props: UserProgressActionListProps) {
|
|||||||
});
|
});
|
||||||
}, [updateUserProgress, roadmapTreeData]);
|
}, [updateUserProgress, roadmapTreeData]);
|
||||||
|
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
const itemCountToShow = 3;
|
||||||
|
const itemsToShow = showAll
|
||||||
|
? progressItemWithText
|
||||||
|
: progressItemWithText.slice(0, itemCountToShow);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="relative my-6 overflow-hidden rounded-lg border border-gray-200 bg-white p-2 first:mt-0 last:mb-0">
|
||||||
{progressItemWithText.map((item) => (
|
<div className="relative flex flex-col gap-2">
|
||||||
|
{itemsToShow.map((item) => (
|
||||||
<ProgressItem
|
<ProgressItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
roadmapId={roadmapId}
|
roadmapId={roadmapId}
|
||||||
topicId={item.id}
|
topicId={item.id}
|
||||||
text={item.text}
|
text={item.text}
|
||||||
action={item.action}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -98,12 +207,22 @@ type ProgressItemProps = {
|
|||||||
topicId: string;
|
topicId: string;
|
||||||
text: string;
|
text: string;
|
||||||
action: UpdateUserProgress['action'];
|
action: UpdateUserProgress['action'];
|
||||||
|
isBulkUpdating: boolean;
|
||||||
|
isBulkUpdateSuccess: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ProgressItem(props: ProgressItemProps) {
|
function ProgressItem(props: ProgressItemProps) {
|
||||||
const { roadmapId, topicId, text, action } = props;
|
const {
|
||||||
|
roadmapId,
|
||||||
|
topicId,
|
||||||
|
text,
|
||||||
|
action,
|
||||||
|
isBulkUpdating,
|
||||||
|
isBulkUpdateSuccess,
|
||||||
|
} = props;
|
||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutate: updateTopicStatus,
|
mutate: updateTopicStatus,
|
||||||
isSuccess,
|
isSuccess,
|
||||||
@@ -142,11 +261,11 @@ function ProgressItem(props: ProgressItemProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2 rounded-md border border-gray-200 p-2">
|
<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>
|
<span className="truncate text-sm text-gray-500">{text}</span>
|
||||||
{!isSuccess && (
|
{!isSuccess && !isBulkUpdateSuccess && (
|
||||||
<button
|
<button
|
||||||
className="min-h-[30px] shrink-0 rounded-md border border-gray-200 bg-gray-100 px-2 py-1 text-sm"
|
className="min-h-[30px] shrink-0 rounded-md border border-gray-200 bg-gray-100 px-2 py-1 text-sm"
|
||||||
onClick={() => updateTopicStatus(action)}
|
onClick={() => updateTopicStatus(action)}
|
||||||
disabled={isUpdating}
|
disabled={isUpdating || isBulkUpdating}
|
||||||
>
|
>
|
||||||
{isUpdating ? (
|
{isUpdating ? (
|
||||||
<Loader2Icon className="size-4 animate-spin" />
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
@@ -155,7 +274,7 @@ function ProgressItem(props: ProgressItemProps) {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isSuccess && (
|
{(isSuccess || isBulkUpdateSuccess) && (
|
||||||
<span className="flex size-[30px] items-center justify-center text-green-500">
|
<span className="flex size-[30px] items-center justify-center text-green-500">
|
||||||
<CheckIcon additionalClasses="size-4" />
|
<CheckIcon additionalClasses="size-4" />
|
||||||
</span>
|
</span>
|
||||||
|
@@ -11,7 +11,7 @@ export function ModalLoader(props: ModalLoaderProps) {
|
|||||||
const { isLoading, text, error } = props;
|
const { isLoading, text, error } = props;
|
||||||
|
|
||||||
return (
|
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="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="popup-body relative rounded-lg bg-white p-5 shadow-sm">
|
||||||
<div className="flex items-center">
|
<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'
|
wrapperClassName='flex-row p-0 lg:p-0 overflow-hidden'
|
||||||
client:load
|
client:load
|
||||||
>
|
>
|
||||||
<RoadmapAIChat roadmapId={roadmapId} client:load />
|
<!-- Make it client:load please -->
|
||||||
|
<RoadmapAIChat roadmapId={roadmapId} client:only='react' />
|
||||||
<CheckSubscriptionVerification client:load />
|
<CheckSubscriptionVerification client:load />
|
||||||
</AITutorLayout>
|
</AITutorLayout>
|
||||||
</SkeletonLayout>
|
</SkeletonLayout>
|
||||||
|
Reference in New Issue
Block a user