Add resource separation

pull/6850/head
Kamran Ahmed 3 months ago
parent 3ce92af265
commit 3d72c49c3f
  1. 1
      src/components/CustomRoadmap/CustomRoadmap.tsx
  2. 33
      src/components/TopicDetail/ResourceListSeparator.tsx
  3. 252
      src/components/TopicDetail/TopicDetail.tsx
  4. 57
      src/components/TopicDetail/TopicDetailLink.tsx
  5. 1
      src/pages/[roadmapId]/courses.astro
  6. 1
      src/pages/[roadmapId]/index.astro
  7. 1
      src/pages/[roadmapId]/projects.astro
  8. 1
      src/pages/best-practices/[bestPracticeId]/index.astro
  9. 1
      src/stores/roadmap.ts

@ -122,6 +122,7 @@ export function CustomRoadmap(props: CustomRoadmapProps) {
{!isEmbed && <RoadmapHeader />}
<FlowRoadmapRenderer isEmbed={isEmbed} roadmap={roadmap!} />
<TopicDetail
resourceId={roadmap!._id}
resourceTitle={roadmap!.title}
resourceType="roadmap"
isEmbed={isEmbed}

@ -0,0 +1,33 @@
import { type LucideIcon, Star } from 'lucide-react';
import { cn } from '../../lib/classname.ts';
type ResourceSeparatorProps = {
text: string;
className?: string;
labelClassName?: string;
icon?: LucideIcon;
};
export function ResourceListSeparator(props: ResourceSeparatorProps) {
const { text, icon: Icon, className = '', labelClassName = '' } = props;
return (
<p
className={cn(
'relative mt-6 flex items-center justify-start text-purple-600',
className,
)}
>
<span
className={cn(
'relative left-3 z-50 inline-flex items-center gap-1 rounded-md border border-current bg-white px-2 py-0.5 text-xs font-medium',
labelClassName,
)}
>
{Icon && <Icon className="inline-block h-3 w-3 fill-current" />}
{text}
</span>
<hr className="absolute inset-x-0 flex-grow border-current" />
</p>
);
}

@ -22,8 +22,7 @@ import type {
RoadmapContentDocument,
} from '../CustomRoadmap/CustomRoadmap';
import { markdownToHtml, sanitizeMarkdown } from '../../lib/markdown';
import { cn } from '../../lib/classname';
import { Ban, FileText, HeartHandshake, X } from 'lucide-react';
import { Ban, FileText, HeartHandshake, Star, X } from 'lucide-react';
import { getUrlParams, parseUrl } from '../../lib/browser';
import { Spinner } from '../ReactIcons/Spinner';
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
@ -31,8 +30,11 @@ import { GoogleIcon } from '../ReactIcons/GoogleIcon.tsx';
import { YouTubeIcon } from '../ReactIcons/YouTubeIcon.tsx';
import { resourceTitleFromId } from '../../lib/roadmap.ts';
import { lockBodyScroll } from '../../lib/dom.ts';
import { TopicDetailLink } from './TopicDetailLink.tsx';
import { ResourceListSeparator } from './ResourceListSeparator.tsx';
type TopicDetailProps = {
resourceId?: string;
resourceTitle?: string;
resourceType?: ResourceType;
@ -40,21 +42,42 @@ type TopicDetailProps = {
canSubmitContribution: boolean;
};
const linkTypes: Record<AllowedLinkTypes, string> = {
article: 'bg-yellow-300',
course: 'bg-green-400',
opensource: 'bg-black text-white',
'roadmap.sh': 'bg-black text-white',
roadmap: 'bg-black text-white',
podcast: 'bg-purple-300',
video: 'bg-purple-300',
website: 'bg-blue-300',
official: 'bg-blue-600 text-white',
feed: "bg-[#ce3df3] text-white"
type PaidResourceType = {
_id?: string;
title: string;
type: 'course' | 'book' | 'other';
url: string;
topicIds: string[];
};
const paidResourcesCache: Record<string, PaidResourceType[]> = {};
async function fetchRoadmapPaidResources(roadmapId: string) {
if (paidResourcesCache[roadmapId]) {
return paidResourcesCache[roadmapId];
}
const { response, error } = await httpGet<PaidResourceType[]>(
`${import.meta.env.PUBLIC_API_URL}/v1-list-roadmap-paid-resources/${roadmapId}`,
);
if (!response || error) {
console.error(error);
return [];
}
paidResourcesCache[roadmapId] = response;
return response;
}
export function TopicDetail(props: TopicDetailProps) {
const { canSubmitContribution, isEmbed = false, resourceTitle } = props;
const {
canSubmitContribution,
resourceId: defaultResourceId,
isEmbed = false,
resourceTitle,
} = props;
const [hasEnoughLinks, setHasEnoughLinks] = useState(false);
const [contributionUrl, setContributionUrl] = useState('');
@ -77,6 +100,7 @@ export function TopicDetail(props: TopicDetailProps) {
const [topicId, setTopicId] = useState('');
const [resourceId, setResourceId] = useState('');
const [resourceType, setResourceType] = useState<ResourceType>('roadmap');
const [paidResources, setPaidResources] = useState<PaidResourceType[]>([]);
// Close the topic detail when user clicks outside the topic detail
useOutsideClick(topicRef, () => {
@ -87,6 +111,16 @@ export function TopicDetail(props: TopicDetailProps) {
setIsActive(false);
});
useEffect(() => {
if (resourceType !== 'roadmap' || !defaultResourceId) {
return;
}
fetchRoadmapPaidResources(defaultResourceId).then((resources) => {
setPaidResources(resources);
});
}, [defaultResourceId]);
// 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.
@ -225,7 +259,13 @@ export function TopicDetail(props: TopicDetailProps) {
// article at third
// videos at fourth
// 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);
});
@ -280,6 +320,12 @@ export function TopicDetail(props: TopicDetailProps) {
const tnsLink =
'https://thenewstack.io/devops/?utm_source=roadmap.sh&utm_medium=Referral&utm_campaign=Topic';
const paidResourcesForTopic = paidResources.filter((resource) => {
const normalizedTopicId =
topicId.indexOf('@') !== -1 ? topicId.split('@')[1] : topicId;
return resource.topicIds.includes(normalizedTopicId);
});
return (
<div className={'relative z-[90]'}>
<div
@ -377,94 +423,108 @@ export function TopicDetail(props: TopicDetailProps) {
)}
{links.length > 0 && (
<ul className="mt-6 space-y-1">
{links.map((link) => {
return (
<li key={link.id}>
<>
<ResourceListSeparator
text="Free Resources"
className="text-green-600"
icon={HeartHandshake}
/>
<ul className="ml-3 mt-4 space-y-1">
{links.map((link) => {
return (
<li key={link.id}>
<TopicDetailLink
url={link.url}
type={link.type}
title={link.title}
onClick={() => {
// if it is one of our roadmaps, we want to track the click
if (canSubmitContribution) {
const parsedUrl = parseUrl(link.url);
window.fireEvent({
category: 'TopicResourceClick',
action: `Click: ${parsedUrl.hostname}`,
label: `${resourceType} / ${resourceId} / ${topicId} / ${link.url}`,
});
}
}}
/>
</li>
);
})}
</ul>
</>
)}
{paidResourcesForTopic.length > 0 && (
<>
<ResourceListSeparator text="Premium Resources" icon={Star} />
<ul className="ml-3 mt-3 space-y-1">
{paidResourcesForTopic.map((resource) => {
return (
<li key={resource._id}>
<TopicDetailLink
url={resource.url}
type={resource.type as any}
title={resource.title}
isPaid={true}
/>
</li>
);
})}
</ul>
</>
)}
{/* Contribution */}
{canSubmitContribution &&
!hasEnoughLinks &&
contributionUrl &&
hasContent && (
<div className="mb-12 mt-3 border-t text-sm text-gray-400 sm:mt-12">
<div className="mb-4 mt-3">
<p className="">
Find more resources using these pre-filled search
queries:
</p>
<div className="mt-3 flex gap-2 text-gray-700">
<a
href={link.url}
href={googleSearchUrl}
target="_blank"
className="group font-medium text-gray-800 underline underline-offset-1 hover:text-black"
onClick={() => {
// if it is one of our roadmaps, we want to track the click
if (canSubmitContribution) {
const parsedUrl = parseUrl(link.url);
window.fireEvent({
category: 'TopicResourceClick',
action: `Click: ${parsedUrl.hostname}`,
label: `${resourceType} / ${resourceId} / ${topicId} / ${link.url}`,
});
}
}}
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"
>
<span
className={cn(
'mr-2 inline-block rounded px-1.5 py-0.5 text-xs uppercase no-underline',
link.type in linkTypes
? linkTypes[link.type]
: 'bg-gray-200',
)}
>
{link.type === 'opensource' ? (
<>
{link.url.includes('github') && 'GitHub'}
{link.url.includes('gitlab') && 'GitLab'}
</>
) : (
link.type
)}
</span>
{link.title}
<GoogleIcon className={'h-4 w-4'} />
Google
</a>
</li>
);
})}
</ul>
)}
<a
href={youtubeSearchUrl}
target="_blank"
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>
{/* Contribution */}
{canSubmitContribution && !hasEnoughLinks && contributionUrl && hasContent && (
<div className="mb-12 mt-3 border-t text-sm text-gray-400 sm:mt-12">
<div className="mb-4 mt-3">
<p className="">
Find more resources using these pre-filled search queries:
<p className="mb-2 mt-2 leading-relaxed">
This popup should be a brief introductory paragraph for
the topic and a few links to good articles, videos, or any
other self-vetted resources. Please consider submitting a
PR to improve this content.
</p>
<div className="mt-3 flex gap-2 text-gray-700">
<a
href={googleSearchUrl}
target="_blank"
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"
>
<GoogleIcon className={'h-4 w-4'} />
Google
</a>
<a
href={youtubeSearchUrl}
target="_blank"
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>
<a
href={contributionUrl}
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
</a>
</div>
<p className="mb-2 mt-2 leading-relaxed">
This popup should be a brief introductory paragraph for the topic and a few links
to good articles, videos, or any other self-vetted resources. Please consider
submitting a PR to improve this content.
</p>
<a
href={contributionUrl}
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
</a>
</div>
)}
)}
</div>
{resourceId === 'devops' && (
<div className="mt-4">

@ -0,0 +1,57 @@
import { cn } from '../../lib/classname.ts';
import type { AllowedLinkTypes } from '../CustomRoadmap/CustomRoadmap.tsx';
const linkTypes: Record<AllowedLinkTypes, string> = {
article: 'bg-yellow-300',
course: 'bg-green-400',
opensource: 'bg-black text-white',
'roadmap.sh': 'bg-black text-white',
roadmap: 'bg-black text-white',
podcast: 'bg-purple-300',
video: 'bg-purple-300',
website: 'bg-blue-300',
official: 'bg-blue-600 text-white',
feed: 'bg-[#ce3df3] text-white',
};
const paidLinkTypes: Record<string, string> = {
course: 'bg-yellow-300',
};
type TopicDetailLinkProps = {
url: string;
onClick?: () => void;
type: AllowedLinkTypes;
title: string;
isPaid?: boolean;
};
export function TopicDetailLink(props: TopicDetailLinkProps) {
const { url, onClick, type, title, isPaid = false } = props;
return (
<a
href={url}
target="_blank"
className="group font-medium text-gray-800 underline underline-offset-1 hover:text-black"
onClick={onClick}
>
<span
className={cn(
'mr-2 inline-block rounded px-1.5 py-0.5 text-xs uppercase no-underline',
(isPaid ? paidLinkTypes[type] : linkTypes[type]) || 'bg-gray-200',
)}
>
{type === 'opensource' ? (
<>
{url.includes('github') && 'GitHub'}
{url.includes('gitlab') && 'GitLab'}
</>
) : (
type
)}
</span>
{title}
</a>
);
}

@ -7,7 +7,6 @@ import RoadmapHeader from '../../components/RoadmapHeader.astro';
import { FolderKanbanIcon } from 'lucide-react';
import { EmptyProjects } from '../../components/Projects/EmptyProjects';
import ShareIcons from '../../components/ShareIcons/ShareIcons.astro';
import { TopicDetail } from '../../components/TopicDetail/TopicDetail';
import { UserProgressModal } from '../../components/UserProgress/UserProgressModal';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getProjectsByRoadmapId } from '../../lib/project';

@ -96,6 +96,7 @@ const projects = await getProjectsByRoadmapId(roadmapId);
<TopicDetail
resourceTitle={roadmapData.title}
resourceId={roadmapId}
resourceType='roadmap'
client:idle
canSubmitContribution={true}

@ -8,7 +8,6 @@ import { FolderKanbanIcon } from 'lucide-react';
import { EmptyProjects } from '../../components/Projects/EmptyProjects';
import { ProjectsList } from '../../components/Projects/ProjectsList';
import ShareIcons from '../../components/ShareIcons/ShareIcons.astro';
import { TopicDetail } from '../../components/TopicDetail/TopicDetail';
import { UserProgressModal } from '../../components/UserProgress/UserProgressModal';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getProjectsByRoadmapId } from '../../lib/project';

@ -99,6 +99,7 @@ const ogImageUrl = getOpenGraphImageUrl({
/>
<TopicDetail
resourceId={bestPracticeId}
resourceTitle={bestPracticeData.title}
resourceType='best-practice'
client:idle

@ -15,3 +15,4 @@ export const roadmapProgress = atom<
{ done: string[]; learning: string[]; skipped: string[] } | undefined
>();
export const totalRoadmapNodes = atom<number | undefined>();

Loading…
Cancel
Save