import { useEffect, useRef, useState } from 'react'; import { wireframeJSONToSVG } from 'roadmap-renderer'; import { Spinner } from '../ReactIcons/Spinner'; import '../FrameRenderer/FrameRenderer.css'; import { useOutsideClick } from '../../hooks/use-outside-click'; import { useKeydown } from '../../hooks/use-keydown'; import type { TeamMember } from './TeamProgressPage'; import { httpGet } from '../../lib/http'; import { renderTopicProgress, type ResourceProgressType, type ResourceType, updateResourceProgress, } from '../../lib/resource-progress'; import { useToast } from '../../hooks/use-toast'; import { useAuth } from '../../hooks/use-auth'; import { pageProgressMessage } from '../../stores/page'; import { MemberProgressModalHeader } from './MemberProgressModalHeader'; import { replaceChildren } from '../../lib/dom.ts'; import { XIcon } from 'lucide-react'; export type ProgressMapProps = { member: TeamMember; teamId: string; resourceId: string; resourceType: 'roadmap' | 'best-practice'; onClose: () => void; onShowMyProgress: () => void; isCustomResource?: boolean; }; type MemberProgressResponse = { removed: string[]; done: string[]; learning: string[]; skipped: string[]; }; export function MemberProgressModal(props: ProgressMapProps) { const { resourceId, member, resourceType, onShowMyProgress, teamId, onClose, } = props; const user = useAuth(); const isCurrentUser = user?.email === member.email; const containerEl = useRef(null); const popupBodyEl = useRef(null); const [showProgressHint, setShowProgressHint] = useState(false); const [memberProgress, setMemberProgress] = useState(); const [isLoading, setIsLoading] = useState(true); const toast = useToast(); let resourceJsonUrl = import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh'; if (resourceType === 'roadmap') { resourceJsonUrl += `/${resourceId}.json`; } else { resourceJsonUrl += `/best-practices/${resourceId}.json`; } async function getMemberProgress( teamId: string, memberId: string, resourceType: string, resourceId: string, ) { const { error, response } = await httpGet( `${ import.meta.env.PUBLIC_API_URL }/v1-get-member-resource-progress/${teamId}/${memberId}?resourceType=${resourceType}&resourceId=${resourceId}`, ); if (error || !response) { toast.error(error?.message || 'Failed to get member progress'); return; } setMemberProgress(response); return response; } async function renderResource(jsonUrl: string) { const res = await fetch(jsonUrl, {}); const json = await res.json(); const svg: SVGElement | null = await wireframeJSONToSVG(json, { fontURL: '/fonts/balsamiq.woff2', }); replaceChildren(containerEl.current!, svg); // containerEl.current?.replaceChildren(svg); } useKeydown('Escape', () => { if (showProgressHint) { return; } onClose(); }); useOutsideClick(popupBodyEl, () => { if (showProgressHint) { return; } onClose(); }); useEffect(() => { if ( !containerEl.current || !resourceJsonUrl || !resourceId || !resourceType || !teamId ) { return; } setIsLoading(true); Promise.all([ renderResource(resourceJsonUrl), getMemberProgress(teamId, member._id, resourceType, resourceId), ]) .then(([_, memberProgress = {}]) => { const { removed = [], done = [], learning = [], skipped = [], } = memberProgress; done.forEach((id: string) => renderTopicProgress(id, 'done')); learning.forEach((id: string) => renderTopicProgress(id, 'learning')); skipped.forEach((id: string) => renderTopicProgress(id, 'skipped')); removed.forEach((id: string) => renderTopicProgress(id, 'removed')); }) .catch((err) => { console.error(err); toast.error(err?.message || 'Something went wrong. Please try again!'); }) .finally(() => { setIsLoading(false); }); }, [member]); function updateTopicStatus(topicId: string, newStatus: ResourceProgressType) { if (!resourceId || !resourceType || !isCurrentUser) { return; } pageProgressMessage.set('Updating progress'); updateResourceProgress( { resourceId: resourceId, resourceType: resourceType as ResourceType, topicId, }, newStatus, ) .then(() => { renderTopicProgress(topicId, newStatus); getMemberProgress(teamId, member._id, resourceType, resourceId).then( (data) => { setMemberProgress(data); }, ); }) .catch((err) => { alert('Something went wrong, please try again.'); console.error(err); }) .finally(() => { pageProgressMessage.set(''); }); return; } async function handleRightClick(e: MouseEvent) { const targetGroup = (e.target as HTMLElement)?.closest('g'); if (!targetGroup) { return; } const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : ''; if (!groupId) { return; } const topicId = groupId.replace(/^\d+-/, ''); if (targetGroup.classList.contains('removed')) { e.preventDefault(); return; } e.preventDefault(); const isCurrentStatusDone = targetGroup?.classList.contains('done'); updateTopicStatus(topicId, !isCurrentStatusDone ? 'done' : 'pending'); } async function handleClick(e: MouseEvent) { const targetGroup = (e.target as HTMLElement)?.closest('g'); if (!targetGroup) { return; } const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : ''; if (!groupId) { return; } const topicId = groupId.replace(/^\d+-/, ''); if (targetGroup.classList.contains('removed')) { return; } e.preventDefault(); const isCurrentStatusLearning = targetGroup.classList.contains('learning'); const isCurrentStatusSkipped = targetGroup.classList.contains('skipped'); if (e.shiftKey) { e.preventDefault(); updateTopicStatus( topicId, !isCurrentStatusLearning ? 'learning' : 'pending', ); return; } if (e.altKey) { e.preventDefault(); updateTopicStatus( topicId, !isCurrentStatusSkipped ? 'skipped' : 'pending', ); return; } } useEffect(() => { if (!member || !containerEl.current) { return; } containerEl.current?.addEventListener('contextmenu', handleRightClick); containerEl.current?.addEventListener('click', handleClick); return () => { containerEl.current?.removeEventListener('contextmenu', handleRightClick); containerEl.current?.removeEventListener('click', handleClick); }; }, [member]); return (
{isLoading && (
)}
); }