wip: add resource api

feat/resources
Arik Chakma 3 months ago
parent 447bf4eb0f
commit a2440f31ec
  1. 326
      src/components/TopicDetail/TopicDetail.tsx

@ -32,6 +32,18 @@ import { YouTubeIcon } from '../ReactIcons/YouTubeIcon.tsx';
import { resourceTitleFromId } from '../../lib/roadmap.ts'; import { resourceTitleFromId } from '../../lib/roadmap.ts';
import { lockBodyScroll } from '../../lib/dom.ts'; import { lockBodyScroll } from '../../lib/dom.ts';
export const allowedRoadmapResourceTypes = ['course', 'book', 'other'] as const;
export type AllowedRoadmapResourceType =
(typeof allowedRoadmapResourceTypes)[number];
type TopicResource = {
_id?: string;
title: string;
type: AllowedRoadmapResourceType;
url: string;
topicIds: string[];
};
type TopicDetailProps = { type TopicDetailProps = {
resourceTitle?: string; resourceTitle?: string;
resourceType?: ResourceType; resourceType?: ResourceType;
@ -50,7 +62,7 @@ const linkTypes: Record<AllowedLinkTypes, string> = {
video: 'bg-purple-300', video: 'bg-purple-300',
website: 'bg-blue-300', website: 'bg-blue-300',
official: 'bg-blue-600 text-white', official: 'bg-blue-600 text-white',
feed: "bg-[#ce3df3] text-white" feed: 'bg-[#ce3df3] text-white',
}; };
export function TopicDetail(props: TopicDetailProps) { export function TopicDetail(props: TopicDetailProps) {
@ -69,6 +81,8 @@ export function TopicDetail(props: TopicDetailProps) {
const [links, setLinks] = useState<RoadmapContentDocument['links']>([]); const [links, setLinks] = useState<RoadmapContentDocument['links']>([]);
const toast = useToast(); const toast = useToast();
const [topicResources, setTopicResources] = useState<TopicResource[]>([]);
const { secret } = getUrlParams() as { secret: string }; const { secret } = getUrlParams() as { secret: string };
const isGuest = useMemo(() => !isLoggedIn(), []); const isGuest = useMemo(() => !isLoggedIn(), []);
const topicRef = useRef<HTMLDivElement>(null); const topicRef = useRef<HTMLDivElement>(null);
@ -87,6 +101,20 @@ export function TopicDetail(props: TopicDetailProps) {
setIsActive(false); setIsActive(false);
}); });
const loadTopicResources = async (roadmapId: string, topicId: string) => {
const sanitizedTopicId = topicId.split('@')?.[1] || topicId;
const { response, error } = await httpGet<TopicResource[]>(
`${import.meta.env.PUBLIC_API_URL}/v1-list-topic-resources/${roadmapId}?t=${sanitizedTopicId}`,
);
if (error) {
toast.error(error?.message || 'Failed to load topic resources');
return;
}
setTopicResources(response || []);
};
// Toggle topic is available even if the component UI is not active // 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 // This is used on the best practice screen where we have the checkboxes
// to mark the topic as done/undone. // to mark the topic as done/undone.
@ -151,114 +179,125 @@ export function TopicDetail(props: TopicDetailProps) {
}`; }`;
} }
httpGet<string | RoadmapContentDocument>( Promise.all([
topicUrl, httpGet<string | RoadmapContentDocument>(
{}, topicUrl,
{ {},
...(!isCustomResource && { {
headers: { ...(!isCustomResource && {
Accept: 'text/html', headers: {
}, Accept: 'text/html',
}), },
}, }),
) },
.then(({ response }) => { )
if (!response) { .then(({ response }) => {
setError('Topic not found.'); if (!response) {
setIsLoading(false); setError('Topic not found.');
return; setIsLoading(false);
} return;
let topicHtml = ''; }
if (!isCustomResource) { let topicHtml = '';
const topicDom = new DOMParser().parseFromString( if (!isCustomResource) {
response as string, const topicDom = new DOMParser().parseFromString(
'text/html', response as string,
); 'text/html',
);
const links = topicDom.querySelectorAll('a');
const urlElem: HTMLElement = const links = topicDom.querySelectorAll('a');
topicDom.querySelector('[data-github-url]')!; const urlElem: HTMLElement =
const contributionUrl = urlElem?.dataset?.githubUrl || ''; topicDom.querySelector('[data-github-url]')!;
const contributionUrl = urlElem?.dataset?.githubUrl || '';
const titleElem: HTMLElement = topicDom.querySelector('h1')!;
const otherElems = topicDom.querySelectorAll('body > *:not(h1, div)'); const titleElem: HTMLElement = topicDom.querySelector('h1')!;
const otherElems = topicDom.querySelectorAll(
let ulWithLinks: HTMLUListElement = document.createElement('ul'); 'body > *:not(h1, div)',
);
// we need to remove the `ul` with just links (i.e. resource links)
// and show them separately. let ulWithLinks: HTMLUListElement = document.createElement('ul');
topicDom.querySelectorAll('ul').forEach((ul) => {
const lisWithJustLinks = Array.from( // we need to remove the `ul` with just links (i.e. resource links)
ul.querySelectorAll('li'), // and show them separately.
).filter((li) => { topicDom.querySelectorAll('ul').forEach((ul) => {
return ( const lisWithJustLinks = Array.from(
li.children.length === 1 && ul.querySelectorAll('li'),
li.children[0].tagName === 'A' && ).filter((li) => {
li.children[0].textContent === li.textContent return (
); li.children.length === 1 &&
li.children[0].tagName === 'A' &&
li.children[0].textContent === li.textContent
);
});
if (lisWithJustLinks.length > 0) {
ulWithLinks = ul;
}
}); });
if (lisWithJustLinks.length > 0) { const listLinks = Array.from(ulWithLinks.querySelectorAll('li > a'))
ulWithLinks = ul; .map((link, counter) => {
const typePattern = /@([a-z.]+)@/;
let linkText = link.textContent || '';
const linkHref = link.getAttribute('href') || '';
const linkType = linkText.match(typePattern)?.[1] || 'article';
linkText = linkText.replace(typePattern, '');
return {
id: `link-${linkHref}-${counter}`,
title: linkText,
url: linkHref,
type: linkType as AllowedLinkTypes,
};
})
.sort((a, b) => {
// official at the top
// opensource at second
// article at third
// videos at fourth
// rest at last
const order = [
'official',
'opensource',
'article',
'video',
'feed',
];
return order.indexOf(a.type) - order.indexOf(b.type);
});
if (ulWithLinks) {
ulWithLinks.remove();
} }
});
const listLinks = Array.from(ulWithLinks.querySelectorAll('li > a'))
.map((link, counter) => {
const typePattern = /@([a-z.]+)@/;
let linkText = link.textContent || '';
const linkHref = link.getAttribute('href') || '';
const linkType = linkText.match(typePattern)?.[1] || 'article';
linkText = linkText.replace(typePattern, '');
return {
id: `link-${linkHref}-${counter}`,
title: linkText,
url: linkHref,
type: linkType as AllowedLinkTypes,
};
})
.sort((a, b) => {
// official at the top
// opensource at second
// article at third
// videos at fourth
// rest at last
const order = ['official', 'opensource', 'article', 'video', 'feed'];
return order.indexOf(a.type) - order.indexOf(b.type);
});
if (ulWithLinks) { topicHtml = topicDom.body.innerHTML;
ulWithLinks.remove();
}
topicHtml = topicDom.body.innerHTML;
setLinks(listLinks); setLinks(listLinks);
setHasContent(otherElems.length > 0); setHasContent(otherElems.length > 0);
setContributionUrl(contributionUrl); setContributionUrl(contributionUrl);
setHasEnoughLinks(links.length >= 3); setHasEnoughLinks(links.length >= 3);
setTopicHtmlTitle(titleElem?.textContent || ''); setTopicHtmlTitle(titleElem?.textContent || '');
} else { } else {
setLinks((response as RoadmapContentDocument)?.links || []); setLinks((response as RoadmapContentDocument)?.links || []);
setTopicTitle((response as RoadmapContentDocument)?.title || ''); setTopicTitle((response as RoadmapContentDocument)?.title || '');
const sanitizedMarkdown = sanitizeMarkdown( const sanitizedMarkdown = sanitizeMarkdown(
(response as RoadmapContentDocument).description || '', (response as RoadmapContentDocument).description || '',
); );
setHasContent(sanitizedMarkdown?.length > 0); setHasContent(sanitizedMarkdown?.length > 0);
topicHtml = markdownToHtml(sanitizedMarkdown, false); topicHtml = markdownToHtml(sanitizedMarkdown, false);
} }
setIsLoading(false); setIsLoading(false);
setTopicHtml(topicHtml); setTopicHtml(topicHtml);
}) })
.catch((err) => { .catch((err) => {
setError('Something went wrong. Please try again later.'); setError('Something went wrong. Please try again later.');
setIsLoading(false); setIsLoading(false);
}); }),
loadTopicResources(resourceId, topicId),
]);
}); });
useEffect(() => { useEffect(() => {
@ -424,47 +463,52 @@ export function TopicDetail(props: TopicDetailProps) {
)} )}
{/* Contribution */} {/* Contribution */}
{canSubmitContribution && !hasEnoughLinks && contributionUrl && hasContent && ( {canSubmitContribution &&
<div className="mb-12 mt-3 border-t text-sm text-gray-400 sm:mt-12"> !hasEnoughLinks &&
<div className="mb-4 mt-3"> contributionUrl &&
<p className=""> hasContent && (
Find more resources using these pre-filled search queries: <div className="mb-12 mt-3 border-t text-sm text-gray-400 sm:mt-12">
</p> <div className="mb-4 mt-3">
<div className="mt-3 flex gap-2 text-gray-700"> <p className="">
<a Find more resources using these pre-filled search
href={googleSearchUrl} queries:
target="_blank" </p>
className="flex items-center gap-2 rounded-md border border-gray-300 px-3 py-1.5 pl-2 text-xs hover:border-gray-700 hover:bg-gray-100" <div className="mt-3 flex gap-2 text-gray-700">
> <a
<GoogleIcon className={'h-4 w-4'} /> href={googleSearchUrl}
Google target="_blank"
</a> className="flex items-center gap-2 rounded-md border border-gray-300 px-3 py-1.5 pl-2 text-xs hover:border-gray-700 hover:bg-gray-100"
<a >
href={youtubeSearchUrl} <GoogleIcon className={'h-4 w-4'} />
target="_blank" Google
className="flex items-center gap-2 rounded-md border border-gray-300 px-3 py-1.5 pl-2 text-xs hover:border-gray-700 hover:bg-gray-100" </a>
> <a
<YouTubeIcon className={'h-4 w-4 text-red-500'} /> href={youtubeSearchUrl}
YouTube target="_blank"
</a> className="flex items-center gap-2 rounded-md border border-gray-300 px-3 py-1.5 pl-2 text-xs hover:border-gray-700 hover:bg-gray-100"
>
<YouTubeIcon className={'h-4 w-4 text-red-500'} />
YouTube
</a>
</div>
</div> </div>
</div>
<p className="mb-2 mt-2 leading-relaxed"> <p className="mb-2 mt-2 leading-relaxed">
This popup should be a brief introductory paragraph for the topic and a few links This popup should be a brief introductory paragraph for
to good articles, videos, or any other self-vetted resources. Please consider the topic and a few links to good articles, videos, or any
submitting a PR to improve this content. other self-vetted resources. Please consider submitting a
</p> PR to improve this content.
<a </p>
href={contributionUrl} <a
target={'_blank'} href={contributionUrl}
className="flex w-full items-center justify-center rounded-md bg-gray-800 p-2 text-sm text-white transition-colors hover:bg-black hover:text-white disabled:bg-green-200 disabled:text-black" target={'_blank'}
> className="flex w-full items-center justify-center rounded-md bg-gray-800 p-2 text-sm text-white transition-colors hover:bg-black hover:text-white disabled:bg-green-200 disabled:text-black"
<GitHubIcon className="mr-2 inline-block h-4 w-4 text-white" /> >
Help us Improve this Content <GitHubIcon className="mr-2 inline-block h-4 w-4 text-white" />
</a> Help us Improve this Content
</div> </a>
)} </div>
)}
</div> </div>
{resourceId === 'devops' && ( {resourceId === 'devops' && (
<div className="mt-4"> <div className="mt-4">
@ -528,4 +572,4 @@ export function TopicDetail(props: TopicDetailProps) {
<div className="fixed inset-0 z-30 bg-gray-900 bg-opacity-50 dark:bg-opacity-80"></div> <div className="fixed inset-0 z-30 bg-gray-900 bg-opacity-50 dark:bg-opacity-80"></div>
</div> </div>
); );
} }

Loading…
Cancel
Save