Compare commits

...

1 Commits

Author SHA1 Message Date
Arik Chakma
330aa6263b feat: project without submission 2025-04-22 01:35:03 +06:00
10 changed files with 364 additions and 65 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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"

View File

@@ -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;

View File

@@ -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>
)}
</>
);
}

View File

@@ -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[];
}

View File

@@ -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>

View File

@@ -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 },

26
src/queries/project.ts Normal file
View File

@@ -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}`,
{},
);
},
});
}