mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-29 12:10:22 +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),
|
||||
};
|
||||
}
|
||||
|
||||
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