1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-25 16:39:02 +02:00
This commit is contained in:
Arik Chakma
2025-05-20 19:44:22 +06:00
parent b3a6a7e4af
commit 525f36e473
6 changed files with 437 additions and 15 deletions

View File

@@ -16,6 +16,7 @@ import { queryClient } from '../../stores/query-client';
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
import { useQuery } from '@tanstack/react-query';
import { useEffect, type RefObject } from 'react';
import { roadmapDetailsOptions } from '../../queries/roadmap';
const extensions = [
DocumentExtension,
@@ -44,6 +45,10 @@ export function ChatEditor(props: ChatEditorProps) {
roadmapTreeMappingOptions(roadmapId),
queryClient,
);
const { data: roadmapDetailsData } = useQuery(
roadmapDetailsOptions(roadmapId),
queryClient,
);
const editor = useEditor({
extensions,
@@ -93,17 +98,20 @@ export function ChatEditor(props: ChatEditorProps) {
});
useEffect(() => {
if (!editor || !roadmapTreeData) {
if (!editor || !roadmapTreeData || !roadmapDetailsData) {
return;
}
editor.storage.variable.variables = roadmapTreeData.map((mapping) => {
return {
id: mapping._id,
label: mapping.text,
id: mapping.nodeId,
// to remove the title of the roadmap
// and only keep the path
// e.g. "Roadmap > Topic > Subtopic" -> "Topic > Subtopic"
label: mapping.text.split(' > ').slice(1).join(' > '),
};
});
}, [editor, roadmapTreeData]);
}, [editor, roadmapTreeData, roadmapDetailsData]);
return (
<div className="chat-editor w-full">

View File

@@ -0,0 +1,68 @@
svg text tspan {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeSpeed;
}
svg > g[data-type='topic'],
svg > g[data-type='subtopic'],
svg g[data-type='link-item'],
svg > g[data-type='button'],
svg > g[data-type='resourceButton'],
svg > g[data-type='todo-checkbox'],
svg > g[data-type='todo'],
svg > g[data-type='checklist'] > g[data-type='checklist-item'] > rect {
cursor: pointer;
}
svg > g[data-type='topic']:hover > rect {
fill: var(--hover-color);
}
svg > g[data-type='subtopic']:hover > rect {
fill: var(--hover-color);
}
svg g[data-type='button']:hover,
svg g[data-type='link-item']:hover,
svg g[data-type='resourceButton']:hover,
svg g[data-type='todo-checkbox']:hover {
opacity: 0.8;
}
svg g[data-type='checklist'] > g[data-type='checklist-item'] > rect:hover {
fill: #cbcbcb !important;
}
svg .done rect {
fill: #cbcbcb !important;
}
svg .done text,
svg .skipped text {
text-decoration: line-through;
}
svg > g[data-type='topic'].learning > rect + text,
svg > g[data-type='topic'].done > rect + text {
fill: black;
}
svg .done text[fill='#ffffff'] {
fill: black;
}
svg > g[data-type='subtipic'].done > rect + text,
svg > g[data-type='subtipic'].learning > rect + text {
fill: #cbcbcb;
}
svg .learning rect {
fill: #dad1fd !important;
}
svg .learning text {
text-decoration: underline;
}
svg .skipped rect {
fill: #496b69 !important;
}

View File

@@ -0,0 +1,258 @@
import './ChatRoadmapRenderer.css';
import { lazy, useCallback, useEffect, useRef } from 'react';
import {
renderResourceProgress,
updateResourceProgress,
type ResourceProgressType,
renderTopicProgress,
refreshProgressCounters,
} from '../../lib/resource-progress';
import { pageProgressMessage } from '../../stores/page';
import { useToast } from '../../hooks/use-toast';
import type { Edge, Node } from '@roadmapsh/editor';
import { slugify } from '../../lib/slugger';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { queryClient } from '../../stores/query-client';
import { userResourceProgressOptions } from '../../queries/resource-progress';
import { useQuery } from '@tanstack/react-query';
const Renderer = lazy(() =>
import('@roadmapsh/editor').then((mod) => ({
default: mod.Renderer,
})),
);
type RoadmapNodeDetails = {
nodeId: string;
nodeType: string;
targetGroup: SVGElement;
title?: string;
};
function getNodeDetails(svgElement: SVGElement): RoadmapNodeDetails | null {
const targetGroup = (svgElement?.closest('g') as SVGElement) || {};
const nodeId = targetGroup?.dataset?.nodeId;
const nodeType = targetGroup?.dataset?.type;
const title = targetGroup?.dataset?.title;
if (!nodeId || !nodeType) {
return null;
}
return { nodeId, nodeType, targetGroup, title };
}
const allowedNodeTypes = [
'topic',
'subtopic',
'button',
'link-item',
'resourceButton',
'todo',
'todo-checkbox',
'checklist-item',
];
export type ChatRoadmapRendererProps = {
roadmapId: string;
nodes: Node[];
edges: Edge[];
};
export function ChatRoadmapRenderer(props: ChatRoadmapRendererProps) {
const { roadmapId, nodes = [], edges = [] } = props;
const roadmapRef = useRef<HTMLDivElement>(null);
const toast = useToast();
const { data: userResourceProgressData } = useQuery(
userResourceProgressOptions('roadmap', roadmapId),
queryClient,
);
async function updateTopicStatus(
topicId: string,
newStatus: ResourceProgressType,
) {
pageProgressMessage.set('Updating progress');
updateResourceProgress(
{
resourceId: roadmapId,
resourceType: 'roadmap',
topicId,
},
newStatus,
)
.then(() => {
renderTopicProgress(topicId, newStatus);
queryClient.invalidateQueries(
userResourceProgressOptions('roadmap', roadmapId),
);
})
.catch((err) => {
toast.error('Something went wrong, please try again.');
console.error(err);
})
.finally(() => {
pageProgressMessage.set('');
});
return;
}
const handleSvgClick = useCallback((e: MouseEvent) => {
const target = e.target as SVGElement;
const { nodeId, nodeType, targetGroup, title } =
getNodeDetails(target) || {};
if (!nodeId || !nodeType || !allowedNodeTypes.includes(nodeType)) {
return;
}
if (
nodeType === 'button' ||
nodeType === 'link-item' ||
nodeType === 'resourceButton'
) {
const link = targetGroup?.dataset?.link || '';
const isExternalLink = link.startsWith('http');
if (isExternalLink) {
window.open(link, '_blank');
} else {
window.location.href = link;
}
return;
}
const isCurrentStatusLearning = targetGroup?.classList.contains('learning');
const isCurrentStatusSkipped = targetGroup?.classList.contains('skipped');
if (nodeType === 'todo-checkbox') {
e.preventDefault();
if (!isLoggedIn()) {
showLoginPopup();
return;
}
const newStatus = targetGroup?.classList.contains('done')
? 'pending'
: 'done';
updateTopicStatus(nodeId, newStatus);
return;
}
if (e.shiftKey) {
e.preventDefault();
if (!isLoggedIn()) {
showLoginPopup();
return;
}
updateTopicStatus(
nodeId,
isCurrentStatusLearning ? 'pending' : 'learning',
);
return;
} else if (e.altKey) {
e.preventDefault();
if (!isLoggedIn()) {
showLoginPopup();
return;
}
updateTopicStatus(nodeId, isCurrentStatusSkipped ? 'pending' : 'skipped');
return;
}
// for the click on rect of checklist-item
if (nodeType === 'checklist-item' && target.tagName === 'rect') {
e.preventDefault();
if (!isLoggedIn()) {
showLoginPopup();
return;
}
const newStatus = targetGroup?.classList.contains('done')
? 'pending'
: 'done';
updateTopicStatus(nodeId, newStatus);
return;
}
// we don't have the topic popup for checklist-item
if (nodeType === 'checklist-item') {
return;
}
if (!title) {
return;
}
}, []);
const handleSvgRightClick = useCallback((e: MouseEvent) => {
e.preventDefault();
const target = e.target as SVGElement;
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {};
if (!nodeId || !nodeType || !allowedNodeTypes.includes(nodeType)) {
return;
}
if (nodeType === 'button') {
return;
}
if (!isLoggedIn()) {
showLoginPopup();
return;
}
const isCurrentStatusDone = targetGroup?.classList.contains('done');
updateTopicStatus(nodeId, isCurrentStatusDone ? 'pending' : 'done');
}, []);
useEffect(() => {
if (!roadmapRef?.current) {
return;
}
roadmapRef?.current?.addEventListener('click', handleSvgClick);
roadmapRef?.current?.addEventListener('contextmenu', handleSvgRightClick);
return () => {
roadmapRef?.current?.removeEventListener('click', handleSvgClick);
roadmapRef?.current?.removeEventListener(
'contextmenu',
handleSvgRightClick,
);
};
}, []);
return (
<Renderer
ref={roadmapRef}
roadmap={{ nodes, edges }}
onRendered={() => {
roadmapRef.current?.setAttribute('data-renderer', 'editor');
if (!userResourceProgressData) {
return;
}
const { done, learning, skipped } = userResourceProgressData;
done.forEach((topicId) => {
renderTopicProgress(topicId, 'done');
});
learning.forEach((topicId) => {
renderTopicProgress(topicId, 'learning');
});
skipped.forEach((topicId) => {
renderTopicProgress(topicId, 'skipped');
});
}}
/>
);
}

View File

@@ -1,8 +1,12 @@
import './RoadmapAIChat.css';
import { useQuery } from '@tanstack/react-query';
import { roadmapJSONOptions } from '../../queries/roadmap';
import {
roadmapDetailsOptions,
roadmapJSONOptions,
} from '../../queries/roadmap';
import { queryClient } from '../../stores/query-client';
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import { Spinner } from '../ReactIcons/Spinner';
import { BotIcon, Loader2Icon, SendIcon } from 'lucide-react';
import { ChatEditor } from '../ChatEditor/ChatEditor';
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
@@ -18,8 +22,13 @@ import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { markdownToHtmlWithHighlighting } from '../../lib/markdown';
import { readStream } from '../../lib/ai';
import { useToast } from '../../hooks/use-toast';
import { userResourceProgressOptions } from '../../queries/resource-progress';
import { renderTopicProgress } from '../../lib/resource-progress';
import { EditorRoadmapRenderer } from '../EditorRoadmap/EditorRoadmapRenderer';
import { ChatRoadmapRenderer } from './ChatRoadmapRenderer';
export type RoamdapAIChatHistoryType = AIChatHistoryType & {
json?: JSONContent;
};
@@ -42,6 +51,11 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
const [streamedMessage, setStreamedMessage] = useState('');
const { data: roadmapDetailsData } = useQuery(
roadmapDetailsOptions(roadmapId),
queryClient,
);
const { data: roadmapJSONData } = useQuery(
roadmapJSONOptions(roadmapId),
queryClient,
@@ -51,6 +65,11 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
queryClient,
);
const { data: userResourceProgressData } = useQuery(
userResourceProgressOptions('roadmap', roadmapId),
queryClient,
);
const roadmapContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -62,12 +81,12 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
}, [roadmapJSONData]);
useEffect(() => {
if (!roadmapTreeData || !roadmapJSONData) {
if (!roadmapTreeData || !roadmapJSONData || !roadmapDetailsData) {
return;
}
setIsLoading(false);
}, [roadmapTreeData, roadmapJSONData]);
}, [roadmapTreeData, roadmapJSONData, roadmapDetailsData]);
const handleChatSubmit = (json: JSONContent) => {
if (!json || isStreamingMessage || !isLoggedIn() || isLoading) {
@@ -183,17 +202,23 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
}, []);
return (
<div className="grid grow grid-cols-3">
<div className="relative col-span-2 h-full overflow-y-scroll">
<div className="grid grow grid-cols-5">
<div className="relative col-span-3 h-full overflow-y-scroll">
{isLoading && (
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
<Loader2Icon className="size-6 animate-spin stroke-[2.5]" />
</div>
)}
<div ref={roadmapContainerRef} hidden={isLoading} className="p-4" />
{roadmapJSONData?.json && !isLoading && (
<ChatRoadmapRenderer
roadmapId={roadmapId}
nodes={roadmapJSONData?.json.nodes}
edges={roadmapJSONData?.json.edges}
/>
)}
</div>
<div className="flex h-full flex-col border-l border-gray-200 bg-white">
<div className="col-span-2 flex h-full flex-col border-l border-gray-200 bg-white">
<div className="flex min-h-[46px] items-center justify-between gap-2 border-b border-gray-200 px-3 py-2 text-sm">
<span className="flex items-center gap-2 text-sm">
<BotIcon className="size-4 shrink-0 text-black" />
@@ -281,9 +306,6 @@ export function htmlFromTiptapJSON(json: JSONContent) {
text += child.text;
break;
case 'paragraph':
// Add a new line before each paragraph
// This is to ensure that the text is formatted correctly
text += '\n';
text += `<p>${htmlFromTiptapJSON(child)}</p>`;
break;
case 'variable':

View File

@@ -0,0 +1,28 @@
import { queryOptions } from '@tanstack/react-query';
import { httpGet } from '../lib/query-http';
export type GetUserResourceProgressResponse = {
done: string[];
learning: string[];
skipped: string[];
isFavorite: boolean;
};
export function userResourceProgressOptions(
resourceType: string,
resourceId: string,
) {
return queryOptions({
queryKey: ['resource-progress', resourceId, resourceType],
queryFn: () => {
return httpGet<GetUserResourceProgressResponse>(
`/v1-get-user-resource-progress`,
{
resourceId,
resourceType,
},
);
},
refetchOnMount: false,
});
}

View File

@@ -21,3 +21,41 @@ export function roadmapJSONOptions(roadmapId: string) {
},
});
}
export const allowedRoadmapRenderer = [
'balsamiq',
'editor',
'infinite-canvas',
] as const;
export type AllowedRoadmapRenderer = (typeof allowedRoadmapRenderer)[number];
export type PagesJSON = {
id: string;
url: string;
title: string;
description: string;
group: string;
authorId?: string;
renderer?: AllowedRoadmapRenderer;
}[];
export function roadmapDetailsOptions(roadmapId: string) {
return queryOptions({
queryKey: ['roadmap-details', roadmapId],
queryFn: async () => {
const baseUrl = import.meta.env.PUBLIC_APP_URL;
const pagesJSON = await httpGet<PagesJSON>(`${baseUrl}/pages.json`);
const roadmapDetails = pagesJSON.find(
(page) =>
page?.group?.toLowerCase() === 'roadmaps' && page.id === roadmapId,
);
if (!roadmapDetails) {
throw new Error('Roadmap details not found');
}
return roadmapDetails;
},
});
}