Refactor project stepper

pull/6513/head
Kamran Ahmed 3 months ago
parent de7df80200
commit b0d80f6769
  1. 166
      src/components/Projects/ProjectStepper.tsx
  2. 27
      src/components/Projects/StatusStepper/MilestoneStep.tsx
  3. 128
      src/components/Projects/StatusStepper/ProjectStepper.tsx
  4. 51
      src/components/Projects/StatusStepper/StepperAction.tsx
  5. 17
      src/components/Projects/StatusStepper/StepperStepSeparator.tsx
  6. 24
      src/hooks/use-sticky-stuck.tsx
  7. 4
      src/pages/projects/[projectId]/index.astro

@ -1,166 +0,0 @@
import {
Blocks,
Check,
Flag,
Hammer,
type LucideIcon,
Play,
PlayCircle,
Send,
} from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { cn } from '../../lib/classname.ts';
type StepperActionProps = {
isActive?: boolean;
isCompleted?: boolean;
onClick?: () => void;
icon: LucideIcon;
text: string;
number: number;
};
function StepperAction(props: StepperActionProps) {
const {
isActive,
onClick = () => null,
isCompleted,
icon: DisplayIcon,
text,
number,
} = props;
if (isActive) {
return (
<button
onClick={onClick}
className="flex items-center gap-1.5 rounded-full bg-purple-600 py-1 pl-2 pr-2.5 text-sm text-white hover:bg-purple-700"
>
<DisplayIcon size={13} />
<span>{text}</span>
</button>
);
}
if (isCompleted) {
return (
<span className="flex cursor-default items-center gap-1.5 text-sm font-medium text-green-600">
<Check size={14} strokeWidth={3} />
<span>{text}</span>
</span>
);
}
return (
<span className="flex cursor-default items-center gap-1.5 text-sm text-gray-400">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-gray-400/70 text-xs text-white">
{number}
</span>
<span>{text}</span>
</span>
);
}
type StepperStepSeparatorProps = {
isActive: boolean;
};
function StepperStepSeparator(props: StepperStepSeparatorProps) {
const { isActive } = props;
return (
<hr
className={cn('flex-grow border border-gray-300', {
'border-green-500': isActive,
})}
/>
);
}
type MilestoneStepProps = {
icon: LucideIcon;
text: string;
isCompleted?: boolean;
};
function MilestoneStep(props: MilestoneStepProps) {
const { icon: DisplayIcon, text, isCompleted } = props;
if (isCompleted) {
return (
<span className="flex cursor-default items-center gap-1.5 text-sm font-medium text-green-600">
<Check size={14} strokeWidth={3} />
<span>{text}</span>
</span>
);
}
return (
<span className="flex cursor-default items-center gap-1.5 text-sm text-gray-400">
<DisplayIcon size={14} />
<span>{text}</span>
</span>
);
}
export function ProjectStepper() {
const stickyElRef = useRef<HTMLDivElement>(null);
const [isSticky, setIsSticky] = useState(false);
// on scroll check if the element has sticky class in effect
useEffect(() => {
const handleScroll = () => {
if (stickyElRef.current) {
setIsSticky(stickyElRef.current.getBoundingClientRect().top <= 8);
}
};
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);
return (
<div
ref={stickyElRef}
className={cn(
'sticky top-0 -mx-2 -mt-2 mb-5 overflow-hidden rounded-lg border bg-white transition-all',
{
'-mx-5 rounded-none border-x-0 border-t-0 bg-gray-50': isSticky,
},
)}
>
<div
className={cn(
'border-b bg-gray-100 px-4 py-2 text-sm text-gray-500 transition-colors',
{
'bg-purple-600 text-white': isSticky,
},
)}
>
Start building, submit solution and get feedback from the community.
</div>
<div className="flex min-h-[60px] items-center justify-between gap-3 px-4">
<StepperAction
isActive={true}
icon={Play}
text={'Start Building'}
number={1}
/>
<StepperStepSeparator isActive={false} />
<StepperAction
isActive={false}
icon={Send}
text={'Submit Solution'}
number={2}
/>
<StepperStepSeparator isActive={false} />
<MilestoneStep isCompleted={false} icon={Flag} text={'5 upvotes'} />
<StepperStepSeparator isActive={false} />
<MilestoneStep isCompleted={false} icon={Flag} text={'10 upvotes'} />
</div>
</div>
);
}

@ -0,0 +1,27 @@
import { Check, type LucideIcon } from 'lucide-react';
type MilestoneStepProps = {
icon: LucideIcon;
text: string;
isCompleted?: boolean;
};
export function MilestoneStep(props: MilestoneStepProps) {
const { icon: DisplayIcon, text, isCompleted } = props;
if (isCompleted) {
return (
<span className="flex cursor-default items-center gap-1.5 text-sm font-medium text-green-600">
<Check size={14} strokeWidth={3} />
<span>{text}</span>
</span>
);
}
return (
<span className="flex cursor-default items-center gap-1.5 text-sm text-gray-400">
<DisplayIcon size={14} />
<span>{text}</span>
</span>
);
}

@ -0,0 +1,128 @@
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';
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<HTMLDivElement>(null);
const isSticky = useStickyStuck(stickyElRef, 8);
const [error, setError] = useState<string | null>(null);
const [activeStep, setActiveStep] = useState<number>(0);
const [isLoadingStatus, setIsLoadingStatus] = useState(true);
const [projectStatus, setProjectStatus] = useState<ProjectStatusResponse>({
upvotes: 0,
downvotes: 0,
});
async function loadProjectStatus() {
setIsLoadingStatus(true);
const { response, error } = await httpGet<ProjectStatusResponse>(
`${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(4);
} else if (submittedAt) {
setActiveStep(3);
} else if (startedAt) {
setActiveStep(1);
}
setProjectStatus(response);
setIsLoadingStatus(false);
}
useEffect(() => {
loadProjectStatus().finally(() => {});
}, []);
return (
<div
ref={stickyElRef}
className={cn(
'sticky top-0 -mx-2 -mt-2 mb-5 overflow-hidden rounded-lg border bg-white transition-all',
{
'-mx-5 rounded-none border-x-0 border-t-0 bg-gray-50': isSticky,
},
)}
>
{isLoadingStatus && (
<div className={cn('striped-loader absolute inset-0 z-10 bg-white')} />
)}
<div
className={cn(
'border-b bg-gray-100 px-4 py-2 text-sm text-gray-500 transition-colors',
{
'bg-purple-600 text-white': isSticky,
},
)}
>
Start building, submit solution and get feedback from the community.
</div>
<div className="flex min-h-[60px] items-center justify-between gap-3 px-4">
<StepperAction
isActive={activeStep === 0}
isCompleted={activeStep > 0}
icon={Play}
text={activeStep > 0 ? 'Started Working' : 'Start Working'}
number={1}
/>
<StepperStepSeparator isActive={activeStep > 0} />
<StepperAction
isActive={activeStep === 1}
isCompleted={activeStep > 1}
icon={Send}
text={activeStep > 1 ? 'Submitted' : 'Submit Solution'}
number={2}
/>
<StepperStepSeparator isActive={activeStep > 1} />
<MilestoneStep
isCompleted={activeStep > 2}
icon={Flag}
text={'5 upvotes'}
/>
<StepperStepSeparator isActive={activeStep > 2} />
<MilestoneStep
isCompleted={activeStep > 3}
icon={Flag}
text={'10 upvotes'}
/>
</div>
</div>
);
}

@ -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 (
<button
onClick={onClick}
className="flex items-center gap-1.5 rounded-full bg-purple-600 py-1 pl-2 pr-2.5 text-sm text-white hover:bg-purple-700"
>
<DisplayIcon size={13} />
<span>{text}</span>
</button>
);
}
if (isCompleted) {
return (
<span className="flex cursor-default items-center gap-1.5 text-sm font-medium text-green-600">
<Check size={14} strokeWidth={3} />
<span>{text}</span>
</span>
);
}
return (
<span className="flex cursor-default items-center gap-1.5 text-sm text-gray-400">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-gray-400/70 text-xs text-white">
{number}
</span>
<span>{text}</span>
</span>
);
}

@ -0,0 +1,17 @@
import { cn } from '../../../lib/classname.ts';
type StepperStepSeparatorProps = {
isActive: boolean;
};
export function StepperStepSeparator(props: StepperStepSeparatorProps) {
const { isActive } = props;
return (
<hr
className={cn('flex-grow border border-gray-300', {
'border-green-500': isActive,
})}
/>
);
}

@ -0,0 +1,24 @@
import { type RefObject, useEffect, useState } from 'react';
// Checks if the sticky element is stuck or not
export function useStickyStuck<T extends HTMLElement>(
ref: RefObject<T>,
offset: number = 0,
): boolean {
const [isSticky, setIsSticky] = useState(false);
useEffect(() => {
const handleScroll = () => {
if (ref.current) {
setIsSticky(ref.current.getBoundingClientRect().top <= offset);
}
};
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [ref, offset]);
return isSticky;
}

@ -8,7 +8,7 @@ import {
} from '../../../lib/project'; } from '../../../lib/project';
import AstroIcon from '../../../components/AstroIcon.astro'; import AstroIcon from '../../../components/AstroIcon.astro';
import { ProjectMilestoneStrip } from '../../../components/Projects/ProjectMilestoneStrip'; import { ProjectMilestoneStrip } from '../../../components/Projects/ProjectMilestoneStrip';
import { ProjectStepper } from "../../../components/Projects/ProjectStepper"; import { ProjectStepper } from "../../../components/Projects/StatusStepper/ProjectStepper";
import { ProjectTabs } from '../../../components/Projects/ProjectTabs'; import { ProjectTabs } from '../../../components/Projects/ProjectTabs';
export async function getStaticPaths() { export async function getStaticPaths() {
@ -72,7 +72,7 @@ const githubUrl = `https://github.com/kamranahmedse/developer-roadmap/tree/maste
</div> </div>
<ProjectMilestoneStrip projectId={projectId} client:load /> <ProjectMilestoneStrip projectId={projectId} client:load />
<ProjectStepper 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 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'

Loading…
Cancel
Save