feat: implement project status (#6513)

* wip

* wip

* wip

* fix: button width

* Add stepper component

* Refactor project stepper

* Refactor stepper

* Refactor stepper

* Update clicker

* Refactor project stepper

* Add projects tip popup

* Add start project modal

* Submission requirement modalg

* Requirement verification functionality

* Update project submission

* Voting and active timeline

* Finalize project solution stepper

* Update empty project page

* Add user avatars

* Solutions listing page

* Update tab design

* Fix styles for loading and pagination

* Redesign project page header

* Make project page responsive

* Make project pages responsive

* Update the leaving roadmap page

* Start project modal updates

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
pull/6627/head
Arik Chakma 3 months ago committed by GitHub
parent 8a5c0eeb5f
commit 1981568501
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      .astro/settings.json
  2. 4
      src/components/Modal.tsx
  3. 2
      src/components/OpenSourceStat.astro
  4. 29
      src/components/Projects/EmptySolutions.tsx
  5. 64
      src/components/Projects/LeavingRoadmapWarningModal.tsx
  6. 327
      src/components/Projects/ListProjectSolutions.tsx
  7. 44
      src/components/Projects/LoadingSolutions.tsx
  8. 69
      src/components/Projects/ProjectTabs.tsx
  9. 169
      src/components/Projects/StartProjectModal.tsx
  10. 37
      src/components/Projects/StatusStepper/MilestoneStep.tsx
  11. 245
      src/components/Projects/StatusStepper/ProjectStepper.tsx
  12. 51
      src/components/Projects/StatusStepper/StepperAction.tsx
  13. 17
      src/components/Projects/StatusStepper/StepperStepSeparator.tsx
  14. 44
      src/components/Projects/SubmissionRequirement.tsx
  15. 299
      src/components/Projects/SubmitProjectModal.tsx
  16. 30
      src/components/Projects/VoteButton.tsx
  17. 27
      src/components/TeamDropdown/TeamDropdown.tsx
  18. 29
      src/hooks/use-sticky-stuck.tsx
  19. 5
      src/layouts/BaseLayout.astro
  20. 6
      src/lib/date.ts
  21. 6
      src/lib/is-mobile.ts
  22. 129
      src/pages/projects/[projectId].astro
  23. 94
      src/pages/projects/[projectId]/index.astro
  24. 66
      src/pages/projects/[projectId]/solutions.astro

@ -3,6 +3,6 @@
"enabled": false "enabled": false
}, },
"_variables": { "_variables": {
"lastUpdateCheck": 1723501110773 "lastUpdateCheck": 1723855511353
} }
} }

@ -33,7 +33,7 @@ export function Modal(props: ModalProps) {
return ( return (
<div <div
className={cn( className={cn(
'popup fixed left-0 right-0 top-0 z-[99] flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50', 'fixed left-0 right-0 top-0 z-[99] flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50',
overlayClassName, overlayClassName,
)} )}
> >
@ -46,7 +46,7 @@ export function Modal(props: ModalProps) {
<div <div
ref={popupBodyEl} ref={popupBodyEl}
className={cn( className={cn(
'popup-body relative h-full rounded-lg bg-white shadow', 'relative h-full rounded-lg bg-white shadow',
bodyClassName, bodyClassName,
)} )}
> >

@ -44,7 +44,7 @@ const isDiscordMembers = text.toLowerCase() === 'discord members';
} }
<div class="flex flex-row items-center sm:flex-col my-1 sm:my-0"> <div class="flex flex-row items-center sm:flex-col my-1 sm:my-0">
<p <p
class='relative my-0 sm:my-4 mr-1 sm:mr-0 text-base font-bold sm:w-auto sm:text-5xl' class='my-0 sm:my-4 mr-1 sm:mr-0 text-base font-bold sm:w-auto sm:text-5xl'
> >
{value} {value}
</p> </p>

@ -0,0 +1,29 @@
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 opacity-10 h-14 w-14" />
<h2 className="mb-1 text-lg font-semibold sm:text-xl">
No solutions submitted yet
</h2>
<p className="mb-3 text-balance text-center text-xs text-gray-400 sm:text-sm">
Be the first to submit a solution for this project
</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"
>
View Project Details
</a>
</div>
</div>
);
}

@ -0,0 +1,64 @@
import { ArrowUpRight, X } from 'lucide-react';
import { Modal } from '../Modal';
import { SubmissionRequirement } from './SubmissionRequirement.tsx';
type LeavingRoadmapWarningModalProps = {
onClose: () => void;
onContinue: () => void;
};
export function LeavingRoadmapWarningModal(
props: LeavingRoadmapWarningModalProps,
) {
const { onClose, onContinue } = props;
return (
<Modal onClose={onClose} bodyClassName="h-auto p-4">
<h2 className="mb-1.5 text-2xl font-semibold">Leaving roadmap.sh</h2>
<p className="text-sm text-gray-500">
You are about to visit the project solution on GitHub. We recommend you
to follow these tips before you leave.
</p>
<div className="my-3">
<p className="rounded-lg bg-gray-200 p-2 text-sm text-gray-900">
Make sure to come back and{' '}
<span className="font-medium text-purple-600">leave an upvote</span>{' '}
if you liked the solution. It helps the author and the community.
</p>
<p className="mt-1 rounded-lg bg-gray-200 p-2 text-sm text-gray-900">
If you have feedback on the solution, open an issue or a pull request
on the{' '}
<span className="font-medium text-purple-600">
solution repository
</span>
.
</p>
<p className="mt-1 rounded-lg bg-gray-200 p-2 text-sm text-gray-900">
Downvote the solution if it is{' '}
<span className="font-medium text-purple-600">
incorrect or misleading
</span>
. It helps the community. It helps the community.
</p>
</div>
<button
className="inline-flex w-full items-center gap-2 rounded-lg bg-black px-3 py-2.5 text-sm 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,327 @@
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 { ThumbsDown, ThumbsUp } 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 { LeavingRoadmapWarningModal } from './LeavingRoadmapWarningModal';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { VoteButton } from './VoteButton.tsx';
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
import { cn } from '../../lib/classname.ts';
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;
};
const submittedAlternatives = [
'submitted their solution',
'got it done',
'submitted their take',
'finished the project',
'submitted their work',
'completed the project',
'got it done',
'delivered their project',
'handed in their solution',
'provided their deliverables',
'submitted their approach',
'sent in their project',
'presented their take',
'shared their completed task',
'submitted their approach',
'completed it',
'finalized their solution',
'delivered their approach',
'turned in their project',
'submitted their final draft',
'delivered their solution',
];
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,
) => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
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>
{leavingRoadmapModal}
<div className="flex min-h-[500px] flex-col divide-y divide-gray-100">
{solutions?.data.map((solution, counter) => {
const isVisited = alreadyVisitedSolutions[solution._id!];
const avatar = solution.user.avatar || '';
return (
<div
key={solution._id}
className={
'flex flex-col justify-between gap-2 py-2 text-sm text-gray-500 sm:flex-row sm:items-center sm:gap-0'
}
>
<div className="flex items-center gap-1.5">
<img
src={
avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
: '/images/default-avatar.png'
}
alt={solution.user.name}
className="mr-0.5 h-7 w-7 rounded-full"
/>
<span className="font-medium text-black">
{solution.user.name}
</span>
<span className="hidden sm:inline">
{submittedAlternatives[
counter % submittedAlternatives.length
] || 'submitted their solution'}
</span>{' '}
<span className="flex-grow text-right text-gray-400 sm:flex-grow-0 sm:text-left sm:font-medium sm:text-black">
{getRelativeTimeString(solution?.submittedAt!)}
</span>
</div>
<div className="flex items-center justify-end gap-1">
<span className="flex items-center overflow-hidden rounded-full border">
<VoteButton
icon={ThumbsUp}
isActive={solution?.voteType === 'upvote'}
count={solution.upvotes || 0}
onClick={() => {
handleSubmitVote(solution._id!, 'upvote');
}}
/>
<VoteButton
icon={ThumbsDown}
isActive={solution?.voteType === 'downvote'}
count={solution.downvotes || 0}
onClick={() => {
handleSubmitVote(solution._id!, 'downvote');
}}
/>
</span>
<a
className="ml-1 flex items-center gap-1 rounded-full border px-2 py-1 text-xs text-black transition-colors hover:border-black hover:bg-black hover:text-white"
onClick={(e) => {
e.preventDefault();
setShowLeavingRoadmapModal(solution);
}}
target="_blank"
href={solution.repositoryUrl}
>
<GitHubIcon className="h-4 w-4 text-current" />
Visit Solution
</a>
</div>
</div>
);
})}
</div>
{(solutions?.totalPages || 0) > 1 && (
<div className="mt-4">
<Pagination
totalPages={solutions?.totalPages || 1}
currPage={solutions?.currPage || 1}
perPage={solutions?.perPage || 21}
totalCount={solutions?.totalCount || 0}
onPageChange={(page) => {
setPageState({
...pageState,
currentPage: page,
});
}}
/>
</div>
)}
</section>
);
}

@ -0,0 +1,44 @@
import { isMobileScreen } from '../../lib/is-mobile.ts';
export function LoadingSolutions() {
const totalCount = isMobileScreen() ? 3 : 11;
const loadingRow = (
<li className="flex min-h-[78px] animate-pulse flex-wrap items-center justify-between overflow-hidden rounded-md bg-gray-200 sm:min-h-[44px] sm:animate-none sm:rounded-none sm:bg-transparent">
<span className="flex items-center">
<span className="block h-[28px] w-[28px] animate-pulse rounded-full bg-gray-200"></span>
<span
className={`ml-2 block h-[26px] w-[350px] animate-pulse rounded-full bg-gray-200`}
></span>
</span>
<span className="flex items-center gap-2">
<span
className={
'animated-pulse h-[26px] w-[80px] rounded-full bg-gray-200'
}
></span>
<span
className={
'animated-pulse h-[26px] w-[113px] rounded-full bg-gray-200'
}
></span>
</span>
</li>
);
return (
<ul className="flex min-h-[500px] flex-col gap-2 divide-y sm:gap-0">
{loadingRow}
{loadingRow}
{loadingRow}
{loadingRow}
{loadingRow}
{loadingRow}
{loadingRow}
{loadingRow}
{loadingRow}
{loadingRow}
{loadingRow}
</ul>
);
}

@ -0,0 +1,69 @@
import { cn } from '../../lib/classname';
import {
Blocks,
BoxSelect,
type LucideIcon,
StickyNote,
Text,
} from 'lucide-react';
export const allowedProjectTabs = ['details', 'solutions'] as const;
export type AllowedProjectTab = (typeof allowedProjectTabs)[number];
type TabButtonProps = {
text: string;
icon: LucideIcon;
smText?: string;
isActive?: boolean;
href: string;
};
function TabButton(props: TabButtonProps) {
const { text, icon: ButtonIcon, smText, isActive, href } = props;
return (
<a
href={href}
className={cn('relative flex items-center gap-1 p-2', {
'text-black': isActive,
'opacity-40 hover:opacity-90': !isActive,
})}
>
{ButtonIcon && <ButtonIcon className="mr-1 inline-block h-4 w-4" />}
<span className="hidden sm:inline">{text}</span>
{smText && <span className="sm:hidden">{smText}</span>}
{isActive && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 translate-y-1/2 bg-black rounded-t-md"></span>
)}
</a>
);
}
type ProjectTabsProps = {
activeTab: AllowedProjectTab;
projectId: string;
};
export function ProjectTabs(props: ProjectTabsProps) {
const { activeTab, projectId } = props;
return (
<div className="my-3 flex flex-row flex-wrap items-center gap-1.5 rounded-md border bg-white px-2.5 text-sm">
<TabButton
text={'Project Detail'}
icon={Text}
smText={'Details'}
isActive={activeTab === 'details'}
href={`/projects/${projectId}`}
/>
<TabButton
text={'Community Solutions'}
icon={Blocks}
smText={'Solutions'}
isActive={activeTab === 'solutions'}
href={`/projects/${projectId}/solutions`}
/>
</div>
);
}

@ -0,0 +1,169 @@
import { Check, CopyIcon, ServerCrash } from 'lucide-react';
import { Modal } from '../Modal';
import { getRelativeTimeString } from '../../lib/date';
import { useEffect, useState } from 'react';
import { Spinner } from '../ReactIcons/Spinner.tsx';
import { httpPost } from '../../lib/http.ts';
import { CheckIcon } from '../ReactIcons/CheckIcon.tsx';
import { useCopyText } from '../../hooks/use-copy-text.ts';
type StepLabelProps = {
label: string;
};
function StepLabel(props: StepLabelProps) {
const { label } = props;
return (
<span className="flex-shrink-0 rounded-full bg-gray-200 px-2 py-1 text-xs text-gray-600">
{label}
</span>
);
}
type StartProjectModalProps = {
projectId: string;
onClose: () => void;
startedAt?: Date;
onStarted: (startedAt: Date) => void;
};
export function StartProjectModal(props: StartProjectModalProps) {
const { onClose, startedAt, onStarted, projectId } = props;
const [isStartingProject, setIsStartingProject] = useState(true);
const [error, setError] = useState<string | null>();
const { isCopied, copyText } = useCopyText();
const projectUrl = `${import.meta.env.PUBLIC_APP_URL}/projects/${projectId}`;
const formattedStartedAt = startedAt ? getRelativeTimeString(startedAt) : '';
async function handleStartProject() {
if (!projectId || startedAt) {
return;
}
setIsStartingProject(true);
const { response, error } = await httpPost<{
startedAt: Date;
}>(`${import.meta.env.PUBLIC_API_URL}/v1-start-project/${projectId}`, {});
if (error || !response) {
setError(error?.message || 'Failed to start project');
setIsStartingProject(false);
return;
}
onStarted(response.startedAt);
}
useEffect(() => {
handleStartProject().finally(() => setIsStartingProject(false));
}, []);
if (error) {
return (
<Modal onClose={onClose} bodyClassName="h-auto text-red-500">
<div className="flex flex-col items-center justify-center gap-2 pb-10 pt-12">
<ServerCrash className={'h-6 w-6'} />
<p className="font-medium">{error}</p>
</div>
</Modal>
);
}
if (isStartingProject) {
return (
<Modal onClose={onClose} bodyClassName="h-auto">
<div className="flex flex-col items-center justify-center gap-4 pb-10 pt-12">
<Spinner className={'h-6 w-6'} isDualRing={false} />
<p className="font-medium">Starting project ..</p>
</div>
</Modal>
);
}
return (
<Modal
onClose={onClose}
bodyClassName="h-auto p-4 relative overflow-hidden"
wrapperClassName={'max-w-md'}
>
<p className="-mx-4 -mt-4 flex items-center bg-yellow-200 px-3 py-2 text-sm text-yellow-900">
<CheckIcon additionalClasses="mr-1.5 w-[15px] text-yellow-800 h-[15px]" />
<span className="mr-1.5 font-normal">Project started</span>{' '}
<span className="font-semibold">{formattedStartedAt}</span>
</p>
<h2 className="mb-1 mt-5 text-2xl font-semibold text-gray-800">
Start Building
</h2>
<p className="text-gray-700">
Follow these steps to complete the project.
</p>
<div className="my-5 space-y-1.5 marker:text-gray-400">
<p className="mt-1 rounded-lg bg-gray-200 p-2 text-sm text-gray-900">
1. Create a new public repository on GitHub.
</p>
<p className="mt-1 rounded-lg bg-gray-200 p-2 text-sm text-gray-900">
2. Complete the project according to the requirements and push your code
to the GitHub repository.
</p>
<p className="mt-1 rounded-lg bg-gray-200 p-2 text-sm text-gray-900">
3. Add a README file with instructions to run the project and the{' '}
<button
onClick={() => {
copyText(projectUrl);
}}
className="font-semibold"
>
{!isCopied && (
<span className="text-purple-600">
project page URL
<CopyIcon
className="relative -top-px ml-1 inline-block h-4 w-4"
strokeWidth={2.5}
/>
</span>
)}
{isCopied && (
<>
copied URL
<Check className="inline-block h-4 w-4" strokeWidth={2.5} />
</>
)}
</button>
</p>
<p className="mt-1 rounded-lg bg-gray-200 p-2 text-sm text-gray-900">
4. Once done, submit your solution to help the others learn and get feedback
from the community.
</p>
</div>
<div className="mb-5">
<p className='text-sm'>
If you get stuck, you can always ask for help in the community{' '}
<a
href="https://roadmap.sh/discord"
target="_blank"
className="font-medium underline underline-offset-2"
>
chat on discord
</a>
.
</p>
</div>
<button
className="w-full rounded-md bg-black py-2 text-sm font-medium text-white hover:bg-gray-900"
onClick={onClose}
>
Close
</button>
</Modal>
);
}

@ -0,0 +1,37 @@
import { Check, type LucideIcon } from 'lucide-react';
type MilestoneStepProps = {
icon: LucideIcon;
text: string;
isCompleted?: boolean;
isActive?: boolean;
};
export function MilestoneStep(props: MilestoneStepProps) {
const { icon: DisplayIcon, text, isActive = false, isCompleted } = props;
if (isActive) {
return (
<span className="flex cursor-default items-center gap-1.5 rounded-md border border-dashed border-current px-1.5 py-0.5 text-sm font-medium text-gray-400">
<DisplayIcon size={14} />
<span>{text}</span>
</span>
);
}
if (isCompleted) {
return (
<span className="flex cursor-default items-center gap-1.5 text-sm font-medium text-green-600">
<Check size={14} strokeWidth={3} />
<span>{text}</span>
</span>
);
}
return (
<span className="flex cursor-default items-center gap-1.5 text-sm text-gray-400">
<DisplayIcon size={14} />
<span>{text}</span>
</span>
);
}

@ -0,0 +1,245 @@
import { Flag, Play, Send } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { cn } from '../../../lib/classname.ts';
import { useStickyStuck } from '../../../hooks/use-sticky-stuck.tsx';
import { StepperAction } from './StepperAction.tsx';
import { StepperStepSeparator } from './StepperStepSeparator.tsx';
import { MilestoneStep } from './MilestoneStep.tsx';
import { httpGet } from '../../../lib/http.ts';
import { StartProjectModal } from '../StartProjectModal.tsx';
import { getRelativeTimeString } from '../../../lib/date.ts';
import { isLoggedIn } from '../../../lib/jwt.ts';
import { showLoginPopup } from '../../../lib/popup.ts';
import { SubmitProjectModal } from '../SubmitProjectModal.tsx';
type ProjectStatusResponse = {
id?: string;
startedAt?: Date;
submittedAt?: Date;
repositoryUrl?: string;
upvotes: number;
downvotes: number;
};
type ProjectStepperProps = {
projectId: string;
};
export function ProjectStepper(props: ProjectStepperProps) {
const { projectId } = props;
const stickyElRef = useRef<HTMLDivElement>(null);
const isSticky = useStickyStuck(stickyElRef, 8);
const [isStartingProject, setIsStartingProject] = useState(false);
const [isSubmittingProject, setIsSubmittingProject] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeStep, setActiveStep] = useState<number>(0);
const [isLoadingStatus, setIsLoadingStatus] = useState(true);
const [projectStatus, setProjectStatus] = useState<ProjectStatusResponse>({
upvotes: 0,
downvotes: 0,
});
async function loadProjectStatus() {
setIsLoadingStatus(true);
const { response, error } = await httpGet<ProjectStatusResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-project-status/${projectId}`,
{},
);
if (error || !response) {
setError(error?.message || 'Error loading project status');
setIsLoadingStatus(false);
return;
}
const { startedAt, submittedAt, upvotes } = response;
if (upvotes >= 10) {
setActiveStep(4);
} else if (upvotes >= 5) {
setActiveStep(3);
} else if (submittedAt) {
setActiveStep(2);
} else if (startedAt) {
setActiveStep(1);
}
setProjectStatus(response);
setIsLoadingStatus(false);
}
useEffect(() => {
loadProjectStatus().finally(() => {});
}, []);
return (
<div
ref={stickyElRef}
className={cn(
'relative sm:sticky top-0 my-5 -mx-4 sm:mx-0 overflow-hidden rounded-none border-x-0 sm:border-x sm:rounded-lg border bg-white transition-all',
{
'sm:-mx-5 sm:rounded-none sm:border-x-0 sm:border-t-0 sm:bg-gray-50': isSticky,
},
)}
>
{isSubmittingProject && (
<SubmitProjectModal
onClose={() => setIsSubmittingProject(false)}
projectId={projectId}
onSubmit={(response) => {
const { repositoryUrl, submittedAt } = response;
setProjectStatus({
...projectStatus,
repositoryUrl,
submittedAt,
});
setActiveStep(2);
}}
repositoryUrl={projectStatus.repositoryUrl}
/>
)}
{isStartingProject && (
<StartProjectModal
projectId={projectId}
onStarted={(startedAt) => {
setProjectStatus({
...projectStatus,
startedAt,
});
setActiveStep(1);
}}
startedAt={projectStatus?.startedAt}
onClose={() => setIsStartingProject(false)}
/>
)}
{error && (
<div className="absolute inset-0 bg-red-100 p-2 text-sm text-red-500">
{error}
</div>
)}
{isLoadingStatus && (
<div className={cn('striped-loader absolute inset-0 z-10 bg-white')} />
)}
<div
className={cn(
'px-4 py-2 text-sm text-gray-500 transition-colors bg-gray-100',
{
'bg-purple-600 text-white': isSticky,
},
)}
>
{activeStep === 0 && (
<>
Start building, submit solution and get feedback from the community.
</>
)}
{activeStep === 1 && (
<>
Started working{' '}
<span
className={cn('font-medium text-gray-800', {
'text-purple-200': isSticky,
})}
>
{getRelativeTimeString(projectStatus.startedAt!)}
</span>
. Follow{' '}
<button
className={cn('underline underline-offset-2 hover:text-black', {
'text-purple-100 hover:text-white': isSticky,
})}
onClick={() => {
setIsStartingProject(true);
}}
>
these tips
</button>{' '}
to get most out of it.
</>
)}
{activeStep >= 2 && (
<>
Congrats on submitting your solution.{' '}
<button
className={cn('underline underline-offset-2 hover:text-black', {
'text-purple-100 hover:text-white': isSticky,
})}
onClick={() => {
setIsSubmittingProject(true);
}}
>
View or update your submission.
</button>
</>
)}
</div>
<div className="flex flex-col sm:flex-row min-h-[60px] items-start sm:items-center justify-between gap-2 sm:gap-3 px-4 py-4 sm:py-0">
<StepperAction
isActive={activeStep === 0}
isCompleted={activeStep > 0}
icon={Play}
text={activeStep > 0 ? 'Started Working' : 'Start Working'}
number={1}
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsStartingProject(true);
}}
/>
<StepperStepSeparator isActive={activeStep > 0} />
<StepperAction
isActive={activeStep === 1}
isCompleted={activeStep > 1}
icon={Send}
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsSubmittingProject(true);
}}
text={activeStep > 1 ? 'Submitted' : 'Submit Solution'}
number={2}
/>
<StepperStepSeparator isActive={activeStep > 1} />
<MilestoneStep
isActive={activeStep === 2}
isCompleted={activeStep > 2}
icon={Flag}
text={
activeStep == 2
? `${projectStatus.upvotes} / 5 upvotes`
: `5 upvotes`
}
/>
<StepperStepSeparator isActive={activeStep > 2} />
<MilestoneStep
isActive={activeStep === 3}
isCompleted={activeStep > 3}
icon={Flag}
text={
activeStep == 3
? `${projectStatus.upvotes} / 10 upvotes`
: activeStep > 3
? `${projectStatus.upvotes} upvotes`
: `10 upvotes`
}
/>
</div>
</div>
);
}

@ -0,0 +1,51 @@
import { Check, type LucideIcon } from 'lucide-react';
type StepperActionProps = {
isActive?: boolean;
isCompleted?: boolean;
onClick?: () => void;
icon: LucideIcon;
text: string;
number: number;
};
export function StepperAction(props: StepperActionProps) {
const {
isActive,
onClick = () => null,
isCompleted,
icon: DisplayIcon,
text,
number,
} = props;
if (isActive) {
return (
<button
onClick={onClick}
className="flex items-center gap-1.5 rounded-full bg-purple-600 py-1 pl-2 pr-2.5 text-sm text-white hover:bg-purple-700"
>
<DisplayIcon size={13} />
<span>{text}</span>
</button>
);
}
if (isCompleted) {
return (
<span className="flex cursor-default items-center gap-1.5 text-sm font-medium text-green-600">
<Check size={14} strokeWidth={3} />
<span>{text}</span>
</span>
);
}
return (
<span className="flex cursor-default items-center gap-1.5 text-sm text-gray-400">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-gray-400/70 text-xs text-white">
{number}
</span>
<span>{text}</span>
</span>
);
}

@ -0,0 +1,17 @@
import { cn } from '../../../lib/classname.ts';
type StepperStepSeparatorProps = {
isActive: boolean;
};
export function StepperStepSeparator(props: StepperStepSeparatorProps) {
const { isActive } = props;
return (
<hr
className={cn('flex-grow hidden sm:flex border border-gray-300', {
'border-green-500': isActive,
})}
/>
);
}

@ -0,0 +1,44 @@
import type { ReactNode } from 'react';
import { cn } from '../../lib/classname.ts';
import { CheckIcon, CircleDashed, Loader, Loader2, X } from 'lucide-react';
import { Spinner } from '../ReactIcons/Spinner.tsx';
type SubmissionRequirementProps = {
status: 'pending' | 'success' | 'error';
children: ReactNode;
isLoading?: boolean;
};
export function SubmissionRequirement(props: SubmissionRequirementProps) {
const { status, isLoading = false, children } = props;
return (
<div
className={cn(`flex items-center rounded-lg p-2 text-sm text-gray-900`, {
'bg-gray-200': status === 'pending',
'bg-green-200': status === 'success',
'bg-red-200': status === 'error',
})}
>
{!isLoading && (
<>
{status === 'pending' ? (
<CircleDashed className="h-4 w-4 flex-shrink-0 text-gray-400" />
) : status === 'success' ? (
<CheckIcon className="h-4 w-4 flex-shrink-0 text-green-800" />
) : (
<X className="h-4 w-4 flex-shrink-0 text-yellow-800" />
)}
</>
)}
{isLoading && (
<Loader2
className={'h-4 w-4 animate-spin text-gray-400'}
strokeWidth={3}
/>
)}
<span className="ml-2">{children}</span>
</div>
);
}

@ -0,0 +1,299 @@
import { CheckIcon, CopyIcon, X } from 'lucide-react';
import { CheckIcon as ReactCheckIcon } from '../ReactIcons/CheckIcon.tsx';
import { Modal } from '../Modal';
import { type FormEvent, useState } from 'react';
import { httpPost } from '../../lib/http';
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
import { SubmissionRequirement } from './SubmissionRequirement.tsx';
import { useCopyText } from '../../hooks/use-copy-text.ts';
type SubmitProjectResponse = {
repositoryUrl: string;
submittedAt: Date;
};
type VerificationChecksType = {
repositoryExists: 'pending' | 'success' | 'error';
readmeExists: 'pending' | 'success' | 'error';
projectUrlExists: 'pending' | 'success' | 'error';
};
type SubmitProjectModalProps = {
onClose: () => void;
projectId: string;
repositoryUrl?: string;
onSubmit: (response: SubmitProjectResponse) => void;
};
export function SubmitProjectModal(props: SubmitProjectModalProps) {
const {
onClose,
projectId,
onSubmit,
repositoryUrl: defaultRepositoryUrl = '',
} = props;
const { isCopied, copyText } = useCopyText();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const [repoUrl, setRepoUrl] = useState(defaultRepositoryUrl);
const [verificationChecks, setVerificationChecks] =
useState<VerificationChecksType>({
repositoryExists: defaultRepositoryUrl ? 'success' : 'pending',
readmeExists: defaultRepositoryUrl ? 'success' : 'pending',
projectUrlExists: defaultRepositoryUrl ? 'success' : 'pending',
});
const projectUrl = `${import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh'}/projects/${projectId}`;
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
try {
setVerificationChecks({
repositoryExists: 'pending',
readmeExists: 'pending',
projectUrlExists: 'pending',
});
setIsLoading(true);
setError('');
setSuccessMessage('');
if (!repoUrl) {
setVerificationChecks({
repositoryExists: 'error',
readmeExists: 'pending',
projectUrlExists: 'pending',
});
throw new Error('Repository URL is required');
}
const repoUrlParts = repoUrl
.replace(/https?:\/\/(www\.)?github\.com/, '')
.split('/');
const username = repoUrlParts[1];
const repoName = repoUrlParts[2];
if (!username || !repoName) {
setVerificationChecks({
repositoryExists: 'error',
readmeExists: 'pending',
projectUrlExists: 'pending',
});
throw new Error('Invalid GitHub repository URL');
}
const mainApiUrl = `https://api.github.com/repos/${username}/${repoName}`;
const allContentsUrl = `${mainApiUrl}/contents`;
const allContentsResponse = await fetch(allContentsUrl);
if (!allContentsResponse.ok) {
setVerificationChecks({
repositoryExists: 'error',
readmeExists: 'pending',
projectUrlExists: 'pending',
});
if (allContentsResponse?.status === 404) {
throw new Error(
'Repository not found. Make sure it exists and is public.',
);
}
throw new Error('Failed to fetch repository contents');
}
const allContentsData = await allContentsResponse.json();
if (!Array.isArray(allContentsData)) {
setVerificationChecks({
repositoryExists: 'error',
readmeExists: 'pending',
projectUrlExists: 'pending',
});
throw new Error('Failed to fetch repository contents');
}
const readmeFile = allContentsData.find(
(file) => file.name.toLowerCase() === 'readme.md',
);
if (!readmeFile || !readmeFile.url) {
setVerificationChecks({
repositoryExists: 'success',
readmeExists: 'error',
projectUrlExists: 'pending',
});
throw new Error('Readme file not found');
}
const readmeUrl = readmeFile.url;
const response = await fetch(readmeUrl);
if (!response.ok || response.status === 404) {
setVerificationChecks({
repositoryExists: 'success',
readmeExists: 'error',
projectUrlExists: 'pending',
});
throw new Error('Readme file not found');
}
const data = await response.json();
if (!data.content) {
setVerificationChecks({
repositoryExists: 'success',
readmeExists: 'error',
projectUrlExists: 'pending',
});
throw new Error('Readme file not found');
}
const readmeContent = window.atob(data.content);
if (!readmeContent.includes(projectUrl)) {
setVerificationChecks({
repositoryExists: 'success',
readmeExists: 'success',
projectUrlExists: 'error',
});
throw new Error('Add the project page URL to the readme file');
}
setVerificationChecks({
repositoryExists: 'success',
readmeExists: 'success',
projectUrlExists: 'success',
});
const submitProjectUrl = `${import.meta.env.PUBLIC_API_URL}/v1-submit-project/${projectId}`;
const { response: submitResponse, error } =
await httpPost<SubmitProjectResponse>(submitProjectUrl, {
repositoryUrl: repoUrl,
});
if (error || !submitResponse) {
throw new Error(
error?.message || 'Error submitting project. Please try again!',
);
}
setSuccessMessage('Solution submitted successfully!');
setIsLoading(false);
onSubmit(submitResponse);
} catch (error: any) {
console.error(error);
setError(error?.message || 'Failed to verify repository');
setIsLoading(false);
}
};
if (successMessage) {
return (
<Modal onClose={onClose} bodyClassName="h-auto p-4">
<div className="flex flex-col items-center justify-center gap-4 pb-10 pt-12">
<ReactCheckIcon additionalClasses={'h-12 text-green-500 w-12'} />
<p className="text-lg font-medium">{successMessage}</p>
</div>
</Modal>
);
}
return (
<Modal onClose={onClose} bodyClassName="h-auto p-4">
<h2 className="mb-2 flex items-center gap-2.5 text-xl font-semibold">
<GitHubIcon className="h-6 w-6 text-black" /> Submit Solution URL
</h2>
<p className="text-sm text-gray-500">
Submit the URL of your GitHub repository with the solution.
</p>
<div className="my-4 flex flex-col gap-1">
<SubmissionRequirement
isLoading={isLoading}
status={verificationChecks.repositoryExists}
>
URL must point to a public GitHub repository
</SubmissionRequirement>
<SubmissionRequirement
isLoading={isLoading}
status={verificationChecks.readmeExists}
>
Repository must contain a README file
</SubmissionRequirement>
<SubmissionRequirement
isLoading={isLoading}
status={verificationChecks.projectUrlExists}
>
README file must contain the{' '}
<button
className={
'font-medium underline underline-offset-2 hover:text-purple-700'
}
onClick={() => {
copyText(projectUrl);
}}
>
{!isCopied && (
<>
project URL{' '}
<CopyIcon
className="relative -top-0.5 inline-block h-3 w-3"
strokeWidth={2.5}
/>
</>
)}
{isCopied && (
<>
copied URL{' '}
<CheckIcon
className="relative -top-0.5 inline-block h-3 w-3"
strokeWidth={2.5}
/>
</>
)}
</button>
</SubmissionRequirement>
</div>
<form className="mt-4" onSubmit={handleSubmit}>
<input
type="text"
className="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-gray-500 focus:outline-none"
placeholder="https://github.com/you/solution-repo"
value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)}
/>
<button
type="submit"
className="mt-2 w-full rounded-lg bg-black p-2 font-medium text-white disabled:opacity-50 text-sm"
disabled={isLoading}
>
{isLoading ? 'Verifying...' : 'Verify and Submit'}
</button>
{error && (
<p className="mt-2 text-sm font-medium text-red-500">{error}</p>
)}
{successMessage && (
<p className="mt-2 text-sm font-medium text-green-500">
{successMessage}
</p>
)}
</form>
<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,30 @@
import { cn } from '../../lib/classname.ts';
import { type LucideIcon, ThumbsUp } from 'lucide-react';
type VoteButtonProps = {
icon: LucideIcon;
isActive: boolean;
count: number;
onClick: () => void;
};
export function VoteButton(props: VoteButtonProps) {
const { icon: VoteIcon, isActive, count, onClick } = props;
return (
<button
className={cn(
'flex items-center gap-1 px-2 py-1 text-sm text-gray-500 hover:bg-gray-100 hover:text-black focus:outline-none',
{
'bg-gray-100 text-orange-600 hover:text-orange-700': isActive,
'bg-transparent text-gray-500 hover:text-black': !isActive,
},
)}
disabled={isActive}
onClick={onClick}
>
<VoteIcon className={cn('size-3.5 stroke-[2.5px]')} />
<span className="relative -top-[0.5px] text-xs font-medium tabular-nums">
{count}
</span>
</button>
);
}

@ -32,24 +32,6 @@ export function TeamDropdown() {
const user = useAuth(); const user = useAuth();
const { teamId } = useTeamId(); const { teamId } = useTeamId();
const [shouldShowTeamsIndicator, setShouldShowTeamsIndicator] =
useState(false);
useEffect(() => {
// Show team dropdown "New" indicator to first 3 refreshes
const viewedTeamsCount = localStorage.getItem('viewedTeamsCount');
const viewedTeamsCountNumber = parseInt(viewedTeamsCount || '0', 10);
const shouldShowTeamIndicator = viewedTeamsCountNumber < 5;
setShouldShowTeamsIndicator(shouldShowTeamIndicator);
if (shouldShowTeamIndicator) {
localStorage.setItem(
'viewedTeamsCount',
(viewedTeamsCountNumber + 1).toString(),
);
}
}, []);
const teamList = useStore($teamList); const teamList = useStore($teamList);
const currentTeam = useStore($currentTeam); const currentTeam = useStore($currentTeam);
@ -102,15 +84,6 @@ export function TeamDropdown() {
<div className="relative mr-2"> <div className="relative mr-2">
<span className="mb-2 flex items-center justify-between text-xs uppercase text-gray-400"> <span className="mb-2 flex items-center justify-between text-xs uppercase text-gray-400">
<span>Choose Team</span> <span>Choose Team</span>
{shouldShowTeamsIndicator && (
<span className="mr-1 inline-flex h-1 w-1 items-center justify-center font-medium text-blue-300">
<span className="relative flex items-center">
<span className="relative rounded-full bg-gray-200 p-1 text-xs" />
<span className="absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-gray-400 p-1 text-xs" />
</span>
</span>
)}
</span> </span>
<button <button
className="relative flex w-full cursor-pointer items-center justify-between rounded border p-2 text-sm hover:bg-gray-100" className="relative flex w-full cursor-pointer items-center justify-between rounded border p-2 text-sm hover:bg-gray-100"

@ -0,0 +1,29 @@
import { type RefObject, useEffect, useState } from 'react';
import { isMobileScreen } from '../lib/is-mobile.ts';
// Checks if the sticky element is stuck or not
export function useStickyStuck<T extends HTMLElement>(
ref: RefObject<T>,
offset: number = 0,
): boolean {
const [isSticky, setIsSticky] = useState(false);
useEffect(() => {
if (isMobileScreen()) {
return;
}
const handleScroll = () => {
if (ref.current) {
setIsSticky(ref.current.getBoundingClientRect().top <= offset);
}
};
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [ref, offset]);
return isSticky;
}

@ -109,7 +109,10 @@ const gaPageIdentifier = Astro.url.pathname
/> />
<meta name='apple-mobile-web-app-title' content='roadmap.sh' /> <meta name='apple-mobile-web-app-title' content='roadmap.sh' />
<meta name='application-name' content='roadmap.sh' /> <meta name='application-name' content='roadmap.sh' />
<meta name="ahrefs-site-verification" content="04588b1b3d0118b4f973fa24f9df38ca6300d152cc26529a639e9a34d09c9880"> <meta
name='ahrefs-site-verification'
content='04588b1b3d0118b4f973fa24f9df38ca6300d152cc26529a639e9a34d09c9880'
/>
<link <link
rel='apple-touch-icon' rel='apple-touch-icon'

@ -33,11 +33,15 @@ export function getRelativeTimeString(
} else { } else {
relativeTime = rtf.format(-diffInDays, 'day'); relativeTime = rtf.format(-diffInDays, 'day');
} }
} else if (diffInDays < 30) {
relativeTime = rtf.format(-Math.round(diffInDays / 7), 'week');
} else if (diffInDays < 365) {
relativeTime = rtf.format(-Math.round(diffInDays / 30), 'month');
} else { } else {
if (isTimed) { if (isTimed) {
relativeTime = dayjs(date).format('MMM D, YYYY h:mm A'); relativeTime = dayjs(date).format('MMM D, YYYY h:mm A');
} else { } else {
relativeTime = rtf.format(-Math.round(diffInDays / 7), 'week'); relativeTime = dayjs(date).format('MMM D, YYYY');
} }
} }

@ -25,3 +25,9 @@ export function isIOS(): boolean {
export function isMobile(): boolean { export function isMobile(): boolean {
return isAndroid() || isIOS(); return isAndroid() || isIOS();
} }
export function isMobileScreen(): boolean {
return (
typeof window !== 'undefined' && (window.innerWidth < 640 || isMobile())
);
}

@ -1,129 +0,0 @@
---
import { EditorRoadmap } from '../../components/EditorRoadmap/EditorRoadmap';
import FrameRenderer from '../../components/FrameRenderer/FrameRenderer.astro';
import RelatedRoadmaps from '../../components/RelatedRoadmaps.astro';
import RoadmapHeader from '../../components/RoadmapHeader.astro';
import ShareIcons from '../../components/ShareIcons/ShareIcons.astro';
import { TopicDetail } from '../../components/TopicDetail/TopicDetail';
import { UserProgressModal } from '../../components/UserProgress/UserProgressModal';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { Badge } from '../../components/Badge';
import {
generateArticleSchema,
generateFAQSchema,
} from '../../lib/jsonld-schema';
import { getOpenGraphImageUrl } from '../../lib/open-graph';
import { type RoadmapFrontmatter, getRoadmapIds } from '../../lib/roadmap';
import RoadmapNote from '../../components/RoadmapNote.astro';
import { RoadmapTitleQuestion } from '../../components/RoadmapTitleQuestion';
import ResourceProgressStats from '../../components/ResourceProgressStats.astro';
import {
getAllProjects,
getProjectById,
getProjectsByRoadmapId,
ProjectFrontmatter,
} from '../../lib/project';
import AstroIcon from '../../components/AstroIcon.astro';
import MarkdownFile from '../../components/MarkdownFile.astro';
import Github from '../github.astro';
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 = [];
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'>
<div
class='my-3 flex flex-wrap flex-row items-center gap-1.5 rounded-md border bg-white px-2 py-2 text-sm'
>
<AstroIcon icon='map' class='h-4 w-4' />
Relevant roadmaps <span class='flex flex-row flex-wrap gap-1'>
{
project.roadmaps.map((roadmap) => (
<a
class='bg-gray-500 text-white text-sm px-1.5 rounded hover:bg-black transition-colors'
href={`/${roadmap.id}`}
>
{roadmap.frontmatter?.briefTitle}
</a>
))
}
</span>
</div>
<div class='mb-3 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 gap-1.5 flex-wrap'>
{
projectData.skills.map((skill) => (
<Badge variant='green' text={skill} />
))
}
</div>
</div>
</div>
<div
class='prose max-w-full prose-blockquote:font-normal prose-blockquote:text-gray-500 prose-h2:mb-3 prose-h2:mt-5 prose-h3:mb-1 prose-h3:mt-5 prose-p:mb-2 prose-pre:my-3 prose-ul:my-3.5 prose-hr:my-5 [&>ul>li]:my-1'
>
<project.Content />
</div>
<div
class='mt-5 flex flex-wrap items-center justify-center rounded-lg bg-yellow-100 p-2.5 text-sm'
>
<AstroIcon class='mr-2 inline-block h-5 w-5' icon='github' />
Found a mistake?
<a
class='ml-1 underline underline-offset-2'
href={githubUrl}
target='_blank'
>
Help us improve this page
</a>
</div>
</div>
</div>
</div>
</BaseLayout>

@ -0,0 +1,94 @@
---
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 { ProjectStepper } from '../../../components/Projects/StatusStepper/ProjectStepper';
import { ProjectTabs } from '../../../components/Projects/ProjectTabs';
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}
>
<div class='bg-gray-50'>
<div class='container'>
<ProjectTabs projectId={projectId} activeTab='details' />
<div class='mb-4 rounded-lg border bg-gradient-to-b from-gray-100 to-white to-10% py-2 p-4 sm:p-5'>
<div class='relative'>
<div class='mb-4 hidden sm:flex items-center justify-between'>
<div class='flex flex-row flex-wrap gap-1.5'>
{
projectData.skills.map((skill) => (
<Badge variant='green' text={skill} />
))
}
</div>
<Badge variant='yellow' text={projectData.difficulty} />
</div>
<div class="my-2 sm:my-7">
<h1 class='mb-1 sm:mb-2 text-xl sm:text-3xl font-semibold'>{projectData.title}</h1>
<p class='text-balance text-sm text-gray-500'>{projectData.description}</p>
</div>
</div>
<ProjectStepper projectId={projectId} client:load />
<div
class='prose max-w-full prose-h2:mb-3 prose-h2:mt-5 prose-h3:mb-1 prose-h3:mt-5 prose-p:mb-2 prose-blockquote:font-normal prose-blockquote:text-gray-500 prose-pre:my-3 prose-ul:my-3.5 prose-hr:my-5 [&>ul>li]:my-1'
>
<project.Content />
</div>
<div
class='mt-5 flex flex-wrap items-center justify-center rounded-lg p-2.5 text-sm'
>
<AstroIcon class='mr-2 inline-block h-5 w-5' icon='github' />
Found a mistake?
<a
class='ml-1 underline underline-offset-2'
href={githubUrl}
target='_blank'
>
Help us improve.
</a>
</div>
</div>
</div>
</div>
</BaseLayout>

@ -0,0 +1,66 @@
---
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 { 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}
>
<div class='bg-gray-50'>
<div class='container'>
<ProjectTabs projectId={projectId} activeTab='solutions' />
<div class='mb-4 overflow-hidden rounded-lg border bg-white p-3 sm:p-5'>
<div class='relative mb-5 hidden sm:block'>
<h1 class='mb-1 text-xl font-semibold'>
{projectData.title} Solutions
</h1>
<p class='text-sm text-gray-500'>
{projectData.description}
</p>
</div>
<ListProjectSolutions projectId={projectId} client:load />
</div>
</div>
</div>
</BaseLayout>
Loading…
Cancel
Save