parent
8db22e81b1
commit
323e553d2a
10 changed files with 714 additions and 35 deletions
@ -0,0 +1,30 @@ |
|||||||
|
import { Blocks, CodeXml } from 'lucide-react'; |
||||||
|
|
||||||
|
type EmptySolutionsProps = { |
||||||
|
projectId: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export function EmptySolutions(props: EmptySolutionsProps) { |
||||||
|
const { projectId } = props; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="flex min-h-[250px] flex-col items-center justify-center rounded-xl px-5 py-3 sm:px-0 sm:py-20"> |
||||||
|
<Blocks className="mb-4 h-8 w-8 opacity-10 sm:h-14 sm:w-14" /> |
||||||
|
<h2 className="mb-1 text-lg font-semibold sm:text-xl"> |
||||||
|
No Solutions Found |
||||||
|
</h2> |
||||||
|
<p className="mb-3 text-balance text-center text-xs text-gray-800 sm:text-sm"> |
||||||
|
No solutions submitted yet. Be the first one to submit a solution. |
||||||
|
</p> |
||||||
|
<div className="flex flex-col items-center gap-1 sm:flex-row sm:gap-1.5"> |
||||||
|
<a |
||||||
|
href={`/projects/${projectId}`} |
||||||
|
className="flex w-full items-center gap-1.5 rounded-md bg-gray-900 px-3 py-1.5 text-xs text-white sm:w-auto sm:text-sm" |
||||||
|
> |
||||||
|
<CodeXml className="h-4 w-4" /> |
||||||
|
Start project |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,55 @@ |
|||||||
|
import { ArrowUpRight, X } from 'lucide-react'; |
||||||
|
import { Modal } from '../Modal'; |
||||||
|
import { getRelativeTimeString } from '../../lib/date'; |
||||||
|
|
||||||
|
type LeavingRoadmapWarningModalProps = { |
||||||
|
onClose: () => void; |
||||||
|
onContinue: () => void; |
||||||
|
}; |
||||||
|
|
||||||
|
export function LeavingRoadmapWarningModal( |
||||||
|
props: LeavingRoadmapWarningModalProps, |
||||||
|
) { |
||||||
|
const { onClose, onContinue } = props; |
||||||
|
|
||||||
|
const projectTips = [ |
||||||
|
'Leave an upvote if you liked the project', |
||||||
|
'Open an issue on the GitHub repository and give the user feedback about their project', |
||||||
|
'Report if the solution is not relevant', |
||||||
|
]; |
||||||
|
|
||||||
|
return ( |
||||||
|
<Modal onClose={onClose} bodyClassName="h-auto p-4"> |
||||||
|
<h2 className="mb-0.5 text-xl font-semibold">Leaving roadmap.sh</h2> |
||||||
|
<p className="text-balance text-sm text-gray-500"> |
||||||
|
You are about to visit the project page on GitHub. Once you have |
||||||
|
reviewed the project, please back and. |
||||||
|
</p> |
||||||
|
|
||||||
|
<ul className="ml-4 mt-4 list-disc space-y-1.5 marker:text-gray-400"> |
||||||
|
{projectTips.map((tip) => { |
||||||
|
return ( |
||||||
|
<li key={tip} className="text-balance"> |
||||||
|
{tip} |
||||||
|
</li> |
||||||
|
); |
||||||
|
})} |
||||||
|
</ul> |
||||||
|
|
||||||
|
<button |
||||||
|
className="mt-4 inline-flex items-center gap-2 rounded-lg bg-black p-2 px-3 text-white" |
||||||
|
onClick={onContinue} |
||||||
|
> |
||||||
|
<ArrowUpRight className="h-5 w-5" /> |
||||||
|
Continue to Solution |
||||||
|
</button> |
||||||
|
|
||||||
|
<button |
||||||
|
className="absolute right-2.5 top-2.5 text-gray-600 hover:text-black" |
||||||
|
onClick={onClose} |
||||||
|
> |
||||||
|
<X className="h-5 w-5" /> |
||||||
|
</button> |
||||||
|
</Modal> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,302 @@ |
|||||||
|
import { useEffect, useState } from 'react'; |
||||||
|
import { useToast } from '../../hooks/use-toast'; |
||||||
|
import { httpGet, httpPost } from '../../lib/http'; |
||||||
|
import { LoadingSolutions } from './LoadingSolutions'; |
||||||
|
import { EmptySolutions } from './EmptySolutions'; |
||||||
|
import { ArrowDown, ArrowUp, CalendarCheck } from 'lucide-react'; |
||||||
|
import { getRelativeTimeString } from '../../lib/date'; |
||||||
|
import { Pagination } from '../Pagination/Pagination'; |
||||||
|
import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser'; |
||||||
|
import { pageProgressMessage } from '../../stores/page'; |
||||||
|
import { cn } from '../../lib/classname'; |
||||||
|
import { LeavingRoadmapWarningModal } from './LeavingRoadmapWarningModal'; |
||||||
|
|
||||||
|
export interface ProjectStatusDocument { |
||||||
|
_id?: string; |
||||||
|
|
||||||
|
userId: string; |
||||||
|
projectId: string; |
||||||
|
|
||||||
|
startedAt?: Date; |
||||||
|
submittedAt?: Date; |
||||||
|
repositoryUrl?: string; |
||||||
|
|
||||||
|
upvotes: number; |
||||||
|
downvotes: number; |
||||||
|
|
||||||
|
isVisible?: boolean; |
||||||
|
|
||||||
|
updated1t: Date; |
||||||
|
} |
||||||
|
|
||||||
|
const allowedVoteType = ['upvote', 'downvote'] as const; |
||||||
|
export type AllowedVoteType = (typeof allowedVoteType)[number]; |
||||||
|
|
||||||
|
type ListProjectSolutionsResponse = { |
||||||
|
data: (ProjectStatusDocument & { |
||||||
|
user: { |
||||||
|
id: string; |
||||||
|
name: string; |
||||||
|
avatar: string; |
||||||
|
}; |
||||||
|
voteType?: AllowedVoteType | 'none'; |
||||||
|
})[]; |
||||||
|
totalCount: number; |
||||||
|
totalPages: number; |
||||||
|
currPage: number; |
||||||
|
perPage: number; |
||||||
|
}; |
||||||
|
|
||||||
|
type QueryParams = { |
||||||
|
p?: string; |
||||||
|
}; |
||||||
|
|
||||||
|
type PageState = { |
||||||
|
currentPage: number; |
||||||
|
}; |
||||||
|
|
||||||
|
const VISITED_SOLUTIONS_KEY = 'visited-project-solutions'; |
||||||
|
|
||||||
|
type ListProjectSolutionsProps = { |
||||||
|
projectId: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export function ListProjectSolutions(props: ListProjectSolutionsProps) { |
||||||
|
const { projectId } = props; |
||||||
|
|
||||||
|
const toast = useToast(); |
||||||
|
const [pageState, setPageState] = useState<PageState>({ |
||||||
|
currentPage: 0, |
||||||
|
}); |
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(true); |
||||||
|
const [solutions, setSolutions] = useState<ListProjectSolutionsResponse>(); |
||||||
|
const [alreadyVisitedSolutions, setAlreadyVisitedSolutions] = useState< |
||||||
|
Record<string, boolean> |
||||||
|
>({}); |
||||||
|
const [showLeavingRoadmapModal, setShowLeavingRoadmapModal] = useState< |
||||||
|
ListProjectSolutionsResponse['data'][number] | null |
||||||
|
>(null); |
||||||
|
|
||||||
|
const loadSolutions = async (page = 1) => { |
||||||
|
const { response, error } = await httpGet<ListProjectSolutionsResponse>( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-list-project-solutions/${projectId}`, |
||||||
|
{ |
||||||
|
currPage: page, |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
if (error || !response) { |
||||||
|
toast.error(error?.message || 'Failed to load project solutions'); |
||||||
|
setIsLoading(false); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setSolutions(response); |
||||||
|
}; |
||||||
|
|
||||||
|
const handleSubmitVote = async ( |
||||||
|
solutionId: string, |
||||||
|
voteType: AllowedVoteType, |
||||||
|
) => { |
||||||
|
pageProgressMessage.set('Submitting vote...'); |
||||||
|
const { response, error } = await httpPost( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-vote-project/${solutionId}`, |
||||||
|
{ |
||||||
|
voteType, |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
if (error || !response) { |
||||||
|
toast.error(error?.message || 'Failed to submit vote'); |
||||||
|
pageProgressMessage.set(''); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
pageProgressMessage.set(''); |
||||||
|
setSolutions((prev) => { |
||||||
|
if (!prev) { |
||||||
|
return prev; |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
...prev, |
||||||
|
data: prev.data.map((solution) => { |
||||||
|
if (solution._id === solutionId) { |
||||||
|
return { |
||||||
|
...solution, |
||||||
|
upvotes: response?.upvotes || 0, |
||||||
|
downvotes: response?.downvotes || 0, |
||||||
|
voteType, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
return solution; |
||||||
|
}), |
||||||
|
}; |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const queryParams = getUrlParams() as QueryParams; |
||||||
|
const alreadyVisitedSolutions = JSON.parse( |
||||||
|
localStorage.getItem(VISITED_SOLUTIONS_KEY) || '{}', |
||||||
|
); |
||||||
|
|
||||||
|
setAlreadyVisitedSolutions(alreadyVisitedSolutions); |
||||||
|
setPageState({ |
||||||
|
currentPage: +(queryParams.p || '1'), |
||||||
|
}); |
||||||
|
}, []); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
setIsLoading(true); |
||||||
|
if (!pageState.currentPage) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (pageState.currentPage !== 1) { |
||||||
|
setUrlParams({ |
||||||
|
p: String(pageState.currentPage), |
||||||
|
}); |
||||||
|
} else { |
||||||
|
deleteUrlParam('p'); |
||||||
|
} |
||||||
|
|
||||||
|
loadSolutions(pageState.currentPage).finally(() => { |
||||||
|
setIsLoading(false); |
||||||
|
}); |
||||||
|
}, [pageState]); |
||||||
|
|
||||||
|
if (isLoading) { |
||||||
|
return <LoadingSolutions />; |
||||||
|
} |
||||||
|
|
||||||
|
const isEmpty = solutions?.data.length === 0; |
||||||
|
if (isEmpty) { |
||||||
|
return <EmptySolutions projectId={projectId} />; |
||||||
|
} |
||||||
|
|
||||||
|
const leavingRoadmapModal = showLeavingRoadmapModal ? ( |
||||||
|
<LeavingRoadmapWarningModal |
||||||
|
onClose={() => setShowLeavingRoadmapModal(null)} |
||||||
|
onContinue={() => { |
||||||
|
const visitedSolutions = { |
||||||
|
...alreadyVisitedSolutions, |
||||||
|
[showLeavingRoadmapModal._id!]: true, |
||||||
|
}; |
||||||
|
localStorage.setItem( |
||||||
|
VISITED_SOLUTIONS_KEY, |
||||||
|
JSON.stringify(visitedSolutions), |
||||||
|
); |
||||||
|
|
||||||
|
window.open(showLeavingRoadmapModal.repositoryUrl, '_blank'); |
||||||
|
}} |
||||||
|
/> |
||||||
|
) : null; |
||||||
|
|
||||||
|
return ( |
||||||
|
<section className="-mx-2"> |
||||||
|
{leavingRoadmapModal} |
||||||
|
|
||||||
|
<Pagination |
||||||
|
variant="minimal" |
||||||
|
totalPages={solutions?.totalPages || 1} |
||||||
|
currPage={solutions?.currPage || 1} |
||||||
|
perPage={solutions?.perPage || 21} |
||||||
|
totalCount={solutions?.totalCount || 0} |
||||||
|
onPageChange={(page) => { |
||||||
|
setPageState({ |
||||||
|
...pageState, |
||||||
|
currentPage: page, |
||||||
|
}); |
||||||
|
}} |
||||||
|
/> |
||||||
|
|
||||||
|
<div className="my-4 flex flex-col gap-2"> |
||||||
|
{solutions?.data.map((solution) => { |
||||||
|
const repoUrlParts = solution?.repositoryUrl |
||||||
|
?.replace(/https?:\/\/(www\.)?github\.com/, '') |
||||||
|
.split('/'); |
||||||
|
const username = repoUrlParts?.[1]; |
||||||
|
const repoName = repoUrlParts?.[2]; |
||||||
|
|
||||||
|
const isVisited = alreadyVisitedSolutions[solution._id!]; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
key={solution._id} |
||||||
|
className="flex items-center justify-between gap-2 rounded-md bg-gray-100 p-2.5" |
||||||
|
> |
||||||
|
<div> |
||||||
|
<a |
||||||
|
href={solution.repositoryUrl} |
||||||
|
target="_blank" |
||||||
|
className="font-medium underline underline-offset-2" |
||||||
|
onClick={(e) => { |
||||||
|
if (!isVisited) { |
||||||
|
e.preventDefault(); |
||||||
|
setShowLeavingRoadmapModal(solution); |
||||||
|
} |
||||||
|
}} |
||||||
|
> |
||||||
|
{username}/{repoName} |
||||||
|
</a> |
||||||
|
|
||||||
|
<div className="mt-2 flex items-center"> |
||||||
|
<button |
||||||
|
className={cn( |
||||||
|
'flex items-center gap-1 text-sm text-gray-500 hover:text-black', |
||||||
|
solution?.voteType === 'upvote' && |
||||||
|
'text-orange-600 hover:text-orange-700', |
||||||
|
)} |
||||||
|
disabled={solution?.voteType === 'upvote'} |
||||||
|
onClick={() => { |
||||||
|
handleSubmitVote(solution._id!, 'upvote'); |
||||||
|
}} |
||||||
|
> |
||||||
|
<ArrowUp className="size-3.5 stroke-[2.5px]" /> |
||||||
|
{solution.upvotes} |
||||||
|
</button> |
||||||
|
<span className="mx-2">·</span> |
||||||
|
<button |
||||||
|
className={cn( |
||||||
|
'flex items-center gap-1 text-sm text-gray-500 hover:text-black', |
||||||
|
solution?.voteType === 'downvote' && |
||||||
|
'text-orange-600 hover:text-orange-700', |
||||||
|
)} |
||||||
|
disabled={solution?.voteType === 'downvote'} |
||||||
|
onClick={() => { |
||||||
|
handleSubmitVote(solution._id!, 'downvote'); |
||||||
|
}} |
||||||
|
> |
||||||
|
<ArrowDown className="size-3.5 stroke-[2.5px]" /> |
||||||
|
{solution.downvotes} |
||||||
|
</button> |
||||||
|
<span className="mx-2">·</span> |
||||||
|
<span className="flex items-center gap-1 text-sm text-gray-500"> |
||||||
|
<CalendarCheck className="size-3.5 stroke-[2.5px]" /> |
||||||
|
{getRelativeTimeString(solution?.submittedAt!)} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
|
||||||
|
<Pagination |
||||||
|
totalPages={solutions?.totalPages || 1} |
||||||
|
currPage={solutions?.currPage || 1} |
||||||
|
perPage={solutions?.perPage || 21} |
||||||
|
totalCount={solutions?.totalCount || 0} |
||||||
|
onPageChange={(page) => { |
||||||
|
setPageState({ |
||||||
|
...pageState, |
||||||
|
currentPage: page, |
||||||
|
}); |
||||||
|
}} |
||||||
|
/> |
||||||
|
</section> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,12 @@ |
|||||||
|
export function LoadingSolutions() { |
||||||
|
return ( |
||||||
|
<ul className="flex flex-col gap-2"> |
||||||
|
{new Array(21).fill(0).map((_, index) => ( |
||||||
|
<li |
||||||
|
key={index} |
||||||
|
className="h-[76px] animate-pulse rounded-md border bg-gray-200" |
||||||
|
/> |
||||||
|
))} |
||||||
|
</ul> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,47 @@ |
|||||||
|
import { cn } from '../../lib/classname'; |
||||||
|
|
||||||
|
export const allowedProjectTabs = ['details', 'solutions'] as const; |
||||||
|
export type AllowedProjectTab = (typeof allowedProjectTabs)[number]; |
||||||
|
|
||||||
|
type ProjectTabsProps = { |
||||||
|
activeTab: AllowedProjectTab; |
||||||
|
projectId: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export function ProjectTabs(props: ProjectTabsProps) { |
||||||
|
const { activeTab, projectId } = props; |
||||||
|
|
||||||
|
const tabs = [ |
||||||
|
{ name: 'Project Details', value: 'details' }, |
||||||
|
{ name: 'Community Solutions', value: 'solutions' }, |
||||||
|
]; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="my-3 flex flex-row flex-wrap items-center gap-1.5 rounded-md border bg-white px-2 text-sm"> |
||||||
|
{tabs.map((tab) => { |
||||||
|
const isActive = tab.value === activeTab; |
||||||
|
|
||||||
|
const href = |
||||||
|
tab.value === 'details' |
||||||
|
? `/projects/${projectId}` |
||||||
|
: `/projects/${projectId}/${tab.value}`; |
||||||
|
|
||||||
|
return ( |
||||||
|
<a |
||||||
|
key={tab.value} |
||||||
|
href={href} |
||||||
|
className={cn( |
||||||
|
'relative p-2', |
||||||
|
isActive ? 'font-medium' : 'opacity-50', |
||||||
|
)} |
||||||
|
> |
||||||
|
{tab.name} |
||||||
|
{isActive && ( |
||||||
|
<span className="absolute bottom-0 left-0 right-0 h-0.5 translate-y-1/2 bg-black"></span> |
||||||
|
)} |
||||||
|
</a> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,78 @@ |
|||||||
|
--- |
||||||
|
import BaseLayout from '../../../layouts/BaseLayout.astro'; |
||||||
|
import { Badge } from '../../../components/Badge'; |
||||||
|
import { |
||||||
|
getAllProjects, |
||||||
|
getProjectById, |
||||||
|
type ProjectFrontmatter, |
||||||
|
} from '../../../lib/project'; |
||||||
|
import AstroIcon from '../../../components/AstroIcon.astro'; |
||||||
|
import { ProjectMilestoneStrip } from '../../../components/Projects/ProjectMilestoneStrip'; |
||||||
|
import { ProjectTabs } from '../../../components/Projects/ProjectTabs'; |
||||||
|
import { ListProjectSolutions } from '../../../components/Projects/ListProjectSolutions'; |
||||||
|
|
||||||
|
export async function getStaticPaths() { |
||||||
|
const projects = await getAllProjects(); |
||||||
|
|
||||||
|
return projects |
||||||
|
.map((project) => project.id) |
||||||
|
.map((projectId) => ({ |
||||||
|
params: { projectId }, |
||||||
|
})); |
||||||
|
} |
||||||
|
|
||||||
|
interface Params extends Record<string, string | undefined> { |
||||||
|
projectId: string; |
||||||
|
} |
||||||
|
|
||||||
|
const { projectId } = Astro.params as Params; |
||||||
|
|
||||||
|
const project = await getProjectById(projectId); |
||||||
|
const projectData = project.frontmatter as ProjectFrontmatter; |
||||||
|
|
||||||
|
let jsonLdSchema: any[] = []; |
||||||
|
|
||||||
|
const ogImageUrl = projectData?.seo?.ogImageUrl || '/images/og-img.png'; |
||||||
|
const githubUrl = `https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/projects/${projectId}.md`; |
||||||
|
--- |
||||||
|
|
||||||
|
<BaseLayout |
||||||
|
permalink={`/projects/${projectId}`} |
||||||
|
title={projectData?.seo?.title} |
||||||
|
briefTitle={projectData.title} |
||||||
|
ogImageUrl={ogImageUrl} |
||||||
|
description={projectData.seo.description} |
||||||
|
keywords={projectData.seo.keywords} |
||||||
|
jsonLd={jsonLdSchema} |
||||||
|
resourceId={projectId} |
||||||
|
resourceType='project' |
||||||
|
> |
||||||
|
<div class='bg-gray-50'> |
||||||
|
<div class='container'> |
||||||
|
<ProjectTabs projectId={projectId} activeTab='solutions' /> |
||||||
|
|
||||||
|
<div class='overflow-hidden rounded-lg border bg-white p-5'> |
||||||
|
<div class='relative -mx-2 -mt-2 mb-5 rounded-lg bg-gray-100/70 p-5'> |
||||||
|
<div class='absolute right-2 top-2'> |
||||||
|
<Badge variant='yellow' text={projectData.difficulty} /> |
||||||
|
</div> |
||||||
|
<div class='mb-5'> |
||||||
|
<h1 class='mb-1.5 text-3xl font-semibold'>{projectData.title}</h1> |
||||||
|
<p class='text-gray-500'>{projectData.description}</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class='mt-4'> |
||||||
|
<div class='flex flex-row flex-wrap gap-1.5'> |
||||||
|
{ |
||||||
|
projectData.skills.map((skill) => ( |
||||||
|
<Badge variant='green' text={skill} /> |
||||||
|
)) |
||||||
|
} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<ListProjectSolutions projectId={projectId} client:load /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</BaseLayout> |
Loading…
Reference in new issue