feat: implement projects page (#7067)
parent
8c615084d3
commit
e3b6bacbc4
4 changed files with 228 additions and 0 deletions
@ -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> |
||||||
|
); |
||||||
|
} |
@ -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> |
||||||
|
); |
||||||
|
} |
@ -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> |
Loading…
Reference in new issue