Projects listing and filtering

feat/projects-list
Kamran Ahmed 4 months ago
parent 2cfcbf9a51
commit 9355c1e770
  1. 14
      src/components/Badge.tsx
  2. 35
      src/components/Projects/ProjectCard.tsx
  3. 133
      src/components/Projects/ProjectsList.tsx
  4. 4
      src/components/RoadmapHeader.astro
  5. 36
      src/data/projects/stock-cli.md
  6. 36
      src/data/projects/task-tracker-cli.md
  7. 2
      src/lib/link-group.ts
  8. 82
      src/lib/project.ts
  9. 3
      src/pages/[roadmapId]/index.astro
  10. 6
      src/pages/[roadmapId]/projects.astro

@ -7,17 +7,17 @@ export function Badge(type: BadgeProps) {
const { variant, text } = type; const { variant, text } = type;
const colors = { const colors = {
blue: 'bg-blue-100 text-blue-700', blue: 'bg-blue-100 text-blue-700 border-blue-200',
green: 'bg-green-100 text-green-700', green: 'bg-green-100 text-green-700 border-green-200',
red: 'bg-red-100 text-red-700', red: 'bg-red-100 text-red-700 border-red-200',
yellow: 'bg-yellow-100 text-yellow-700', yellow: 'bg-yellow-100 text-yellow-700 border-yellow-200',
grey: 'bg-gray-100 text-gray-700', grey: 'bg-gray-100 text-gray-700 border-gray-200',
white: 'bg-white text-black', white: 'bg-white text-black border-gray-200',
}; };
return ( return (
<span <span
className={`rounded-md border ${colors[variant]} px-1 py-0.5 text-xs tracking-wide`} className={`rounded-md border capitalize ${colors[variant]} px-1 py-0.5 text-xs tracking-wide`}
> >
{text} {text}
</span> </span>

@ -1,19 +1,38 @@
import { Badge } from '../Badge.tsx'; import { Badge } from '../Badge.tsx';
import type {
ProjectDifficultyType,
ProjectFileType,
} from '../../lib/project.ts';
type ProjectCardProps = {
project: ProjectFileType;
};
const badgeVariants: Record<ProjectDifficultyType, string> = {
beginner: 'yellow',
intermediate: 'green',
advanced: 'red',
};
export function ProjectCard(props: ProjectCardProps) {
const { project } = props;
const { frontmatter, id } = project;
export function ProjectCard() {
return ( return (
<a <a
href="#" href={`/projects/${id}`}
className="flex flex-col rounded-md border bg-white p-3 transition-colors hover:border-gray-300 hover:bg-gray-50" 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"> <span className="flex justify-between gap-1.5">
<Badge variant={'yellow'} text={'Beginner'} /> <Badge
<Badge variant={'grey'} text={'API'} /> variant={badgeVariants[frontmatter.difficulty] as any}
</span> text={frontmatter.difficulty}
<span className="mb-1 mt-2.5 font-medium">Bank Application</span> />
<span className="text-sm text-gray-500"> <Badge variant={'grey'} text={frontmatter.nature} />
Create a simple CLI to collect and calculate the taxes.
</span> </span>
<span className="mb-1 mt-2.5 font-medium">{frontmatter.title}</span>
<span className="text-sm text-gray-500">{frontmatter.description}</span>
</a> </a>
); );
} }

@ -1,10 +1,20 @@
import { ProjectCard } from './ProjectCard.tsx'; import { ProjectCard } from './ProjectCard.tsx';
import { Diff, HeartHandshake } from 'lucide-react'; import { HeartHandshake, Trash2 } from 'lucide-react';
import { cn } from '../../lib/classname.ts'; import { cn } from '../../lib/classname.ts';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import {
projectDifficulties,
type ProjectDifficultyType,
type ProjectFileType,
} from '../../lib/project.ts';
import {
deleteUrlParam,
getUrlParams,
setUrlParams,
} from '../../lib/browser.ts';
type DifficultyButtonProps = { type DifficultyButtonProps = {
difficulty: 'beginner' | 'intermediate' | 'senior'; difficulty: ProjectDifficultyType;
isActive?: boolean; isActive?: boolean;
onClick?: () => void; onClick?: () => void;
}; };
@ -28,36 +38,65 @@ function DifficultyButton(props: DifficultyButtonProps) {
); );
} }
export function ProjectsList() { type ProjectsListProps = {
projects: ProjectFileType[];
};
export function ProjectsList(props: ProjectsListProps) {
const { projects } = props;
const { difficulty: urlDifficulty } = getUrlParams();
const [difficulty, setDifficulty] = useState< const [difficulty, setDifficulty] = useState<
'beginner' | 'intermediate' | 'senior' ProjectDifficultyType | undefined
>(); >(urlDifficulty);
const projectsByDifficulty: Map<ProjectDifficultyType, ProjectFileType[]> =
useMemo(() => {
const result = new Map<ProjectDifficultyType, ProjectFileType[]>();
for (const project of projects) {
const difficulty = project.frontmatter.difficulty;
if (!result.has(difficulty)) {
result.set(difficulty, []);
}
result.get(difficulty)?.push(project);
}
return result;
}, [projects]);
const matchingProjects = difficulty
? projectsByDifficulty.get(difficulty) || []
: projects;
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="my-2.5 flex items-center justify-between"> <div className="my-2.5 flex items-center justify-between">
<div className="flex items-center gap-1"> <div className="flex gap-1">
<DifficultyButton {projectDifficulties.map((projectDifficulty) => (
onClick={() => { <DifficultyButton
setDifficulty('beginner'); onClick={() => {
}} setDifficulty(projectDifficulty);
difficulty={'beginner'} setUrlParams({ difficulty: projectDifficulty });
isActive={difficulty === 'beginner'} }}
/> difficulty={projectDifficulty}
<DifficultyButton isActive={projectDifficulty === difficulty}
onClick={() => { />
setDifficulty('intermediate'); ))}
}} {difficulty && (
difficulty={'intermediate'} <button
isActive={difficulty === 'intermediate'} onClick={() => {
/> setDifficulty(undefined);
<DifficultyButton deleteUrlParam('difficulty');
onClick={() => { }}
setDifficulty('senior'); className="flex items-center gap-1.5 rounded-md border border-red-500 bg-transparent px-2 py-0.5 text-sm text-red-500 transition-colors hover:bg-red-500 hover:text-white"
}} >
difficulty={'senior'} <Trash2 className="h-3.5 w-3.5" strokeWidth={2.25} />
isActive={difficulty === 'senior'} Clear
/> </button>
)}
</div> </div>
<a <a
href={''} href={''}
@ -68,27 +107,23 @@ export function ProjectsList() {
</a> </a>
</div> </div>
<div className="mb-24 grid grid-cols-3 gap-1.5"> <div className="mb-24 grid grid-cols-3 gap-1.5">
<ProjectCard /> {matchingProjects.length === 0 && (
<ProjectCard /> <div className="col-span-3 rounded-md border bg-white p-4 text-left text-sm text-gray-500">
<ProjectCard /> <p>No matching projects found.</p>
<ProjectCard /> </div>
<ProjectCard /> )}
<ProjectCard />
<ProjectCard /> {matchingProjects
<ProjectCard /> .sort((project) => {
<ProjectCard /> return project.frontmatter.difficulty === 'beginner'
<ProjectCard /> ? -1
<ProjectCard /> : project.frontmatter.difficulty === 'intermediate'
<ProjectCard /> ? 0
<ProjectCard /> : 1;
<ProjectCard /> })
<ProjectCard /> .map((matchingProject) => (
<ProjectCard /> <ProjectCard project={matchingProject} />
<ProjectCard /> ))}
<ProjectCard />
<ProjectCard />
<ProjectCard />
<ProjectCard />
</div> </div>
</div> </div>
); );

@ -21,6 +21,7 @@ export interface Props {
roadmapId: string; roadmapId: string;
isUpcoming?: boolean; isUpcoming?: boolean;
hasSearch?: boolean; hasSearch?: boolean;
projectCount?: number;
question?: RoadmapFrontmatter['question']; question?: RoadmapFrontmatter['question'];
hasTopics?: boolean; hasTopics?: boolean;
isForkable?: boolean; isForkable?: boolean;
@ -35,6 +36,7 @@ const {
isUpcoming = false, isUpcoming = false,
note, note,
hasTopics = false, hasTopics = false,
projectCount = 0,
question, question,
activeTab = 'roadmap', activeTab = 'roadmap',
} = Astro.props; } = Astro.props;
@ -123,7 +125,7 @@ const hasTnsBanner = !!tnsBannerLink;
icon={FolderKanbanIcon} icon={FolderKanbanIcon}
text='Projects' text='Projects'
isActive={activeTab === 'projects'} isActive={activeTab === 'projects'}
badgeText='soon' badgeText={projectCount > 0 ? '' : 'soon'}
/> />
</div> </div>

@ -0,0 +1,36 @@
---
title: 'Stock CLI'
description: 'Build a command line interface (CLI) to track your tasks and manage your to-do list.'
isNew: false
difficulty: 'intermediate'
nature: 'API'
skills:
- 'Programming Language'
- 'CLI'
- 'Filesystem'
- 'Logic Building'
seo:
title: 'Task Tracker CLI'
description: 'Build a command line interface (CLI) to track your tasks and manage your to-do list.'
keywords:
- 'task tracker cli'
- 'backend project idea'
roadmapIds:
- 'backend'
---
# Task Tracker CLI
Build a command line interface (CLI) to track your tasks and manage your to-do list.
## Description
You are required to build a command line interface (CLI) application that allows users to manage their tasks and to-do list. The application should be able to perform the following operations:
- Add a new task
- List all tasks
- Mark a task as done
- Delete a task
- Update a task
- Search for a task
- Filter tasks by status (done, pending)

@ -0,0 +1,36 @@
---
title: 'Task Tracker CLI'
description: 'Build a command line interface (CLI) to track your tasks and manage your to-do list.'
isNew: false
difficulty: 'beginner'
nature: 'API'
skills:
- 'Programming Language'
- 'CLI'
- 'Filesystem'
- 'Logic Building'
seo:
title: 'Task Tracker CLI'
description: 'Build a command line interface (CLI) to track your tasks and manage your to-do list.'
keywords:
- 'task tracker cli'
- 'backend project idea'
roadmapIds:
- 'backend'
---
# Task Tracker CLI
Build a command line interface (CLI) to track your tasks and manage your to-do list.
## Description
You are required to build a command line interface (CLI) application that allows users to manage their tasks and to-do list. The application should be able to perform the following operations:
- Add a new task
- List all tasks
- Mark a task as done
- Delete a task
- Update a task
- Search for a task
- Filter tasks by status (done, pending)

@ -25,7 +25,7 @@ function linkGroupPathToId(filePath: string): string {
* @returns Promisifed linkGroup files * @returns Promisifed linkGroup files
*/ */
export async function getAllLinkGroups(): Promise<LinkGroupFileType[]> { export async function getAllLinkGroups(): Promise<LinkGroupFileType[]> {
const linkGroups = await import.meta.glob<LinkGroupFileType>( const linkGroups = import.meta.glob<LinkGroupFileType>(
'/src/data/link-groups/*.md', '/src/data/link-groups/*.md',
{ {
eager: true, eager: true,

@ -0,0 +1,82 @@
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;
difficulty: ProjectDifficultyType;
nature: string;
skills: string[];
seo: {
title: string;
description: string;
keywords: string[];
};
roadmapIds: string[];
}
export type ProjectFileType = MarkdownFileType<ProjectFrontmatter> & {
id: string;
};
/**
* 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`);
return {
...project,
id: projectPathToId(project.file),
};
}

@ -17,6 +17,7 @@ import { type RoadmapFrontmatter, getRoadmapIds } from '../../lib/roadmap';
import RoadmapNote from '../../components/RoadmapNote.astro'; import RoadmapNote from '../../components/RoadmapNote.astro';
import { RoadmapTitleQuestion } from '../../components/RoadmapTitleQuestion'; import { RoadmapTitleQuestion } from '../../components/RoadmapTitleQuestion';
import ResourceProgressStats from '../../components/ResourceProgressStats.astro'; import ResourceProgressStats from '../../components/ResourceProgressStats.astro';
import { getProjectsByRoadmapId } from '../../lib/project';
export async function getStaticPaths() { export async function getStaticPaths() {
const roadmapIds = await getRoadmapIds(); const roadmapIds = await getRoadmapIds();
@ -68,6 +69,7 @@ const ogImageUrl =
const question = roadmapData?.question; const question = roadmapData?.question;
const note = roadmapData.note; const note = roadmapData.note;
const projects = await getProjectsByRoadmapId(roadmapId);
--- ---
<BaseLayout <BaseLayout
@ -110,6 +112,7 @@ const note = roadmapData.note;
isUpcoming={roadmapData.isUpcoming} isUpcoming={roadmapData.isUpcoming}
isForkable={roadmapData.isForkable} isForkable={roadmapData.isForkable}
question={roadmapData.question} question={roadmapData.question}
projectCount={projects.length}
/> />
<div class='container mt-2.5'> <div class='container mt-2.5'>

@ -11,6 +11,7 @@ import ShareIcons from '../../components/ShareIcons/ShareIcons.astro';
import { TopicDetail } from '../../components/TopicDetail/TopicDetail'; import { TopicDetail } from '../../components/TopicDetail/TopicDetail';
import { UserProgressModal } from '../../components/UserProgress/UserProgressModal'; import { UserProgressModal } from '../../components/UserProgress/UserProgressModal';
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import { getProjectsByRoadmapId } from '../../lib/project';
import { import {
generateArticleSchema, generateArticleSchema,
generateFAQSchema, generateFAQSchema,
@ -64,7 +65,7 @@ const nounTitle =
descriptionNoun[roadmapData.briefTitle] || roadmapData.briefTitle; descriptionNoun[roadmapData.briefTitle] || roadmapData.briefTitle;
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 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 = ['a']; const projects = await getProjectsByRoadmapId(roadmapId);
--- ---
<BaseLayout <BaseLayout
@ -90,11 +91,12 @@ const projects = ['a'];
isForkable={roadmapData.isForkable} isForkable={roadmapData.isForkable}
question={roadmapData.question} question={roadmapData.question}
activeTab='projects' activeTab='projects'
projectCount={projects.length}
/> />
<div class='container'> <div class='container'>
{projects.length === 0 && <EmptyProjects client:load />} {projects.length === 0 && <EmptyProjects client:load />}
{projects.length > 0 && <ProjectsList client:load />} {projects.length > 0 && <ProjectsList projects={projects} client:load />}
</div> </div>
</div> </div>
</BaseLayout> </BaseLayout>

Loading…
Cancel
Save