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. 146
      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 />} {!isEmbed && <RoadmapHeader />}
<FlowRoadmapRenderer isEmbed={isEmbed} roadmap={roadmap!} /> <FlowRoadmapRenderer isEmbed={isEmbed} roadmap={roadmap!} />
<TopicDetail <TopicDetail
resourceId={roadmap!._id}
resourceTitle={roadmap!.title} resourceTitle={roadmap!.title}
resourceType="roadmap" resourceType="roadmap"
isEmbed={isEmbed} 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, RoadmapContentDocument,
} from '../CustomRoadmap/CustomRoadmap'; } from '../CustomRoadmap/CustomRoadmap';
import { markdownToHtml, sanitizeMarkdown } from '../../lib/markdown'; import { markdownToHtml, sanitizeMarkdown } from '../../lib/markdown';
import { cn } from '../../lib/classname'; import { Ban, FileText, HeartHandshake, Star, X } from 'lucide-react';
import { Ban, FileText, HeartHandshake, X } from 'lucide-react';
import { getUrlParams, parseUrl } from '../../lib/browser'; import { getUrlParams, parseUrl } from '../../lib/browser';
import { Spinner } from '../ReactIcons/Spinner'; import { Spinner } from '../ReactIcons/Spinner';
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx'; import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
@ -31,8 +30,11 @@ import { GoogleIcon } from '../ReactIcons/GoogleIcon.tsx';
import { YouTubeIcon } from '../ReactIcons/YouTubeIcon.tsx'; 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';
import { TopicDetailLink } from './TopicDetailLink.tsx';
import { ResourceListSeparator } from './ResourceListSeparator.tsx';
type TopicDetailProps = { type TopicDetailProps = {
resourceId?: string;
resourceTitle?: string; resourceTitle?: string;
resourceType?: ResourceType; resourceType?: ResourceType;
@ -40,21 +42,42 @@ type TopicDetailProps = {
canSubmitContribution: boolean; canSubmitContribution: boolean;
}; };
const linkTypes: Record<AllowedLinkTypes, string> = { type PaidResourceType = {
article: 'bg-yellow-300', _id?: string;
course: 'bg-green-400', title: string;
opensource: 'bg-black text-white', type: 'course' | 'book' | 'other';
'roadmap.sh': 'bg-black text-white', url: string;
roadmap: 'bg-black text-white', topicIds: string[];
podcast: 'bg-purple-300',
video: 'bg-purple-300',
website: 'bg-blue-300',
official: 'bg-blue-600 text-white',
feed: "bg-[#ce3df3] text-white"
}; };
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) { 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 [hasEnoughLinks, setHasEnoughLinks] = useState(false);
const [contributionUrl, setContributionUrl] = useState(''); const [contributionUrl, setContributionUrl] = useState('');
@ -77,6 +100,7 @@ export function TopicDetail(props: TopicDetailProps) {
const [topicId, setTopicId] = useState(''); const [topicId, setTopicId] = useState('');
const [resourceId, setResourceId] = useState(''); const [resourceId, setResourceId] = useState('');
const [resourceType, setResourceType] = useState<ResourceType>('roadmap'); const [resourceType, setResourceType] = useState<ResourceType>('roadmap');
const [paidResources, setPaidResources] = useState<PaidResourceType[]>([]);
// Close the topic detail when user clicks outside the topic detail // Close the topic detail when user clicks outside the topic detail
useOutsideClick(topicRef, () => { useOutsideClick(topicRef, () => {
@ -87,6 +111,16 @@ export function TopicDetail(props: TopicDetailProps) {
setIsActive(false); 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 // 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.
@ -225,7 +259,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);
}); });
@ -280,6 +320,12 @@ export function TopicDetail(props: TopicDetailProps) {
const tnsLink = const tnsLink =
'https://thenewstack.io/devops/?utm_source=roadmap.sh&utm_medium=Referral&utm_campaign=Topic'; '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 ( return (
<div className={'relative z-[90]'}> <div className={'relative z-[90]'}>
<div <div
@ -377,14 +423,20 @@ export function TopicDetail(props: TopicDetailProps) {
)} )}
{links.length > 0 && ( {links.length > 0 && (
<ul className="mt-6 space-y-1"> <>
<ResourceListSeparator
text="Free Resources"
className="text-green-600"
icon={HeartHandshake}
/>
<ul className="ml-3 mt-4 space-y-1">
{links.map((link) => { {links.map((link) => {
return ( return (
<li key={link.id}> <li key={link.id}>
<a <TopicDetailLink
href={link.url} url={link.url}
target="_blank" type={link.type}
className="group font-medium text-gray-800 underline underline-offset-1 hover:text-black" title={link.title}
onClick={() => { onClick={() => {
// if it is one of our roadmaps, we want to track the click // if it is one of our roadmaps, we want to track the click
if (canSubmitContribution) { if (canSubmitContribution) {
@ -397,38 +449,45 @@ export function TopicDetail(props: TopicDetailProps) {
}); });
} }
}} }}
> />
<span </li>
className={cn( );
'mr-2 inline-block rounded px-1.5 py-0.5 text-xs uppercase no-underline', })}
link.type in linkTypes </ul>
? linkTypes[link.type]
: 'bg-gray-200',
)}
>
{link.type === 'opensource' ? (
<>
{link.url.includes('github') && 'GitHub'}
{link.url.includes('gitlab') && 'GitLab'}
</> </>
) : (
link.type
)} )}
</span>
{link.title} {paidResourcesForTopic.length > 0 && (
</a> <>
<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> </li>
); );
})} })}
</ul> </ul>
</>
)} )}
{/* 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 +510,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}

@ -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 { FolderKanbanIcon } from 'lucide-react';
import { EmptyProjects } from '../../components/Projects/EmptyProjects'; import { EmptyProjects } from '../../components/Projects/EmptyProjects';
import ShareIcons from '../../components/ShareIcons/ShareIcons.astro'; import ShareIcons from '../../components/ShareIcons/ShareIcons.astro';
import { TopicDetail } from '../../components/TopicDetail/TopicDetail';
import { UserProgressModal } from '../../components/UserProgress/UserProgressModal'; import { UserProgressModal } from '../../components/UserProgress/UserProgressModal';
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import { getProjectsByRoadmapId } from '../../lib/project'; import { getProjectsByRoadmapId } from '../../lib/project';

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

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

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

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

Loading…
Cancel
Save