mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-22 00:43:01 +02:00
Add resource separation
This commit is contained in:
@@ -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}
|
||||||
|
33
src/components/TopicDetail/ResourceListSeparator.tsx
Normal file
33
src/components/TopicDetail/ResourceListSeparator.tsx
Normal file
@@ -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,94 +423,108 @@ export function TopicDetail(props: TopicDetailProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{links.length > 0 && (
|
{links.length > 0 && (
|
||||||
<ul className="mt-6 space-y-1">
|
<>
|
||||||
{links.map((link) => {
|
<ResourceListSeparator
|
||||||
return (
|
text="Free Resources"
|
||||||
<li key={link.id}>
|
className="text-green-600"
|
||||||
<a
|
icon={HeartHandshake}
|
||||||
href={link.url}
|
/>
|
||||||
target="_blank"
|
<ul className="ml-3 mt-4 space-y-1">
|
||||||
className="group font-medium text-gray-800 underline underline-offset-1 hover:text-black"
|
{links.map((link) => {
|
||||||
onClick={() => {
|
return (
|
||||||
// if it is one of our roadmaps, we want to track the click
|
<li key={link.id}>
|
||||||
if (canSubmitContribution) {
|
<TopicDetailLink
|
||||||
const parsedUrl = parseUrl(link.url);
|
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({
|
window.fireEvent({
|
||||||
category: 'TopicResourceClick',
|
category: 'TopicResourceClick',
|
||||||
action: `Click: ${parsedUrl.hostname}`,
|
action: `Click: ${parsedUrl.hostname}`,
|
||||||
label: `${resourceType} / ${resourceId} / ${topicId} / ${link.url}`,
|
label: `${resourceType} / ${resourceId} / ${topicId} / ${link.url}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<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',
|
)}
|
||||||
)}
|
|
||||||
>
|
{paidResourcesForTopic.length > 0 && (
|
||||||
{link.type === 'opensource' ? (
|
<>
|
||||||
<>
|
<ResourceListSeparator text="Premium Resources" icon={Star} />
|
||||||
{link.url.includes('github') && 'GitHub'}
|
|
||||||
{link.url.includes('gitlab') && 'GitLab'}
|
<ul className="ml-3 mt-3 space-y-1">
|
||||||
</>
|
{paidResourcesForTopic.map((resource) => {
|
||||||
) : (
|
return (
|
||||||
link.type
|
<li key={resource._id}>
|
||||||
)}
|
<TopicDetailLink
|
||||||
</span>
|
url={resource.url}
|
||||||
{link.title}
|
type={resource.type as any}
|
||||||
</a>
|
title={resource.title}
|
||||||
</li>
|
isPaid={true}
|
||||||
);
|
/>
|
||||||
})}
|
</li>
|
||||||
</ul>
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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">
|
||||||
|
57
src/components/TopicDetail/TopicDetailLink.tsx
Normal file
57
src/components/TopicDetail/TopicDetailLink.tsx
Normal file
@@ -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>();
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user