mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-01 21:32:35 +02:00
feat: implement projects page (#7067)
This commit is contained in:
150
src/components/Projects/ProjectsPage.tsx
Normal file
150
src/components/Projects/ProjectsPage.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { cn } from '../../lib/classname.ts';
|
||||||
|
import { Filter, X } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
deleteUrlParam,
|
||||||
|
getUrlParams,
|
||||||
|
setUrlParams,
|
||||||
|
} from '../../lib/browser.ts';
|
||||||
|
import { CategoryFilterButton } from '../Roadmaps/CategoryFilterButton.tsx';
|
||||||
|
import {
|
||||||
|
projectDifficulties,
|
||||||
|
type ProjectFileType,
|
||||||
|
} from '../../lib/project.ts';
|
||||||
|
import { ProjectCard } from './ProjectCard.tsx';
|
||||||
|
|
||||||
|
type ProjectsPageProps = {
|
||||||
|
roadmapsProjects: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
projects: ProjectFileType[];
|
||||||
|
}[];
|
||||||
|
userCounts: Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProjectsPage(props: ProjectsPageProps) {
|
||||||
|
const { roadmapsProjects, userCounts } = props;
|
||||||
|
const allUniqueProjectIds = new Set<string>(
|
||||||
|
roadmapsProjects.flatMap((group) =>
|
||||||
|
group.projects.map((project) => project.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const allUniqueProjects = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(allUniqueProjectIds)
|
||||||
|
.map((id) =>
|
||||||
|
roadmapsProjects
|
||||||
|
.flatMap((group) => group.projects)
|
||||||
|
.find((project) => project.id === id),
|
||||||
|
)
|
||||||
|
.filter(Boolean) as ProjectFileType[],
|
||||||
|
[allUniqueProjectIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [activeGroup, setActiveGroup] = useState<string>('');
|
||||||
|
const [visibleProjects, setVisibleProjects] =
|
||||||
|
useState<ProjectFileType[]>(allUniqueProjects);
|
||||||
|
|
||||||
|
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { g } = getUrlParams() as { g: string };
|
||||||
|
if (!g) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveGroup(g);
|
||||||
|
const group = roadmapsProjects.find((group) => group.id === g);
|
||||||
|
if (!group) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisibleProjects(group.projects);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sortedVisibleProjects = useMemo(
|
||||||
|
() =>
|
||||||
|
visibleProjects.sort((a, b) => {
|
||||||
|
const projectADifficulty = a?.frontmatter.difficulty || 'beginner';
|
||||||
|
const projectBDifficulty = b?.frontmatter.difficulty || 'beginner';
|
||||||
|
return (
|
||||||
|
projectDifficulties.indexOf(projectADifficulty) -
|
||||||
|
projectDifficulties.indexOf(projectBDifficulty)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[visibleProjects],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t bg-gray-100">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsFilterOpen(!isFilterOpen);
|
||||||
|
}}
|
||||||
|
id="filter-button"
|
||||||
|
className={cn(
|
||||||
|
'-mt-1 flex w-full items-center justify-center bg-gray-300 py-2 text-sm text-black focus:shadow-none focus:outline-0 sm:hidden',
|
||||||
|
{
|
||||||
|
'mb-3': !isFilterOpen,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!isFilterOpen && <Filter size={13} className="mr-1" />}
|
||||||
|
{isFilterOpen && <X size={13} className="mr-1" />}
|
||||||
|
Categories
|
||||||
|
</button>
|
||||||
|
<div className="container relative 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-gradient-to-l sm:pt-6',
|
||||||
|
{
|
||||||
|
'hidden sm:flex': !isFilterOpen,
|
||||||
|
'z-50 flex': isFilterOpen,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="absolute top-0 -mx-4 w-full bg-white pb-0 shadow-xl sm:sticky sm:top-10 sm:mx-0 sm:bg-transparent sm:pb-20 sm:shadow-none">
|
||||||
|
<div className="grid grid-cols-1">
|
||||||
|
<CategoryFilterButton
|
||||||
|
onClick={() => {
|
||||||
|
setActiveGroup('');
|
||||||
|
setVisibleProjects(allUniqueProjects);
|
||||||
|
setIsFilterOpen(false);
|
||||||
|
deleteUrlParam('g');
|
||||||
|
}}
|
||||||
|
category={'All Projects'}
|
||||||
|
selected={activeGroup === ''}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{roadmapsProjects.map((group) => (
|
||||||
|
<CategoryFilterButton
|
||||||
|
key={group.id}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveGroup(group.id);
|
||||||
|
setIsFilterOpen(false);
|
||||||
|
document?.getElementById('filter-button')?.scrollIntoView();
|
||||||
|
setVisibleProjects(group.projects);
|
||||||
|
setUrlParams({ g: group.id });
|
||||||
|
}}
|
||||||
|
category={group.title}
|
||||||
|
selected={activeGroup === group.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-grow flex-col gap-6 pb-20 pt-2 sm:pt-6">
|
||||||
|
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||||
|
{sortedVisibleProjects.map((project) => (
|
||||||
|
<ProjectCard
|
||||||
|
key={project.id}
|
||||||
|
project={project}
|
||||||
|
userCount={userCounts[project.id] || 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
17
src/components/Projects/ProjectsPageHeader.tsx
Normal file
17
src/components/Projects/ProjectsPageHeader.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { isLoggedIn } from '../../lib/jwt.ts';
|
||||||
|
import { showLoginPopup } from '../../lib/popup.ts';
|
||||||
|
|
||||||
|
export function ProjectsPageHeader() {
|
||||||
|
return (
|
||||||
|
<div className="bg-white py-3 sm:py-12">
|
||||||
|
<div className="container">
|
||||||
|
<div className="flex flex-col items-start bg-white sm:items-center">
|
||||||
|
<h1 className="text-2xl font-bold sm:text-5xl">Project Ideas</h1>
|
||||||
|
<p className="mt-1 text-sm sm:my-3 sm:text-lg">
|
||||||
|
Browse the ever-growing list of projects ideas and solutions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -93,3 +93,22 @@ export async function getProjectById(
|
|||||||
id: projectPathToId(project.file),
|
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;
|
||||||
|
}
|
||||||
|
42
src/pages/projects/index.astro
Normal file
42
src/pages/projects/index.astro
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import { getRoadmapsProjects } from '../../lib/project';
|
||||||
|
import { getRoadmapsByIds } from '../../lib/roadmap';
|
||||||
|
import { ProjectsPageHeader } from '../../components/Projects/ProjectsPageHeader';
|
||||||
|
import { ProjectsPage } from '../../components/Projects/ProjectsPage';
|
||||||
|
import { projectApi } from '../../api/project';
|
||||||
|
|
||||||
|
const roadmapProjects = await getRoadmapsProjects();
|
||||||
|
const allRoadmapIds = Object.keys(roadmapProjects);
|
||||||
|
|
||||||
|
const allRoadmaps = await getRoadmapsByIds(allRoadmapIds);
|
||||||
|
const enrichedRoadmaps = allRoadmaps.map((roadmap) => {
|
||||||
|
const projects = roadmapProjects[roadmap.id];
|
||||||
|
return {
|
||||||
|
id: roadmap.id,
|
||||||
|
title: roadmap.frontmatter.briefTitle,
|
||||||
|
projects,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectIds = allRoadmapIds
|
||||||
|
.map((id) => roadmapProjects[id])
|
||||||
|
.flat()
|
||||||
|
.map((project) => project.id);
|
||||||
|
const projectApiClient = projectApi(Astro);
|
||||||
|
const { response: userCounts } =
|
||||||
|
await projectApiClient.listProjectsUserCount(projectIds);
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout
|
||||||
|
title='Project Ideas'
|
||||||
|
description='Explore project ideas to take you from beginner to advanced in different technologies'
|
||||||
|
permalink='/projects'
|
||||||
|
>
|
||||||
|
<ProjectsPageHeader client:load />
|
||||||
|
<ProjectsPage
|
||||||
|
roadmapsProjects={enrichedRoadmaps}
|
||||||
|
userCounts={userCounts || {}}
|
||||||
|
client:load
|
||||||
|
/>
|
||||||
|
</BaseLayout>
|
Reference in New Issue
Block a user