computer-scienceangular-roadmapbackend-roadmapblockchain-roadmapdba-roadmapdeveloper-roadmapdevops-roadmapfrontend-roadmapgo-roadmaphactoberfestjava-roadmapjavascript-roadmapnodejs-roadmappython-roadmapqa-roadmapreact-roadmaproadmapstudy-planvue-roadmapweb3-roadmap
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
262 lines
8.5 KiB
262 lines
8.5 KiB
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; |
|
import CheckIcon from '../../icons/check.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 { |
|
isTopicDone, |
|
renderTopicProgress, |
|
ResourceType, |
|
toggleMarkTopicDone as toggleMarkTopicDoneApi, |
|
} 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 [isDone, setIsDone] = useState<boolean>(); |
|
const [isUpdatingProgress, setIsUpdatingProgress] = useState(true); |
|
|
|
const isGuest = useMemo(() => !isLoggedIn(), []); |
|
const topicRef = useRef<HTMLDivElement>(null); |
|
|
|
// Details of the currently loaded topic |
|
const [topicId, setTopicId] = useState(''); |
|
const [resourceId, setResourceId] = useState(''); |
|
const [resourceType, setResourceType] = useState<ResourceType>('roadmap'); |
|
|
|
const showLoginPopup = () => { |
|
const popupEl = document.querySelector(`#login-popup`); |
|
if (!popupEl) { |
|
return; |
|
} |
|
|
|
popupEl.classList.remove('hidden'); |
|
popupEl.classList.add('flex'); |
|
const focusEl = popupEl.querySelector<HTMLElement>('[autofocus]'); |
|
if (focusEl) { |
|
focusEl.focus(); |
|
} |
|
}; |
|
|
|
const toggleMarkTopicDone = (isDone: boolean) => { |
|
setIsUpdatingProgress(true); |
|
toggleMarkTopicDoneApi({ topicId, resourceId, resourceType }, isDone) |
|
.then(() => { |
|
setIsDone(isDone); |
|
setIsActive(false); |
|
renderTopicProgress(topicId, isDone); |
|
}) |
|
.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); |
|
isTopicDone({ topicId, resourceId, resourceType }) |
|
.then((status: boolean) => { |
|
setIsUpdatingProgress(false); |
|
setIsDone(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 toggleMarkTopicDoneApi( |
|
{ |
|
topicId, |
|
resourceId, |
|
resourceType, |
|
}, |
|
!oldIsDone |
|
); |
|
}) |
|
.then((newIsDone) => renderTopicProgress(topicId, newIsDone)) |
|
.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<string>( |
|
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 ( |
|
<div> |
|
<div |
|
ref={topicRef} |
|
className="fixed right-0 top-0 z-40 h-screen w-full overflow-y-auto bg-white p-4 sm:max-w-[600px] sm:p-6" |
|
> |
|
{isLoading && ( |
|
<div className="flex w-full justify-center"> |
|
<img |
|
src={SpinnerIcon} |
|
alt="Loading" |
|
className="h-6 w-6 animate-spin fill-blue-600 text-gray-200 sm:h-12 sm:w-12" |
|
/> |
|
</div> |
|
)} |
|
|
|
{!isLoading && !error && ( |
|
<> |
|
{/* Actions for the topic */} |
|
<div className="mb-2"> |
|
{isGuest && ( |
|
<button |
|
data-popup="login-popup" |
|
className="inline-flex items-center rounded-md bg-green-600 p-1 px-2 text-sm text-white hover:bg-green-700" |
|
onClick={() => setIsActive(false)} |
|
> |
|
<img alt="Check" class='w-3' src={CheckIcon} /> |
|
<span className="ml-2">Mark as Done</span> |
|
</button> |
|
)} |
|
|
|
{!isGuest && ( |
|
<> |
|
{isUpdatingProgress && ( |
|
<button className="inline-flex cursor-default items-center rounded-md border border-gray-300 bg-white p-1 px-2 text-sm text-black"> |
|
<img |
|
alt="Check" |
|
class="h-4 w-4 animate-spin" |
|
src={SpinnerIcon} |
|
/> |
|
<span className="ml-2">Updating Status..</span> |
|
</button> |
|
)} |
|
{!isUpdatingProgress && !isDone && ( |
|
<button |
|
className="inline-flex items-center rounded-md border border-green-600 bg-green-600 p-1 px-2 text-sm text-white hover:bg-green-700" |
|
onClick={() => toggleMarkTopicDone(true)} |
|
> |
|
<img alt="Check" class="w-3" src={CheckIcon} /> |
|
<span className="ml-2">Mark as Done</span> |
|
</button> |
|
)} |
|
|
|
{!isUpdatingProgress && isDone && ( |
|
<button |
|
className="inline-flex items-center rounded-md border border-red-600 bg-red-600 p-1 px-2 text-sm text-white hover:bg-red-700" |
|
onClick={() => toggleMarkTopicDone(false)} |
|
> |
|
<img alt="Check" class="h-4" src={ResetIcon} /> |
|
<span className="ml-2">Mark as Pending</span> |
|
</button> |
|
)} |
|
</> |
|
)} |
|
|
|
<button |
|
type="button" |
|
id="close-topic" |
|
className="absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900" |
|
onClick={() => setIsActive(false)} |
|
> |
|
<img alt="Close" class="h-5 w-5" src={CloseIcon} /> |
|
</button> |
|
</div> |
|
|
|
{/* Topic Content */} |
|
<div |
|
id="topic-content" |
|
className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5" |
|
dangerouslySetInnerHTML={{ __html: topicHtml }} |
|
></div> |
|
</> |
|
)} |
|
</div> |
|
<div class="fixed inset-0 z-30 bg-gray-900 bg-opacity-50 dark:bg-opacity-80"></div> |
|
</div> |
|
); |
|
}
|
|
|