From 525f36e4734d62a16ac7b23e764e4965a13527a3 Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Tue, 20 May 2025 19:44:22 +0600 Subject: [PATCH] wip --- src/components/ChatEditor/ChatEditor.tsx | 16 +- .../RoadmapAIChat/ChatRoadmapRenderer.css | 68 +++++ .../RoadmapAIChat/ChatRoadmapRenderer.tsx | 258 ++++++++++++++++++ .../RoadmapAIChat/RoadmapAIChat.tsx | 44 ++- src/queries/resource-progress.ts | 28 ++ src/queries/roadmap.ts | 38 +++ 6 files changed, 437 insertions(+), 15 deletions(-) create mode 100644 src/components/RoadmapAIChat/ChatRoadmapRenderer.css create mode 100644 src/components/RoadmapAIChat/ChatRoadmapRenderer.tsx create mode 100644 src/queries/resource-progress.ts 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 && (
)} - -
+
@@ -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; + }, + }); +}