computer-scienceangular-roadmapbackend-roadmapblockchain-roadmapdba-roadmapdeveloper-roadmapdevops-roadmapfrontend-roadmapgo-roadmaphactoberfestjava-roadmapjavascript-roadmapnodejs-roadmappython-roadmapqa-roadmapreact-roadmaproadmapstudy-planvue-roadmapweb3-roadmap
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
178 lines
4.9 KiB
178 lines
4.9 KiB
1 year ago
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
|
import { Renderer } from '../../../renderer';
|
||
|
import './RoadmapRenderer.css';
|
||
|
import {
|
||
|
renderResourceProgress,
|
||
|
updateResourceProgress,
|
||
|
type ResourceProgressType,
|
||
|
renderTopicProgress,
|
||
|
refreshProgressCounters,
|
||
|
} from '../../lib/resource-progress';
|
||
|
import { pageProgressMessage } from '../../stores/page';
|
||
|
import { useToast } from '../../hooks/use-toast';
|
||
|
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
|
||
|
import { EmptyRoadmap } from './EmptyRoadmap';
|
||
|
import { isLoggedIn } from '../../lib/jwt';
|
||
|
import { httpPost } from '../../lib/http';
|
||
|
|
||
|
type RoadmapRendererProps = {
|
||
|
roadmap: RoadmapDocument;
|
||
|
};
|
||
|
|
||
|
type RoadmapNodeDetails = {
|
||
|
nodeId: string;
|
||
|
nodeType: string;
|
||
|
targetGroup: SVGElement;
|
||
|
};
|
||
|
|
||
|
export function getNodeDetails(
|
||
|
svgElement: SVGElement
|
||
|
): RoadmapNodeDetails | null {
|
||
|
const targetGroup = (svgElement?.closest('g') as SVGElement) || {};
|
||
|
|
||
|
const nodeId = targetGroup?.dataset?.nodeId;
|
||
|
const nodeType = targetGroup?.dataset?.type;
|
||
|
if (!nodeId || !nodeType) return null;
|
||
|
|
||
|
return { nodeId, nodeType, targetGroup };
|
||
|
}
|
||
|
|
||
|
export const allowedClickableNodeTypes = [
|
||
|
'topic',
|
||
|
'subtopic',
|
||
|
'button',
|
||
|
'link-item',
|
||
|
];
|
||
|
|
||
|
export function RoadmapRenderer(props: RoadmapRendererProps) {
|
||
|
const { roadmap } = props;
|
||
|
const roadmapRef = useRef<HTMLDivElement>(null);
|
||
|
const roadmapId = roadmap._id!;
|
||
|
|
||
|
const toast = useToast();
|
||
|
const [hideRenderer, setHideRenderer] = useState(false);
|
||
|
|
||
|
async function updateTopicStatus(
|
||
|
topicId: string,
|
||
|
newStatus: ResourceProgressType
|
||
|
) {
|
||
|
pageProgressMessage.set('Updating progress');
|
||
|
updateResourceProgress(
|
||
|
{
|
||
|
resourceId: roadmapId,
|
||
|
resourceType: 'roadmap',
|
||
|
topicId,
|
||
|
},
|
||
|
newStatus
|
||
|
)
|
||
|
.then(() => {
|
||
|
renderTopicProgress(topicId, newStatus);
|
||
|
})
|
||
|
.catch((err) => {
|
||
|
toast.error('Something went wrong, please try again.');
|
||
|
console.error(err);
|
||
|
})
|
||
|
.finally(() => {
|
||
|
pageProgressMessage.set('');
|
||
|
refreshProgressCounters();
|
||
|
});
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const handleSvgClick = useCallback((e: MouseEvent) => {
|
||
|
const target = e.target as SVGElement;
|
||
|
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {};
|
||
|
if (!nodeId || !nodeType || !allowedClickableNodeTypes.includes(nodeType))
|
||
|
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;
|
||
|
}
|
||
|
|
||
|
const isCurrentStatusLearning = targetGroup?.classList.contains('learning');
|
||
|
const isCurrentStatusSkipped = targetGroup?.classList.contains('skipped');
|
||
|
|
||
|
if (e.shiftKey) {
|
||
|
e.preventDefault();
|
||
|
updateTopicStatus(
|
||
|
nodeId,
|
||
|
isCurrentStatusLearning ? 'pending' : 'learning'
|
||
|
);
|
||
|
return;
|
||
|
} else if (e.altKey) {
|
||
|
e.preventDefault();
|
||
|
updateTopicStatus(nodeId, isCurrentStatusSkipped ? 'pending' : 'skipped');
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
window.dispatchEvent(
|
||
|
new CustomEvent('roadmap.node.click', {
|
||
|
detail: {
|
||
|
topicId: nodeId,
|
||
|
resourceId: roadmap?._id,
|
||
|
resourceType: 'roadmap',
|
||
|
isCustomResource: true,
|
||
|
},
|
||
|
})
|
||
|
);
|
||
|
}, []);
|
||
|
|
||
|
const handleSvgRightClick = useCallback((e: MouseEvent) => {
|
||
|
e.preventDefault();
|
||
|
|
||
|
const target = e.target as SVGElement;
|
||
|
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {};
|
||
|
if (!nodeId || !nodeType || !allowedClickableNodeTypes.includes(nodeType))
|
||
|
return;
|
||
|
|
||
|
if (nodeType === 'button' || nodeType === 'link-item') {
|
||
|
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 (
|
||
|
<div className="flex grow bg-gray-50 pb-8 pt-4 sm:pt-12">
|
||
|
<div className="container !max-w-[1000px]">
|
||
|
<Renderer
|
||
|
ref={roadmapRef}
|
||
|
roadmap={{ nodes: roadmap?.nodes!, edges: roadmap?.edges! }}
|
||
|
onRendered={() => {
|
||
|
renderResourceProgress('roadmap', roadmapId).then(() => {
|
||
|
if (roadmap?.nodes?.length === 0) {
|
||
|
setHideRenderer(true);
|
||
|
roadmapRef?.current?.classList.add('hidden');
|
||
|
}
|
||
|
});
|
||
|
}}
|
||
|
/>
|
||
|
{hideRenderer && <EmptyRoadmap />}
|
||
|
</div>
|
||
|
</div>
|
||
|
);
|
||
|
}
|