feat: project without submission (#8530)

pull/8533/head
Arik Chakma 2 days ago committed by GitHub
parent 05db236a3c
commit d36af2d3fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 74
      src/components/Projects/CompleteProjectConfirmation.tsx
  2. 22
      src/components/Projects/ProjectTabs.tsx
  3. 71
      src/components/Projects/StartProjectConfirmation.tsx
  4. 30
      src/components/Projects/StartProjectModal.tsx
  5. 28
      src/components/Projects/StatusStepper/ProjectStepper.tsx
  6. 123
      src/components/Projects/StatusStepper/ProjectTrackingActions.tsx
  7. 3
      src/lib/project.ts
  8. 25
      src/pages/projects/[projectId]/index.astro
  9. 1
      src/pages/projects/[projectId]/solutions.astro
  10. 26
      src/queries/project.ts

@ -0,0 +1,74 @@
import { useMutation } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import { Modal } from '../Modal';
import { httpPost } from '../../lib/query-http';
import { useToast } from '../../hooks/use-toast';
import { Loader2Icon } from 'lucide-react';
import { projectStatusOptions } from '../../queries/project';
type CompleteProjectConfirmationProps = {
projectId: string;
onClose: () => void;
};
export function CompleteProjectConfirmation(
props: CompleteProjectConfirmationProps,
) {
const { onClose, projectId } = props;
const toast = useToast();
const { mutate: completeProject, isPending: isCompletingProject } =
useMutation(
{
mutationFn: () => {
return httpPost<{
startedAt: Date;
}>(
`${import.meta.env.PUBLIC_API_URL}/v1-mark-as-done-project/${projectId}`,
{},
);
},
onSettled: () => {
queryClient.invalidateQueries(projectStatusOptions(projectId));
},
onError: (error) => {
toast.error(error?.message || 'Failed to start project');
},
onSuccess: () => {
onClose();
},
},
queryClient,
);
return (
<Modal onClose={onClose} bodyClassName="h-auto p-4">
<h2 className="mb-2 flex items-center gap-2.5 text-xl font-semibold">
Complete Project
</h2>
<p className="text-sm text-gray-500">
Are you sure you want to mark this project as completed?
</p>
<div className="mt-4 grid grid-cols-2 gap-2">
<button
onClick={onClose}
className="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-500 hover:bg-gray-200"
>
Cancel
</button>
<button
onClick={() => completeProject()}
className="flex h-9 items-center justify-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
>
{isCompletingProject ? (
<Loader2Icon className="h-4 w-4 animate-spin stroke-[2.5]" />
) : (
'Complete Project'
)}
</button>
</div>
</Modal>
);
}

@ -1,13 +1,5 @@
import { cn } from '../../lib/classname'; import { cn } from '../../lib/classname';
import { import { ArrowLeft, Blocks, type LucideIcon, Text } from 'lucide-react';
ArrowLeft,
Blocks,
BoxSelect,
type LucideIcon,
StepBackIcon,
StickyNote,
Text,
} from 'lucide-react';
export const allowedProjectTabs = ['details', 'solutions'] as const; export const allowedProjectTabs = ['details', 'solutions'] as const;
export type AllowedProjectTab = (typeof allowedProjectTabs)[number]; export type AllowedProjectTab = (typeof allowedProjectTabs)[number];
@ -36,7 +28,7 @@ function TabButton(props: TabButtonProps) {
{smText && <span className="sm:hidden">{smText}</span>} {smText && <span className="sm:hidden">{smText}</span>}
{isActive && ( {isActive && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 translate-y-1/2 rounded-t-md bg-black"></span> <span className="absolute right-0 bottom-0 left-0 h-0.5 translate-y-1/2 rounded-t-md bg-black"></span>
)} )}
</a> </a>
); );
@ -46,10 +38,16 @@ type ProjectTabsProps = {
activeTab: AllowedProjectTab; activeTab: AllowedProjectTab;
projectId: string; projectId: string;
parentRoadmapId?: string; parentRoadmapId?: string;
hasNoSubmission?: boolean;
}; };
export function ProjectTabs(props: ProjectTabsProps) { export function ProjectTabs(props: ProjectTabsProps) {
const { activeTab, parentRoadmapId, projectId } = props; const {
activeTab,
parentRoadmapId,
projectId,
hasNoSubmission = false,
} = props;
return ( return (
<div className="my-3 flex flex-row flex-wrap items-center gap-1.5 overflow-hidden rounded-md border bg-white px-2.5 text-sm"> <div className="my-3 flex flex-row flex-wrap items-center gap-1.5 overflow-hidden rounded-md border bg-white px-2.5 text-sm">
@ -69,6 +67,7 @@ export function ProjectTabs(props: ProjectTabsProps) {
isActive={activeTab === 'details'} isActive={activeTab === 'details'}
href={`/projects/${projectId}`} href={`/projects/${projectId}`}
/> />
{!hasNoSubmission && (
<TabButton <TabButton
text={'Community Solutions'} text={'Community Solutions'}
icon={Blocks} icon={Blocks}
@ -76,6 +75,7 @@ export function ProjectTabs(props: ProjectTabsProps) {
isActive={activeTab === 'solutions'} isActive={activeTab === 'solutions'}
href={`/projects/${projectId}/solutions`} href={`/projects/${projectId}/solutions`}
/> />
)}
</div> </div>
); );
} }

@ -0,0 +1,71 @@
import { useMutation } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import { Modal } from '../Modal';
import { httpPost } from '../../lib/query-http';
import { useToast } from '../../hooks/use-toast';
import { Loader2Icon } from 'lucide-react';
import { projectStatusOptions } from '../../queries/project';
type StartProjectConfirmationProps = {
projectId: string;
onClose: () => void;
};
export function StartProjectConfirmation(props: StartProjectConfirmationProps) {
const { onClose, projectId } = props;
const toast = useToast();
const { mutate: startProject, isPending: isStartingProject } = useMutation(
{
mutationFn: () => {
return httpPost<{
startedAt: Date;
}>(
`${import.meta.env.PUBLIC_API_URL}/v1-start-project/${projectId}`,
{},
);
},
onSettled: () => {
queryClient.invalidateQueries(projectStatusOptions(projectId));
},
onSuccess: () => {
onClose();
},
onError: (error) => {
toast.error(error?.message || 'Failed to start project');
},
},
queryClient,
);
return (
<Modal onClose={onClose} bodyClassName="h-auto p-4">
<h2 className="mb-2 flex items-center gap-2.5 text-xl font-semibold">
Start Project
</h2>
<p className="text-sm text-gray-500">
Are you sure you want to start this project?
</p>
<div className="mt-4 grid grid-cols-2 gap-2">
<button
onClick={onClose}
className="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-500 hover:bg-gray-200"
>
Cancel
</button>
<button
onClick={() => startProject()}
className="flex h-9 items-center justify-center gap-2 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700"
>
{isStartingProject ? (
<Loader2Icon className="h-4 w-4 animate-spin stroke-[2.5]" />
) : (
'Start Project'
)}
</button>
</div>
</Modal>
);
}

@ -7,20 +7,6 @@ import { httpPost } from '../../lib/http.ts';
import { CheckIcon } from '../ReactIcons/CheckIcon.tsx'; import { CheckIcon } from '../ReactIcons/CheckIcon.tsx';
import { useCopyText } from '../../hooks/use-copy-text.ts'; import { useCopyText } from '../../hooks/use-copy-text.ts';
type StepLabelProps = {
label: string;
};
function StepLabel(props: StepLabelProps) {
const { label } = props;
return (
<span className="shrink-0 rounded-full bg-gray-200 px-2 py-1 text-xs text-gray-600">
{label}
</span>
);
}
type StartProjectModalProps = { type StartProjectModalProps = {
projectId: string; projectId: string;
onClose: () => void; onClose: () => void;
@ -66,7 +52,7 @@ export function StartProjectModal(props: StartProjectModalProps) {
if (error) { if (error) {
return ( return (
<Modal onClose={onClose} bodyClassName="h-auto text-red-500"> <Modal onClose={onClose} bodyClassName="h-auto text-red-500">
<div className="flex flex-col items-center justify-center gap-2 pb-10 pt-12"> <div className="flex flex-col items-center justify-center gap-2 pt-12 pb-10">
<ServerCrash className={'h-6 w-6'} /> <ServerCrash className={'h-6 w-6'} />
<p className="font-medium">{error}</p> <p className="font-medium">{error}</p>
</div> </div>
@ -77,7 +63,7 @@ export function StartProjectModal(props: StartProjectModalProps) {
if (isStartingProject) { if (isStartingProject) {
return ( return (
<Modal onClose={onClose} bodyClassName="h-auto"> <Modal onClose={onClose} bodyClassName="h-auto">
<div className="flex flex-col items-center justify-center gap-4 pb-10 pt-12"> <div className="flex flex-col items-center justify-center gap-4 pt-12 pb-10">
<Spinner className={'h-6 w-6'} isDualRing={false} /> <Spinner className={'h-6 w-6'} isDualRing={false} />
<p className="font-medium">Starting project ..</p> <p className="font-medium">Starting project ..</p>
</div> </div>
@ -96,7 +82,7 @@ export function StartProjectModal(props: StartProjectModalProps) {
<span className="mr-1.5 font-normal">Project started</span>{' '} <span className="mr-1.5 font-normal">Project started</span>{' '}
<span className="font-semibold">{formattedStartedAt}</span> <span className="font-semibold">{formattedStartedAt}</span>
</p> </p>
<h2 className="mb-1 mt-5 text-2xl font-semibold text-gray-800"> <h2 className="mt-5 mb-1 text-2xl font-semibold text-gray-800">
Start Building Start Building
</h2> </h2>
<p className="text-gray-700"> <p className="text-gray-700">
@ -109,8 +95,8 @@ export function StartProjectModal(props: StartProjectModalProps) {
</p> </p>
<p className="mt-1 rounded-lg bg-gray-200 p-2 text-sm text-gray-900"> <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 2. Complete the project according to the requirements and push your
to the GitHub repository. code to the GitHub repository.
</p> </p>
<p className="mt-1 rounded-lg bg-gray-200 p-2 text-sm text-gray-900"> <p className="mt-1 rounded-lg bg-gray-200 p-2 text-sm text-gray-900">
@ -139,13 +125,13 @@ export function StartProjectModal(props: StartProjectModalProps) {
</button> </button>
</p> </p>
<p className="mt-1 rounded-lg bg-gray-200 p-2 text-sm text-gray-900"> <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 4. Once done, submit your solution to help the others learn and get
from the community. feedback from the community.
</p> </p>
</div> </div>
<div className="mb-5"> <div className="mb-5">
<p className='text-sm'> <p className="text-sm">
If you get stuck, you can always ask for help in the community{' '} If you get stuck, you can always ask for help in the community{' '}
<a <a
href="https://roadmap.sh/discord" href="https://roadmap.sh/discord"

@ -1,19 +1,19 @@
import { Flag, Play, Send, Share, Square, StopCircle, X } from 'lucide-react'; import { Flag, Play, Send, Share, Square, StopCircle, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { cn } from '../../../lib/classname.ts'; import { cn } from '../../../lib/classname';
import { useStickyStuck } from '../../../hooks/use-sticky-stuck.tsx'; import { useStickyStuck } from '../../../hooks/use-sticky-stuck';
import { StepperAction } from './StepperAction.tsx'; import { StepperAction } from './StepperAction';
import { StepperStepSeparator } from './StepperStepSeparator.tsx'; import { StepperStepSeparator } from './StepperStepSeparator';
import { MilestoneStep } from './MilestoneStep.tsx'; import { MilestoneStep } from './MilestoneStep';
import { httpGet, httpPost } from '../../../lib/http.ts'; import { httpGet, httpPost } from '../../../lib/http';
import { StartProjectModal } from '../StartProjectModal.tsx'; import { StartProjectModal } from '../StartProjectModal';
import { getRelativeTimeString } from '../../../lib/date.ts'; import { getRelativeTimeString } from '../../../lib/date';
import { getUser, isLoggedIn } from '../../../lib/jwt.ts'; import { getUser, isLoggedIn } from '../../../lib/jwt';
import { showLoginPopup } from '../../../lib/popup.ts'; import { showLoginPopup } from '../../../lib/popup';
import { SubmitProjectModal } from '../SubmitProjectModal.tsx'; import { SubmitProjectModal } from '../SubmitProjectModal';
import { useCopyText } from '../../../hooks/use-copy-text.ts'; import { useCopyText } from '../../../hooks/use-copy-text';
import { CheckIcon } from '../../ReactIcons/CheckIcon.tsx'; import { CheckIcon } from '../../ReactIcons/CheckIcon';
import { pageProgressMessage } from '../../../stores/page.ts'; import { pageProgressMessage } from '../../../stores/page';
type ProjectStatusResponse = { type ProjectStatusResponse = {
id?: string; id?: string;

@ -0,0 +1,123 @@
import { CheckIcon, PlayIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { StartProjectConfirmation } from '../StartProjectConfirmation';
import { projectStatusOptions } from '../../../queries/project';
import { queryClient } from '../../../stores/query-client';
import { useQuery } from '@tanstack/react-query';
import { cn } from '../../../lib/classname';
import { isLoggedIn } from '../../../lib/jwt';
import { showLoginPopup } from '../../../lib/popup';
import { getRelativeTimeString } from '../../../lib/date';
import { CompleteProjectConfirmation } from '../CompleteProjectConfirmation';
type ProjectTrackingActionsProps = {
projectId: string;
};
export function ProjectTrackingActions(props: ProjectTrackingActionsProps) {
const { projectId } = props;
const { data: projectStatus } = useQuery(
projectStatusOptions(projectId),
queryClient,
);
const [isLoading, setIsLoading] = useState(true);
const [isStartingProject, setIsStartingProject] = useState(false);
const [isCompletingProject, setIsCompletingProject] = useState(false);
useEffect(() => {
if (!projectStatus) {
return;
}
setIsLoading(false);
}, [projectStatus]);
const { startedAt, submittedAt } = projectStatus || {};
const formattedStartedAt = startedAt ? getRelativeTimeString(startedAt) : '';
const formattedSubmittedAt = submittedAt
? getRelativeTimeString(submittedAt)
: '';
const isCompleted = !!submittedAt;
return (
<>
{isStartingProject && (
<StartProjectConfirmation
onClose={() => setIsStartingProject(false)}
projectId={projectId}
/>
)}
{isCompletingProject && (
<CompleteProjectConfirmation
onClose={() => setIsCompletingProject(false)}
projectId={projectId}
/>
)}
{!startedAt && (
<button
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsStartingProject(true);
}}
className={cn(
'relative flex items-center gap-1.5 overflow-hidden rounded-full bg-purple-600 py-1 pr-2.5 pl-2 text-sm text-white hover:bg-purple-700',
isLoading && 'bg-white text-gray-500',
)}
disabled={isLoading}
>
<PlayIcon size={13} />
<span>Start Working</span>
{isLoading && (
<div
className={cn('striped-loader absolute inset-0 z-10 bg-white')}
/>
)}
</button>
)}
{startedAt && !isLoading && (
<div className="flex flex-col gap-1">
<button
onClick={() => setIsCompletingProject(true)}
className={cn(
'relative flex items-center gap-1.5 overflow-hidden rounded-full bg-green-600 py-1 pr-2.5 pl-2 text-sm text-white hover:bg-green-700',
isCompleted &&
'cursor-default bg-gray-200 text-gray-500 hover:bg-gray-200',
)}
disabled={isCompleted}
>
<CheckIcon size={13} className="stroke-[2.5]" />
{isCompleted ? (
<span>Completed</span>
) : (
<span>Mark as Completed</span>
)}
</button>
<div className="text-end text-xs text-gray-500">
{isCompleted ? (
<>
Completed{' '}
<span className="font-medium">{formattedSubmittedAt}</span>
</>
) : (
<>
Started working{' '}
<span className="font-medium">{formattedStartedAt}</span>
</>
)}
</div>
</div>
)}
</>
);
}

@ -1,5 +1,5 @@
import type { MarkdownFileType } from './file'; import type { MarkdownFileType } from './file';
import { getRoadmapById, type RoadmapFileType } from './roadmap.ts'; import { getRoadmapById, type RoadmapFileType } from './roadmap';
export const projectDifficulties = [ export const projectDifficulties = [
'beginner', 'beginner',
@ -22,6 +22,7 @@ export interface ProjectFrontmatter {
keywords: string[]; keywords: string[];
ogImageUrl: string; ogImageUrl: string;
}; };
hasNoSubmission: boolean;
roadmapIds: string[]; roadmapIds: string[];
} }

@ -8,6 +8,7 @@ import {
} from '../../../lib/project'; } from '../../../lib/project';
import AstroIcon from '../../../components/AstroIcon.astro'; import AstroIcon from '../../../components/AstroIcon.astro';
import { ProjectStepper } from '../../../components/Projects/StatusStepper/ProjectStepper'; import { ProjectStepper } from '../../../components/Projects/StatusStepper/ProjectStepper';
import { ProjectTrackingActions } from '../../../components/Projects/StatusStepper/ProjectTrackingActions';
import { ProjectTabs } from '../../../components/Projects/ProjectTabs'; import { ProjectTabs } from '../../../components/Projects/ProjectTabs';
export const prerender = true; export const prerender = true;
@ -51,7 +52,12 @@ const parentRoadmapId = projectData?.roadmapIds?.[0] || '';
> >
<div class='bg-gray-50'> <div class='bg-gray-50'>
<div class='container'> <div class='container'>
<ProjectTabs parentRoadmapId={parentRoadmapId} projectId={projectId} activeTab='details' /> <ProjectTabs
parentRoadmapId={parentRoadmapId}
projectId={projectId}
activeTab='details'
hasNoSubmission={projectData?.hasNoSubmission}
/>
<div <div
class='mb-4 rounded-lg border bg-linear-to-b from-gray-100 to-white to-10% p-4 py-2 sm:p-5' class='mb-4 rounded-lg border bg-linear-to-b from-gray-100 to-white to-10% p-4 py-2 sm:p-5'
@ -67,20 +73,31 @@ const parentRoadmapId = projectData?.roadmapIds?.[0] || '';
</div> </div>
<Badge variant='yellow' text={projectData.difficulty} /> <Badge variant='yellow' text={projectData.difficulty} />
</div> </div>
<div class='my-2 sm:my-7'> <div class='my-2 flex items-center justify-between gap-2 sm:my-7'>
<div class=''>
<h1 class='mb-1 text-xl font-semibold sm:mb-2 sm:text-3xl'> <h1 class='mb-1 text-xl font-semibold sm:mb-2 sm:text-3xl'>
{projectData.title} {projectData.title}
</h1> </h1>
<p class='text-balance text-sm text-gray-500'> <p class='text-sm text-balance text-gray-500'>
{projectData.description} {projectData.description}
</p> </p>
</div> </div>
{
projectData?.hasNoSubmission && (
<ProjectTrackingActions projectId={projectId} client:load />
)
}
</div>
</div> </div>
{
!projectData?.hasNoSubmission && (
<ProjectStepper projectId={projectId} client:load /> <ProjectStepper projectId={projectId} client:load />
)
}
<div <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' class='prose 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 max-w-full [&>ul>li]:my-1'
> >
<project.Content /> <project.Content />
</div> </div>

@ -15,6 +15,7 @@ export async function getStaticPaths() {
const projects = await getAllProjects(); const projects = await getAllProjects();
return projects return projects
.filter((project) => !(project?.frontmatter?.hasNoSubmission || false))
.map((project) => project.id) .map((project) => project.id)
.map((projectId) => ({ .map((projectId) => ({
params: { projectId }, params: { projectId },

@ -0,0 +1,26 @@
import { queryOptions } from '@tanstack/react-query';
import { httpGet } from '../lib/query-http';
import { isLoggedIn } from '../lib/jwt';
type ProjectStatusResponse = {
id?: string;
startedAt?: Date;
submittedAt?: Date;
repositoryUrl?: string;
upvotes: number;
downvotes: number;
};
export function projectStatusOptions(projectId: string) {
return queryOptions({
queryKey: ['project-status', projectId],
queryFn: () => {
return httpGet<ProjectStatusResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-project-status/${projectId}`,
{},
);
},
});
}
Loading…
Cancel
Save