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:
@@ -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>
|
||||
);
|
||||
|
@@ -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">
|
||||
|
@@ -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 && (
|
||||
|
Reference in New Issue
Block a user