feat: add custom renderer for roadmaps (#5691)

* wip

* fix: update packages

* wip

* wip

* feat: editor content generator

* fix: add dimensions

* feat: add renderer

* feat: add progress modal renderer

* Add API design roadmap

* Update API roadmap rendering

* fix: button click

* fix: link item

* feat: render pdf for editor roadmaps

* Add API roadmap

* Fix broken link of full-stack roadmap

* Update content dir

* Fix typos in api roadmap

* Add assets for pdf and svg

* Add content for api roadmap

* Add todo

* fix: close on editor roadmap select

* Update link not working

* Add api roadmap to get-started and roadmaps page

---------

Co-authored-by: Arik Chakma <arikchangma@gmail.com>
This commit is contained in:
Kamran AhmedandArik Chakma authored and GitHub committed 2024-05-17 17:28:24 +01:00
1 parent 375ad931f7
commit 5e50ffbc30
119 files changed
+1360 -146

No files matched your search

@@ -0,0 +1,85 @@
import { useEffect, useState, type CSSProperties } from 'react';
import {
EditorRoadmapRenderer,
type RoadmapRendererProps,
} from './EditorRoadmapRenderer';
import { Spinner } from '../ReactIcons/Spinner';
import type { ResourceType } from '../../lib/resource-progress';
import { httpGet } from '../../lib/http';
import { ProgressNudge } from '../FrameRenderer/ProgressNudge';
type EditorRoadmapProps = {
resourceId: string;
resourceType?: ResourceType;
dimensions: {
width: number;
height: number;
};
};
export function EditorRoadmap(props: EditorRoadmapProps) {
const { resourceId, resourceType = 'roadmap', dimensions } = props;
const [isLoading, setIsLoading] = useState(true);
const [roadmapData, setRoadmapData] = useState<
Omit<RoadmapRendererProps, 'resourceId'> | undefined
>(undefined);
const loadRoadmapData = async () => {
setIsLoading(true);
const { response, error } = await httpGet<
Omit<RoadmapRendererProps, 'resourceId'>
>(`/${resourceId}.json`);
if (error) {
console.error(error);
return;
}
setRoadmapData(response);
setIsLoading(false);
};
useEffect(() => {
loadRoadmapData().finally();
}, [resourceId]);
if (!roadmapData || isLoading) {
return (
<div
style={
{
'--aspect-ratio': dimensions.width / dimensions.height,
} as CSSProperties
}
className="flex aspect-[var(--aspect-ratio)] w-full justify-center"
>
<div className="flex w-full justify-center">
<Spinner
innerFill="#2563eb"
outerFill="#E5E7EB"
className="h-6 w-6 animate-spin sm:h-12 sm:w-12"
/>
</div>
</div>
);
}
return (
<div
style={
{
'--aspect-ratio': dimensions.width / dimensions.height,
} as CSSProperties
}
className="flex aspect-[var(--aspect-ratio)] w-full justify-center"
>
<EditorRoadmapRenderer
{...roadmapData}
dimensions={dimensions}
resourceId={resourceId}
/>
<ProgressNudge resourceId={resourceId} resourceType={resourceType} />
</div>
);
}
@@ -0,0 +1,56 @@
svg text tspan {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeSpeed;
}
svg > g[data-type='topic'],
svg > g[data-type='subtopic'],
svg g[data-type='link-item'],
svg > g[data-type='button'] {
cursor: pointer;
}
svg > g[data-type='topic']:hover > rect {
fill: #d6d700;
}
svg > g[data-type='subtopic']:hover > rect {
fill: #f3c950;
}
svg > g[data-type='button']:hover {
opacity: 0.8;
}
svg g[data-type='link-item']:hover {
opacity: 0.8;
}
svg .done rect {
fill: #cbcbcb !important;
}
svg .done text,
svg .skipped text {
text-decoration: line-through;
}
svg > g[data-type='topic'].learning > rect + text,
svg > g[data-type='topic'].done > rect + text {
fill: black;
}
svg > g[data-type='subtipic'].done > rect + text,
svg > g[data-type='subtipic'].learning > rect + text {
fill: #cbcbcb;
}
svg .learning rect {
fill: #dad1fd !important;
}
svg .learning text {
text-decoration: underline;
}
svg .skipped rect {
fill: #496b69 !important;
}
@@ -0,0 +1,176 @@
import { useCallback, useEffect, useRef } from 'react';
import './EditorRoadmapRenderer.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 { Edge, Node } from 'reactflow';
import { Renderer } from '../../../editor/renderer';
import { slugify } from '../../lib/slugger';
export type RoadmapRendererProps = {
resourceId: string;
nodes: Node[];
edges: Edge[];
dimensions: {
width: number;
height: number;
};
};
type RoadmapNodeDetails = {
nodeId: string;
nodeType: string;
targetGroup: SVGElement;
title?: string;
};
function getNodeDetails(svgElement: SVGElement): RoadmapNodeDetails | null {
const targetGroup = (svgElement?.closest('g') as SVGElement) || {};
const nodeId = targetGroup?.dataset?.nodeId;
const nodeType = targetGroup?.dataset?.type;
const title = targetGroup?.dataset?.title;
if (!nodeId || !nodeType) {
return null;
}
return { nodeId, nodeType, targetGroup, title };
}
const allowedNodeTypes = ['topic', 'subtopic', 'button', 'link-item'];
export function EditorRoadmapRenderer(props: RoadmapRendererProps) {
const { resourceId, nodes = [], edges = [] } = props;
const roadmapRef = useRef<HTMLDivElement>(null);
const toast = useToast();
async function updateTopicStatus(
topicId: string,
newStatus: ResourceProgressType,
) {
pageProgressMessage.set('Updating progress');
updateResourceProgress(
{
resourceId,
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, title } =
getNodeDetails(target) || {};
if (!nodeId || !nodeType || !allowedNodeTypes.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;
}
if (!title) {
return;
}
const detailsPattern = `${slugify(title)}@${nodeId}`;
window.dispatchEvent(
new CustomEvent('roadmap.node.click', {
detail: {
topicId: detailsPattern,
resourceId,
resourceType: 'roadmap',
},
}),
);
}, []);
const handleSvgRightClick = useCallback((e: MouseEvent) => {
e.preventDefault();
const target = e.target as SVGElement;
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {};
if (!nodeId || !nodeType || !allowedNodeTypes.includes(nodeType)) {
return;
}
if (nodeType === 'button') {
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 (
<Renderer
ref={roadmapRef}
roadmap={{ nodes, edges }}
onRendered={() => {
roadmapRef.current?.setAttribute('data-renderer', 'editor');
renderResourceProgress('roadmap', resourceId).finally();
}}
/>
);
}