Compare commits

...

3 Commits

Author SHA1 Message Date
Arik Chakma
71dd89b062 fix: beginner roadmaps 2025-09-02 22:38:12 +06:00
Arik Chakma
1c29af4826 fix: project card 2025-09-02 18:32:38 +06:00
Arik Chakma
1e6d546712 feat: official project 2025-09-02 18:31:14 +06:00
15 changed files with 267 additions and 301 deletions

View File

@@ -3,21 +3,16 @@ import { useToast } from '../../hooks/use-toast';
import { httpGet, httpPost } from '../../lib/http';
import { LoadingSolutions } from './LoadingSolutions';
import { EmptySolutions } from './EmptySolutions';
import { ThumbsDown, ThumbsUp } from 'lucide-react';
import { getRelativeTimeString } from '../../lib/date';
import { Pagination } from '../Pagination/Pagination';
import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser';
import { pageProgressMessage } from '../../stores/page';
import { LeavingRoadmapWarningModal } from './LeavingRoadmapWarningModal';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { VoteButton } from './VoteButton.tsx';
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
import { SelectLanguages } from './SelectLanguages.tsx';
import type { ProjectFrontmatter } from '../../lib/project.ts';
import { ProjectSolutionModal } from './ProjectSolutionModal.tsx';
import { SortProjects } from './SortProjects.tsx';
import { ProjectSolutionRow } from './ProjectSolutionRow';
import type { OfficialProjectDocument } from '../../queries/official-project.ts';
export interface ProjectStatusDocument {
_id?: string;
@@ -69,12 +64,12 @@ type PageState = {
};
type ListProjectSolutionsProps = {
project: ProjectFrontmatter;
project: OfficialProjectDocument;
projectId: string;
};
export function ListProjectSolutions(props: ListProjectSolutionsProps) {
const { projectId, project: projectData } = props;
const { projectId, project } = props;
const toast = useToast();
const [pageState, setPageState] = useState<PageState>({
@@ -226,7 +221,7 @@ export function ListProjectSolutions(props: ListProjectSolutionsProps) {
<div className="relative mb-5 hidden items-center justify-between sm:flex">
<div>
<h1 className="mb-1 text-xl font-semibold">
{projectData.title} Solutions
{project.title} Solutions
</h1>
<p className="text-sm text-gray-500">
Solutions submitted by the community

View File

@@ -1,47 +1,43 @@
import { Badge } from '../Badge.tsx';
import type {
ProjectDifficultyType,
ProjectFileType,
} from '../../lib/project.ts';
import { Users } from 'lucide-react';
import { formatCommaNumber } from '../../lib/number.ts';
import { cn } from '../../lib/classname.ts';
import { isLoggedIn } from '../../lib/jwt.ts';
import type { OfficialProjectDocument } from '../../queries/official-project.ts';
type ProjectCardProps = {
project: ProjectFileType;
project: OfficialProjectDocument;
userCount?: number;
status?: 'completed' | 'started' | 'none';
};
const badgeVariants: Record<ProjectDifficultyType, string> = {
const badgeVariants = {
beginner: 'yellow',
intermediate: 'green',
advanced: 'blue',
};
} as const;
export function ProjectCard(props: ProjectCardProps) {
const { project, userCount = 0, status } = props;
const { frontmatter, id } = project;
const { difficulty, title, description, slug, topics = [] } = project;
const isLoadingStatus = status === undefined;
const userStartedCount = status !== 'none' && userCount === 0 ? userCount + 1 : userCount;
const userStartedCount =
status !== 'none' && userCount === 0 ? userCount + 1 : userCount;
return (
<a
href={`/projects/${id}`}
href={`/projects/${slug}`}
className="flex flex-col rounded-md border bg-white p-3 transition-colors hover:border-gray-300 hover:bg-gray-50"
>
<span className="flex justify-between gap-1.5">
<Badge
variant={badgeVariants[frontmatter.difficulty] as any}
text={frontmatter.difficulty}
/>
<Badge variant={'grey'} text={frontmatter.nature} />
<Badge variant={badgeVariants[difficulty]} text={difficulty} />
{topics?.map((topic, index) => (
<Badge key={`${topic}-${index}`} variant={'grey'} text={topic} />
))}
</span>
<span className="my-3 flex min-h-[100px] flex-col">
<span className="mb-1 font-medium">{frontmatter.title}</span>
<span className="text-sm text-gray-500">{frontmatter.description}</span>
<span className="mb-1 font-medium">{title}</span>
<span className="text-sm text-gray-500">{description}</span>
</span>
<span className="flex min-h-[22px] items-center justify-between gap-2 text-xs text-gray-400">
{isLoadingStatus ? (

View File

@@ -0,0 +1,25 @@
import { guideRenderer } from '../../lib/guide-renderer';
import type { OfficialProjectDocument } from '../../queries/official-project';
type ProjectContentProps = {
project: OfficialProjectDocument;
};
export function ProjectContent(props: ProjectContentProps) {
const { project } = props;
const isContentString = typeof project?.content === 'string';
return (
<div
className="prose prose-h2:mb-3 prose-h2:mt-5 prose-h3:mb-1 prose-h3:mt-5 prose-p:mb-2 prose-blockquote:font-normal prose-blockquote:text-gray-500 prose-pre:my-3 prose-ul:my-3.5 prose-hr:my-5 prose-li:[&>p]:m-0 max-w-full [&>ul>li]:my-1"
{...(isContentString
? {
dangerouslySetInnerHTML: { __html: project?.content },
}
: {
children: guideRenderer.render(project?.content),
})}
/>
);
}

View File

@@ -2,11 +2,7 @@ import { ProjectCard } from './ProjectCard.tsx';
import { HeartHandshake, Trash2 } from 'lucide-react';
import { cn } from '../../lib/classname.ts';
import { useEffect, useMemo, useState } from 'react';
import {
projectDifficulties,
type ProjectDifficultyType,
type ProjectFileType,
} from '../../lib/project.ts';
import {
deleteUrlParam,
getUrlParams,
@@ -14,9 +10,14 @@ import {
} from '../../lib/browser.ts';
import { httpPost } from '../../lib/http.ts';
import { isLoggedIn } from '../../lib/jwt.ts';
import {
allowedOfficialProjectDifficulty,
type AllowedOfficialProjectDifficulty,
type OfficialProjectDocument,
} from '../../queries/official-project.ts';
type DifficultyButtonProps = {
difficulty: ProjectDifficultyType;
difficulty: AllowedOfficialProjectDifficulty;
isActive?: boolean;
onClick?: () => void;
};
@@ -46,7 +47,7 @@ export type ListProjectStatusesResponse = Record<
>;
type ProjectsListProps = {
projects: ProjectFileType[];
projects: OfficialProjectDocument[];
userCounts: Record<string, number>;
};
@@ -55,7 +56,7 @@ export function ProjectsList(props: ProjectsListProps) {
const { difficulty: urlDifficulty } = getUrlParams();
const [difficulty, setDifficulty] = useState<
ProjectDifficultyType | undefined
AllowedOfficialProjectDifficulty | undefined
>(urlDifficulty);
const [projectStatuses, setProjectStatuses] =
useState<ListProjectStatusesResponse>();
@@ -66,7 +67,7 @@ export function ProjectsList(props: ProjectsListProps) {
return;
}
const projectIds = projects.map((project) => project.id);
const projectIds = projects.map((project) => project.slug);
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-list-project-statuses`,
{
@@ -82,22 +83,27 @@ export function ProjectsList(props: ProjectsListProps) {
setProjectStatuses(response);
};
const projectsByDifficulty: Map<ProjectDifficultyType, ProjectFileType[]> =
useMemo(() => {
const result = new Map<ProjectDifficultyType, ProjectFileType[]>();
const projectsByDifficulty: Map<
AllowedOfficialProjectDifficulty,
OfficialProjectDocument[]
> = useMemo(() => {
const result = new Map<
AllowedOfficialProjectDifficulty,
OfficialProjectDocument[]
>();
for (const project of projects) {
const difficulty = project.frontmatter.difficulty;
for (const project of projects) {
const difficulty = project.difficulty;
if (!result.has(difficulty)) {
result.set(difficulty, []);
}
result.get(difficulty)?.push(project);
if (!result.has(difficulty)) {
result.set(difficulty, []);
}
return result;
}, [projects]);
result.get(difficulty)?.push(project);
}
return result;
}, [projects]);
const matchingProjects = difficulty
? projectsByDifficulty.get(difficulty) || []
@@ -111,7 +117,7 @@ export function ProjectsList(props: ProjectsListProps) {
<div className="flex flex-col">
<div className="my-2.5 flex items-center justify-between">
<div className="flex flex-wrap gap-1">
{projectDifficulties.map((projectDifficulty) => (
{allowedOfficialProjectDifficulty.map((projectDifficulty) => (
<DifficultyButton
key={projectDifficulty}
onClick={() => {
@@ -122,6 +128,7 @@ export function ProjectsList(props: ProjectsListProps) {
isActive={projectDifficulty === difficulty}
/>
))}
{difficulty && (
<button
onClick={() => {
@@ -155,25 +162,25 @@ export function ProjectsList(props: ProjectsListProps) {
{matchingProjects
.sort((project) => {
return project.frontmatter.difficulty === 'beginner'
return project.difficulty === 'beginner'
? -1
: project.frontmatter.difficulty === 'intermediate'
: project.difficulty === 'intermediate'
? 0
: 1;
})
.sort((a, b) => {
return a.frontmatter.sort - b.frontmatter.sort;
return a.order - b.order;
})
.map((matchingProject) => {
const count = userCounts[matchingProject?.id] || 0;
const count = userCounts[matchingProject?.slug] || 0;
return (
<ProjectCard
key={matchingProject.id}
key={matchingProject.slug}
project={matchingProject}
userCount={count}
status={
projectStatuses
? (projectStatuses?.[matchingProject.id] || 'none')
? projectStatuses?.[matchingProject.slug] || 'none'
: undefined
}
/>

View File

@@ -7,16 +7,16 @@ import {
setUrlParams,
} from '../../lib/browser.ts';
import { CategoryFilterButton } from '../Roadmaps/CategoryFilterButton.tsx';
import {
projectDifficulties,
type ProjectFileType,
} from '../../lib/project.ts';
import { ProjectCard } from './ProjectCard.tsx';
import {
allowedOfficialProjectDifficulty,
type OfficialProjectDocument,
} from '../../queries/official-project.ts';
type ProjectGroup = {
id: string;
title: string;
projects: ProjectFileType[];
projects: OfficialProjectDocument[];
};
type ProjectsPageProps = {
@@ -28,7 +28,7 @@ export function ProjectsPage(props: ProjectsPageProps) {
const { roadmapsProjects, userCounts } = props;
const allUniqueProjectIds = new Set<string>(
roadmapsProjects.flatMap((group) =>
group.projects.map((project) => project.id),
group.projects.map((project) => project.slug),
),
);
const allUniqueProjects = useMemo(
@@ -37,15 +37,15 @@ export function ProjectsPage(props: ProjectsPageProps) {
.map((id) =>
roadmapsProjects
.flatMap((group) => group.projects)
.find((project) => project.id === id),
.find((project) => project.slug === id),
)
.filter(Boolean) as ProjectFileType[],
.filter(Boolean) as OfficialProjectDocument[],
[allUniqueProjectIds],
);
const [activeGroup, setActiveGroup] = useState<string>('');
const [visibleProjects, setVisibleProjects] =
useState<ProjectFileType[]>(allUniqueProjects);
useState<OfficialProjectDocument[]>(allUniqueProjects);
const [isFilterOpen, setIsFilterOpen] = useState(false);
@@ -67,11 +67,11 @@ export function ProjectsPage(props: ProjectsPageProps) {
const sortedVisibleProjects = useMemo(
() =>
visibleProjects.sort((a, b) => {
const projectADifficulty = a?.frontmatter.difficulty || 'beginner';
const projectBDifficulty = b?.frontmatter.difficulty || 'beginner';
const projectADifficulty = a?.difficulty || 'beginner';
const projectBDifficulty = b?.difficulty || 'beginner';
return (
projectDifficulties.indexOf(projectADifficulty) -
projectDifficulties.indexOf(projectBDifficulty)
allowedOfficialProjectDifficulty.indexOf(projectADifficulty) -
allowedOfficialProjectDifficulty.indexOf(projectBDifficulty)
);
}),
[visibleProjects],
@@ -111,7 +111,7 @@ export function ProjectsPage(props: ProjectsPageProps) {
{isFilterOpen && <X size={13} className="mr-1" />}
Categories
</button>
<div className="container relative flex flex-col gap-4 sm:flex-row">
<div className="relative container flex flex-col gap-4 sm:flex-row">
<div
className={cn(
'hidden w-full flex-col from-gray-100 sm:w-[160px] sm:shrink-0 sm:border-r sm:bg-linear-to-l sm:pt-6',
@@ -171,7 +171,7 @@ export function ProjectsPage(props: ProjectsPageProps) {
</div>
</div>
</div>
<div className="flex grow flex-col pb-20 pt-2 sm:pt-6">
<div className="flex grow flex-col pt-2 pb-20 sm:pt-6">
<div className="mb-4 flex items-center justify-between text-sm text-gray-500">
<h3 className={'flex items-center'}>
<Box size={15} className="mr-1" strokeWidth={2} />
@@ -187,9 +187,9 @@ export function ProjectsPage(props: ProjectsPageProps) {
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{sortedVisibleProjects.map((project) => (
<ProjectCard
key={project.id}
key={project.slug}
project={project}
userCount={userCounts[project.id] || 0}
userCount={userCounts[project.slug] || 0}
status={'none'}
/>
))}

View File

@@ -1,118 +0,0 @@
import {
officialRoadmapDetails,
type OfficialRoadmapDocument,
} from '../queries/official-roadmap';
import type { MarkdownFileType } from './file';
export const projectDifficulties = [
'beginner',
'intermediate',
'advanced',
] as const;
export type ProjectDifficultyType = (typeof projectDifficulties)[number];
export interface ProjectFrontmatter {
title: string;
description: string;
isNew: boolean;
sort: number;
difficulty: ProjectDifficultyType;
nature: string;
skills: string[];
seo: {
title: string;
description: string;
keywords: string[];
ogImageUrl: string;
};
hasNoSubmission: boolean;
roadmapIds: string[];
}
export type ProjectFileType = MarkdownFileType<ProjectFrontmatter> & {
id: string;
roadmaps: OfficialRoadmapDocument[];
};
/**
* Generates id from the given project file
* @param filePath Markdown file path
*
* @returns unique project identifier
*/
function projectPathToId(filePath: string): string {
const fileName = filePath.split('/').pop() || '';
return fileName.replace('.md', '');
}
export async function getProjectsByRoadmapId(
roadmapId: string,
): Promise<ProjectFileType[]> {
const projects = await getAllProjects();
return projects.filter((project) =>
project.frontmatter?.roadmapIds?.includes(roadmapId),
);
}
let tempProjects: ProjectFileType[] = [];
/**
* Gets all the projects sorted by the publishing date
* @returns Promisifed project files
*/
export async function getAllProjects(): Promise<ProjectFileType[]> {
if (tempProjects.length) {
return tempProjects;
}
const projects = import.meta.glob<ProjectFileType>(
'/src/data/projects/*.md',
{
eager: true,
},
);
tempProjects = Object.values(projects).map((projectFile) => ({
...projectFile,
id: projectPathToId(projectFile.file),
}));
return tempProjects;
}
export async function getProjectById(
groupId: string,
): Promise<ProjectFileType> {
const project = await import(`../data/projects/${groupId}.md`);
const roadmapIds = project.frontmatter.roadmapIds || [];
const roadmaps = await Promise.all(
roadmapIds.map((roadmapId: string) => officialRoadmapDetails(roadmapId)),
);
return {
...project,
roadmaps: roadmaps,
id: projectPathToId(project.file),
};
}
export async function getRoadmapsProjects(): Promise<
Record<string, ProjectFileType[]>
> {
const projects = await getAllProjects();
const roadmapsProjects: Record<string, ProjectFileType[]> = {};
projects.forEach((project) => {
project.frontmatter.roadmapIds.forEach((roadmapId) => {
if (!roadmapsProjects[roadmapId]) {
roadmapsProjects[roadmapId] = [];
}
roadmapsProjects[roadmapId].push(project);
});
});
return roadmapsProjects;
}

View File

@@ -2,7 +2,7 @@
import RoadmapHeader from '../../components/RoadmapHeader.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getOpenGraphImageUrl } from '../../lib/open-graph';
import { getProjectsByRoadmapId } from '../../lib/project';
import { listOfficialProjects } from '../../queries/official-project';
import { officialRoadmapDetails } from '../../queries/official-roadmap';
export const prerender = false;
@@ -42,7 +42,7 @@ const nounTitle =
descriptionNoun[roadmapData.title.card] || roadmapData.title.card;
const seoDescription = `Seeking ${nounTitle.toLowerCase()} courses to enhance your skills? Explore our top free and paid courses to help you become a ${nounTitle} expert!`;
const projects = await getProjectsByRoadmapId(roadmapId);
const projects = await listOfficialProjects({ roadmapId });
const courses = roadmapData?.courses || [];
---

View File

@@ -14,10 +14,10 @@ import {
import { getOpenGraphImageUrl } from '../../lib/open-graph';
import { RoadmapTitleQuestion } from '../../components/RoadmapTitleQuestion';
import ResourceProgressStats from '../../components/ResourceProgressStats.astro';
import { getProjectsByRoadmapId } from '../../lib/project';
import { CheckSubscriptionVerification } from '../../components/Billing/CheckSubscriptionVerification';
import { officialRoadmapDetails } from '../../queries/official-roadmap';
import { DateTime } from 'luxon';
import { listOfficialProjects } from '../../queries/official-project';
export const prerender = false;
@@ -73,7 +73,7 @@ if (faqs.length) {
jsonLdSchema.push(generateFAQSchema(faqs));
}
const projects = await getProjectsByRoadmapId(roadmapId);
const projects = await listOfficialProjects({ roadmapId });
const courses = roadmapData.courses || [];
---

View File

@@ -3,10 +3,10 @@ import RoadmapHeader from '../../components/RoadmapHeader.astro';
import { EmptyProjects } from '../../components/Projects/EmptyProjects';
import { ProjectsList } from '../../components/Projects/ProjectsList';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getProjectsByRoadmapId } from '../../lib/project';
import { getOpenGraphImageUrl } from '../../lib/open-graph';
import { projectApi } from '../../api/project';
import { officialRoadmapDetails } from '../../queries/official-roadmap';
import { listOfficialProjects } from '../../queries/official-project';
export const prerender = false;
@@ -46,8 +46,8 @@ const nounTitle =
descriptionNoun[roadmapData?.title.card] || roadmapData.title.card;
const seoDescription = `Seeking ${nounTitle.toLowerCase()} projects to enhance your skills? Explore our top 20 project ideas, from simple apps to complex systems. Start building today!`;
const projects = await getProjectsByRoadmapId(roadmapId);
const projectIds = projects.map((project) => project.id);
const projects = await listOfficialProjects({ roadmapId });
const projectIds = projects.map((project) => project.slug);
const projectApiClient = projectApi(Astro);
const { response: userCounts } =

View File

@@ -1,35 +1,28 @@
import { getAllBestPractices } from '../lib/best-practice';
import { getAllVideos } from '../lib/video';
import { getAllQuestionGroups } from '../lib/question-group';
import { getAllProjects } from '../lib/project';
import {
listOfficialAuthors,
listOfficialGuides,
} from '../queries/official-guide';
import { listOfficialRoadmaps } from '../queries/official-roadmap';
// Add utility to fetch beginner roadmap file IDs
function getBeginnerRoadmapIds() {
const files = import.meta.glob('/src/data/roadmaps/*/*-beginner.json', {
eager: true,
});
return Object.keys(files).map((filePath) => {
const fileName = filePath.split('/').pop() || '';
return fileName.replace('.json', '');
});
}
import {
listOfficialBeginnerRoadmaps,
listOfficialRoadmaps,
} from '../queries/official-roadmap';
import { listOfficialProjects } from '../queries/official-project';
export async function GET() {
const guides = await listOfficialGuides();
const authors = await listOfficialAuthors();
const videos = await getAllVideos();
const questionGroups = await getAllQuestionGroups();
const roadmaps = await listOfficialRoadmaps();
const mainRoadmaps = await listOfficialRoadmaps();
const beginnerRoadmaps = await listOfficialBeginnerRoadmaps();
const bestPractices = await getAllBestPractices();
const projects = await getAllProjects();
const projects = await listOfficialProjects();
const roadmaps = [...mainRoadmaps, ...beginnerRoadmaps];
// Transform main roadmaps into page objects first so that we can reuse their meta for beginner variants
const roadmapPages = roadmaps
.map((roadmap) => {
@@ -114,11 +107,11 @@ export async function GET() {
group: 'Videos',
})),
...projects.map((project) => ({
id: project.id,
url: `/projects/${project.id}`,
title: project.frontmatter.title,
description: project.frontmatter.description,
shortTitle: project.frontmatter.title,
id: project.slug,
url: `/projects/${project.slug}`,
title: project.title,
description: project.description,
shortTitle: project.title,
group: 'Projects',
})),
]),

View File

@@ -1,15 +1,10 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import { Badge } from '../../../components/Badge';
import {
getAllProjects,
getProjectById,
type ProjectFrontmatter,
} from '../../../lib/project';
import AstroIcon from '../../../components/AstroIcon.astro';
import { ProjectStepper } from '../../../components/Projects/StatusStepper/ProjectStepper';
import { ProjectTrackingActions } from '../../../components/Projects/StatusStepper/ProjectTrackingActions';
import { ProjectTabs } from '../../../components/Projects/ProjectTabs';
import { officialProjectDetails } from '../../../queries/official-project';
import { ProjectContent } from '../../../components/Projects/ProjectContent';
export const prerender = false;
@@ -19,31 +14,22 @@ interface Params extends Record<string, string | undefined> {
const { projectId } = Astro.params as Params;
const project = await getProjectById(projectId);
const project = await officialProjectDetails(projectId);
if (!project) {
Astro.response.status = 404;
Astro.response.statusText = 'Not found';
return Astro.rewrite('/404');
}
const projectData = project.frontmatter as ProjectFrontmatter;
let jsonLdSchema: any[] = [];
const ogImageUrl = projectData?.seo?.ogImageUrl || '/img/og-img.png';
const githubUrl = `https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/projects/${projectId}.md`;
const parentRoadmapId = projectData?.roadmapIds?.[0] || '';
const parentRoadmapId = project?.roadmapIds?.[0] || '';
---
<BaseLayout
permalink={`/projects/${projectId}`}
title={projectData?.seo?.title}
briefTitle={projectData.title}
ogImageUrl={ogImageUrl}
description={projectData.seo.description}
keywords={projectData.seo.keywords}
jsonLd={jsonLdSchema}
title={project?.seo?.title || project?.title}
briefTitle={project?.title}
description={project?.seo?.description || project?.description}
keywords={project?.seo?.keywords || []}
resourceId={projectId}
>
<div class='bg-gray-50'>
@@ -52,7 +38,6 @@ const parentRoadmapId = projectData?.roadmapIds?.[0] || '';
parentRoadmapId={parentRoadmapId}
projectId={projectId}
activeTab='details'
hasNoSubmission={projectData?.hasNoSubmission}
/>
<div
@@ -62,55 +47,27 @@ const parentRoadmapId = projectData?.roadmapIds?.[0] || '';
<div class='mb-4 hidden items-center justify-between sm:flex'>
<div class='flex flex-row flex-wrap gap-1.5'>
{
projectData.skills.map((skill) => (
project?.skills.map((skill) => (
<Badge variant='green' text={skill} />
))
}
</div>
<Badge variant='yellow' text={projectData.difficulty} />
<Badge variant='yellow' text={project?.difficulty} />
</div>
<div class='my-2 flex items-center justify-between gap-2 sm:my-7'>
<div class=''>
<h1 class='mb-1 text-xl font-semibold sm:mb-2 sm:text-3xl'>
{projectData.title}
{project?.title}
</h1>
<p class='text-sm text-balance text-gray-500'>
{projectData.description}
{project?.description}
</p>
</div>
{
projectData?.hasNoSubmission && (
<ProjectTrackingActions projectId={projectId} client:load />
)
}
</div>
</div>
{
!projectData?.hasNoSubmission && (
<ProjectStepper projectId={projectId} client:load />
)
}
<div
class='prose prose-h2:mb-3 prose-h2:mt-5 prose-h3:mb-1 prose-h3:mt-5 prose-p:mb-2 prose-blockquote:font-normal prose-blockquote:text-gray-500 prose-pre:my-3 prose-ul:my-3.5 prose-hr:my-5 max-w-full [&>ul>li]:my-1'
>
<project.Content />
</div>
<div
class='mt-5 flex flex-wrap items-center justify-center rounded-lg p-2.5 text-sm'
>
<AstroIcon class='mr-2 inline-block h-5 w-5' icon='github' />
Found a mistake?
<a
class='ml-1 underline underline-offset-2'
href={githubUrl}
target='_blank'
>
Help us improve.
</a>
</div>
<ProjectStepper projectId={projectId} client:load />
<ProjectContent project={project} client:load />
</div>
</div>
</div>

View File

@@ -1,13 +1,9 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import {
getAllProjects,
getProjectById,
type ProjectFrontmatter,
} from '../../../lib/project';
import { ProjectTabs } from '../../../components/Projects/ProjectTabs';
import { ListProjectSolutions } from '../../../components/Projects/ListProjectSolutions';
import { ProjectSolutionModal } from '../../../components/Projects/ProjectSolutionModal';
import { officialProjectDetails } from '../../../queries/official-project';
export const prerender = false;
@@ -17,31 +13,22 @@ interface Params extends Record<string, string | undefined> {
const { projectId } = Astro.params as Params;
const project = await getProjectById(projectId);
const project = await officialProjectDetails(projectId);
if (!project) {
Astro.response.status = 404;
Astro.response.statusText = 'Not found';
return Astro.rewrite('/404');
}
const projectData = project.frontmatter as ProjectFrontmatter;
let jsonLdSchema: any[] = [];
const parentRoadmapId = projectData?.roadmapIds?.[0] || '';
const ogImageUrl = projectData?.seo?.ogImageUrl || '/img/og-img.png';
const githubUrl = `https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/projects/${projectId}.md`;
const parentRoadmapId = project?.roadmapIds?.[0] || '';
---
<BaseLayout
permalink={`/projects/${projectId}/solutions`}
title={projectData?.seo?.title}
briefTitle={projectData.title}
ogImageUrl={ogImageUrl}
description={projectData.seo.description}
keywords={projectData.seo.keywords}
jsonLd={jsonLdSchema}
title={project?.seo?.title || project.title}
briefTitle={project.title}
description={project.seo.description || project.description}
keywords={project.seo.keywords}
resourceId={projectId}
>
<div class='bg-gray-50'>
@@ -53,15 +40,15 @@ const githubUrl = `https://github.com/kamranahmedse/developer-roadmap/tree/maste
/>
<ListProjectSolutions
project={projectData}
project={project}
projectId={projectId}
client:load
/>
<ProjectSolutionModal
projectId={projectId}
projectTitle={projectData.title}
projectDescription={projectData.description}
projectTitle={project.title}
projectDescription={project.description}
client:only='react'
/>
</div>

View File

@@ -1,10 +1,10 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getRoadmapsProjects } from '../../lib/project';
import { ProjectsPageHeader } from '../../components/Projects/ProjectsPageHeader';
import { ProjectsPage } from '../../components/Projects/ProjectsPage';
import { projectApi } from '../../api/project';
import { listOfficialRoadmaps } from '../../queries/official-roadmap';
import { getRoadmapsProjects } from '../../queries/official-project';
export const prerender = false;
@@ -18,7 +18,7 @@ const allRoadmaps = roadmaps.filter((roadmap) =>
const enrichedRoadmaps = allRoadmaps.map((roadmap) => {
const projects = (roadmapProjects[roadmap.slug] || []).sort((a, b) => {
return a.frontmatter.sort - b.frontmatter.sort;
return a.order - b.order;
});
return {
@@ -31,7 +31,7 @@ const enrichedRoadmaps = allRoadmaps.map((roadmap) => {
const projectIds = allRoadmapIds
.map((id) => roadmapProjects[id])
.flat()
.map((project) => project.id);
.map((project) => project.slug);
const projectApiClient = projectApi(Astro);
const { response: userCounts } =
await projectApiClient.listProjectsUserCount(projectIds);

View File

@@ -0,0 +1,108 @@
import { DateTime } from 'luxon';
import { FetchError, httpGet } from '../lib/query-http';
export const allowedOfficialProjectDifficulty = [
'beginner',
'intermediate',
'advanced',
] as const;
export type AllowedOfficialProjectDifficulty =
(typeof allowedOfficialProjectDifficulty)[number];
export const allowedOfficialProjectStatus = ['draft', 'published'] as const;
export type AllowedOfficialProjectStatus =
(typeof allowedOfficialProjectStatus)[number];
export interface OfficialProjectDocument {
_id: string;
order: number;
title: string;
description: string;
slug: string;
difficulty: AllowedOfficialProjectDifficulty;
topics: string[];
status: AllowedOfficialProjectStatus;
publishedAt?: Date;
content: any;
seo: {
title?: string;
description?: string;
keywords?: string[];
};
skills: string[];
roadmapIds: string[];
createdAt: Date;
updatedAt: Date;
}
export async function officialProjectDetails(projectSlug: string) {
try {
const project = await httpGet<OfficialProjectDocument>(
`/v1-official-project/${projectSlug}`,
);
return project;
} catch (error) {
if (FetchError.isFetchError(error) && error.status === 404) {
return null;
}
throw error;
}
}
type ListOfficialProjectsQuery = {
roadmapId?: string;
};
export async function listOfficialProjects(
query: ListOfficialProjectsQuery = {},
) {
try {
const projects = await httpGet<OfficialProjectDocument[]>(
`/v1-list-official-projects`,
query,
);
return projects;
} catch (error) {
if (FetchError.isFetchError(error) && error.status === 404) {
return [];
}
throw error;
}
}
export function isNewProject(createdAt: Date) {
return (
createdAt &&
DateTime.now().diff(DateTime.fromJSDate(new Date(createdAt)), 'days').days <
45
);
}
export async function getRoadmapsProjects(): Promise<
Record<string, OfficialProjectDocument[]>
> {
const projects = await listOfficialProjects();
const roadmapsProjects: Record<string, OfficialProjectDocument[]> = {};
projects.forEach((project) => {
project.roadmapIds.forEach((roadmapId) => {
if (!roadmapsProjects[roadmapId]) {
roadmapsProjects[roadmapId] = [];
}
roadmapsProjects[roadmapId].push(project);
});
});
return roadmapsProjects;
}

View File

@@ -128,6 +128,22 @@ export async function listOfficialRoadmaps() {
}
}
export async function listOfficialBeginnerRoadmaps() {
try {
const roadmaps = await httpGet<OfficialRoadmapDocument[]>(
`/v1-list-official-beginner-roadmaps`,
);
return roadmaps;
} catch (error) {
if (FetchError.isFetchError(error) && error.status === 404) {
return [];
}
throw error;
}
}
export function isNewRoadmap(createdAt: Date) {
return (
createdAt &&