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. 36
      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. 37
      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 {
ArrowLeft,
Blocks,
BoxSelect,
type LucideIcon,
StepBackIcon,
StickyNote,
Text,
} from 'lucide-react';
import { ArrowLeft, Blocks, type LucideIcon, Text } from 'lucide-react';
export const allowedProjectTabs = ['details', 'solutions'] as const;
export type AllowedProjectTab = (typeof allowedProjectTabs)[number];
@ -36,7 +28,7 @@ function TabButton(props: TabButtonProps) {
{smText && <span className="sm:hidden">{smText}</span>}
{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>
);
@ -46,10 +38,16 @@ type ProjectTabsProps = {
activeTab: AllowedProjectTab;
projectId: string;
parentRoadmapId?: string;
hasNoSubmission?: boolean;
};
export function ProjectTabs(props: ProjectTabsProps) {
const { activeTab, parentRoadmapId, projectId } = props;
const {
activeTab,
parentRoadmapId,
projectId,
hasNoSubmission = false,
} = props;
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">
@ -69,13 +67,15 @@ export function ProjectTabs(props: ProjectTabsProps) {
isActive={activeTab === 'details'}
href={`/projects/${projectId}`}
/>
<TabButton
text={'Community Solutions'}
icon={Blocks}
smText={'Solutions'}
isActive={activeTab === 'solutions'}
href={`/projects/${projectId}/solutions`}
/>
{!hasNoSubmission && (
<TabButton
text={'Community Solutions'}
icon={Blocks}
smText={'Solutions'}
isActive={activeTab === 'solutions'}
href={`/projects/${projectId}/solutions`}
/>
)}
</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 { 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 = {
projectId: string;
onClose: () => void;
@ -66,7 +52,7 @@ export function StartProjectModal(props: StartProjectModalProps) {
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">
<div className="flex flex-col items-center justify-center gap-2 pt-12 pb-10">
<ServerCrash className={'h-6 w-6'} />
<p className="font-medium">{error}</p>
</div>
@ -77,7 +63,7 @@ export function StartProjectModal(props: StartProjectModalProps) {
if (isStartingProject) {
return (
<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} />
<p className="font-medium">Starting project ..</p>
</div>
@ -96,7 +82,7 @@ export function StartProjectModal(props: StartProjectModalProps) {
<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">
<h2 className="mt-5 mb-1 text-2xl font-semibold text-gray-800">
Start Building
</h2>
<p className="text-gray-700">
@ -109,8 +95,8 @@ export function StartProjectModal(props: StartProjectModalProps) {
</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.
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">
@ -139,13 +125,13 @@ export function StartProjectModal(props: StartProjectModalProps) {
</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.
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'>
<p className="text-sm">
If you get stuck, you can always ask for help in the community{' '}
<a
href="https://roadmap.sh/discord"

@ -1,19 +1,19 @@
import { Flag, Play, Send, Share, Square, StopCircle, X } 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, httpPost } from '../../../lib/http.ts';
import { StartProjectModal } from '../StartProjectModal.tsx';
import { getRelativeTimeString } from '../../../lib/date.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';
import { pageProgressMessage } from '../../../stores/page.ts';
import { cn } from '../../../lib/classname';
import { useStickyStuck } from '../../../hooks/use-sticky-stuck';
import { StepperAction } from './StepperAction';
import { StepperStepSeparator } from './StepperStepSeparator';
import { MilestoneStep } from './MilestoneStep';
import { httpGet, httpPost } from '../../../lib/http';
import { StartProjectModal } from '../StartProjectModal';
import { getRelativeTimeString } from '../../../lib/date';
import { getUser, isLoggedIn } from '../../../lib/jwt';
import { showLoginPopup } from '../../../lib/popup';
import { SubmitProjectModal } from '../SubmitProjectModal';
import { useCopyText } from '../../../hooks/use-copy-text';
import { CheckIcon } from '../../ReactIcons/CheckIcon';
import { pageProgressMessage } from '../../../stores/page';
type ProjectStatusResponse = {
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 { getRoadmapById, type RoadmapFileType } from './roadmap.ts';
import { getRoadmapById, type RoadmapFileType } from './roadmap';
export const projectDifficulties = [
'beginner',
@ -22,6 +22,7 @@ export interface ProjectFrontmatter {
keywords: string[];
ogImageUrl: string;
};
hasNoSubmission: boolean;
roadmapIds: string[];
}

@ -8,6 +8,7 @@ import {
} from '../../../lib/project';
import AstroIcon from '../../../components/AstroIcon.astro';
import { ProjectStepper } from '../../../components/Projects/StatusStepper/ProjectStepper';
import { ProjectTrackingActions } from '../../../components/Projects/StatusStepper/ProjectTrackingActions';
import { ProjectTabs } from '../../../components/Projects/ProjectTabs';
export const prerender = true;
@ -51,7 +52,12 @@ const parentRoadmapId = projectData?.roadmapIds?.[0] || '';
>
<div class='bg-gray-50'>
<div class='container'>
<ProjectTabs parentRoadmapId={parentRoadmapId} projectId={projectId} activeTab='details' />
<ProjectTabs
parentRoadmapId={parentRoadmapId}
projectId={projectId}
activeTab='details'
hasNoSubmission={projectData?.hasNoSubmission}
/>
<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'
@ -67,20 +73,31 @@ const parentRoadmapId = projectData?.roadmapIds?.[0] || '';
</div>
<Badge variant='yellow' text={projectData.difficulty} />
</div>
<div class='my-2 sm:my-7'>
<h1 class='mb-1 text-xl font-semibold sm:mb-2 sm:text-3xl'>
{projectData.title}
</h1>
<p class='text-balance text-sm text-gray-500'>
{projectData.description}
</p>
<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'>
{projectData.title}
</h1>
<p class='text-sm text-balance text-gray-500'>
{projectData.description}
</p>
</div>
{
projectData?.hasNoSubmission && (
<ProjectTrackingActions projectId={projectId} client:load />
)
}
</div>
</div>
<ProjectStepper projectId={projectId} client:load />
{
!projectData?.hasNoSubmission && (
<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'
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 />
</div>

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