mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-31 04:59:50 +02:00
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>
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
"enabled": false
|
||||
},
|
||||
"_variables": {
|
||||
"lastUpdateCheck": 1723501110773
|
||||
"lastUpdateCheck": 1723855511353
|
||||
}
|
||||
}
|
@@ -33,7 +33,7 @@ export function Modal(props: ModalProps) {
|
||||
return (
|
||||
<div
|
||||
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,
|
||||
)}
|
||||
>
|
||||
@@ -46,7 +46,7 @@ export function Modal(props: ModalProps) {
|
||||
<div
|
||||
ref={popupBodyEl}
|
||||
className={cn(
|
||||
'popup-body relative h-full rounded-lg bg-white shadow',
|
||||
'relative h-full rounded-lg bg-white shadow',
|
||||
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">
|
||||
<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}
|
||||
</p>
|
||||
|
29
src/components/Projects/EmptySolutions.tsx
Normal file
29
src/components/Projects/EmptySolutions.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
64
src/components/Projects/LeavingRoadmapWarningModal.tsx
Normal file
64
src/components/Projects/LeavingRoadmapWarningModal.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
327
src/components/Projects/ListProjectSolutions.tsx
Normal file
327
src/components/Projects/ListProjectSolutions.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
44
src/components/Projects/LoadingSolutions.tsx
Normal file
44
src/components/Projects/LoadingSolutions.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
69
src/components/Projects/ProjectTabs.tsx
Normal file
69
src/components/Projects/ProjectTabs.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
169
src/components/Projects/StartProjectModal.tsx
Normal file
169
src/components/Projects/StartProjectModal.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
37
src/components/Projects/StatusStepper/MilestoneStep.tsx
Normal file
37
src/components/Projects/StatusStepper/MilestoneStep.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
245
src/components/Projects/StatusStepper/ProjectStepper.tsx
Normal file
245
src/components/Projects/StatusStepper/ProjectStepper.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
51
src/components/Projects/StatusStepper/StepperAction.tsx
Normal file
51
src/components/Projects/StatusStepper/StepperAction.tsx
Normal file
@@ -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,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
44
src/components/Projects/SubmissionRequirement.tsx
Normal file
44
src/components/Projects/SubmissionRequirement.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
299
src/components/Projects/SubmitProjectModal.tsx
Normal file
299
src/components/Projects/SubmitProjectModal.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
30
src/components/Projects/VoteButton.tsx
Normal file
30
src/components/Projects/VoteButton.tsx
Normal file
@@ -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 { 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 currentTeam = useStore($currentTeam);
|
||||
|
||||
@@ -102,15 +84,6 @@ export function TeamDropdown() {
|
||||
<div className="relative mr-2">
|
||||
<span className="mb-2 flex items-center justify-between text-xs uppercase text-gray-400">
|
||||
<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>
|
||||
<button
|
||||
className="relative flex w-full cursor-pointer items-center justify-between rounded border p-2 text-sm hover:bg-gray-100"
|
||||
|
29
src/hooks/use-sticky-stuck.tsx
Normal file
29
src/hooks/use-sticky-stuck.tsx
Normal file
@@ -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='application-name' content='roadmap.sh' />
|
||||
<meta name="ahrefs-site-verification" content="04588b1b3d0118b4f973fa24f9df38ca6300d152cc26529a639e9a34d09c9880">
|
||||
<meta
|
||||
name='ahrefs-site-verification'
|
||||
content='04588b1b3d0118b4f973fa24f9df38ca6300d152cc26529a639e9a34d09c9880'
|
||||
/>
|
||||
|
||||
<link
|
||||
rel='apple-touch-icon'
|
||||
|
@@ -33,11 +33,15 @@ export function getRelativeTimeString(
|
||||
} else {
|
||||
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 {
|
||||
if (isTimed) {
|
||||
relativeTime = dayjs(date).format('MMM D, YYYY h:mm A');
|
||||
} 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 {
|
||||
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>
|
94
src/pages/projects/[projectId]/index.astro
Normal file
94
src/pages/projects/[projectId]/index.astro
Normal file
@@ -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>
|
66
src/pages/projects/[projectId]/solutions.astro
Normal file
66
src/pages/projects/[projectId]/solutions.astro
Normal file
@@ -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>
|
Reference in New Issue
Block a user