From 1981568501cd27145131ee87bae365d3e305d773 Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Sat, 17 Aug 2024 17:59:35 +0600 Subject: [PATCH] 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 --- .astro/settings.json | 2 +- src/components/Modal.tsx | 4 +- src/components/OpenSourceStat.astro | 2 +- src/components/Projects/EmptySolutions.tsx | 29 ++ .../Projects/LeavingRoadmapWarningModal.tsx | 64 ++++ .../Projects/ListProjectSolutions.tsx | 327 ++++++++++++++++++ src/components/Projects/LoadingSolutions.tsx | 44 +++ src/components/Projects/ProjectTabs.tsx | 69 ++++ src/components/Projects/StartProjectModal.tsx | 169 +++++++++ .../Projects/StatusStepper/MilestoneStep.tsx | 37 ++ .../Projects/StatusStepper/ProjectStepper.tsx | 245 +++++++++++++ .../Projects/StatusStepper/StepperAction.tsx | 51 +++ .../StatusStepper/StepperStepSeparator.tsx | 17 + .../Projects/SubmissionRequirement.tsx | 44 +++ .../Projects/SubmitProjectModal.tsx | 299 ++++++++++++++++ src/components/Projects/VoteButton.tsx | 30 ++ src/components/TeamDropdown/TeamDropdown.tsx | 27 -- src/hooks/use-sticky-stuck.tsx | 29 ++ src/layouts/BaseLayout.astro | 5 +- src/lib/date.ts | 6 +- src/lib/is-mobile.ts | 6 + src/pages/projects/[projectId].astro | 129 ------- src/pages/projects/[projectId]/index.astro | 94 +++++ .../projects/[projectId]/solutions.astro | 66 ++++ 24 files changed, 1633 insertions(+), 162 deletions(-) create mode 100644 src/components/Projects/EmptySolutions.tsx create mode 100644 src/components/Projects/LeavingRoadmapWarningModal.tsx create mode 100644 src/components/Projects/ListProjectSolutions.tsx create mode 100644 src/components/Projects/LoadingSolutions.tsx create mode 100644 src/components/Projects/ProjectTabs.tsx create mode 100644 src/components/Projects/StartProjectModal.tsx create mode 100644 src/components/Projects/StatusStepper/MilestoneStep.tsx create mode 100644 src/components/Projects/StatusStepper/ProjectStepper.tsx create mode 100644 src/components/Projects/StatusStepper/StepperAction.tsx create mode 100644 src/components/Projects/StatusStepper/StepperStepSeparator.tsx create mode 100644 src/components/Projects/SubmissionRequirement.tsx create mode 100644 src/components/Projects/SubmitProjectModal.tsx create mode 100644 src/components/Projects/VoteButton.tsx create mode 100644 src/hooks/use-sticky-stuck.tsx delete mode 100644 src/pages/projects/[projectId].astro create mode 100644 src/pages/projects/[projectId]/index.astro create mode 100644 src/pages/projects/[projectId]/solutions.astro diff --git a/.astro/settings.json b/.astro/settings.json index 166c25ab1..76d2425cb 100644 --- a/.astro/settings.json +++ b/.astro/settings.json @@ -3,6 +3,6 @@ "enabled": false }, "_variables": { - "lastUpdateCheck": 1723501110773 + "lastUpdateCheck": 1723855511353 } } \ No newline at end of file diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index ac28c9e7c..d8167ff7a 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -33,7 +33,7 @@ export function Modal(props: ModalProps) { return (
@@ -46,7 +46,7 @@ export function Modal(props: ModalProps) {
diff --git a/src/components/OpenSourceStat.astro b/src/components/OpenSourceStat.astro index e95a94bd8..f6e2bb063 100644 --- a/src/components/OpenSourceStat.astro +++ b/src/components/OpenSourceStat.astro @@ -44,7 +44,7 @@ const isDiscordMembers = text.toLowerCase() === 'discord members'; }

{value}

diff --git a/src/components/Projects/EmptySolutions.tsx b/src/components/Projects/EmptySolutions.tsx new file mode 100644 index 000000000..dcaf5b591 --- /dev/null +++ b/src/components/Projects/EmptySolutions.tsx @@ -0,0 +1,29 @@ +import { Blocks, CodeXml } from 'lucide-react'; + +type EmptySolutionsProps = { + projectId: string; +}; + +export function EmptySolutions(props: EmptySolutionsProps) { + const { projectId } = props; + + return ( +
+ +

+ No solutions submitted yet +

+

+ Be the first to submit a solution for this project +

+ +
+ ); +} diff --git a/src/components/Projects/LeavingRoadmapWarningModal.tsx b/src/components/Projects/LeavingRoadmapWarningModal.tsx new file mode 100644 index 000000000..9a48846df --- /dev/null +++ b/src/components/Projects/LeavingRoadmapWarningModal.tsx @@ -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 ( + +

Leaving roadmap.sh

+

+ You are about to visit the project solution on GitHub. We recommend you + to follow these tips before you leave. +

+ +
+

+ Make sure to come back and{' '} + leave an upvote{' '} + if you liked the solution. It helps the author and the community. +

+ +

+ If you have feedback on the solution, open an issue or a pull request + on the{' '} + + solution repository + + . +

+ +

+ Downvote the solution if it is{' '} + + incorrect or misleading + + . It helps the community. It helps the community. +

+
+ + + + +
+ ); +} diff --git a/src/components/Projects/ListProjectSolutions.tsx b/src/components/Projects/ListProjectSolutions.tsx new file mode 100644 index 000000000..28542a88f --- /dev/null +++ b/src/components/Projects/ListProjectSolutions.tsx @@ -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({ + currentPage: 0, + }); + + const [isLoading, setIsLoading] = useState(true); + const [solutions, setSolutions] = useState(); + const [alreadyVisitedSolutions, setAlreadyVisitedSolutions] = useState< + Record + >({}); + const [showLeavingRoadmapModal, setShowLeavingRoadmapModal] = useState< + ListProjectSolutionsResponse['data'][number] | null + >(null); + + const loadSolutions = async (page = 1) => { + const { response, error } = await httpGet( + `${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 ; + } + + const isEmpty = solutions?.data.length === 0; + if (isEmpty) { + return ; + } + + const leavingRoadmapModal = showLeavingRoadmapModal ? ( + setShowLeavingRoadmapModal(null)} + onContinue={() => { + const visitedSolutions = { + ...alreadyVisitedSolutions, + [showLeavingRoadmapModal._id!]: true, + }; + localStorage.setItem( + VISITED_SOLUTIONS_KEY, + JSON.stringify(visitedSolutions), + ); + + window.open(showLeavingRoadmapModal.repositoryUrl, '_blank'); + }} + /> + ) : null; + + return ( +
+ {leavingRoadmapModal} + +
+ {solutions?.data.map((solution, counter) => { + const isVisited = alreadyVisitedSolutions[solution._id!]; + const avatar = solution.user.avatar || ''; + + return ( +
+
+ {solution.user.name} + + {solution.user.name} + + + {submittedAlternatives[ + counter % submittedAlternatives.length + ] || 'submitted their solution'} + {' '} + + {getRelativeTimeString(solution?.submittedAt!)} + +
+ +
+ + { + handleSubmitVote(solution._id!, 'upvote'); + }} + /> + + { + handleSubmitVote(solution._id!, 'downvote'); + }} + /> + + + { + e.preventDefault(); + setShowLeavingRoadmapModal(solution); + }} + target="_blank" + href={solution.repositoryUrl} + > + + Visit Solution + +
+
+ ); + })} +
+ + {(solutions?.totalPages || 0) > 1 && ( +
+ { + setPageState({ + ...pageState, + currentPage: page, + }); + }} + /> +
+ )} +
+ ); +} diff --git a/src/components/Projects/LoadingSolutions.tsx b/src/components/Projects/LoadingSolutions.tsx new file mode 100644 index 000000000..f327fd81e --- /dev/null +++ b/src/components/Projects/LoadingSolutions.tsx @@ -0,0 +1,44 @@ +import { isMobileScreen } from '../../lib/is-mobile.ts'; + +export function LoadingSolutions() { + const totalCount = isMobileScreen() ? 3 : 11; + + const loadingRow = ( +
  • + + + + + + + + +
  • + ); + + return ( +
      + {loadingRow} + {loadingRow} + {loadingRow} + {loadingRow} + {loadingRow} + {loadingRow} + {loadingRow} + {loadingRow} + {loadingRow} + {loadingRow} + {loadingRow} +
    + ); +} diff --git a/src/components/Projects/ProjectTabs.tsx b/src/components/Projects/ProjectTabs.tsx new file mode 100644 index 000000000..9c9931d0d --- /dev/null +++ b/src/components/Projects/ProjectTabs.tsx @@ -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 ( + + {ButtonIcon && } + {text} + {smText && {smText}} + + {isActive && ( + + )} + + ); +} + +type ProjectTabsProps = { + activeTab: AllowedProjectTab; + projectId: string; +}; + +export function ProjectTabs(props: ProjectTabsProps) { + const { activeTab, projectId } = props; + + return ( +
    + + +
    + ); +} diff --git a/src/components/Projects/StartProjectModal.tsx b/src/components/Projects/StartProjectModal.tsx new file mode 100644 index 000000000..69212ee6e --- /dev/null +++ b/src/components/Projects/StartProjectModal.tsx @@ -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 ( + + {label} + + ); +} + +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(); + + 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 ( + +
    + +

    {error}

    +
    +
    + ); + } + + if (isStartingProject) { + return ( + +
    + +

    Starting project ..

    +
    +
    + ); + } + + return ( + +

    + + Project started{' '} + {formattedStartedAt} +

    +

    + Start Building +

    +

    + Follow these steps to complete the project. +

    + +
    +

    + 1. Create a new public repository on GitHub. +

    + +

    + 2. Complete the project according to the requirements and push your code + to the GitHub repository. +

    + +

    + 3. Add a README file with instructions to run the project and the{' '} + +

    +

    + 4. Once done, submit your solution to help the others learn and get feedback + from the community. +

    +
    + +
    +

    + If you get stuck, you can always ask for help in the community{' '} + + chat on discord + + . +

    +
    + + +
    + ); +} diff --git a/src/components/Projects/StatusStepper/MilestoneStep.tsx b/src/components/Projects/StatusStepper/MilestoneStep.tsx new file mode 100644 index 000000000..cb7f965e5 --- /dev/null +++ b/src/components/Projects/StatusStepper/MilestoneStep.tsx @@ -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 ( + + + {text} + + ); + } + + if (isCompleted) { + return ( + + + {text} + + ); + } + + return ( + + + {text} + + ); +} diff --git a/src/components/Projects/StatusStepper/ProjectStepper.tsx b/src/components/Projects/StatusStepper/ProjectStepper.tsx new file mode 100644 index 000000000..dee1a485d --- /dev/null +++ b/src/components/Projects/StatusStepper/ProjectStepper.tsx @@ -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(null); + const isSticky = useStickyStuck(stickyElRef, 8); + + const [isStartingProject, setIsStartingProject] = useState(false); + const [isSubmittingProject, setIsSubmittingProject] = useState(false); + + const [error, setError] = useState(null); + const [activeStep, setActiveStep] = useState(0); + const [isLoadingStatus, setIsLoadingStatus] = useState(true); + const [projectStatus, setProjectStatus] = useState({ + upvotes: 0, + downvotes: 0, + }); + + async function loadProjectStatus() { + setIsLoadingStatus(true); + + const { response, error } = await httpGet( + `${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 ( +
    + {isSubmittingProject && ( + setIsSubmittingProject(false)} + projectId={projectId} + onSubmit={(response) => { + const { repositoryUrl, submittedAt } = response; + + setProjectStatus({ + ...projectStatus, + repositoryUrl, + submittedAt, + }); + + setActiveStep(2); + }} + repositoryUrl={projectStatus.repositoryUrl} + /> + )} + {isStartingProject && ( + { + setProjectStatus({ + ...projectStatus, + startedAt, + }); + setActiveStep(1); + }} + startedAt={projectStatus?.startedAt} + onClose={() => setIsStartingProject(false)} + /> + )} + + {error && ( +
    + {error} +
    + )} + {isLoadingStatus && ( +
    + )} +
    + {activeStep === 0 && ( + <> + Start building, submit solution and get feedback from the community. + + )} + {activeStep === 1 && ( + <> + Started working{' '} + + {getRelativeTimeString(projectStatus.startedAt!)} + + . Follow{' '} + {' '} + to get most out of it. + + )} + {activeStep >= 2 && ( + <> + Congrats on submitting your solution.{' '} + + + )} +
    + +
    + 0} + icon={Play} + text={activeStep > 0 ? 'Started Working' : 'Start Working'} + number={1} + onClick={() => { + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + setIsStartingProject(true); + }} + /> + 0} /> + 1} + icon={Send} + onClick={() => { + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + setIsSubmittingProject(true); + }} + text={activeStep > 1 ? 'Submitted' : 'Submit Solution'} + number={2} + /> + 1} /> + 2} + icon={Flag} + text={ + activeStep == 2 + ? `${projectStatus.upvotes} / 5 upvotes` + : `5 upvotes` + } + /> + 2} /> + 3} + icon={Flag} + text={ + activeStep == 3 + ? `${projectStatus.upvotes} / 10 upvotes` + : activeStep > 3 + ? `${projectStatus.upvotes} upvotes` + : `10 upvotes` + } + /> +
    +
    + ); +} diff --git a/src/components/Projects/StatusStepper/StepperAction.tsx b/src/components/Projects/StatusStepper/StepperAction.tsx new file mode 100644 index 000000000..a6555fe13 --- /dev/null +++ b/src/components/Projects/StatusStepper/StepperAction.tsx @@ -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 ( + + ); + } + + if (isCompleted) { + return ( + + + {text} + + ); + } + + return ( + + + {number} + + {text} + + ); +} diff --git a/src/components/Projects/StatusStepper/StepperStepSeparator.tsx b/src/components/Projects/StatusStepper/StepperStepSeparator.tsx new file mode 100644 index 000000000..b169daab6 --- /dev/null +++ b/src/components/Projects/StatusStepper/StepperStepSeparator.tsx @@ -0,0 +1,17 @@ +import { cn } from '../../../lib/classname.ts'; + +type StepperStepSeparatorProps = { + isActive: boolean; +}; + +export function StepperStepSeparator(props: StepperStepSeparatorProps) { + const { isActive } = props; + + return ( + + ); +} diff --git a/src/components/Projects/SubmissionRequirement.tsx b/src/components/Projects/SubmissionRequirement.tsx new file mode 100644 index 000000000..778efc9a1 --- /dev/null +++ b/src/components/Projects/SubmissionRequirement.tsx @@ -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 ( +
    + {!isLoading && ( + <> + {status === 'pending' ? ( + + ) : status === 'success' ? ( + + ) : ( + + )} + + )} + + {isLoading && ( + + )} + {children} +
    + ); +} diff --git a/src/components/Projects/SubmitProjectModal.tsx b/src/components/Projects/SubmitProjectModal.tsx new file mode 100644 index 000000000..ac8b84e4c --- /dev/null +++ b/src/components/Projects/SubmitProjectModal.tsx @@ -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({ + 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(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 ( + +
    + +

    {successMessage}

    +
    +
    + ); + } + + return ( + +

    + Submit Solution URL +

    +

    + Submit the URL of your GitHub repository with the solution. +

    + +
    + + URL must point to a public GitHub repository + + + Repository must contain a README file + + + README file must contain the{' '} + + +
    + +
    + setRepoUrl(e.target.value)} + /> + + + {error && ( +

    {error}

    + )} + + {successMessage && ( +

    + {successMessage} +

    + )} +
    + + +
    + ); +} diff --git a/src/components/Projects/VoteButton.tsx b/src/components/Projects/VoteButton.tsx new file mode 100644 index 000000000..3b047ae79 --- /dev/null +++ b/src/components/Projects/VoteButton.tsx @@ -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 ( + + ); +} diff --git a/src/components/TeamDropdown/TeamDropdown.tsx b/src/components/TeamDropdown/TeamDropdown.tsx index e4979d72b..9d6c1c82e 100644 --- a/src/components/TeamDropdown/TeamDropdown.tsx +++ b/src/components/TeamDropdown/TeamDropdown.tsx @@ -32,24 +32,6 @@ export function TeamDropdown() { const user = useAuth(); const { teamId } = useTeamId(); - const [shouldShowTeamsIndicator, setShouldShowTeamsIndicator] = - useState(false); - - useEffect(() => { - // Show team dropdown "New" indicator to first 3 refreshes - const viewedTeamsCount = localStorage.getItem('viewedTeamsCount'); - const viewedTeamsCountNumber = parseInt(viewedTeamsCount || '0', 10); - const shouldShowTeamIndicator = viewedTeamsCountNumber < 5; - - setShouldShowTeamsIndicator(shouldShowTeamIndicator); - if (shouldShowTeamIndicator) { - localStorage.setItem( - 'viewedTeamsCount', - (viewedTeamsCountNumber + 1).toString(), - ); - } - }, []); - const teamList = useStore($teamList); const currentTeam = useStore($currentTeam); @@ -102,15 +84,6 @@ export function TeamDropdown() {
    Choose Team - - {shouldShowTeamsIndicator && ( - - - - - - - )}