parent
de7df80200
commit
b0d80f6769
7 changed files with 249 additions and 168 deletions
@ -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; |
||||
} |
Loading…
Reference in new issue