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:
1 parent
375ad931f7
commit
5e50ffbc30
119 files changed
+1360
-146
No files matched your search
@@ -18,6 +18,7 @@ import { GuideIcon } from '../ReactIcons/GuideIcon.tsx';
|
||||
import { HomeIcon } from '../ReactIcons/HomeIcon.tsx';
|
||||
import { VideoIcon } from '../ReactIcons/VideoIcon.tsx';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import type { AllowedRoadmapRenderer } from '../../lib/roadmap.ts';
|
||||
|
||||
export type PageType = {
|
||||
id: string;
|
||||
@@ -27,6 +28,7 @@ export type PageType = {
|
||||
icon?: ReactElement;
|
||||
isProtected?: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
renderer?: AllowedRoadmapRenderer;
|
||||
};
|
||||
|
||||
const defaultPages: PageType[] = [
|
||||
|
||||
@@ -24,6 +24,7 @@ export type TeamResourceConfig = {
|
||||
topics?: number;
|
||||
sharedTeamMemberIds: string[];
|
||||
sharedFriendIds: string[];
|
||||
defaultRoadmapId?: string;
|
||||
}[];
|
||||
|
||||
type RoadmapSelectorProps = {
|
||||
@@ -106,6 +107,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
}
|
||||
|
||||
pageProgressMessage.set(`Adding roadmap to team`);
|
||||
const renderer = allRoadmaps.find((r) => r.id === roadmapId)?.renderer;
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
@@ -115,6 +117,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
removed: [],
|
||||
renderer: renderer || 'balsamiq',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -124,6 +127,9 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
}
|
||||
|
||||
setTeamResources(response);
|
||||
if (renderer === 'editor') {
|
||||
setShowSelectRoadmapModal(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -52,7 +52,7 @@ svg .done rect {
|
||||
fill: #cbcbcb !important;
|
||||
}
|
||||
|
||||
svg .done rect[stroke="rgb(255,229,153)"] {
|
||||
svg .done rect[stroke='rgb(255,229,153)'] {
|
||||
stroke: #cbcbcb !important;
|
||||
}
|
||||
|
||||
@@ -133,10 +133,12 @@ svg .removed path {
|
||||
}
|
||||
}
|
||||
|
||||
#customized-roadmap #resource-svg-wrap g:not([class]),
|
||||
#customized-roadmap #resource-svg-wrap circle,
|
||||
#customized-roadmap #resource-svg-wrap path[stroke='#fff'],
|
||||
#customized-roadmap #resource-svg-wrap g[data-group-id$='-note'] {
|
||||
#customized-roadmap #resource-svg-wrap:not([data-renderer]) g:not([class]),
|
||||
#customized-roadmap #resource-svg-wrap:not([data-renderer]) circle,
|
||||
#customized-roadmap #resource-svg-wrap:not([data-renderer]) path[stroke='#fff'],
|
||||
#customized-roadmap
|
||||
#resource-svg-wrap:not([data-renderer])
|
||||
g[data-group-id$='-note'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ import { useToast } from '../../hooks/use-toast';
|
||||
import { TrashIcon } from '../ReactIcons/TrashIcon';
|
||||
import { AddedUserIcon } from '../ReactIcons/AddedUserIcon';
|
||||
import { AddUserIcon } from '../ReactIcons/AddUserIcon';
|
||||
import type { AllowedRoadmapRenderer } from '../../lib/roadmap';
|
||||
|
||||
type FriendProgressItemProps = {
|
||||
friend: ListFriendsResponse[0];
|
||||
onShowResourceProgress: (
|
||||
resourceId: string,
|
||||
isCustomResource?: boolean
|
||||
isCustomResource?: boolean,
|
||||
renderer?: AllowedRoadmapRenderer,
|
||||
) => void;
|
||||
onReload: () => void;
|
||||
};
|
||||
@@ -27,7 +29,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
pageProgressMessage.set('Please wait...');
|
||||
const { response, error } = await httpDelete(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-friend/${userId}`,
|
||||
{}
|
||||
{},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
@@ -43,7 +45,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
pageProgressMessage.set('Please wait...');
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-add-friend/${userId}`,
|
||||
{}
|
||||
{},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
@@ -92,7 +94,8 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
onClick={() =>
|
||||
onShowResourceProgress(
|
||||
progress.resourceId,
|
||||
progress.isCustomResource
|
||||
progress.isCustomResource,
|
||||
progress?.renderer,
|
||||
)
|
||||
}
|
||||
className="group relative overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
|
||||
@@ -160,7 +163,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
deleteFriend(friend.userId, 'Friend removed').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -198,7 +201,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
addFriend(friend.userId, 'Friend request accepted').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -225,7 +228,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
deleteFriend(friend.userId, 'Friend request removed').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -267,7 +270,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
onClick={() => {
|
||||
deleteFriend(
|
||||
friend.userId,
|
||||
'Friend request withdrawn'
|
||||
'Friend request withdrawn',
|
||||
).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
@@ -304,7 +307,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
addFriend(friend.userId, 'Friend request accepted').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
className="mb-1 block w-full max-w-[150px] rounded-md bg-black py-1.5 text-sm text-white"
|
||||
@@ -316,7 +319,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
onClick={() => {
|
||||
deleteFriend(
|
||||
friend.userId,
|
||||
'Friend request rejected'
|
||||
'Friend request rejected',
|
||||
).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { UserProgressModal } from '../UserProgress/UserProgressModal';
|
||||
import { InviteFriendPopup } from './InviteFriendPopup';
|
||||
import { UserCustomProgressModal } from '../UserProgress/UserCustomProgressModal';
|
||||
import { UserIcon } from 'lucide-react';
|
||||
import type { AllowedRoadmapRenderer } from '../../lib/roadmap';
|
||||
|
||||
type FriendResourceProgress = {
|
||||
updatedAt: string;
|
||||
@@ -22,6 +23,7 @@ type FriendResourceProgress = {
|
||||
skipped: number;
|
||||
done: number;
|
||||
total: number;
|
||||
renderer?: AllowedRoadmapRenderer;
|
||||
};
|
||||
|
||||
export type ListFriendsResponse = {
|
||||
@@ -55,6 +57,7 @@ export function FriendsPage() {
|
||||
resourceId: string;
|
||||
friend: ListFriendsResponse[0];
|
||||
isCustomResource?: boolean;
|
||||
renderer?: AllowedRoadmapRenderer;
|
||||
}>();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -92,8 +95,8 @@ export function FriendsPage() {
|
||||
(grouping) => grouping.value === selectedGrouping,
|
||||
);
|
||||
|
||||
const filteredFriends = friends.filter(
|
||||
(friend) => selectedGroupingType?.statuses.includes(friend.status),
|
||||
const filteredFriends = friends.filter((friend) =>
|
||||
selectedGroupingType?.statuses.includes(friend.status),
|
||||
);
|
||||
|
||||
const receivedRequests = friends.filter(
|
||||
@@ -124,6 +127,7 @@ export function FriendsPage() {
|
||||
resourceType={'roadmap'}
|
||||
onClose={() => setShowFriendProgress(undefined)}
|
||||
isCustomResource={showFriendProgress?.isCustomResource}
|
||||
renderer={showFriendProgress?.renderer}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -182,11 +186,16 @@ export function FriendsPage() {
|
||||
{filteredFriends.map((friend) => (
|
||||
<FriendProgressItem
|
||||
friend={friend}
|
||||
onShowResourceProgress={(resourceId, isCustomResource) => {
|
||||
onShowResourceProgress={(
|
||||
resourceId,
|
||||
isCustomResource,
|
||||
renderer,
|
||||
) => {
|
||||
setShowFriendProgress({
|
||||
resourceId,
|
||||
friend,
|
||||
isCustomResource,
|
||||
renderer,
|
||||
});
|
||||
}}
|
||||
key={friend.userId}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import { getQuestionGroupsByIds } from '../lib/question-group';
|
||||
import { getRoadmapsByIds, RoadmapFrontmatter } from '../lib/roadmap';
|
||||
import { getRoadmapsByIds, type RoadmapFrontmatter } from '../lib/roadmap';
|
||||
import { Map, Clipboard } from 'lucide-react';
|
||||
|
||||
export interface Props {
|
||||
@@ -59,14 +59,19 @@ const relatedQuestionDetails = await getQuestionGroupsByIds(relatedQuestions);
|
||||
|
||||
{
|
||||
relatedRoadmaps.length && (
|
||||
<div class:list={['border-t bg-gray-100', {
|
||||
'mt-8': !relatedQuestionDetails.length
|
||||
}]}>
|
||||
<div
|
||||
class:list={[
|
||||
'border-t bg-gray-100',
|
||||
{
|
||||
'mt-8': !relatedQuestionDetails.length,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div class='container'>
|
||||
<div class='relative -top-5 flex justify-between'>
|
||||
<span class='text-md flex items-center rounded-md border bg-white px-3 py-1 font-medium'>
|
||||
<Map className='text-black mr-1.5' size='17px' />
|
||||
Related <span class="hidden sm:inline">Roadmaps</span>
|
||||
Related <span class='hidden sm:inline'>Roadmaps</span>
|
||||
</span>
|
||||
<a
|
||||
href='/roadmaps'
|
||||
|
||||
@@ -23,7 +23,7 @@ const hasTnsBanner = !!tnsBannerLink;
|
||||
|
||||
<div
|
||||
class:list={[
|
||||
'mt-4 sm:mt-7 border-0 sm:border rounded-md mb-0 bg-white',
|
||||
'mb-0 mt-4 rounded-md border-0 bg-white sm:mt-7 sm:border',
|
||||
...(hasTnsBanner
|
||||
? [
|
||||
{
|
||||
@@ -42,7 +42,7 @@ const hasTnsBanner = !!tnsBannerLink;
|
||||
<ResourceProgressStats
|
||||
resourceId={roadmapId}
|
||||
resourceType='roadmap'
|
||||
hasSecondaryBanner={hasTitleQuestion}
|
||||
hasSecondaryBanner={Boolean(hasTitleQuestion)}
|
||||
/>
|
||||
|
||||
{
|
||||
|
||||
@@ -77,6 +77,12 @@ const groups: GroupType[] = [
|
||||
type: 'role',
|
||||
otherGroups: ['Web Development', 'Absolute Beginners'],
|
||||
},
|
||||
{
|
||||
title: 'API Design',
|
||||
link: '/api',
|
||||
type: 'role',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'QA',
|
||||
link: '/qa',
|
||||
|
||||
@@ -189,10 +189,10 @@ export function TeamActivityPage() {
|
||||
Team Activity
|
||||
</h3>
|
||||
<ul className="mb-4 mt-2 flex flex-col gap-3">
|
||||
{usersWithActivities.map((user) => {
|
||||
{usersWithActivities.map((user, index) => {
|
||||
return (
|
||||
<TeamActivityItem
|
||||
key={user._id}
|
||||
key={`${user._id}-${index}`}
|
||||
user={user}
|
||||
teamId={teamId}
|
||||
onTopicClick={setSelectedActivity}
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { ResourceType } from '../../lib/resource-progress';
|
||||
import { MemberProgressModal } from '../TeamProgress/MemberProgressModal';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $currentTeam } from '../../stores/team';
|
||||
import { MemberCustomProgressModal } from '../TeamProgress/MemberCustomProgressModal';
|
||||
|
||||
type GetTeamMemberProgressesResponse = TeamMemberDocument & {
|
||||
name: string;
|
||||
@@ -99,10 +100,15 @@ export function TeamMemberDetailsPage() {
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${memberProgress?.avatar}`
|
||||
: '/images/default-avatar.png';
|
||||
|
||||
const ProgressModal =
|
||||
selectedResource && !selectedResource.isCustomResource
|
||||
? MemberProgressModal
|
||||
: MemberCustomProgressModal;
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedResource && (
|
||||
<MemberProgressModal
|
||||
<ProgressModal
|
||||
teamId={teamId}
|
||||
member={{
|
||||
...memberProgress,
|
||||
@@ -183,7 +189,11 @@ export function TeamMemberDetailsPage() {
|
||||
memberActivity?.data?.flatMap((act) => act.activity) || []
|
||||
}
|
||||
onResourceClick={(resourceId, resourceType, isCustomResource) => {
|
||||
setSelectedResource({ resourceId, resourceType, isCustomResource });
|
||||
setSelectedResource({
|
||||
resourceId,
|
||||
resourceType,
|
||||
isCustomResource,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Pagination
|
||||
|
||||
@@ -147,6 +147,7 @@ export function TeamRoadmaps() {
|
||||
toast.loading('Adding roadmap');
|
||||
pageProgressMessage.set('Adding roadmap');
|
||||
setIsLoading(true);
|
||||
const roadmap = allRoadmaps.find((r) => r.id === roadmapId);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
@@ -156,6 +157,7 @@ export function TeamRoadmaps() {
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
removed: [],
|
||||
renderer: roadmap?.renderer || 'balsamiq',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -166,6 +168,9 @@ export function TeamRoadmaps() {
|
||||
|
||||
setTeamResources(response);
|
||||
toast.success('Roadmap added');
|
||||
if (roadmap?.renderer === 'editor') {
|
||||
setIsAddingRoadmap(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemove(resourceId: string) {
|
||||
@@ -219,11 +224,14 @@ export function TeamRoadmaps() {
|
||||
/>
|
||||
);
|
||||
|
||||
const filteredAllRoadmaps = allRoadmaps.filter(
|
||||
(r) => !teamResources.find((c) => c?.defaultRoadmapId === r.id),
|
||||
);
|
||||
const addRoadmapModal = isAddingRoadmap && (
|
||||
<SelectRoadmapModal
|
||||
onClose={() => setIsAddingRoadmap(false)}
|
||||
teamResourceConfig={teamResources}
|
||||
allRoadmaps={allRoadmaps}
|
||||
allRoadmaps={filteredAllRoadmaps}
|
||||
teamId={teamId}
|
||||
onRoadmapAdd={(roadmapId: string) => {
|
||||
onAdd(roadmapId).finally(() => {
|
||||
|
||||
@@ -106,6 +106,10 @@ export function TeamVersions(props: TeamVersionsProps) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTeamVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearResourceProgress();
|
||||
|
||||
// teams have customizations. Assigning #customized-roadmap to roadmapSvgWrap
|
||||
|
||||
@@ -225,11 +225,12 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
className="fixed right-0 top-0 z-40 flex h-screen w-full flex-col overflow-y-auto bg-white p-4 focus:outline-0 sm:max-w-[600px] sm:p-6"
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="flex w-full justify-center">
|
||||
<div className="flex w-full h-full items-center justify-center">
|
||||
<Spinner
|
||||
outerFill="#d1d5db"
|
||||
className="h-6 w-6 sm:h-12 sm:w-12"
|
||||
className="h-6 w-6 sm:h-8 sm:w-8"
|
||||
innerFill="#2563eb"
|
||||
isDualRing={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,9 @@ import { useAuth } from '../../hooks/use-auth';
|
||||
import { ModalLoader } from './ModalLoader.tsx';
|
||||
import { UserProgressModalHeader } from './UserProgressModalHeader';
|
||||
import { X } from 'lucide-react';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu.tsx';
|
||||
import type { AllowedRoadmapRenderer } from '../../lib/roadmap.ts';
|
||||
import { renderFlowJSON } from '../../../editor/renderer/renderer.ts';
|
||||
|
||||
export type ProgressMapProps = {
|
||||
userId?: string;
|
||||
@@ -18,6 +21,7 @@ export type ProgressMapProps = {
|
||||
resourceType: ResourceType;
|
||||
onClose?: () => void;
|
||||
isCustomResource?: boolean;
|
||||
renderer?: AllowedRoadmapRenderer;
|
||||
};
|
||||
|
||||
export type UserProgressResponse = {
|
||||
@@ -39,6 +43,7 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
resourceType,
|
||||
userId: propUserId,
|
||||
onClose: onModalClose,
|
||||
renderer = 'balsamiq',
|
||||
} = props;
|
||||
|
||||
const { s: userId = propUserId } = getUrlParams();
|
||||
@@ -87,15 +92,18 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
|
||||
async function getRoadmapSVG(
|
||||
jsonUrl: string,
|
||||
renderer: AllowedRoadmapRenderer = 'balsamiq',
|
||||
): Promise<SVGElement | undefined> {
|
||||
const { error, response: roadmapJson } = await httpGet(jsonUrl);
|
||||
if (error || !roadmapJson) {
|
||||
throw error || new Error('Something went wrong. Please try again!');
|
||||
}
|
||||
|
||||
return await wireframeJSONToSVG(roadmapJson, {
|
||||
fontURL: '/fonts/balsamiq.woff2',
|
||||
});
|
||||
return renderer === 'editor'
|
||||
? await renderFlowJSON(roadmapJson as any)
|
||||
: await wireframeJSONToSVG(roadmapJson, {
|
||||
fontURL: '/fonts/balsamiq.woff2',
|
||||
});
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
@@ -124,8 +132,10 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
Promise.all([
|
||||
getRoadmapSVG(resourceJsonUrl),
|
||||
getRoadmapSVG(resourceJsonUrl, renderer),
|
||||
getUserProgress(userId, resourceType, resourceId),
|
||||
])
|
||||
.then(([svg, user]) => {
|
||||
|
||||
Reference in new issue
Block a user