import { useEffect, useMemo, useRef, useState } from 'react'; 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 { showLoginPopup } from '../../lib/popup'; import { useToast } from '../../hooks/use-toast'; import type { AllowedLinkTypes, RoadmapContentDocument, } from '../CustomRoadmap/CustomRoadmap'; import { markdownToHtml, sanitizeMarkdown } from '../../lib/markdown'; import { cn } from '../../lib/classname'; import { Ban, FileText, HeartHandshake, X } from 'lucide-react'; import { getUrlParams } from '../../lib/browser'; import { Spinner } from '../ReactIcons/Spinner'; import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx'; import { GoogleIcon } from '../ReactIcons/GoogleIcon.tsx'; import { YouTubeIcon } from '../ReactIcons/YouTubeIcon.tsx'; import { resourceTitleFromId } from '../../lib/roadmap.ts'; type TopicDetailProps = { resourceTitle?: string; resourceType?: ResourceType; isEmbed?: boolean; 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, isEmbed = false, resourceTitle } = props; const [hasEnoughLinks, setHasEnoughLinks] = useState(false); const [contributionUrl, setContributionUrl] = 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 [hasContent, setHasContent] = useState(false); const [topicTitle, setTopicTitle] = useState(''); const [topicHtmlTitle, setTopicHtmlTitle] = 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); }); useKeydown('Escape', () => { setIsActive(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); 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) { topicHtml = response as string; const topicDom = new DOMParser().parseFromString( topicHtml, 'text/html', ); const links = topicDom.querySelectorAll('a'); const urlElem: HTMLElement = topicDom.querySelector('[data-github-url]')!; const contributionUrl = urlElem?.dataset?.githubUrl || ''; const titleElem: HTMLElement = topicDom.querySelector('h1')!; const otherElems = topicDom.querySelectorAll('body > *:not(h1, div)'); setHasContent(otherElems.length > 0); setContributionUrl(contributionUrl); setHasEnoughLinks(links.length >= 3); setTopicHtmlTitle(titleElem?.textContent || ''); } else { setLinks((response as RoadmapContentDocument)?.links || []); setTopicTitle((response as RoadmapContentDocument)?.title || ''); const sanitizedMarkdown = sanitizeMarkdown( (response as RoadmapContentDocument).description || '', ); setHasContent(sanitizedMarkdown?.length > 0); topicHtml = markdownToHtml(sanitizedMarkdown, false); } setIsLoading(false); setTopicHtml(topicHtml); }) .catch((err) => { setError('Something went wrong. Please try again later.'); setIsLoading(false); }); }); useEffect(() => { if (isActive) topicRef?.current?.focus(); }, [isActive]); if (!isActive) { return null; } const resourceTitleForSearch = resourceTitle ?.toLowerCase() ?.replace(/\s+?roadmap/gi, ''); const googleSearchUrl = `https://www.google.com/search?q=${topicHtmlTitle?.toLowerCase()} guide for ${resourceTitleForSearch}`; const youtubeSearchUrl = `https://www.youtube.com/results?search_query=${topicHtmlTitle?.toLowerCase()} for ${resourceTitleForSearch}`; const tnsLink = 'https://thenewstack.io/devops/?utm_source=roadmap.sh&utm_medium=Referral&utm_campaign=Topic'; return (
{isLoading && (
)} {!isContributing && !isLoading && !error && ( <>
{/* Actions for the topic */}
{!isEmbed && ( { setIsActive(false); }} /> )}
{/* Topic Content */} {hasContent ? ( <>
{topicTitle &&

{topicTitle}

}
) : ( <> {!canSubmitContribution && (

Empty Content

)} {canSubmitContribution && (

Help us write this content

Write a brief introduction to this topic and submit a link to a good article, podcast, video, or any other self-vetted resource that helped you understand this topic better.

Edit this Content
)} )} {links.length > 0 && ( )} {/* Contribution */} {canSubmitContribution && !hasEnoughLinks && contributionUrl && (

Find more resources using these pre-filled search queries:

Help us improve this introduction and submit a link to a good article, podcast, video, or any other self-vetted resource that helped you understand this topic better.

Edit this Content
)}
{resourceId === 'devops' && ( )} )} {/* Error */} {!isContributing && !isLoading && error && ( <>

{error}

)}
); }