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