mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-13 02:01:57 +08:00
Compare commits
5 Commits
fix/count
...
feat/list-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ceab552f6 | ||
|
|
e39fadb032 | ||
|
|
8f6cdff0d8 | ||
|
|
018a8d6f0f | ||
|
|
7da86f173b |
@@ -5,6 +5,10 @@ import { ResourceProgress } from './ResourceProgress';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { EmptyActivity } from './EmptyActivity';
|
||||
import { ActivityStream, type UserStreamActivity } from './ActivityStream';
|
||||
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { ProjectProgress } from './ProjectProgress';
|
||||
|
||||
type ProgressResponse = {
|
||||
updatedAt: string;
|
||||
@@ -47,11 +51,14 @@ export type ActivityResponse = {
|
||||
};
|
||||
}[];
|
||||
activities: UserStreamActivity[];
|
||||
projects: ProjectStatusDocument[];
|
||||
};
|
||||
|
||||
export function ActivityPage() {
|
||||
const toast = useToast();
|
||||
const [activity, setActivity] = useState<ActivityResponse>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [projectDetails, setProjectDetails] = useState<PageType[]>([]);
|
||||
|
||||
async function loadActivity() {
|
||||
const { error, response } = await httpGet<ActivityResponse>(
|
||||
@@ -68,11 +75,30 @@ export function ActivityPage() {
|
||||
setActivity(response);
|
||||
}
|
||||
|
||||
async function loadAllProjectDetails() {
|
||||
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allProjects = response.filter((page) => page.group === 'Projects');
|
||||
console.log(allProjects);
|
||||
setProjectDetails(allProjects);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadActivity().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
setIsLoading(false);
|
||||
});
|
||||
Promise.allSettled([loadActivity(), loadAllProjectDetails()]).finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
setIsLoading(false);
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
const learningRoadmaps = activity?.learning.roadmaps || [];
|
||||
@@ -106,6 +132,17 @@ export function ActivityPage() {
|
||||
learningRoadmapsToShow.length !== 0 ||
|
||||
learningBestPracticesToShow.length !== 0;
|
||||
|
||||
const enrichedProjects = activity?.projects.map((project) => {
|
||||
const projectDetail = projectDetails.find(
|
||||
(page) => page.id === project.projectId,
|
||||
);
|
||||
|
||||
return {
|
||||
...project,
|
||||
title: projectDetail?.title || 'N/A',
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivityCounters
|
||||
@@ -201,6 +238,19 @@ export function ActivityPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{enrichedProjects && enrichedProjects?.length > 0 && (
|
||||
<div className="mx-0 px-0 py-5 pb-0 md:-mx-10 md:px-8 md:py-8 md:pb-0">
|
||||
<h2 className="mb-3 text-xs uppercase text-gray-400">
|
||||
Your Projects
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||
{enrichedProjects.map((project) => (
|
||||
<ProjectProgress key={project._id} projectStatus={project} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasProgress && (
|
||||
<ActivityStream activities={activity?.activities || []} />
|
||||
)}
|
||||
|
||||
50
src/components/Activity/ProjectProgress.tsx
Normal file
50
src/components/Activity/ProjectProgress.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { getUser } from '../../lib/jwt';
|
||||
import { getPercentage } from '../../helper/number';
|
||||
import { ProjectProgressActions } from './ProjectProgressActions';
|
||||
import { cn } from '../../lib/classname';
|
||||
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions';
|
||||
import { ProjectStatus } from './ProjectStatus';
|
||||
import { ThumbsUp } from 'lucide-react';
|
||||
|
||||
type ProjectProgressType = {
|
||||
projectStatus: ProjectStatusDocument & {
|
||||
title: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function ProjectProgress(props: ProjectProgressType) {
|
||||
const { projectStatus } = props;
|
||||
const userId = getUser()?.id;
|
||||
|
||||
const shouldShowActions =
|
||||
projectStatus.submittedAt && projectStatus.submittedAt !== null;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<a
|
||||
className={cn(
|
||||
'group relative flex w-full items-center justify-between overflow-hidden rounded-md border border-gray-300 bg-white px-3 py-2 pr-7 text-left text-sm transition-all hover:border-gray-400',
|
||||
shouldShowActions ? '' : 'pr-3',
|
||||
)}
|
||||
href={`/projects/${projectStatus.projectId}`}
|
||||
target="_blank"
|
||||
>
|
||||
<ProjectStatus projectStatus={projectStatus} />
|
||||
<span className="ml-2 flex-grow truncate">{projectStatus?.title}</span>
|
||||
<span className="inline-flex items-center gap-1 text-xs text-gray-400">
|
||||
{projectStatus.upvotes}
|
||||
<ThumbsUp className="size-2.5 stroke-[2.5px]" />
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{shouldShowActions && (
|
||||
<div className="absolute right-2 top-0 flex h-full items-center">
|
||||
<ProjectProgressActions
|
||||
userId={userId!}
|
||||
projectId={projectStatus.projectId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/components/Activity/ProjectProgressActions.tsx
Normal file
68
src/components/Activity/ProjectProgressActions.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { MoreVertical, X } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { useCopyText } from '../../hooks/use-copy-text';
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||
import { ShareIcon } from '../ReactIcons/ShareIcon';
|
||||
|
||||
type ProjectProgressActionsType = {
|
||||
userId: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export function ProjectProgressActions(props: ProjectProgressActionsType) {
|
||||
const { userId, projectId } = props;
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { copyText, isCopied } = useCopyText();
|
||||
|
||||
const projectSolutionUrl = `${import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh'}/projects/${projectId}/solutions?u=${userId}`;
|
||||
|
||||
useOutsideClick(dropdownRef, () => {
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative h-full" ref={dropdownRef}>
|
||||
<button
|
||||
className="h-full text-gray-400 hover:text-gray-700"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-8 z-10 w-48 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-1.5 p-2 text-xs font-medium hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-70 sm:text-sm',
|
||||
isCopied ? 'text-green-500' : 'text-gray-500 hover:text-black',
|
||||
)}
|
||||
onClick={() => {
|
||||
copyText(projectSolutionUrl);
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<CheckIcon additionalClasses="h-3.5 w-3.5" /> Link Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShareIcon className="h-3.5 w-3.5 stroke-[2.5px]" /> Share
|
||||
Solution
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/components/Activity/ProjectStatus.tsx
Normal file
24
src/components/Activity/ProjectStatus.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { CircleDashed } from 'lucide-react';
|
||||
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions';
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||
|
||||
type ProjectStatusType = {
|
||||
projectStatus: ProjectStatusDocument & {
|
||||
title: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function ProjectStatus(props: ProjectStatusType) {
|
||||
const { projectStatus } = props;
|
||||
|
||||
const { submittedAt, repositoryUrl } = projectStatus;
|
||||
const status = submittedAt && repositoryUrl ? 'submitted' : 'started';
|
||||
|
||||
if (status === 'submitted') {
|
||||
return <CheckIcon additionalClasses="size-3 text-gray-500 shrink-0" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<CircleDashed className="size-3 shrink-0 stroke-[2.5px] text-gray-400" />
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { VoteButton } from './VoteButton.tsx';
|
||||
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
|
||||
import { SelectLanguages } from './SelectLanguages.tsx';
|
||||
import type { ProjectFrontmatter } from '../../lib/project.ts';
|
||||
import { ProjectSolutionModal } from './ProjectSolutionModal.tsx';
|
||||
|
||||
export interface ProjectStatusDocument {
|
||||
_id?: string;
|
||||
@@ -32,7 +33,7 @@ export interface ProjectStatusDocument {
|
||||
|
||||
isVisible?: boolean;
|
||||
|
||||
updated1t: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const allowedVoteType = ['upvote', 'downvote'] as const;
|
||||
@@ -68,7 +69,7 @@ type ListProjectSolutionsProps = {
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
const submittedAlternatives = [
|
||||
export const submittedAlternatives = [
|
||||
'submitted their solution',
|
||||
'got it done',
|
||||
'submitted their take',
|
||||
|
||||
198
src/components/Projects/ProjectSolutionModal.tsx
Normal file
198
src/components/Projects/ProjectSolutionModal.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { deleteUrlParam, getUrlParams } from '../../lib/browser';
|
||||
import { ModalLoader } from '../UserProgress/ModalLoader';
|
||||
import { Modal } from '../Modal';
|
||||
import { httpGet, httpPost } from '../../lib/http';
|
||||
import {
|
||||
submittedAlternatives,
|
||||
type AllowedVoteType,
|
||||
} from './ListProjectSolutions';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
import { ArrowUpRight, ThumbsDown, ThumbsUp } from 'lucide-react';
|
||||
import { VoteButton } from './VoteButton';
|
||||
import { GitHubIcon } from '../ReactIcons/GitHubIcon';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
type UserProjectSolutionResponse = {
|
||||
id?: string;
|
||||
|
||||
startedAt?: Date;
|
||||
submittedAt?: Date;
|
||||
repositoryUrl?: string;
|
||||
|
||||
upvotes?: number;
|
||||
downvotes?: number;
|
||||
|
||||
voteType?: AllowedVoteType | 'none';
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ProjectSolutionModalProps = {
|
||||
projectId: string;
|
||||
projectTitle: string;
|
||||
projectDescription: string;
|
||||
};
|
||||
|
||||
export function ProjectSolutionModal(props: ProjectSolutionModalProps) {
|
||||
const { projectId, projectTitle, projectDescription } = props;
|
||||
|
||||
const { u: userId } = getUrlParams();
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toast = useToast();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [solution, setSolution] = useState<UserProjectSolutionResponse>();
|
||||
|
||||
const loadUserProjectSolution = async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpGet<UserProjectSolutionResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-project-solution/${projectId}/${userId}`,
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Something went wrong');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSolution(response);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
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('');
|
||||
setSolution((prev) => {
|
||||
if (!prev) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
upvotes: response?.upvotes || 0,
|
||||
downvotes: response?.downvotes || 0,
|
||||
voteType,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadUserProjectSolution().finally();
|
||||
}, []);
|
||||
|
||||
if (isLoading || error) {
|
||||
return (
|
||||
<ModalLoader
|
||||
text="Loading project solution..."
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const avatar = solution?.user.avatar;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={() => {
|
||||
deleteUrlParam('u');
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
<div className="relative p-4">
|
||||
<h1 className="text-xl font-semibold">{projectTitle}</h1>
|
||||
<p className="mt-1 max-w-xs text-sm text-gray-500">
|
||||
{projectDescription}
|
||||
</p>
|
||||
|
||||
<hr className="-mx-4 my-4 border-gray-300" />
|
||||
|
||||
<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[
|
||||
Math.floor(Math.random() * 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="mt-4 flex items-center justify-between gap-2">
|
||||
<a
|
||||
className="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"
|
||||
href={solution?.repositoryUrl}
|
||||
target="_blank"
|
||||
>
|
||||
<GitHubIcon className="h-4 w-4 text-current" />
|
||||
View Solution
|
||||
</a>
|
||||
|
||||
<div className="flex shrink-0 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}
|
||||
hideCount={true}
|
||||
onClick={() => {
|
||||
handleSubmitVote(solution?.id!, 'downvote');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Flag, Play, Send } from 'lucide-react';
|
||||
import { Flag, Play, Send, Share } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { cn } from '../../../lib/classname.ts';
|
||||
import { useStickyStuck } from '../../../hooks/use-sticky-stuck.tsx';
|
||||
@@ -8,9 +8,11 @@ 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 { getUser, isLoggedIn } from '../../../lib/jwt.ts';
|
||||
import { showLoginPopup } from '../../../lib/popup.ts';
|
||||
import { SubmitProjectModal } from '../SubmitProjectModal.tsx';
|
||||
import { useCopyText } from '../../../hooks/use-copy-text.ts';
|
||||
import { CheckIcon } from '../../ReactIcons/CheckIcon.tsx';
|
||||
|
||||
type ProjectStatusResponse = {
|
||||
id?: string;
|
||||
@@ -32,9 +34,11 @@ export function ProjectStepper(props: ProjectStepperProps) {
|
||||
|
||||
const stickyElRef = useRef<HTMLDivElement>(null);
|
||||
const isSticky = useStickyStuck(stickyElRef, 8);
|
||||
const currentUser = getUser();
|
||||
|
||||
const [isStartingProject, setIsStartingProject] = useState(false);
|
||||
const [isSubmittingProject, setIsSubmittingProject] = useState(false);
|
||||
const { copyText, isCopied } = useCopyText();
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeStep, setActiveStep] = useState<number>(0);
|
||||
@@ -78,13 +82,16 @@ export function ProjectStepper(props: ProjectStepperProps) {
|
||||
loadProjectStatus().finally(() => {});
|
||||
}, []);
|
||||
|
||||
const projectSolutionUrl = `${import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh'}/projects/${projectId}/solutions?u=${currentUser?.id}`;
|
||||
|
||||
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',
|
||||
'relative top-0 -mx-4 my-5 overflow-hidden rounded-none border border-x-0 bg-white transition-all sm:sticky sm:mx-0 sm:rounded-lg sm:border-x',
|
||||
{
|
||||
'sm:-mx-5 sm:rounded-none sm:border-x-0 sm:border-t-0 sm:bg-gray-50': isSticky,
|
||||
'sm:-mx-5 sm:rounded-none sm:border-x-0 sm:border-t-0 sm:bg-gray-50':
|
||||
isSticky,
|
||||
},
|
||||
)}
|
||||
>
|
||||
@@ -131,7 +138,7 @@ export function ProjectStepper(props: ProjectStepperProps) {
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm text-gray-500 transition-colors bg-gray-100',
|
||||
'bg-gray-100 px-4 py-2 text-sm text-gray-500 transition-colors sm:flex sm:items-center',
|
||||
{
|
||||
'bg-purple-600 text-white': isSticky,
|
||||
},
|
||||
@@ -144,7 +151,7 @@ export function ProjectStepper(props: ProjectStepperProps) {
|
||||
)}
|
||||
{activeStep === 1 && (
|
||||
<>
|
||||
Started working{' '}
|
||||
Started working
|
||||
<span
|
||||
className={cn('font-medium text-gray-800', {
|
||||
'text-purple-200': isSticky,
|
||||
@@ -152,7 +159,7 @@ export function ProjectStepper(props: ProjectStepperProps) {
|
||||
>
|
||||
{getRelativeTimeString(projectStatus.startedAt!)}
|
||||
</span>
|
||||
. Follow{' '}
|
||||
. Follow
|
||||
<button
|
||||
className={cn('underline underline-offset-2 hover:text-black', {
|
||||
'text-purple-100 hover:text-white': isSticky,
|
||||
@@ -162,13 +169,13 @@ export function ProjectStepper(props: ProjectStepperProps) {
|
||||
}}
|
||||
>
|
||||
these tips
|
||||
</button>{' '}
|
||||
to get most out of it.
|
||||
</button>
|
||||
to get most out of it.
|
||||
</>
|
||||
)}
|
||||
{activeStep >= 2 && (
|
||||
<>
|
||||
Congrats on submitting your solution.{' '}
|
||||
Congrats on submitting your solution.
|
||||
<button
|
||||
className={cn('underline underline-offset-2 hover:text-black', {
|
||||
'text-purple-100 hover:text-white': isSticky,
|
||||
@@ -181,9 +188,34 @@ export function ProjectStepper(props: ProjectStepperProps) {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeStep >= 2 && (
|
||||
<button
|
||||
className={cn(
|
||||
'ml-auto hidden items-center gap-1 text-sm sm:flex',
|
||||
isCopied ? 'text-green-500' : '',
|
||||
)}
|
||||
onClick={() => {
|
||||
copyText(projectSolutionUrl);
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<CheckIcon additionalClasses="h-3 w-3" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Share className="h-3 w-3 stroke-[2.5px]" />
|
||||
<span className="hidden md:inline">Share Solution</span>
|
||||
<span className="md:hidden">Share</span>
|
||||
</>
|
||||
)}
|
||||
</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">
|
||||
<div className="flex min-h-[60px] flex-col items-start justify-between gap-2 px-4 py-4 sm:flex-row sm:items-center sm:gap-3 sm:py-0">
|
||||
<StepperAction
|
||||
isActive={activeStep === 0}
|
||||
isCompleted={activeStep > 0}
|
||||
@@ -200,21 +232,46 @@ export function ProjectStepper(props: ProjectStepperProps) {
|
||||
}}
|
||||
/>
|
||||
<StepperStepSeparator isActive={activeStep > 0} />
|
||||
<StepperAction
|
||||
isActive={activeStep === 1}
|
||||
isCompleted={activeStep > 1}
|
||||
icon={Send}
|
||||
onClick={() => {
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
<div className="flex items-center gap-2">
|
||||
<StepperAction
|
||||
isActive={activeStep === 1}
|
||||
isCompleted={activeStep > 1}
|
||||
icon={Send}
|
||||
onClick={() => {
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmittingProject(true);
|
||||
}}
|
||||
text={activeStep > 1 ? 'Submitted' : 'Submit Solution'}
|
||||
number={2}
|
||||
/>
|
||||
setIsSubmittingProject(true);
|
||||
}}
|
||||
text={activeStep > 1 ? 'Submitted' : 'Submit Solution'}
|
||||
number={2}
|
||||
/>
|
||||
|
||||
<span className="text-gray-600 sm:hidden">·</span>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm sm:hidden',
|
||||
isCopied ? 'text-green-500' : 'text-gray-600',
|
||||
)}
|
||||
onClick={() => {
|
||||
copyText(projectSolutionUrl);
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<CheckIcon additionalClasses="h-3 w-3" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Share className="h-3 w-3 stroke-[2.5px]" />
|
||||
Share Solution
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<StepperStepSeparator isActive={activeStep > 1} />
|
||||
<MilestoneStep
|
||||
isActive={activeStep === 2}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
|
||||
import { SubmissionRequirement } from './SubmissionRequirement.tsx';
|
||||
import { useCopyText } from '../../hooks/use-copy-text.ts';
|
||||
import { getTopGitHubLanguages } from '../../lib/github.ts';
|
||||
import { SubmitSuccessModal } from './SubmitSuccessModal.tsx';
|
||||
|
||||
type SubmitProjectResponse = {
|
||||
repositoryUrl: string;
|
||||
@@ -211,12 +212,11 @@ export function SubmitProjectModal(props: SubmitProjectModalProps) {
|
||||
|
||||
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>
|
||||
<SubmitSuccessModal
|
||||
projectId={projectId}
|
||||
onClose={onClose}
|
||||
successMessage={successMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
112
src/components/Projects/SubmitSuccessModal.tsx
Normal file
112
src/components/Projects/SubmitSuccessModal.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clipboard,
|
||||
Facebook,
|
||||
Linkedin,
|
||||
Twitter,
|
||||
} from 'lucide-react';
|
||||
import { getUser } from '../../lib/jwt.ts';
|
||||
import { Modal } from '../Modal';
|
||||
import { CheckIcon as ReactCheckIcon } from '../ReactIcons/CheckIcon.tsx';
|
||||
import { useCopyText } from '../../hooks/use-copy-text.ts';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
|
||||
type SubmitSuccessModalProps = {
|
||||
projectId: string;
|
||||
onClose: () => void;
|
||||
successMessage: string;
|
||||
};
|
||||
|
||||
export function SubmitSuccessModal(props: SubmitSuccessModalProps) {
|
||||
const { onClose, successMessage, projectId } = props;
|
||||
|
||||
const user = getUser();
|
||||
|
||||
const description = 'Check out my solution to this project on Roadmap.sh';
|
||||
const projectSolutionUrl = `${import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh'}/projects/${projectId}/solutions?u=${user?.id}`;
|
||||
|
||||
const { isCopied, copyText } = useCopyText();
|
||||
|
||||
const socialShareLinks = [
|
||||
{
|
||||
title: 'Twitter',
|
||||
href: `https://x.com/intent/tweet?text=${description}&url=${projectSolutionUrl}`,
|
||||
icon: <Twitter className="size-4 text-gray-700" />,
|
||||
},
|
||||
{
|
||||
title: 'Facebook',
|
||||
href: `https://www.facebook.com/sharer/sharer.php?quote=${description}&u=${projectSolutionUrl}`,
|
||||
icon: <Facebook className="size-4 text-gray-700" />,
|
||||
},
|
||||
{
|
||||
title: 'Linkedin',
|
||||
href: `https://www.linkedin.com/sharing/share-offsite/?url=${projectSolutionUrl}`,
|
||||
icon: <Linkedin className="size-4 text-gray-700" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} bodyClassName="h-auto p-4">
|
||||
<div className="flex flex-col items-center justify-center pb-5 pt-12">
|
||||
<ReactCheckIcon additionalClasses="h-12 text-green-500 w-12" />
|
||||
<p className="mt-4 text-lg font-medium">{successMessage}</p>
|
||||
<p className="mt-0.5 text-center text-sm text-gray-500">
|
||||
You can use the link to share your solution with others.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex w-full items-stretch rounded-md border bg-gray-50">
|
||||
<input
|
||||
type="text"
|
||||
readOnly={true}
|
||||
value={projectSolutionUrl}
|
||||
className="w-full bg-transparent px-2.5 py-2 text-sm text-gray-700 focus:outline-none"
|
||||
onClick={(e) => {
|
||||
e.currentTarget.select();
|
||||
copyText(projectSolutionUrl);
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
className={cn(
|
||||
'm-1 ml-0 flex items-center gap-1 rounded-md bg-gray-200 px-2 py-1.5 text-xs font-medium text-black',
|
||||
isCopied ? 'bg-green-200 text-green-900' : '',
|
||||
)}
|
||||
onClick={() => {
|
||||
copyText(projectSolutionUrl);
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<CheckCircle2 className="size-3 stroke-[2.5px]" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clipboard className="size-3 stroke-[2.5px]" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex justify-center gap-2">
|
||||
{socialShareLinks.map((socialLink) => (
|
||||
<a
|
||||
key={socialLink.title}
|
||||
href={socialLink.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border bg-gray-50 hover:bg-gray-100"
|
||||
>
|
||||
{socialLink.icon}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-gray-500">
|
||||
Share your solution with the others!
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getAllGuides } from '../lib/guide';
|
||||
import { getRoadmapsByTag } from '../lib/roadmap';
|
||||
import { getAllVideos } from '../lib/video';
|
||||
import { getAllQuestionGroups } from '../lib/question-group';
|
||||
import { getAllProjects } from '../lib/project';
|
||||
|
||||
export async function GET() {
|
||||
const guides = await getAllGuides();
|
||||
@@ -10,6 +11,7 @@ export async function GET() {
|
||||
const questionGroups = await getAllQuestionGroups();
|
||||
const roadmaps = await getRoadmapsByTag('roadmap');
|
||||
const bestPractices = await getAllBestPractices();
|
||||
const projects = await getAllProjects();
|
||||
|
||||
return new Response(
|
||||
JSON.stringify([
|
||||
@@ -53,6 +55,13 @@ export async function GET() {
|
||||
title: video.frontmatter.title,
|
||||
group: 'Videos',
|
||||
})),
|
||||
...projects.map((project) => ({
|
||||
id: project.id,
|
||||
url: `/projects/${project.id}`,
|
||||
title: project.frontmatter.title,
|
||||
description: project.frontmatter.description,
|
||||
group: 'Projects',
|
||||
})),
|
||||
]),
|
||||
{
|
||||
status: 200,
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
---
|
||||
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';
|
||||
import { ProjectSolutionModal } from '../../../components/Projects/ProjectSolutionModal';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const projects = await getAllProjects();
|
||||
@@ -49,13 +48,24 @@ const githubUrl = `https://github.com/kamranahmedse/developer-roadmap/tree/maste
|
||||
>
|
||||
<div class='bg-gray-50'>
|
||||
<div class='container'>
|
||||
<ProjectTabs parentRoadmapId={parentRoadmapId} projectId={projectId} activeTab='solutions' />
|
||||
<ProjectTabs
|
||||
parentRoadmapId={parentRoadmapId}
|
||||
projectId={projectId}
|
||||
activeTab='solutions'
|
||||
/>
|
||||
|
||||
<ListProjectSolutions
|
||||
project={projectData}
|
||||
projectId={projectId}
|
||||
client:load
|
||||
/>
|
||||
|
||||
<ProjectSolutionModal
|
||||
projectId={projectId}
|
||||
projectTitle={projectData.title}
|
||||
projectDescription={projectData.description}
|
||||
client:only='react'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
Reference in New Issue
Block a user