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