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 { StartProjectModal } from './StartProjectModal';
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 = {
projectId: string;
@ -10,19 +24,97 @@ type ProjectMilestoneStripProps = {
export function ProjectMilestoneStrip(props: ProjectMilestoneStripProps) {
const { projectId } = props;
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
const [stepIndex, setStepIndex] = useState(0);
const [projectStatus, setProjectStatus] = useState<ProjectStatusResponse>({
upvotes: 0,
downvotes: 0,
});
const [isStartProjectModalOpen, setIsStartProjectModalOpen] = useState(false);
const [isSubmitProjectModalOpen, setIsSubmitProjectModalOpen] =
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 ? (
<StartProjectModal onClose={() => setIsStartProjectModalOpen(false)} />
<StartProjectModal
onClose={() => setIsStartProjectModalOpen(false)}
startedAt={projectStatus?.startedAt}
/>
) : null;
const submitProjectModal = isSubmitProjectModalOpen ? (
<SubmitProjectModal
onClose={() => setIsSubmitProjectModalOpen(false)}
projectId={projectId}
onSubmit={() => setStepIndex(2)}
onSubmit={(response) => {
const { repositoryUrl, submittedAt } = response;
setProjectStatus({
...projectStatus,
repositoryUrl,
submittedAt,
});
setStepIndex(2);
}}
repositoryUrl={projectStatus.repositoryUrl}
/>
) : null;
@ -31,40 +123,69 @@ export function ProjectMilestoneStrip(props: ProjectMilestoneStripProps) {
{startProjectModal}
{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="flex flex-col">
<MilestoneStep isActive={stepIndex === 1} />
<MilestoneStep isActive={stepIndex >= 1} />
<button
className="mt-3 text-left text-sm font-medium text-blue-600 underline underline-offset-2"
onClick={() => setIsStartProjectModalOpen(true)}
className={cn(
'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
</button>
</div>
<div className="flex flex-col">
<MilestoneStep isActive={stepIndex === 2} position="middle" />
<MilestoneStep isActive={stepIndex >= 2} />
<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)}
disabled={stepIndex < 1}
>
Submit Solution
{projectStatus?.repositoryUrl
? 'Update Solution'
: 'Submit Solution'}
</button>
</div>
<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">
Get 5 Likes
<span
className="mt-3 w-full text-center text-sm font-medium aria-disabled:opacity-50"
aria-disabled={stepIndex < 3}
>
5 Upvotes
</span>
</div>
<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">
Get 10 Likes
<span
className="mt-3 w-full text-center text-sm font-medium aria-disabled:opacity-50"
aria-disabled={stepIndex < 4}
>
10 Upvotes
</span>
</div>
</div>
@ -75,26 +196,25 @@ export function ProjectMilestoneStrip(props: ProjectMilestoneStripProps) {
type MilestoneStepProps = {
isActive: boolean;
position?: 'start' | 'middle' | 'end';
isLast?: boolean;
};
function MilestoneStep(props: MilestoneStepProps) {
const { isActive = false, position = 'start' } = props;
const { isActive = false, isLast = false } = props;
return (
<div
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',
isLast && 'bg-transparent',
)}
>
<span
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',
position === 'start' && 'left-0',
position === 'middle' && 'left-1/2',
position === 'end' && 'right-0 translate-x-1/2',
isLast && '-translate-x-0',
)}
></span>
</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 { Modal } from '../Modal';
import { getRelativeTimeString } from '../../lib/date';
type StartProjectModalProps = {
onClose: () => void;
startedAt?: Date;
};
export function StartProjectModal(props: StartProjectModalProps) {
const { onClose } = props;
const { onClose, startedAt } = props;
const projectTips = [
'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.',
];
const formattedStartedAt = startedAt ? getRelativeTimeString(startedAt) : '';
return (
<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">
You have started working on the project. Here are some tips to get most
out of it.

@ -2,20 +2,32 @@ import { X } from 'lucide-react';
import { Modal } from '../Modal';
import { useState, type FormEvent } from 'react';
import { useToast } from '../../hooks/use-toast';
import { httpPost } from '../../lib/http';
type SubmitProjectResponse = {
repositoryUrl: string;
submittedAt: Date;
};
type SubmitProjectModalProps = {
onClose: () => void;
projectId: string;
onSubmit: () => void;
repositoryUrl?: string;
onSubmit: (response: SubmitProjectResponse) => void;
};
export function SubmitProjectModal(props: SubmitProjectModalProps) {
const { onClose, projectId, onSubmit } = props;
const {
onClose,
projectId,
onSubmit,
repositoryUrl: defaultRepositoryUrl = '',
} = props;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const [repoUrl, setRepoUrl] = useState('');
const [repoUrl, setRepoUrl] = useState(defaultRepositoryUrl);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
@ -76,10 +88,19 @@ export function SubmitProjectModal(props: SubmitProjectModalProps) {
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');
setIsLoading(false);
onSubmit();
onSubmit(submitResponse);
} catch (error: any) {
console.error(error);
setError(error?.message || 'Failed to verify repository');

@ -1,13 +1,14 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { Badge } from '../../components/Badge';
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';
} from '../../../lib/project';
import AstroIcon from '../../../components/AstroIcon.astro';
import { ProjectMilestoneStrip } from '../../../components/Projects/ProjectMilestoneStrip';
import { ProjectTabs } from '../../../components/Projects/ProjectTabs';
export async function getStaticPaths() {
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='container'>
<ProjectTabs projectId={projectId} activeTab='details' />
<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'
>

@ -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