diff --git a/src/components/ChatEditor/ChatEditor.tsx b/src/components/ChatEditor/ChatEditor.tsx
index 3fe5284f7..40e407d58 100644
--- a/src/components/ChatEditor/ChatEditor.tsx
+++ b/src/components/ChatEditor/ChatEditor.tsx
@@ -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 (
diff --git a/src/components/RoadmapAIChat/ChatRoadmapRenderer.css b/src/components/RoadmapAIChat/ChatRoadmapRenderer.css
new file mode 100644
index 000000000..88da24f40
--- /dev/null
+++ b/src/components/RoadmapAIChat/ChatRoadmapRenderer.css
@@ -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;
+}
diff --git a/src/components/RoadmapAIChat/ChatRoadmapRenderer.tsx b/src/components/RoadmapAIChat/ChatRoadmapRenderer.tsx
new file mode 100644
index 000000000..70ae79bf7
--- /dev/null
+++ b/src/components/RoadmapAIChat/ChatRoadmapRenderer.tsx
@@ -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
(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 (
+ {
+ 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');
+ });
+ }}
+ />
+ );
+}
diff --git a/src/components/RoadmapAIChat/RoadmapAIChat.tsx b/src/components/RoadmapAIChat/RoadmapAIChat.tsx
index db0f49eec..fb2c59818 100644
--- a/src/components/RoadmapAIChat/RoadmapAIChat.tsx
+++ b/src/components/RoadmapAIChat/RoadmapAIChat.tsx
@@ -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(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 (
-
-
+
+
{isLoading && (
)}
-
+ {roadmapJSONData?.json && !isLoading && (
+
+ )}
-
+
@@ -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 += `${htmlFromTiptapJSON(child)}
`;
break;
case 'variable':
diff --git a/src/queries/resource-progress.ts b/src/queries/resource-progress.ts
new file mode 100644
index 000000000..db58f1b9e
--- /dev/null
+++ b/src/queries/resource-progress.ts
@@ -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(
+ `/v1-get-user-resource-progress`,
+ {
+ resourceId,
+ resourceType,
+ },
+ );
+ },
+ refetchOnMount: false,
+ });
+}
diff --git a/src/queries/roadmap.ts b/src/queries/roadmap.ts
index 642e206d7..30c511026 100644
--- a/src/queries/roadmap.ts
+++ b/src/queries/roadmap.ts
@@ -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(`${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;
+ },
+ });
+}