|
|
@ -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,6 +179,7 @@ export function TopicDetail(props: TopicDetailProps) { |
|
|
|
}`;
|
|
|
|
}`;
|
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Promise.all([ |
|
|
|
httpGet<string | RoadmapContentDocument>( |
|
|
|
httpGet<string | RoadmapContentDocument>( |
|
|
|
topicUrl, |
|
|
|
topicUrl, |
|
|
|
{}, |
|
|
|
{}, |
|
|
@ -181,7 +210,9 @@ export function TopicDetail(props: TopicDetailProps) { |
|
|
|
const contributionUrl = urlElem?.dataset?.githubUrl || ''; |
|
|
|
const contributionUrl = urlElem?.dataset?.githubUrl || ''; |
|
|
|
|
|
|
|
|
|
|
|
const titleElem: HTMLElement = topicDom.querySelector('h1')!; |
|
|
|
const titleElem: HTMLElement = topicDom.querySelector('h1')!; |
|
|
|
const otherElems = topicDom.querySelectorAll('body > *:not(h1, div)'); |
|
|
|
const otherElems = topicDom.querySelectorAll( |
|
|
|
|
|
|
|
'body > *:not(h1, div)', |
|
|
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
let ulWithLinks: HTMLUListElement = document.createElement('ul'); |
|
|
|
let ulWithLinks: HTMLUListElement = document.createElement('ul'); |
|
|
|
|
|
|
|
|
|
|
@ -225,7 +256,13 @@ export function TopicDetail(props: TopicDetailProps) { |
|
|
|
// article at third
|
|
|
|
// article at third
|
|
|
|
// videos at fourth
|
|
|
|
// videos at fourth
|
|
|
|
// rest at last
|
|
|
|
// rest at last
|
|
|
|
const order = ['official', 'opensource', 'article', 'video', 'feed']; |
|
|
|
const order = [ |
|
|
|
|
|
|
|
'official', |
|
|
|
|
|
|
|
'opensource', |
|
|
|
|
|
|
|
'article', |
|
|
|
|
|
|
|
'video', |
|
|
|
|
|
|
|
'feed', |
|
|
|
|
|
|
|
]; |
|
|
|
return order.indexOf(a.type) - order.indexOf(b.type); |
|
|
|
return order.indexOf(a.type) - order.indexOf(b.type); |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
@ -258,7 +295,9 @@ export function TopicDetail(props: TopicDetailProps) { |
|
|
|
.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,11 +463,15 @@ export function TopicDetail(props: TopicDetailProps) { |
|
|
|
)} |
|
|
|
)} |
|
|
|
|
|
|
|
|
|
|
|
{/* Contribution */} |
|
|
|
{/* Contribution */} |
|
|
|
{canSubmitContribution && !hasEnoughLinks && contributionUrl && hasContent && ( |
|
|
|
{canSubmitContribution && |
|
|
|
|
|
|
|
!hasEnoughLinks && |
|
|
|
|
|
|
|
contributionUrl && |
|
|
|
|
|
|
|
hasContent && ( |
|
|
|
<div className="mb-12 mt-3 border-t text-sm text-gray-400 sm:mt-12"> |
|
|
|
<div className="mb-12 mt-3 border-t text-sm text-gray-400 sm:mt-12"> |
|
|
|
<div className="mb-4 mt-3"> |
|
|
|
<div className="mb-4 mt-3"> |
|
|
|
<p className=""> |
|
|
|
<p className=""> |
|
|
|
Find more resources using these pre-filled search queries: |
|
|
|
Find more resources using these pre-filled search |
|
|
|
|
|
|
|
queries: |
|
|
|
</p> |
|
|
|
</p> |
|
|
|
<div className="mt-3 flex gap-2 text-gray-700"> |
|
|
|
<div className="mt-3 flex gap-2 text-gray-700"> |
|
|
|
<a |
|
|
|
<a |
|
|
@ -451,9 +494,10 @@ export function TopicDetail(props: TopicDetailProps) { |
|
|
|
</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 |
|
|
|
|
|
|
|
PR to improve this content. |
|
|
|
</p> |
|
|
|
</p> |
|
|
|
<a |
|
|
|
<a |
|
|
|
href={contributionUrl} |
|
|
|
href={contributionUrl} |
|
|
|