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

feat: node click message populate

This commit is contained in:
Arik Chakma
2025-06-25 18:42:34 +06:00
parent 288032cb78
commit e9fd3f0a57
3 changed files with 107 additions and 4 deletions

View File

@@ -1,7 +1,7 @@
import './AIRoadmap.css';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { useToast } from '../../hooks/use-toast';
import { queryClient } from '../../stores/query-client';
@@ -9,10 +9,14 @@ import { AITutorLayout } from '../AITutor/AITutorLayout';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { aiRoadmapOptions, generateAIRoadmap } from '../../queries/ai-roadmap';
import { GenerateAIRoadmap } from './GenerateAIRoadmap';
import { AIRoadmapContent } from './AIRoadmapContent';
import { AIRoadmapContent, type RoadmapNodeDetails } from './AIRoadmapContent';
import { AIRoadmapChat } from './AIRoadmapChat';
import { AlertCircleIcon } from 'lucide-react';
export type AIRoadmapChatActions = {
handleNodeClick: (node: RoadmapNodeDetails) => void;
};
type AIRoadmapProps = {
roadmapSlug?: string;
};
@@ -28,6 +32,8 @@ export function AIRoadmap(props: AIRoadmapProps) {
null,
);
const aiChatActionsRef = useRef<AIRoadmapChatActions | null>(null);
// only fetch the guide if the guideSlug is provided
// otherwise we are still generating the guide
const {
@@ -77,6 +83,13 @@ export function AIRoadmap(props: AIRoadmapProps) {
const isLoading = isLoadingBySlug || isRegenerating;
const handleNodeClick = useCallback(
(node: RoadmapNodeDetails) => {
aiChatActionsRef.current?.handleNodeClick(node);
},
[aiChatActionsRef],
);
return (
<AITutorLayout
wrapperClassName="flex-row p-0 lg:p-0 overflow-hidden relative bg-white"
@@ -104,6 +117,7 @@ export function AIRoadmap(props: AIRoadmapProps) {
isLoading={isLoading}
onRegenerate={handleRegenerate}
roadmapSlug={roadmapSlug}
onNodeClick={handleNodeClick}
/>
)}
{!roadmapSlug && !aiRoadmapError && (
@@ -115,6 +129,7 @@ export function AIRoadmap(props: AIRoadmapProps) {
roadmapSlug={roadmapSlug}
isRoadmapLoading={!aiRoadmap}
onUpgrade={() => setShowUpgradeModal(true)}
aiChatActionsRef={aiChatActionsRef}
/>
</AITutorLayout>
);

View File

@@ -1,9 +1,11 @@
import {
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
type RefObject,
} from 'react';
import { useChat, type ChatMessage } from '../../hooks/use-chat';
import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard';
@@ -29,15 +31,18 @@ import { billingDetailsOptions } from '../../queries/billing';
import { LoadingChip } from '../LoadingChip';
import { getTailwindScreenDimension } from '../../lib/is-mobile';
import { useToast } from '../../hooks/use-toast';
import type { AIRoadmapChatActions } from './AIRoadmap';
import type { RoadmapNodeDetails } from './AIRoadmapContent';
type AIRoadmapChatProps = {
roadmapSlug?: string;
isRoadmapLoading?: boolean;
onUpgrade?: () => void;
aiChatActionsRef?: RefObject<AIRoadmapChatActions | null>;
};
export function AIRoadmapChat(props: AIRoadmapChatProps) {
const { roadmapSlug, isRoadmapLoading, onUpgrade } = props;
const { roadmapSlug, isRoadmapLoading, onUpgrade, aiChatActionsRef } = props;
const toast = useToast();
const scrollareaRef = useRef<HTMLDivElement>(null);
@@ -167,6 +172,16 @@ export function AIRoadmapChat(props: AIRoadmapChatProps) {
}
}, [isChatOpen, isMobile]);
useImperativeHandle(aiChatActionsRef, () => ({
handleNodeClick: (node: RoadmapNodeDetails) => {
flushSync(() => {
setInputValue(`Explain what is ${node.nodeTitle} topic in detail.`);
});
inputRef.current?.focus();
},
}));
if (!isChatOpen) {
return (
<div className="absolute inset-x-0 bottom-0 flex justify-center p-2">

View File

@@ -1,16 +1,88 @@
import { cn } from '../../lib/classname';
import { AIRoadmapRegenerate } from './AIRoadmapRegenerate';
import { LoadingChip } from '../LoadingChip';
import { type MouseEvent, useCallback } from 'react';
export type RoadmapNodeDetails = {
nodeId: string;
nodeType: string;
targetGroup?: SVGElement;
nodeTitle?: string;
parentTitle?: string;
parentId?: string;
};
export function getNodeDetails(
svgElement: SVGElement,
): RoadmapNodeDetails | null {
const targetGroup = (svgElement?.closest('g') as SVGElement) || {};
const nodeId = targetGroup?.dataset?.nodeId;
const nodeType = targetGroup?.dataset?.type;
const nodeTitle = targetGroup?.dataset?.title;
const parentTitle = targetGroup?.dataset?.parentTitle;
const parentId = targetGroup?.dataset?.parentId;
if (!nodeId || !nodeType) return null;
return { nodeId, nodeType, targetGroup, nodeTitle, parentTitle, parentId };
}
export const allowedClickableNodeTypes = [
'topic',
'subtopic',
'button',
'link-item',
];
type AIRoadmapContentProps = {
isLoading?: boolean;
svgHtml: string;
onRegenerate?: (prompt?: string) => void;
roadmapSlug?: string;
onNodeClick?: (node: RoadmapNodeDetails) => void;
};
export function AIRoadmapContent(props: AIRoadmapContentProps) {
const { isLoading, svgHtml, onRegenerate, roadmapSlug } = props;
const { isLoading, svgHtml, onRegenerate, roadmapSlug, onNodeClick } = props;
const handleNodeClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
if (isLoading) {
return;
}
const target = e.target as SVGElement;
const { nodeId, nodeType, targetGroup, nodeTitle, parentTitle } =
getNodeDetails(target) || {};
if (
!nodeId ||
!nodeType ||
!allowedClickableNodeTypes.includes(nodeType) ||
!nodeTitle
)
return;
if (nodeType === 'button' || nodeType === 'link-item') {
const link = targetGroup?.dataset?.link || '';
const isExternalLink = link.startsWith('http');
if (isExternalLink) {
window.open(link, '_blank');
} else {
window.location.href = link;
}
return;
}
onNodeClick?.({
nodeId,
nodeType,
nodeTitle,
...(nodeType === 'subtopic' && { parentTitle }),
});
},
[isLoading, onNodeClick],
);
return (
<div
@@ -23,6 +95,7 @@ export function AIRoadmapContent(props: AIRoadmapContentProps) {
id="roadmap-container"
className="relative min-h-[400px] [&>svg]:mx-auto"
dangerouslySetInnerHTML={{ __html: svgHtml }}
onClick={handleNodeClick}
/>
{isLoading && !svgHtml && (