mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-02 13:52:46 +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
|
"enabled": false
|
||||||
},
|
},
|
||||||
"_variables": {
|
"_variables": {
|
||||||
"lastUpdateCheck": 1723501110773
|
"lastUpdateCheck": 1723855511353
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -33,7 +33,7 @@ export function Modal(props: ModalProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'popup fixed left-0 right-0 top-0 z-[99] flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50',
|
'fixed left-0 right-0 top-0 z-[99] flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50',
|
||||||
overlayClassName,
|
overlayClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -46,7 +46,7 @@ export function Modal(props: ModalProps) {
|
|||||||
<div
|
<div
|
||||||
ref={popupBodyEl}
|
ref={popupBodyEl}
|
||||||
className={cn(
|
className={cn(
|
||||||
'popup-body relative h-full rounded-lg bg-white shadow',
|
'relative h-full rounded-lg bg-white shadow',
|
||||||
bodyClassName,
|
bodyClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@@ -44,7 +44,7 @@ const isDiscordMembers = text.toLowerCase() === 'discord members';
|
|||||||
}
|
}
|
||||||
<div class="flex flex-row items-center sm:flex-col my-1 sm:my-0">
|
<div class="flex flex-row items-center sm:flex-col my-1 sm:my-0">
|
||||||
<p
|
<p
|
||||||
class='relative my-0 sm:my-4 mr-1 sm:mr-0 text-base font-bold sm:w-auto sm:text-5xl'
|
class='my-0 sm:my-4 mr-1 sm:mr-0 text-base font-bold sm:w-auto sm:text-5xl'
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</p>
|
</p>
|
||||||
|
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 user = useAuth();
|
||||||
const { teamId } = useTeamId();
|
const { teamId } = useTeamId();
|
||||||
|
|
||||||
const [shouldShowTeamsIndicator, setShouldShowTeamsIndicator] =
|
|
||||||
useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Show team dropdown "New" indicator to first 3 refreshes
|
|
||||||
const viewedTeamsCount = localStorage.getItem('viewedTeamsCount');
|
|
||||||
const viewedTeamsCountNumber = parseInt(viewedTeamsCount || '0', 10);
|
|
||||||
const shouldShowTeamIndicator = viewedTeamsCountNumber < 5;
|
|
||||||
|
|
||||||
setShouldShowTeamsIndicator(shouldShowTeamIndicator);
|
|
||||||
if (shouldShowTeamIndicator) {
|
|
||||||
localStorage.setItem(
|
|
||||||
'viewedTeamsCount',
|
|
||||||
(viewedTeamsCountNumber + 1).toString(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const teamList = useStore($teamList);
|
const teamList = useStore($teamList);
|
||||||
const currentTeam = useStore($currentTeam);
|
const currentTeam = useStore($currentTeam);
|
||||||
|
|
||||||
@@ -102,15 +84,6 @@ export function TeamDropdown() {
|
|||||||
<div className="relative mr-2">
|
<div className="relative mr-2">
|
||||||
<span className="mb-2 flex items-center justify-between text-xs uppercase text-gray-400">
|
<span className="mb-2 flex items-center justify-between text-xs uppercase text-gray-400">
|
||||||
<span>Choose Team</span>
|
<span>Choose Team</span>
|
||||||
|
|
||||||
{shouldShowTeamsIndicator && (
|
|
||||||
<span className="mr-1 inline-flex h-1 w-1 items-center justify-center font-medium text-blue-300">
|
|
||||||
<span className="relative flex items-center">
|
|
||||||
<span className="relative rounded-full bg-gray-200 p-1 text-xs" />
|
|
||||||
<span className="absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-gray-400 p-1 text-xs" />
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
className="relative flex w-full cursor-pointer items-center justify-between rounded border p-2 text-sm hover:bg-gray-100"
|
className="relative flex w-full cursor-pointer items-center justify-between rounded border p-2 text-sm hover:bg-gray-100"
|
||||||
|
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='apple-mobile-web-app-title' content='roadmap.sh' />
|
||||||
<meta name='application-name' content='roadmap.sh' />
|
<meta name='application-name' content='roadmap.sh' />
|
||||||
<meta name="ahrefs-site-verification" content="04588b1b3d0118b4f973fa24f9df38ca6300d152cc26529a639e9a34d09c9880">
|
<meta
|
||||||
|
name='ahrefs-site-verification'
|
||||||
|
content='04588b1b3d0118b4f973fa24f9df38ca6300d152cc26529a639e9a34d09c9880'
|
||||||
|
/>
|
||||||
|
|
||||||
<link
|
<link
|
||||||
rel='apple-touch-icon'
|
rel='apple-touch-icon'
|
||||||
|
@@ -33,11 +33,15 @@ export function getRelativeTimeString(
|
|||||||
} else {
|
} else {
|
||||||
relativeTime = rtf.format(-diffInDays, 'day');
|
relativeTime = rtf.format(-diffInDays, 'day');
|
||||||
}
|
}
|
||||||
|
} else if (diffInDays < 30) {
|
||||||
|
relativeTime = rtf.format(-Math.round(diffInDays / 7), 'week');
|
||||||
|
} else if (diffInDays < 365) {
|
||||||
|
relativeTime = rtf.format(-Math.round(diffInDays / 30), 'month');
|
||||||
} else {
|
} else {
|
||||||
if (isTimed) {
|
if (isTimed) {
|
||||||
relativeTime = dayjs(date).format('MMM D, YYYY h:mm A');
|
relativeTime = dayjs(date).format('MMM D, YYYY h:mm A');
|
||||||
} else {
|
} else {
|
||||||
relativeTime = rtf.format(-Math.round(diffInDays / 7), 'week');
|
relativeTime = dayjs(date).format('MMM D, YYYY');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -25,3 +25,9 @@ export function isIOS(): boolean {
|
|||||||
export function isMobile(): boolean {
|
export function isMobile(): boolean {
|
||||||
return isAndroid() || isIOS();
|
return isAndroid() || isIOS();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isMobileScreen(): boolean {
|
||||||
|
return (
|
||||||
|
typeof window !== 'undefined' && (window.innerWidth < 640 || isMobile())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -1,129 +0,0 @@
|
|||||||
---
|
|
||||||
import { EditorRoadmap } from '../../components/EditorRoadmap/EditorRoadmap';
|
|
||||||
import FrameRenderer from '../../components/FrameRenderer/FrameRenderer.astro';
|
|
||||||
import RelatedRoadmaps from '../../components/RelatedRoadmaps.astro';
|
|
||||||
import RoadmapHeader from '../../components/RoadmapHeader.astro';
|
|
||||||
import ShareIcons from '../../components/ShareIcons/ShareIcons.astro';
|
|
||||||
import { TopicDetail } from '../../components/TopicDetail/TopicDetail';
|
|
||||||
import { UserProgressModal } from '../../components/UserProgress/UserProgressModal';
|
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
||||||
import { Badge } from '../../components/Badge';
|
|
||||||
import {
|
|
||||||
generateArticleSchema,
|
|
||||||
generateFAQSchema,
|
|
||||||
} from '../../lib/jsonld-schema';
|
|
||||||
import { getOpenGraphImageUrl } from '../../lib/open-graph';
|
|
||||||
import { type RoadmapFrontmatter, getRoadmapIds } from '../../lib/roadmap';
|
|
||||||
import RoadmapNote from '../../components/RoadmapNote.astro';
|
|
||||||
import { RoadmapTitleQuestion } from '../../components/RoadmapTitleQuestion';
|
|
||||||
import ResourceProgressStats from '../../components/ResourceProgressStats.astro';
|
|
||||||
import {
|
|
||||||
getAllProjects,
|
|
||||||
getProjectById,
|
|
||||||
getProjectsByRoadmapId,
|
|
||||||
ProjectFrontmatter,
|
|
||||||
} from '../../lib/project';
|
|
||||||
import AstroIcon from '../../components/AstroIcon.astro';
|
|
||||||
import MarkdownFile from '../../components/MarkdownFile.astro';
|
|
||||||
import Github from '../github.astro';
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
|
||||||
const projects = await getAllProjects();
|
|
||||||
|
|
||||||
return projects
|
|
||||||
.map((project) => project.id)
|
|
||||||
.map((projectId) => ({
|
|
||||||
params: { projectId },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Params extends Record<string, string | undefined> {
|
|
||||||
projectId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { projectId } = Astro.params as Params;
|
|
||||||
|
|
||||||
const project = await getProjectById(projectId);
|
|
||||||
const projectData = project.frontmatter as ProjectFrontmatter;
|
|
||||||
|
|
||||||
let jsonLdSchema = [];
|
|
||||||
|
|
||||||
const ogImageUrl = projectData?.seo?.ogImageUrl || '/images/og-img.png';
|
|
||||||
const githubUrl = `https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/projects/${projectId}.md`;
|
|
||||||
---
|
|
||||||
|
|
||||||
<BaseLayout
|
|
||||||
permalink={`/projects/${projectId}`}
|
|
||||||
title={projectData?.seo?.title}
|
|
||||||
briefTitle={projectData.title}
|
|
||||||
ogImageUrl={ogImageUrl}
|
|
||||||
description={projectData.seo.description}
|
|
||||||
keywords={projectData.seo.keywords}
|
|
||||||
jsonLd={jsonLdSchema}
|
|
||||||
resourceId={projectId}
|
|
||||||
resourceType='project'
|
|
||||||
>
|
|
||||||
<div class='bg-gray-50'>
|
|
||||||
<div class='container'>
|
|
||||||
<div
|
|
||||||
class='my-3 flex flex-wrap flex-row items-center gap-1.5 rounded-md border bg-white px-2 py-2 text-sm'
|
|
||||||
>
|
|
||||||
<AstroIcon icon='map' class='h-4 w-4' />
|
|
||||||
Relevant roadmaps <span class='flex flex-row flex-wrap gap-1'>
|
|
||||||
{
|
|
||||||
project.roadmaps.map((roadmap) => (
|
|
||||||
<a
|
|
||||||
class='bg-gray-500 text-white text-sm px-1.5 rounded hover:bg-black transition-colors'
|
|
||||||
href={`/${roadmap.id}`}
|
|
||||||
>
|
|
||||||
{roadmap.frontmatter?.briefTitle}
|
|
||||||
</a>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class='mb-3 overflow-hidden rounded-lg border bg-white p-5'>
|
|
||||||
<div class='relative -mx-2 -mt-2 mb-5 rounded-lg bg-gray-100/70 p-5'>
|
|
||||||
<div class='absolute right-2 top-2'>
|
|
||||||
<Badge variant='yellow' text={projectData.difficulty} />
|
|
||||||
</div>
|
|
||||||
<div class='mb-5'>
|
|
||||||
<h1 class='mb-1.5 text-3xl font-semibold'>{projectData.title}</h1>
|
|
||||||
<p class='text-gray-500'>{projectData.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class='mt-4'>
|
|
||||||
<div class='flex flex-row gap-1.5 flex-wrap'>
|
|
||||||
{
|
|
||||||
projectData.skills.map((skill) => (
|
|
||||||
<Badge variant='green' text={skill} />
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class='prose max-w-full prose-blockquote:font-normal prose-blockquote:text-gray-500 prose-h2:mb-3 prose-h2:mt-5 prose-h3:mb-1 prose-h3:mt-5 prose-p:mb-2 prose-pre:my-3 prose-ul:my-3.5 prose-hr:my-5 [&>ul>li]:my-1'
|
|
||||||
>
|
|
||||||
<project.Content />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class='mt-5 flex flex-wrap items-center justify-center rounded-lg bg-yellow-100 p-2.5 text-sm'
|
|
||||||
>
|
|
||||||
<AstroIcon class='mr-2 inline-block h-5 w-5' icon='github' />
|
|
||||||
Found a mistake?
|
|
||||||
<a
|
|
||||||
class='ml-1 underline underline-offset-2'
|
|
||||||
href={githubUrl}
|
|
||||||
target='_blank'
|
|
||||||
>
|
|
||||||
Help us improve this page
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</BaseLayout>
|
|
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