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

View File

@@ -1,9 +1,11 @@
import { import {
useCallback, useCallback,
useEffect, useEffect,
useImperativeHandle,
useLayoutEffect, useLayoutEffect,
useRef, useRef,
useState, useState,
type RefObject,
} from 'react'; } from 'react';
import { useChat, type ChatMessage } from '../../hooks/use-chat'; import { useChat, type ChatMessage } from '../../hooks/use-chat';
import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard'; import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard';
@@ -29,15 +31,18 @@ import { billingDetailsOptions } from '../../queries/billing';
import { LoadingChip } from '../LoadingChip'; import { LoadingChip } from '../LoadingChip';
import { getTailwindScreenDimension } from '../../lib/is-mobile'; import { getTailwindScreenDimension } from '../../lib/is-mobile';
import { useToast } from '../../hooks/use-toast'; import { useToast } from '../../hooks/use-toast';
import type { AIRoadmapChatActions } from './AIRoadmap';
import type { RoadmapNodeDetails } from './AIRoadmapContent';
type AIRoadmapChatProps = { type AIRoadmapChatProps = {
roadmapSlug?: string; roadmapSlug?: string;
isRoadmapLoading?: boolean; isRoadmapLoading?: boolean;
onUpgrade?: () => void; onUpgrade?: () => void;
aiChatActionsRef?: RefObject<AIRoadmapChatActions | null>;
}; };
export function AIRoadmapChat(props: AIRoadmapChatProps) { export function AIRoadmapChat(props: AIRoadmapChatProps) {
const { roadmapSlug, isRoadmapLoading, onUpgrade } = props; const { roadmapSlug, isRoadmapLoading, onUpgrade, aiChatActionsRef } = props;
const toast = useToast(); const toast = useToast();
const scrollareaRef = useRef<HTMLDivElement>(null); const scrollareaRef = useRef<HTMLDivElement>(null);
@@ -167,6 +172,16 @@ export function AIRoadmapChat(props: AIRoadmapChatProps) {
} }
}, [isChatOpen, isMobile]); }, [isChatOpen, isMobile]);
useImperativeHandle(aiChatActionsRef, () => ({
handleNodeClick: (node: RoadmapNodeDetails) => {
flushSync(() => {
setInputValue(`Explain what is ${node.nodeTitle} topic in detail.`);
});
inputRef.current?.focus();
},
}));
if (!isChatOpen) { if (!isChatOpen) {
return ( return (
<div className="absolute inset-x-0 bottom-0 flex justify-center p-2"> <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 { cn } from '../../lib/classname';
import { AIRoadmapRegenerate } from './AIRoadmapRegenerate'; import { AIRoadmapRegenerate } from './AIRoadmapRegenerate';
import { LoadingChip } from '../LoadingChip'; 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 = { type AIRoadmapContentProps = {
isLoading?: boolean; isLoading?: boolean;
svgHtml: string; svgHtml: string;
onRegenerate?: (prompt?: string) => void; onRegenerate?: (prompt?: string) => void;
roadmapSlug?: string; roadmapSlug?: string;
onNodeClick?: (node: RoadmapNodeDetails) => void;
}; };
export function AIRoadmapContent(props: AIRoadmapContentProps) { 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 ( return (
<div <div
@@ -23,6 +95,7 @@ export function AIRoadmapContent(props: AIRoadmapContentProps) {
id="roadmap-container" id="roadmap-container"
className="relative min-h-[400px] [&>svg]:mx-auto" className="relative min-h-[400px] [&>svg]:mx-auto"
dangerouslySetInnerHTML={{ __html: svgHtml }} dangerouslySetInnerHTML={{ __html: svgHtml }}
onClick={handleNodeClick}
/> />
{isLoading && !svgHtml && ( {isLoading && !svgHtml && (