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