import { useMemo, useRef, useState } from 'react'; import CloseIcon from '../../icons/close.svg'; import SpinnerIcon from '../../icons/spinner.svg'; import { useKeydown } from '../../hooks/use-keydown'; import { useLoadTopic } from '../../hooks/use-load-topic'; import { useOutsideClick } from '../../hooks/use-outside-click'; import { useToggleTopic } from '../../hooks/use-toggle-topic'; import { httpGet } from '../../lib/http'; import { isLoggedIn } from '../../lib/jwt'; import type { ResourceType } from '../../lib/resource-progress'; import { isTopicDone, refreshProgressCounters, renderTopicProgress, updateResourceProgress as updateResourceProgressApi, } from '../../lib/resource-progress'; import { pageProgressMessage, sponsorHidden } from '../../stores/page'; import { TopicProgressButton } from './TopicProgressButton'; import { ContributionForm } from './ContributionForm'; import { showLoginPopup } from '../../lib/popup'; import { useToast } from '../../hooks/use-toast'; import type { AllowedLinkTypes, RoadmapContentDocument, } from '../CustomRoadmap/CustomRoadmap'; import { markdownToHtml } from '../../lib/markdown'; import { cn } from '../../lib/classname'; import { Ban, FileText } from 'lucide-react'; import { getUrlParams } from '../../lib/browser'; type TopicDetailProps = { canSubmitContribution: boolean; }; const linkTypes: Record = { article: 'bg-yellow-200', course: 'bg-green-200', opensource: 'bg-blue-200', podcast: 'bg-purple-200', video: 'bg-pink-200', website: 'bg-red-200', }; export function TopicDetail(props: TopicDetailProps) { const { canSubmitContribution } = props; const [contributionAlertMessage, setContributionAlertMessage] = useState(''); const [isActive, setIsActive] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isContributing, setIsContributing] = useState(false); const [error, setError] = useState(''); const [topicHtml, setTopicHtml] = useState(''); const [topicTitle, setTopicTitle] = useState(''); const [links, setLinks] = useState([]); const toast = useToast(); const { secret } = getUrlParams() as { secret: string }; const isGuest = useMemo(() => !isLoggedIn(), []); const topicRef = useRef(null); // Details of the currently loaded topic const [topicId, setTopicId] = useState(''); const [resourceId, setResourceId] = useState(''); const [resourceType, setResourceType] = useState('roadmap'); // Close the topic detail when user clicks outside the topic detail useOutsideClick(topicRef, () => { setIsActive(false); setIsContributing(false); }); useKeydown('Escape', () => { setIsActive(false); setIsContributing(false); }); // Toggle topic is available even if the component UI is not active // This is used on the best practice screen where we have the checkboxes // to mark the topic as done/undone. useToggleTopic(({ topicId, resourceType, resourceId }) => { if (isGuest) { showLoginPopup(); return; } pageProgressMessage.set('Updating'); // Toggle the topic status isTopicDone({ topicId, resourceId, resourceType }) .then((oldIsDone) => updateResourceProgressApi( { topicId, resourceId, resourceType, }, oldIsDone ? 'pending' : 'done', ), ) .then(({ done = [] }) => { renderTopicProgress( topicId, done.includes(topicId) ? 'done' : 'pending', ); refreshProgressCounters(); }) .catch((err) => { toast.error(err.message); console.error(err); }) .finally(() => { pageProgressMessage.set(''); }); }); // Load the topic detail when the topic detail is active useLoadTopic(({ topicId, resourceType, resourceId, isCustomResource }) => { setError(''); setIsLoading(true); setIsActive(true); sponsorHidden.set(true); setContributionAlertMessage(''); setTopicId(topicId); setResourceType(resourceType); setResourceId(resourceId); const topicPartial = topicId.replaceAll(':', '/'); let topicUrl = resourceType === 'roadmap' ? `/${resourceId}/${topicPartial}` : `/best-practices/${resourceId}/${topicPartial}`; if (isCustomResource) { topicUrl = `${ import.meta.env.PUBLIC_API_URL }/v1-get-node-content/${resourceId}/${topicId}${ secret ? `?secret=${secret}` : '' }`; } httpGet( topicUrl, {}, { ...(!isCustomResource && { headers: { Accept: 'text/html', }, }), }, ) .then(({ response }) => { if (!response) { setError('Topic not found.'); setIsLoading(false); return; } let topicHtml = ''; if (!isCustomResource) { // It's full HTML with page body, head etc. // We only need the inner HTML of the #main-content const node = new DOMParser().parseFromString( response as string, 'text/html', ); topicHtml = node?.getElementById('main-content')?.outerHTML || ''; } else { setLinks((response as RoadmapContentDocument)?.links || []); setTopicTitle((response as RoadmapContentDocument)?.title || ''); topicHtml = markdownToHtml( (response as RoadmapContentDocument)?.description || '', false, ); } setIsLoading(false); setTopicHtml(topicHtml); }) .catch((err) => { setError('Something went wrong. Please try again later.'); setIsLoading(false); }); }); if (!isActive) { return null; } const hasContent = topicHtml?.length > 0 || links?.length > 0 || topicTitle; return (
{isLoading && (
Loading
)} {!isLoading && isContributing && ( { if (message) { setContributionAlertMessage(message); } setIsContributing(false); }} /> )} {!isContributing && !isLoading && !error && ( <> {/* Actions for the topic */}
{ setIsActive(false); setIsContributing(false); }} />
{/* Topic Content */} {hasContent ? (
{topicTitle &&

{topicTitle}

}
) : (

Empty Content

)} {links.length > 0 && ( )} {/* Contribution */} {canSubmitContribution && (

Help others learn by submitting links to learn more about this topic{' '}

)} )} {/* Error */} {!isContributing && !isLoading && error && ( <>

{error}

)}
); }