feat: project listing

feat/list-projects
Arik Chakma 2 months ago
parent e39fadb032
commit 3ceab552f6
  1. 54
      src/components/Activity/ActivityPage.tsx
  2. 50
      src/components/Activity/ProjectProgress.tsx
  3. 68
      src/components/Activity/ProjectProgressActions.tsx
  4. 24
      src/components/Activity/ProjectStatus.tsx
  5. 2
      src/components/Projects/ListProjectSolutions.tsx
  6. 9
      src/pages/pages.json.ts

@ -5,6 +5,10 @@ import { ResourceProgress } from './ResourceProgress';
import { pageProgressMessage } from '../../stores/page';
import { EmptyActivity } from './EmptyActivity';
import { ActivityStream, type UserStreamActivity } from './ActivityStream';
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions';
import type { PageType } from '../CommandMenu/CommandMenu';
import { useToast } from '../../hooks/use-toast';
import { ProjectProgress } from './ProjectProgress';
type ProgressResponse = {
updatedAt: string;
@ -47,11 +51,14 @@ export type ActivityResponse = {
};
}[];
activities: UserStreamActivity[];
projects: ProjectStatusDocument[];
};
export function ActivityPage() {
const toast = useToast();
const [activity, setActivity] = useState<ActivityResponse>();
const [isLoading, setIsLoading] = useState(true);
const [projectDetails, setProjectDetails] = useState<PageType[]>([]);
async function loadActivity() {
const { error, response } = await httpGet<ActivityResponse>(
@ -68,11 +75,30 @@ export function ActivityPage() {
setActivity(response);
}
async function loadAllProjectDetails() {
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
if (error) {
toast.error(error.message || 'Something went wrong');
return;
}
if (!response) {
return [];
}
const allProjects = response.filter((page) => page.group === 'Projects');
console.log(allProjects);
setProjectDetails(allProjects);
}
useEffect(() => {
loadActivity().finally(() => {
Promise.allSettled([loadActivity(), loadAllProjectDetails()]).finally(
() => {
pageProgressMessage.set('');
setIsLoading(false);
});
},
);
}, []);
const learningRoadmaps = activity?.learning.roadmaps || [];
@ -106,6 +132,17 @@ export function ActivityPage() {
learningRoadmapsToShow.length !== 0 ||
learningBestPracticesToShow.length !== 0;
const enrichedProjects = activity?.projects.map((project) => {
const projectDetail = projectDetails.find(
(page) => page.id === project.projectId,
);
return {
...project,
title: projectDetail?.title || 'N/A',
};
});
return (
<>
<ActivityCounters
@ -201,6 +238,19 @@ export function ActivityPage() {
)}
</div>
{enrichedProjects && enrichedProjects?.length > 0 && (
<div className="mx-0 px-0 py-5 pb-0 md:-mx-10 md:px-8 md:py-8 md:pb-0">
<h2 className="mb-3 text-xs uppercase text-gray-400">
Your Projects
</h2>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{enrichedProjects.map((project) => (
<ProjectProgress key={project._id} projectStatus={project} />
))}
</div>
</div>
)}
{hasProgress && (
<ActivityStream activities={activity?.activities || []} />
)}

@ -0,0 +1,50 @@
import { getUser } from '../../lib/jwt';
import { getPercentage } from '../../helper/number';
import { ProjectProgressActions } from './ProjectProgressActions';
import { cn } from '../../lib/classname';
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions';
import { ProjectStatus } from './ProjectStatus';
import { ThumbsUp } from 'lucide-react';
type ProjectProgressType = {
projectStatus: ProjectStatusDocument & {
title: string;
};
};
export function ProjectProgress(props: ProjectProgressType) {
const { projectStatus } = props;
const userId = getUser()?.id;
const shouldShowActions =
projectStatus.submittedAt && projectStatus.submittedAt !== null;
return (
<div className="relative">
<a
className={cn(
'group relative flex w-full items-center justify-between overflow-hidden rounded-md border border-gray-300 bg-white px-3 py-2 pr-7 text-left text-sm transition-all hover:border-gray-400',
shouldShowActions ? '' : 'pr-3',
)}
href={`/projects/${projectStatus.projectId}`}
target="_blank"
>
<ProjectStatus projectStatus={projectStatus} />
<span className="ml-2 flex-grow truncate">{projectStatus?.title}</span>
<span className="inline-flex items-center gap-1 text-xs text-gray-400">
{projectStatus.upvotes}
<ThumbsUp className="size-2.5 stroke-[2.5px]" />
</span>
</a>
{shouldShowActions && (
<div className="absolute right-2 top-0 flex h-full items-center">
<ProjectProgressActions
userId={userId!}
projectId={projectStatus.projectId}
/>
</div>
)}
</div>
);
}

@ -0,0 +1,68 @@
import { MoreVertical, X } from 'lucide-react';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { useKeydown } from '../../hooks/use-keydown';
import { cn } from '../../lib/classname';
import { useCopyText } from '../../hooks/use-copy-text';
import { CheckIcon } from '../ReactIcons/CheckIcon';
import { ShareIcon } from '../ReactIcons/ShareIcon';
type ProjectProgressActionsType = {
userId: string;
projectId: string;
};
export function ProjectProgressActions(props: ProjectProgressActionsType) {
const { userId, projectId } = props;
const dropdownRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const { copyText, isCopied } = useCopyText();
const projectSolutionUrl = `${import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh'}/projects/${projectId}/solutions?u=${userId}`;
useOutsideClick(dropdownRef, () => {
setIsOpen(false);
});
useKeydown('Escape', () => {
setIsOpen(false);
});
return (
<div className="relative h-full" ref={dropdownRef}>
<button
className="h-full text-gray-400 hover:text-gray-700"
onClick={() => setIsOpen(!isOpen)}
>
<MoreVertical size={16} />
</button>
{isOpen && (
<div className="absolute right-0 top-8 z-10 w-48 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
<button
className={cn(
'flex w-full items-center gap-1.5 p-2 text-xs font-medium hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-70 sm:text-sm',
isCopied ? 'text-green-500' : 'text-gray-500 hover:text-black',
)}
onClick={() => {
copyText(projectSolutionUrl);
}}
>
{isCopied ? (
<>
<CheckIcon additionalClasses="h-3.5 w-3.5" /> Link Copied
</>
) : (
<>
<ShareIcon className="h-3.5 w-3.5 stroke-[2.5px]" /> Share
Solution
</>
)}
</button>
</div>
)}
</div>
);
}

@ -0,0 +1,24 @@
import { CircleDashed } from 'lucide-react';
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions';
import { CheckIcon } from '../ReactIcons/CheckIcon';
type ProjectStatusType = {
projectStatus: ProjectStatusDocument & {
title: string;
};
};
export function ProjectStatus(props: ProjectStatusType) {
const { projectStatus } = props;
const { submittedAt, repositoryUrl } = projectStatus;
const status = submittedAt && repositoryUrl ? 'submitted' : 'started';
if (status === 'submitted') {
return <CheckIcon additionalClasses="size-3 text-gray-500 shrink-0" />;
}
return (
<CircleDashed className="size-3 shrink-0 stroke-[2.5px] text-gray-400" />
);
}

@ -33,7 +33,7 @@ export interface ProjectStatusDocument {
isVisible?: boolean;
updated1t: Date;
updatedAt: Date;
}
const allowedVoteType = ['upvote', 'downvote'] as const;

@ -3,6 +3,7 @@ import { getAllGuides } from '../lib/guide';
import { getRoadmapsByTag } from '../lib/roadmap';
import { getAllVideos } from '../lib/video';
import { getAllQuestionGroups } from '../lib/question-group';
import { getAllProjects } from '../lib/project';
export async function GET() {
const guides = await getAllGuides();
@ -10,6 +11,7 @@ export async function GET() {
const questionGroups = await getAllQuestionGroups();
const roadmaps = await getRoadmapsByTag('roadmap');
const bestPractices = await getAllBestPractices();
const projects = await getAllProjects();
return new Response(
JSON.stringify([
@ -53,6 +55,13 @@ export async function GET() {
title: video.frontmatter.title,
group: 'Videos',
})),
...projects.map((project) => ({
id: project.id,
url: `/projects/${project.id}`,
title: project.frontmatter.title,
description: project.frontmatter.description,
group: 'Projects',
})),
]),
{
status: 200,

Loading…
Cancel
Save