import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import CheckIcon from '../../icons/check.svg'; import ProgressIcon from '../../icons/progress.svg'; import CloseIcon from '../../icons/close.svg'; import ResetIcon from '../../icons/reset.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 { getTopicStatus, isTopicDone, renderTopicProgress, ResourceProgressType, ResourceType, updateResourceProgress as updateResourceProgressApi, } from '../../lib/resource-progress'; import { pageLoadingMessage, sponsorHidden } from '../../stores/page'; export function TopicDetail() { const [isActive, setIsActive] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); const [topicHtml, setTopicHtml] = useState(''); const [progress, setProgress] = useState('pending'); const [isUpdatingProgress, setIsUpdatingProgress] = useState(true); 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'); const showLoginPopup = () => { const popupEl = document.querySelector(`#login-popup`); if (!popupEl) { return; } popupEl.classList.remove('hidden'); popupEl.classList.add('flex'); const focusEl = popupEl.querySelector('[autofocus]'); if (focusEl) { focusEl.focus(); } }; const handleUpdateResourceProgress = (progress: ResourceProgressType) => { setIsUpdatingProgress(true); updateResourceProgressApi( { topicId, resourceId, resourceType, }, progress ) .then(() => { setProgress(progress); setIsActive(false); renderTopicProgress( topicId, progress === 'done', progress === 'learning' ); }) .catch((err) => { alert(err.message); console.error(err); }) .finally(() => { setIsUpdatingProgress(false); }); }; // Load the topic status when the topic detail is active useEffect(() => { if (!topicId || !resourceId || !resourceType) { return; } setIsUpdatingProgress(true); getTopicStatus({ topicId, resourceId, resourceType }) .then((status) => { setIsUpdatingProgress(false); setProgress(status); }) .catch(console.error); }, [topicId, resourceId, resourceType]); // 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; } pageLoadingMessage.set('Updating'); // Toggle the topic status isTopicDone({ topicId, resourceId, resourceType }) .then((oldIsDone) => { return updateResourceProgressApi( { topicId, resourceId, resourceType, }, oldIsDone ? 'pending' : 'done' ); }) .then((updatedResult) => { const newIsDone = updatedResult.done.includes(topicId); renderTopicProgress(topicId, newIsDone, false); }) .catch((err) => { alert(err.message); console.error(err); }) .finally(() => { pageLoadingMessage.set(''); }); }); // Load the topic detail when the topic detail is active useLoadTopic(({ topicId, resourceType, resourceId }) => { setIsLoading(true); setIsActive(true); sponsorHidden.set(true); setTopicId(topicId); setResourceType(resourceType); setResourceId(resourceId); const topicPartial = topicId.replaceAll(':', '/'); const topicUrl = resourceType === 'roadmap' ? `/${resourceId}/${topicPartial}` : `/best-practices/${resourceId}/${topicPartial}`; httpGet( topicUrl, {}, { headers: { Accept: 'text/html', }, } ) .then(({ response }) => { if (!response) { setError('Topic not found.'); return; } // 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, 'text/html'); const topicHtml = node?.getElementById('main-content')?.outerHTML || ''; setIsLoading(false); setTopicHtml(topicHtml); }) .catch((err) => { setError('Something went wrong. Please try again later.'); setIsLoading(false); }); }); if (!isActive) { return null; } return (
{isLoading && (
Loading
)} {!isLoading && !error && ( <> {/* Actions for the topic */}
{isGuest && (
)} {!isGuest && ( <> {isUpdatingProgress && ( )} {!isUpdatingProgress && progress === 'pending' && (
)} {!isUpdatingProgress && progress === 'done' && ( )} {!isUpdatingProgress && progress === 'learning' && (
)} )}
{/* Topic Content */}
)}
); }