pull/6513/head
Arik Chakma 3 months ago
parent 8db22e81b1
commit 323e553d2a
  1. 30
      src/components/Projects/EmptySolutions.tsx
  2. 55
      src/components/Projects/LeavingRoadmapWarningModal.tsx
  3. 302
      src/components/Projects/ListProjectSolutions.tsx
  4. 12
      src/components/Projects/LoadingSolutions.tsx
  5. 166
      src/components/Projects/ProjectMilestoneStrip.tsx
  6. 47
      src/components/Projects/ProjectTabs.tsx
  7. 15
      src/components/Projects/StartProjectModal.tsx
  8. 31
      src/components/Projects/SubmitProjectModal.tsx
  9. 13
      src/pages/projects/[projectId]/index.astro
  10. 78
      src/pages/projects/[projectId]/solutions.astro

@ -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">&middot;</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">&middot;</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>
);
}

@ -1,7 +1,21 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { cn } from '../../lib/classname'; import { cn } from '../../lib/classname';
import { StartProjectModal } from './StartProjectModal'; import { StartProjectModal } from './StartProjectModal';
import { SubmitProjectModal } from './SubmitProjectModal'; import { SubmitProjectModal } from './SubmitProjectModal';
import { pageProgressMessage } from '../../stores/page';
import { httpGet, httpPost } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
type ProjectStatusResponse = {
id?: string;
startedAt?: Date;
submittedAt?: Date;
repositoryUrl?: string;
upvotes: number;
downvotes: number;
};
type ProjectMilestoneStripProps = { type ProjectMilestoneStripProps = {
projectId: string; projectId: string;
@ -10,19 +24,97 @@ type ProjectMilestoneStripProps = {
export function ProjectMilestoneStrip(props: ProjectMilestoneStripProps) { export function ProjectMilestoneStrip(props: ProjectMilestoneStripProps) {
const { projectId } = props; const { projectId } = props;
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
const [stepIndex, setStepIndex] = useState(0); const [stepIndex, setStepIndex] = useState(0);
const [projectStatus, setProjectStatus] = useState<ProjectStatusResponse>({
upvotes: 0,
downvotes: 0,
});
const [isStartProjectModalOpen, setIsStartProjectModalOpen] = useState(false); const [isStartProjectModalOpen, setIsStartProjectModalOpen] = useState(false);
const [isSubmitProjectModalOpen, setIsSubmitProjectModalOpen] = const [isSubmitProjectModalOpen, setIsSubmitProjectModalOpen] =
useState(false); useState(false);
const handleStartProject = async () => {
pageProgressMessage.set('Starting project...');
setIsStartProjectModalOpen(true);
const { response, error } = await httpPost<{
startedAt: Date;
}>(`${import.meta.env.PUBLIC_API_URL}/v1-start-project/${projectId}`, {});
if (error || !response) {
toast.error(error?.message || 'Failed to start project');
pageProgressMessage.set('');
return;
}
setStepIndex(1);
setProjectStatus({
...projectStatus,
startedAt: response.startedAt,
});
pageProgressMessage.set('');
};
const loadProjectStatus = async () => {
setIsLoading(true);
const { response, error } = await httpGet<ProjectStatusResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-project-status/${projectId}`,
{},
);
if (error || !response) {
toast.error(error?.message || 'Failed to load project status');
setIsLoading(false);
return;
}
const { startedAt, submittedAt, upvotes } = response;
if (upvotes >= 10) {
setStepIndex(4);
} else if (upvotes >= 5) {
setStepIndex(3);
} else if (submittedAt) {
setStepIndex(2);
} else if (startedAt) {
setStepIndex(1);
}
setProjectStatus(response);
setIsLoading(false);
};
useEffect(() => {
loadProjectStatus().finally(() => {});
}, []);
const startProjectModal = isStartProjectModalOpen ? ( const startProjectModal = isStartProjectModalOpen ? (
<StartProjectModal onClose={() => setIsStartProjectModalOpen(false)} /> <StartProjectModal
onClose={() => setIsStartProjectModalOpen(false)}
startedAt={projectStatus?.startedAt}
/>
) : null; ) : null;
const submitProjectModal = isSubmitProjectModalOpen ? ( const submitProjectModal = isSubmitProjectModalOpen ? (
<SubmitProjectModal <SubmitProjectModal
onClose={() => setIsSubmitProjectModalOpen(false)} onClose={() => setIsSubmitProjectModalOpen(false)}
projectId={projectId} projectId={projectId}
onSubmit={() => setStepIndex(2)} onSubmit={(response) => {
const { repositoryUrl, submittedAt } = response;
setProjectStatus({
...projectStatus,
repositoryUrl,
submittedAt,
});
setStepIndex(2);
}}
repositoryUrl={projectStatus.repositoryUrl}
/> />
) : null; ) : null;
@ -31,40 +123,69 @@ export function ProjectMilestoneStrip(props: ProjectMilestoneStripProps) {
{startProjectModal} {startProjectModal}
{submitProjectModal} {submitProjectModal}
<div className="relative -mx-2 -mt-2 mb-5 rounded-lg bg-gray-100/70 p-5"> <div className="relative -mx-2 -mt-2 mb-5 overflow-hidden rounded-lg bg-gray-100/70 p-5">
<div
className={cn(
'striped-loader absolute inset-0 z-10 bg-white',
!isLoading && 'hidden',
)}
/>
<div className="grid grid-cols-4"> <div className="grid grid-cols-4">
<div className="flex flex-col"> <div className="flex flex-col">
<MilestoneStep isActive={stepIndex === 1} /> <MilestoneStep isActive={stepIndex >= 1} />
<button <button
className="mt-3 text-left text-sm font-medium text-blue-600 underline underline-offset-2" className={cn(
onClick={() => setIsStartProjectModalOpen(true)} 'mt-3 text-center text-sm font-medium text-blue-600 underline underline-offset-2 hover:opacity-60',
stepIndex >= 1 && 'text-black no-underline',
)}
onClick={() => {
if (stepIndex < 1) {
handleStartProject().finally(() => {});
} else {
setIsStartProjectModalOpen(true);
}
}}
> >
Start Project Start Project
</button> </button>
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<MilestoneStep isActive={stepIndex === 2} position="middle" /> <MilestoneStep isActive={stepIndex >= 2} />
<button <button
className="mt-3 text-sm font-medium text-blue-600 underline underline-offset-2" className={cn(
'mt-3 text-sm font-medium text-blue-600 underline underline-offset-2 hover:opacity-60',
stepIndex >= 2 && 'text-black no-underline',
stepIndex < 1 && 'text-black opacity-50 hover:opacity-50',
)}
onClick={() => setIsSubmitProjectModalOpen(true)} onClick={() => setIsSubmitProjectModalOpen(true)}
disabled={stepIndex < 1}
> >
Submit Solution {projectStatus?.repositoryUrl
? 'Update Solution'
: 'Submit Solution'}
</button> </button>
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<MilestoneStep isActive={stepIndex === 2} position="middle" /> <MilestoneStep isActive={stepIndex >= 3} />
<span className="mt-3 w-full text-center text-sm font-medium"> <span
Get 5 Likes className="mt-3 w-full text-center text-sm font-medium aria-disabled:opacity-50"
aria-disabled={stepIndex < 3}
>
5 Upvotes
</span> </span>
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<MilestoneStep isActive={stepIndex === 2} position="end" /> <MilestoneStep isActive={stepIndex >= 4} isLast={true} />
<span className="mt-3 w-full text-right text-sm font-medium"> <span
Get 10 Likes className="mt-3 w-full text-center text-sm font-medium aria-disabled:opacity-50"
aria-disabled={stepIndex < 4}
>
10 Upvotes
</span> </span>
</div> </div>
</div> </div>
@ -75,26 +196,25 @@ export function ProjectMilestoneStrip(props: ProjectMilestoneStripProps) {
type MilestoneStepProps = { type MilestoneStepProps = {
isActive: boolean; isActive: boolean;
position?: 'start' | 'middle' | 'end'; isLast?: boolean;
}; };
function MilestoneStep(props: MilestoneStepProps) { function MilestoneStep(props: MilestoneStepProps) {
const { isActive = false, position = 'start' } = props; const { isActive = false, isLast = false } = props;
return ( return (
<div <div
className={cn( className={cn(
'relative h-1 w-full bg-gray-300', 'relative h-1 w-full translate-x-1/2 bg-gray-300',
isActive && 'bg-gray-500', isActive && 'bg-gray-500',
isLast && 'bg-transparent',
)} )}
> >
<span <span
className={cn( className={cn(
'absolute -top-[4px] size-3 -translate-x-1/2 rounded-full border bg-white', 'absolute -top-[4px] left-0 size-3 -translate-x-1/2 rounded-full border bg-white',
isActive && 'border-black bg-black', isActive && 'border-black bg-black',
position === 'start' && 'left-0', isLast && '-translate-x-0',
position === 'middle' && 'left-1/2',
position === 'end' && 'right-0 translate-x-1/2',
)} )}
></span> ></span>
</div> </div>

@ -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>
);
}

@ -1,12 +1,14 @@
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { Modal } from '../Modal'; import { Modal } from '../Modal';
import { getRelativeTimeString } from '../../lib/date';
type StartProjectModalProps = { type StartProjectModalProps = {
onClose: () => void; onClose: () => void;
startedAt?: Date;
}; };
export function StartProjectModal(props: StartProjectModalProps) { export function StartProjectModal(props: StartProjectModalProps) {
const { onClose } = props; const { onClose, startedAt } = props;
const projectTips = [ const projectTips = [
'Create a repository on GitHub', 'Create a repository on GitHub',
@ -16,9 +18,18 @@ export function StartProjectModal(props: StartProjectModalProps) {
'Feel free to join our discord and ask for help if you get stuck.', 'Feel free to join our discord and ask for help if you get stuck.',
]; ];
const formattedStartedAt = startedAt ? getRelativeTimeString(startedAt) : '';
return ( return (
<Modal onClose={onClose} bodyClassName="h-auto p-4"> <Modal onClose={onClose} bodyClassName="h-auto p-4">
<h2 className="mb-0.5 text-xl font-semibold">Started working...</h2> <h2 className="mb-0.5 text-xl font-semibold">
Started working...
{formattedStartedAt ? (
<span className="ml-1 rounded-md border border-yellow-400 bg-yellow-200 px-1 py-0.5 text-sm font-normal leading-none text-yellow-800">
{formattedStartedAt}
</span>
) : null}
</h2>
<p className="text-balance text-sm text-gray-500"> <p className="text-balance text-sm text-gray-500">
You have started working on the project. Here are some tips to get most You have started working on the project. Here are some tips to get most
out of it. out of it.

@ -2,20 +2,32 @@ import { X } from 'lucide-react';
import { Modal } from '../Modal'; import { Modal } from '../Modal';
import { useState, type FormEvent } from 'react'; import { useState, type FormEvent } from 'react';
import { useToast } from '../../hooks/use-toast'; import { useToast } from '../../hooks/use-toast';
import { httpPost } from '../../lib/http';
type SubmitProjectResponse = {
repositoryUrl: string;
submittedAt: Date;
};
type SubmitProjectModalProps = { type SubmitProjectModalProps = {
onClose: () => void; onClose: () => void;
projectId: string; projectId: string;
onSubmit: () => void; repositoryUrl?: string;
onSubmit: (response: SubmitProjectResponse) => void;
}; };
export function SubmitProjectModal(props: SubmitProjectModalProps) { export function SubmitProjectModal(props: SubmitProjectModalProps) {
const { onClose, projectId, onSubmit } = props; const {
onClose,
projectId,
onSubmit,
repositoryUrl: defaultRepositoryUrl = '',
} = props;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [successMessage, setSuccessMessage] = useState(''); const [successMessage, setSuccessMessage] = useState('');
const [repoUrl, setRepoUrl] = useState(''); const [repoUrl, setRepoUrl] = useState(defaultRepositoryUrl);
const handleSubmit = async (e: FormEvent) => { const handleSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -76,10 +88,19 @@ export function SubmitProjectModal(props: SubmitProjectModalProps) {
throw new Error('Project URL not found in the readme file'); throw new Error('Project URL not found in the readme file');
} }
// TODO: Make API call to update the project status 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 || 'Failed to submit project');
}
setSuccessMessage('Repository verified successfully'); setSuccessMessage('Repository verified successfully');
setIsLoading(false); setIsLoading(false);
onSubmit(); onSubmit(submitResponse);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
setError(error?.message || 'Failed to verify repository'); setError(error?.message || 'Failed to verify repository');

@ -1,13 +1,14 @@
--- ---
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../../layouts/BaseLayout.astro';
import { Badge } from '../../components/Badge'; import { Badge } from '../../../components/Badge';
import { import {
getAllProjects, getAllProjects,
getProjectById, getProjectById,
type ProjectFrontmatter, type ProjectFrontmatter,
} from '../../lib/project'; } from '../../../lib/project';
import AstroIcon from '../../components/AstroIcon.astro'; import AstroIcon from '../../../components/AstroIcon.astro';
import { ProjectMilestoneStrip } from '../../components/Projects/ProjectMilestoneStrip'; import { ProjectMilestoneStrip } from '../../../components/Projects/ProjectMilestoneStrip';
import { ProjectTabs } from '../../../components/Projects/ProjectTabs';
export async function getStaticPaths() { export async function getStaticPaths() {
const projects = await getAllProjects(); const projects = await getAllProjects();
@ -47,6 +48,8 @@ const githubUrl = `https://github.com/kamranahmedse/developer-roadmap/tree/maste
> >
<div class='bg-gray-50'> <div class='bg-gray-50'>
<div class='container'> <div class='container'>
<ProjectTabs projectId={projectId} activeTab='details' />
<div <div
class='my-3 flex flex-row flex-wrap items-center gap-1.5 rounded-md border bg-white px-2 py-2 text-sm' class='my-3 flex flex-row flex-wrap items-center gap-1.5 rounded-md border bg-white px-2 py-2 text-sm'
> >

@ -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…
Cancel
Save