diff --git a/src/components/Projects/ListProjectSolutions.tsx b/src/components/Projects/ListProjectSolutions.tsx index b25d10983..cea49f11c 100644 --- a/src/components/Projects/ListProjectSolutions.tsx +++ b/src/components/Projects/ListProjectSolutions.tsx @@ -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({ @@ -226,7 +221,7 @@ export function ListProjectSolutions(props: ListProjectSolutionsProps) {

- {projectData.title} Solutions + {project.title} Solutions

Solutions submitted by the community diff --git a/src/components/Projects/ProjectCard.tsx b/src/components/Projects/ProjectCard.tsx index 18f6bc957..16fd9d830 100644 --- a/src/components/Projects/ProjectCard.tsx +++ b/src/components/Projects/ProjectCard.tsx @@ -1,20 +1,19 @@ 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 { + AllowedOfficialProjectDifficulty, + OfficialProjectDocument, +} from '../../queries/official-project.ts'; type ProjectCardProps = { - project: ProjectFileType; + project: OfficialProjectDocument; userCount?: number; status?: 'completed' | 'started' | 'none'; }; -const badgeVariants: Record = { +const badgeVariants: Record = { beginner: 'yellow', intermediate: 'green', advanced: 'blue', @@ -22,26 +21,24 @@ const badgeVariants: Record = { 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 ( - - + + {topics?.map((topic) => )} - {frontmatter.title} - {frontmatter.description} + {title} + {description} {isLoadingStatus ? ( diff --git a/src/components/Projects/ProjectContent.tsx b/src/components/Projects/ProjectContent.tsx new file mode 100644 index 000000000..e5aca0463 --- /dev/null +++ b/src/components/Projects/ProjectContent.tsx @@ -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 ( +

+ ); +} diff --git a/src/components/Projects/ProjectsList.tsx b/src/components/Projects/ProjectsList.tsx index f8570224a..84cc0926e 100644 --- a/src/components/Projects/ProjectsList.tsx +++ b/src/components/Projects/ProjectsList.tsx @@ -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; }; @@ -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(); @@ -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 = - useMemo(() => { - const result = new Map(); + 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) {
- {projectDifficulties.map((projectDifficulty) => ( + {allowedOfficialProjectDifficulty.map((projectDifficulty) => ( { @@ -122,6 +128,7 @@ export function ProjectsList(props: ProjectsListProps) { isActive={projectDifficulty === difficulty} /> ))} + {difficulty && ( -
+
-
+

@@ -187,9 +187,9 @@ export function ProjectsPage(props: ProjectsPageProps) {
{sortedVisibleProjects.map((project) => ( ))} diff --git a/src/lib/project.ts b/src/lib/project.ts deleted file mode 100644 index 1a16aea66..000000000 --- a/src/lib/project.ts +++ /dev/null @@ -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 & { - 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 { - 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 { - if (tempProjects.length) { - return tempProjects; - } - - const projects = import.meta.glob( - '/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 { - 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 -> { - const projects = await getAllProjects(); - const roadmapsProjects: Record = {}; - - projects.forEach((project) => { - project.frontmatter.roadmapIds.forEach((roadmapId) => { - if (!roadmapsProjects[roadmapId]) { - roadmapsProjects[roadmapId] = []; - } - - roadmapsProjects[roadmapId].push(project); - }); - }); - - return roadmapsProjects; -} diff --git a/src/pages/[roadmapId]/courses.astro b/src/pages/[roadmapId]/courses.astro index 6570657db..bd896f86d 100644 --- a/src/pages/[roadmapId]/courses.astro +++ b/src/pages/[roadmapId]/courses.astro @@ -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 || []; --- diff --git a/src/pages/[roadmapId]/index.astro b/src/pages/[roadmapId]/index.astro index bcf9f6982..b66c5d39c 100644 --- a/src/pages/[roadmapId]/index.astro +++ b/src/pages/[roadmapId]/index.astro @@ -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 || []; --- diff --git a/src/pages/[roadmapId]/projects.astro b/src/pages/[roadmapId]/projects.astro index ccff751d7..df18d9a9a 100644 --- a/src/pages/[roadmapId]/projects.astro +++ b/src/pages/[roadmapId]/projects.astro @@ -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 } = diff --git a/src/pages/pages.json.ts b/src/pages/pages.json.ts index eec165088..48627bc6c 100644 --- a/src/pages/pages.json.ts +++ b/src/pages/pages.json.ts @@ -1,12 +1,12 @@ 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'; +import { listOfficialProjects } from '../queries/official-project'; // Add utility to fetch beginner roadmap file IDs function getBeginnerRoadmapIds() { @@ -28,7 +28,7 @@ export async function GET() { const roadmaps = await listOfficialRoadmaps(); const bestPractices = await getAllBestPractices(); - const projects = await getAllProjects(); + const projects = await listOfficialProjects(); // Transform main roadmaps into page objects first so that we can reuse their meta for beginner variants const roadmapPages = roadmaps @@ -114,11 +114,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', })), ]), diff --git a/src/pages/projects/[projectId]/index.astro b/src/pages/projects/[projectId]/index.astro index 038044ee2..8ce0b7aae 100644 --- a/src/pages/projects/[projectId]/index.astro +++ b/src/pages/projects/[projectId]/index.astro @@ -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 { 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] || ''; ---
@@ -52,7 +38,6 @@ const parentRoadmapId = projectData?.roadmapIds?.[0] || ''; parentRoadmapId={parentRoadmapId} projectId={projectId} activeTab='details' - hasNoSubmission={projectData?.hasNoSubmission} />

- {projectData.title} + {project?.title}

- {projectData.description} + {project?.description}

- { - projectData?.hasNoSubmission && ( - - ) - }
- { - !projectData?.hasNoSubmission && ( - - ) - } - -
- -
- -
- - Found a mistake? - - Help us improve. - -
+ +

diff --git a/src/pages/projects/[projectId]/solutions.astro b/src/pages/projects/[projectId]/solutions.astro index d533956db..e9c7a13f4 100644 --- a/src/pages/projects/[projectId]/solutions.astro +++ b/src/pages/projects/[projectId]/solutions.astro @@ -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 { 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] || ''; ---
@@ -53,15 +40,15 @@ const githubUrl = `https://github.com/kamranahmedse/developer-roadmap/tree/maste />
diff --git a/src/pages/projects/index.astro b/src/pages/projects/index.astro index 8e52067b7..8aec6e622 100644 --- a/src/pages/projects/index.astro +++ b/src/pages/projects/index.astro @@ -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); diff --git a/src/queries/official-project.ts b/src/queries/official-project.ts new file mode 100644 index 000000000..114d5cf59 --- /dev/null +++ b/src/queries/official-project.ts @@ -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( + `/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( + `/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 +> { + const projects = await listOfficialProjects(); + const roadmapsProjects: Record = {}; + + projects.forEach((project) => { + project.roadmapIds.forEach((roadmapId) => { + if (!roadmapsProjects[roadmapId]) { + roadmapsProjects[roadmapId] = []; + } + + roadmapsProjects[roadmapId].push(project); + }); + }); + + return roadmapsProjects; +}