feat: implement dashboard page (#6965)
* wip: implement success modal * feat: share solution modal * fix: step count issue * fix: responsiveness share button * feat: project listing * wip * wip: project status * feat: personal dashboard * wip: team activity * feat: personal dashboard page * feat: add team member tooltip * feat: dashboard favourite * fix: invite team page * fix: invite team * wip: update design * fix: add custom roadmaps * feat: add projects in public page * wip: dashboard re-design * feat: add teams * feat: update dashboard design * feat: update dashboard design * feat: add streak stats * feat: add topics done today count * UI changes for dashboard * Refactor progress stack * Progress stack UI * Progress stack card fixes * Update card designs * AI and custom roadmap * Update recommendation * Update recommendation UI * Add AI roadmap listing * Redirect to team page from dashboard --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>pull/7082/head
parent
2959ea3fda
commit
a913da47a7
39 changed files with 2121 additions and 36 deletions
@ -0,0 +1,57 @@ |
||||
import { getUser } from '../../lib/jwt'; |
||||
import { getPercentage } from '../../helper/number'; |
||||
import { ProjectProgressActions } from './ProjectProgressActions'; |
||||
import { cn } from '../../lib/classname'; |
||||
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions'; |
||||
import { ProjectStatus } from './ProjectStatus'; |
||||
import { ThumbsUp } from 'lucide-react'; |
||||
|
||||
type ProjectProgressType = { |
||||
projectStatus: ProjectStatusDocument & { |
||||
title: string; |
||||
}; |
||||
showActions?: boolean; |
||||
userId?: string; |
||||
}; |
||||
|
||||
export function ProjectProgress(props: ProjectProgressType) { |
||||
const { |
||||
projectStatus, |
||||
showActions = true, |
||||
userId: defaultUserId = getUser()?.id, |
||||
} = props; |
||||
|
||||
const shouldShowActions = |
||||
projectStatus.submittedAt && |
||||
projectStatus.submittedAt !== null && |
||||
showActions; |
||||
|
||||
return ( |
||||
<div className="relative"> |
||||
<a |
||||
className={cn( |
||||
'group relative flex w-full items-center justify-between overflow-hidden rounded-md border border-gray-300 bg-white px-3 py-2 pr-7 text-left text-sm transition-all hover:border-gray-400', |
||||
shouldShowActions ? '' : 'pr-3', |
||||
)} |
||||
href={`/projects/${projectStatus.projectId}`} |
||||
target="_blank" |
||||
> |
||||
<ProjectStatus projectStatus={projectStatus} /> |
||||
<span className="ml-2 flex-grow truncate">{projectStatus?.title}</span> |
||||
<span className="inline-flex items-center gap-1 text-xs text-gray-400"> |
||||
{projectStatus.upvotes} |
||||
<ThumbsUp className="size-2.5 stroke-[2.5px]" /> |
||||
</span> |
||||
</a> |
||||
|
||||
{shouldShowActions && ( |
||||
<div className="absolute right-2 top-0 flex h-full items-center"> |
||||
<ProjectProgressActions |
||||
userId={defaultUserId!} |
||||
projectId={projectStatus.projectId} |
||||
/> |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,68 @@ |
||||
import { MoreVertical, X } from 'lucide-react'; |
||||
import { useRef, useState } from 'react'; |
||||
import { useOutsideClick } from '../../hooks/use-outside-click'; |
||||
import { useKeydown } from '../../hooks/use-keydown'; |
||||
import { cn } from '../../lib/classname'; |
||||
import { useCopyText } from '../../hooks/use-copy-text'; |
||||
import { CheckIcon } from '../ReactIcons/CheckIcon'; |
||||
import { ShareIcon } from '../ReactIcons/ShareIcon'; |
||||
|
||||
type ProjectProgressActionsType = { |
||||
userId: string; |
||||
projectId: string; |
||||
}; |
||||
|
||||
export function ProjectProgressActions(props: ProjectProgressActionsType) { |
||||
const { userId, projectId } = props; |
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null); |
||||
const [isOpen, setIsOpen] = useState(false); |
||||
|
||||
const { copyText, isCopied } = useCopyText(); |
||||
|
||||
const projectSolutionUrl = `${import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh'}/projects/${projectId}/solutions?u=${userId}`; |
||||
|
||||
useOutsideClick(dropdownRef, () => { |
||||
setIsOpen(false); |
||||
}); |
||||
|
||||
useKeydown('Escape', () => { |
||||
setIsOpen(false); |
||||
}); |
||||
|
||||
return ( |
||||
<div className="relative h-full" ref={dropdownRef}> |
||||
<button |
||||
className="h-full text-gray-400 hover:text-gray-700" |
||||
onClick={() => setIsOpen(!isOpen)} |
||||
> |
||||
<MoreVertical size={16} /> |
||||
</button> |
||||
|
||||
{isOpen && ( |
||||
<div className="absolute right-0 top-8 z-10 w-48 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg"> |
||||
<button |
||||
className={cn( |
||||
'flex w-full items-center gap-1.5 p-2 text-xs font-medium hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-70 sm:text-sm', |
||||
isCopied ? 'text-green-500' : 'text-gray-500 hover:text-black', |
||||
)} |
||||
onClick={() => { |
||||
copyText(projectSolutionUrl); |
||||
}} |
||||
> |
||||
{isCopied ? ( |
||||
<> |
||||
<CheckIcon additionalClasses="h-3.5 w-3.5" /> Link Copied |
||||
</> |
||||
) : ( |
||||
<> |
||||
<ShareIcon className="h-3.5 w-3.5 stroke-[2.5px]" /> Share |
||||
Solution |
||||
</> |
||||
)} |
||||
</button> |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,24 @@ |
||||
import { CircleDashed } from 'lucide-react'; |
||||
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions'; |
||||
import { CheckIcon } from '../ReactIcons/CheckIcon'; |
||||
|
||||
type ProjectStatusType = { |
||||
projectStatus: ProjectStatusDocument & { |
||||
title: string; |
||||
}; |
||||
}; |
||||
|
||||
export function ProjectStatus(props: ProjectStatusType) { |
||||
const { projectStatus } = props; |
||||
|
||||
const { submittedAt, repositoryUrl } = projectStatus; |
||||
const status = submittedAt && repositoryUrl ? 'submitted' : 'started'; |
||||
|
||||
if (status === 'submitted') { |
||||
return <CheckIcon additionalClasses="size-3 text-gray-500 shrink-0" />; |
||||
} |
||||
|
||||
return ( |
||||
<CircleDashed className="size-3 shrink-0 stroke-[2.5px] text-gray-400" /> |
||||
); |
||||
} |
@ -0,0 +1,78 @@ |
||||
import type { UserProgress } from '../TeamProgress/TeamProgressPage'; |
||||
import { DashboardCustomProgressCard } from './DashboardCustomProgressCard'; |
||||
import { DashboardCardLink } from './DashboardCardLink'; |
||||
import { useState } from 'react'; |
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal'; |
||||
import { Simulate } from 'react-dom/test-utils'; |
||||
import { Bot, BrainCircuit, Map, PencilRuler } from 'lucide-react'; |
||||
|
||||
type DashboardAiRoadmapsProps = { |
||||
roadmaps: { |
||||
id: string; |
||||
title: string; |
||||
slug: string; |
||||
}[]; |
||||
isLoading?: boolean; |
||||
}; |
||||
|
||||
export function DashboardAiRoadmaps(props: DashboardAiRoadmapsProps) { |
||||
const { roadmaps, isLoading = false } = props; |
||||
|
||||
return ( |
||||
<> |
||||
<h2 className="mb-2 mt-6 text-xs uppercase text-gray-400"> |
||||
AI Generated Roadmaps |
||||
</h2> |
||||
|
||||
{!isLoading && roadmaps.length === 0 && ( |
||||
<DashboardCardLink |
||||
className="mt-0" |
||||
icon={BrainCircuit} |
||||
href="/ai" |
||||
title="Generate Roadmaps with AI" |
||||
description="You can generate your own roadmap with AI" |
||||
/> |
||||
)} |
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3"> |
||||
{isLoading && ( |
||||
<> |
||||
{Array.from({ length: 9 }).map((_, index) => ( |
||||
<RoadmapCardSkeleton key={index} /> |
||||
))} |
||||
</> |
||||
)} |
||||
|
||||
{!isLoading && roadmaps.length > 0 && ( |
||||
<> |
||||
{roadmaps.map((roadmap) => ( |
||||
<a |
||||
href={`/r/${roadmap.slug}`} |
||||
className="relative rounded-md border bg-white p-2.5 text-left text-sm shadow-sm truncate hover:border-gray-400 hover:bg-gray-50" |
||||
> |
||||
{roadmap.title} |
||||
</a> |
||||
))} |
||||
|
||||
<a |
||||
className="flex items-center justify-center rounded-lg border border-dashed border-gray-300 bg-white p-2.5 text-sm font-medium text-gray-500 hover:bg-gray-50 hover:text-gray-600" |
||||
href={'/ai'} |
||||
> |
||||
+ Generate New |
||||
</a> |
||||
</> |
||||
)} |
||||
</div> |
||||
</> |
||||
); |
||||
} |
||||
|
||||
type CustomProgressCardSkeletonProps = {}; |
||||
|
||||
function RoadmapCardSkeleton( |
||||
props: CustomProgressCardSkeletonProps, |
||||
) { |
||||
return ( |
||||
<div className="h-[42px] w-full animate-pulse rounded-md bg-gray-200" /> |
||||
); |
||||
} |
@ -0,0 +1,36 @@ |
||||
import { Bookmark } from 'lucide-react'; |
||||
import type { UserProgress } from '../TeamProgress/TeamProgressPage'; |
||||
|
||||
type DashboardBookmarkCardProps = { |
||||
bookmark: UserProgress; |
||||
}; |
||||
|
||||
export function DashboardBookmarkCard(props: DashboardBookmarkCardProps) { |
||||
const { |
||||
resourceType, |
||||
resourceId, |
||||
resourceTitle, |
||||
roadmapSlug, |
||||
isCustomResource, |
||||
} = props.bookmark; |
||||
|
||||
let url = |
||||
resourceType === 'roadmap' |
||||
? `/${resourceId}` |
||||
: `/best-practices/${resourceId}`; |
||||
|
||||
if (isCustomResource) { |
||||
url = `/r/${roadmapSlug}`; |
||||
} |
||||
|
||||
return ( |
||||
<a |
||||
href={url} |
||||
key={resourceId} |
||||
className="group relative flex w-full items-center gap-2 text-left text-sm hover:text-black hover:underline" |
||||
> |
||||
<Bookmark className="size-4 fill-current text-gray-400" /> |
||||
<h4 className="truncate font-medium text-gray-900">{resourceTitle}</h4> |
||||
</a> |
||||
); |
||||
} |
@ -0,0 +1,30 @@ |
||||
import { ArrowUpRight, type LucideIcon } from 'lucide-react'; |
||||
import { cn } from '../../lib/classname'; |
||||
|
||||
type DashboardCardLinkProps = { |
||||
href: string; |
||||
title: string; |
||||
icon: LucideIcon; |
||||
description: string; |
||||
className?: string; |
||||
}; |
||||
|
||||
export function DashboardCardLink(props: DashboardCardLinkProps) { |
||||
const { href, title, description, icon: Icon, className } = props; |
||||
|
||||
return ( |
||||
<a |
||||
className={cn( |
||||
'relative mt-4 flex min-h-[220px] flex-col justify-end rounded-lg border border-gray-300 bg-gradient-to-br from-white to-gray-50 py-5 px-6 hover:border-gray-400 hover:from-white hover:to-gray-100', |
||||
className, |
||||
)} |
||||
href={href} |
||||
target="_blank" |
||||
> |
||||
<Icon className="mb-4 size-10 text-gray-300" strokeWidth={1.25} /> |
||||
<h4 className="text-xl font-semibold tracking-wide">{title}</h4> |
||||
<p className="mt-1 text-gray-500">{description}</p> |
||||
<ArrowUpRight className="absolute right-3 top-3 size-4" /> |
||||
</a> |
||||
); |
||||
} |
@ -0,0 +1,64 @@ |
||||
import { getPercentage } from '../../helper/number'; |
||||
import { getRelativeTimeString } from '../../lib/date'; |
||||
import type { UserProgress } from '../TeamProgress/TeamProgressPage'; |
||||
|
||||
type DashboardCustomProgressCardProps = { |
||||
progress: UserProgress; |
||||
}; |
||||
|
||||
export function DashboardCustomProgressCard(props: DashboardCustomProgressCardProps) { |
||||
const { progress } = props; |
||||
|
||||
const { |
||||
resourceType, |
||||
resourceId, |
||||
resourceTitle, |
||||
total: totalCount, |
||||
done: doneCount, |
||||
skipped: skippedCount, |
||||
roadmapSlug, |
||||
isCustomResource, |
||||
updatedAt, |
||||
} = progress; |
||||
|
||||
let url = |
||||
resourceType === 'roadmap' |
||||
? `/${resourceId}` |
||||
: `/best-practices/${resourceId}`; |
||||
|
||||
if (isCustomResource) { |
||||
url = `/r/${roadmapSlug}`; |
||||
} |
||||
|
||||
const totalMarked = doneCount + skippedCount; |
||||
const progressPercentage = getPercentage(totalMarked, totalCount); |
||||
|
||||
return ( |
||||
<a |
||||
href={url} |
||||
className="group relative flex min-h-[80px] w-full flex-col justify-between overflow-hidden rounded-md border bg-white p-3 text-left text-sm shadow-sm transition-all hover:border-gray-400 hover:bg-gray-50" |
||||
> |
||||
<h4 className="truncate font-medium text-gray-900">{resourceTitle}</h4> |
||||
|
||||
<div className="mt-6 flex items-center gap-2"> |
||||
<div className="h-2 w-full overflow-hidden rounded-md bg-black/10"> |
||||
<div |
||||
className="h-full bg-black/20" |
||||
style={{ width: `${progressPercentage}%` }} |
||||
></div> |
||||
</div> |
||||
<span className="text-xs text-gray-500"> |
||||
{Math.floor(+progressPercentage)}% |
||||
</span> |
||||
</div> |
||||
|
||||
<p className="mt-1 text-xs text-gray-400"> |
||||
{isCustomResource ? ( |
||||
<>Last updated {getRelativeTimeString(updatedAt)}</> |
||||
) : ( |
||||
<>Last practiced {getRelativeTimeString(updatedAt)}</> |
||||
)} |
||||
</p> |
||||
</a> |
||||
); |
||||
} |
@ -0,0 +1,124 @@ |
||||
import { useEffect, useState } from 'react'; |
||||
import { httpGet } from '../../lib/http'; |
||||
import { useToast } from '../../hooks/use-toast'; |
||||
import { useStore } from '@nanostores/react'; |
||||
import { $teamList } from '../../stores/team'; |
||||
import type { TeamListResponse } from '../TeamDropdown/TeamDropdown'; |
||||
import { DashboardTab } from './DashboardTab'; |
||||
import { PersonalDashboard, type BuiltInRoadmap } from './PersonalDashboard'; |
||||
import { TeamDashboard } from './TeamDashboard'; |
||||
import { getUser } from '../../lib/jwt'; |
||||
|
||||
type DashboardPageProps = { |
||||
builtInRoleRoadmaps?: BuiltInRoadmap[]; |
||||
builtInSkillRoadmaps?: BuiltInRoadmap[]; |
||||
builtInBestPractices?: BuiltInRoadmap[]; |
||||
}; |
||||
|
||||
export function DashboardPage(props: DashboardPageProps) { |
||||
const { builtInRoleRoadmaps, builtInBestPractices, builtInSkillRoadmaps } = |
||||
props; |
||||
|
||||
const currentUser = getUser(); |
||||
const toast = useToast(); |
||||
const teamList = useStore($teamList); |
||||
|
||||
const [isLoading, setIsLoading] = useState(true); |
||||
const [selectedTeamId, setSelectedTeamId] = useState<string>(); |
||||
|
||||
async function getAllTeams() { |
||||
if (teamList.length > 0) { |
||||
return; |
||||
} |
||||
|
||||
const { response, error } = await httpGet<TeamListResponse>( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`, |
||||
); |
||||
if (error || !response) { |
||||
toast.error(error?.message || 'Something went wrong'); |
||||
return; |
||||
} |
||||
|
||||
$teamList.set(response); |
||||
} |
||||
|
||||
useEffect(() => { |
||||
getAllTeams().finally(() => setIsLoading(false)); |
||||
}, []); |
||||
|
||||
const userAvatar = |
||||
currentUser?.avatar && !isLoading |
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${currentUser.avatar}` |
||||
: '/images/default-avatar.png'; |
||||
|
||||
return ( |
||||
<div className="min-h-screen bg-gray-50 pb-20 pt-8"> |
||||
<div className="container"> |
||||
<div className="mb-8 flex flex-wrap items-center gap-1.5"> |
||||
<DashboardTab |
||||
label="Personal" |
||||
isActive={!selectedTeamId} |
||||
onClick={() => setSelectedTeamId(undefined)} |
||||
avatar={userAvatar} |
||||
/> |
||||
{isLoading && ( |
||||
<> |
||||
<DashboardTabSkeleton /> |
||||
<DashboardTabSkeleton /> |
||||
</> |
||||
)} |
||||
|
||||
{!isLoading && ( |
||||
<> |
||||
{teamList.map((team) => { |
||||
const { avatar } = team; |
||||
const avatarUrl = avatar |
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}` |
||||
: '/images/default-avatar.png'; |
||||
return ( |
||||
<DashboardTab |
||||
key={team._id} |
||||
label={team.name} |
||||
isActive={team._id === selectedTeamId} |
||||
{...(team.status === 'invited' |
||||
? { |
||||
href: `/respond-invite?i=${team.memberId}`, |
||||
} |
||||
: { |
||||
href: `/team/activity?t=${team._id}`, |
||||
// onClick: () => {
|
||||
// setSelectedTeamId(team._id);
|
||||
// },
|
||||
})} |
||||
avatar={avatarUrl} |
||||
/> |
||||
); |
||||
})} |
||||
<DashboardTab |
||||
label="+ Create Team" |
||||
isActive={false} |
||||
href="/team/new" |
||||
className="border border-dashed border-gray-300 bg-transparent px-3 text-[13px] text-sm text-gray-500 hover:border-gray-600 hover:text-black" |
||||
/> |
||||
</> |
||||
)} |
||||
</div> |
||||
|
||||
{!selectedTeamId && ( |
||||
<PersonalDashboard |
||||
builtInRoleRoadmaps={builtInRoleRoadmaps} |
||||
builtInSkillRoadmaps={builtInSkillRoadmaps} |
||||
builtInBestPractices={builtInBestPractices} |
||||
/> |
||||
)} |
||||
{selectedTeamId && <TeamDashboard teamId={selectedTeamId} />} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function DashboardTabSkeleton() { |
||||
return ( |
||||
<div className="h-[30px] w-[114px] animate-pulse rounded-md border bg-white"></div> |
||||
); |
||||
} |
@ -0,0 +1,54 @@ |
||||
import { getPercentage } from '../../helper/number'; |
||||
import type { UserProgress } from '../TeamProgress/TeamProgressPage'; |
||||
import { ArrowUpRight, ExternalLink } from 'lucide-react'; |
||||
|
||||
type DashboardProgressCardProps = { |
||||
progress: UserProgress; |
||||
}; |
||||
|
||||
export function DashboardProgressCard(props: DashboardProgressCardProps) { |
||||
const { progress } = props; |
||||
const { |
||||
resourceType, |
||||
resourceId, |
||||
resourceTitle, |
||||
total: totalCount, |
||||
done: doneCount, |
||||
skipped: skippedCount, |
||||
roadmapSlug, |
||||
isCustomResource, |
||||
updatedAt, |
||||
} = progress; |
||||
|
||||
let url = |
||||
resourceType === 'roadmap' |
||||
? `/${resourceId}` |
||||
: `/best-practices/${resourceId}`; |
||||
|
||||
if (isCustomResource) { |
||||
url = `/r/${roadmapSlug}`; |
||||
} |
||||
|
||||
const totalMarked = doneCount + skippedCount; |
||||
const progressPercentage = getPercentage(totalMarked, totalCount); |
||||
|
||||
return ( |
||||
<a |
||||
href={url} |
||||
key={resourceId} |
||||
className="group relative flex w-full items-center justify-between overflow-hidden rounded-md border border-gray-300 bg-white px-3 py-2 text-left text-sm transition-all hover:border-gray-400" |
||||
> |
||||
<span className="flex-grow truncate">{resourceTitle}</span> |
||||
<span className="text-xs text-gray-400"> |
||||
{parseInt(progressPercentage, 10)}% |
||||
</span> |
||||
|
||||
<span |
||||
className="absolute left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 transition-colors group-hover:bg-black/10" |
||||
style={{ |
||||
width: `${progressPercentage}%`, |
||||
}} |
||||
/> |
||||
</a> |
||||
); |
||||
} |
@ -0,0 +1,55 @@ |
||||
import { Check, CircleCheck, CircleDashed } from 'lucide-react'; |
||||
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions'; |
||||
import { cn } from '../../lib/classname.ts'; |
||||
import { getRelativeTimeString } from '../../lib/date.ts'; |
||||
|
||||
type DashboardProjectCardProps = { |
||||
project: ProjectStatusDocument & { |
||||
title: string; |
||||
}; |
||||
}; |
||||
|
||||
export function DashboardProjectCard(props: DashboardProjectCardProps) { |
||||
const { project } = props; |
||||
|
||||
const { title, projectId, submittedAt, startedAt, repositoryUrl } = project; |
||||
|
||||
const url = `/projects/${projectId}`; |
||||
const status = submittedAt && repositoryUrl ? 'submitted' : 'started'; |
||||
|
||||
return ( |
||||
<a |
||||
href={url} |
||||
key={projectId} |
||||
className="group relative flex w-full items-center gap-2 text-left text-sm underline-offset-2" |
||||
> |
||||
<span |
||||
className={cn( |
||||
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full', |
||||
{ |
||||
'border border-green-500 bg-green-500 group-hover:border-green-600 group-hover:bg-green-600': |
||||
status === 'submitted', |
||||
'border border-dashed border-gray-400 bg-transparent group-hover:border-gray-500': |
||||
status === 'started', |
||||
}, |
||||
)} |
||||
> |
||||
{status === 'submitted' && ( |
||||
<Check |
||||
className="relative top-[0.5px] size-3 text-white" |
||||
strokeWidth={3} |
||||
/> |
||||
)} |
||||
</span> |
||||
<span className="flex-grow truncate group-hover:underline">{title.replace(/(System)|(Service)/, '')}</span> |
||||
<span className="flex-shrink-0 bg-transparent text-xs text-gray-400 no-underline"> |
||||
{!!startedAt && |
||||
status === 'started' && |
||||
getRelativeTimeString(startedAt)} |
||||
{!!submittedAt && |
||||
status === 'submitted' && |
||||
getRelativeTimeString(submittedAt)} |
||||
</span> |
||||
</a> |
||||
); |
||||
} |
@ -0,0 +1,40 @@ |
||||
import type { ReactNode } from 'react'; |
||||
import { cn } from '../../lib/classname'; |
||||
|
||||
type DashboardTabProps = { |
||||
label: string | ReactNode; |
||||
isActive: boolean; |
||||
onClick?: () => void; |
||||
className?: string; |
||||
href?: string; |
||||
avatar?: string; |
||||
icon?: ReactNode; |
||||
}; |
||||
|
||||
export function DashboardTab(props: DashboardTabProps) { |
||||
const { isActive, onClick, label, className, href, avatar, icon } = props; |
||||
|
||||
const Slot = href ? 'a' : 'button'; |
||||
|
||||
return ( |
||||
<Slot |
||||
onClick={onClick} |
||||
className={cn( |
||||
'flex h-[30px] shrink-0 items-center gap-1 rounded-md border bg-white p-1.5 px-2 text-sm leading-none text-gray-600', |
||||
isActive ? 'border-gray-500 bg-gray-200 text-gray-900' : '', |
||||
className, |
||||
)} |
||||
{...(href ? { href } : {})} |
||||
> |
||||
{avatar && ( |
||||
<img |
||||
src={avatar} |
||||
alt="avatar" |
||||
className="h-4 w-4 mr-0.5 rounded-full object-cover" |
||||
/> |
||||
)} |
||||
{icon} |
||||
{label} |
||||
</Slot> |
||||
); |
||||
} |
@ -0,0 +1,112 @@ |
||||
import type { UserProgress } from '../TeamProgress/TeamProgressPage'; |
||||
import { DashboardCustomProgressCard } from './DashboardCustomProgressCard'; |
||||
import { DashboardCardLink } from './DashboardCardLink'; |
||||
import { useState } from 'react'; |
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal'; |
||||
import { Simulate } from 'react-dom/test-utils'; |
||||
import {Bot, BrainCircuit, Map, PencilRuler} from 'lucide-react'; |
||||
|
||||
type ListDashboardCustomProgressProps = { |
||||
progresses: UserProgress[]; |
||||
isLoading?: boolean; |
||||
isCustomResources?: boolean; |
||||
isAIGeneratedRoadmaps?: boolean; |
||||
}; |
||||
|
||||
export function ListDashboardCustomProgress( |
||||
props: ListDashboardCustomProgressProps, |
||||
) { |
||||
const { |
||||
progresses, |
||||
isLoading = false, |
||||
isAIGeneratedRoadmaps = false, |
||||
} = props; |
||||
const [isCreateCustomRoadmapModalOpen, setIsCreateCustomRoadmapModalOpen] = |
||||
useState(false); |
||||
|
||||
const customRoadmapModal = isCreateCustomRoadmapModalOpen ? ( |
||||
<CreateRoadmapModal |
||||
onClose={() => setIsCreateCustomRoadmapModalOpen(false)} |
||||
onCreated={(roadmap) => { |
||||
window.location.href = `${ |
||||
import.meta.env.PUBLIC_EDITOR_APP_URL |
||||
}/${roadmap?._id}`;
|
||||
return; |
||||
}} |
||||
/> |
||||
) : null; |
||||
|
||||
return ( |
||||
<> |
||||
{customRoadmapModal} |
||||
|
||||
<h2 className="mb-2 mt-6 text-xs uppercase text-gray-400"> |
||||
{isAIGeneratedRoadmaps ? 'AI Generated Roadmaps' : 'Custom Roadmaps'} |
||||
</h2> |
||||
|
||||
{!isLoading && progresses.length === 0 && isAIGeneratedRoadmaps && ( |
||||
<DashboardCardLink |
||||
className="mt-0" |
||||
icon={BrainCircuit} |
||||
href="/ai" |
||||
title="Generate Roadmaps with AI" |
||||
description="You can generate your own roadmap with AI" |
||||
/> |
||||
)} |
||||
|
||||
{!isLoading && progresses.length === 0 && !isAIGeneratedRoadmaps && ( |
||||
<DashboardCardLink |
||||
className="mt-0" |
||||
icon={PencilRuler} |
||||
href="https://draw.roadmap.sh" |
||||
title="Draw your own Roadmap" |
||||
description="Use our editor to draw your own roadmap" |
||||
/> |
||||
)} |
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-4"> |
||||
{isLoading && ( |
||||
<> |
||||
{Array.from({ length: 8 }).map((_, index) => ( |
||||
<CustomProgressCardSkeleton key={index} /> |
||||
))} |
||||
</> |
||||
)} |
||||
|
||||
{!isLoading && progresses.length > 0 && ( |
||||
<> |
||||
{progresses.map((progress) => ( |
||||
<DashboardCustomProgressCard |
||||
key={progress.resourceId} |
||||
progress={progress} |
||||
/> |
||||
))} |
||||
|
||||
<a |
||||
className="flex min-h-[80px] items-center justify-center rounded-lg border border-dashed border-gray-300 bg-white p-4 text-sm font-medium text-gray-500 hover:bg-gray-50 hover:text-gray-600" |
||||
href={'/ai'} |
||||
onClick={(e) => { |
||||
if (!isAIGeneratedRoadmaps) { |
||||
e.preventDefault(); |
||||
setIsCreateCustomRoadmapModalOpen(true); |
||||
} |
||||
}} |
||||
> |
||||
{isAIGeneratedRoadmaps ? '+ Generate New' : '+ Create New'} |
||||
</a> |
||||
</> |
||||
)} |
||||
</div> |
||||
</> |
||||
); |
||||
} |
||||
|
||||
type CustomProgressCardSkeletonProps = {}; |
||||
|
||||
export function CustomProgressCardSkeleton( |
||||
props: CustomProgressCardSkeletonProps, |
||||
) { |
||||
return ( |
||||
<div className="h-[106px] w-full animate-pulse rounded-md bg-gray-200" /> |
||||
); |
||||
} |
@ -0,0 +1,14 @@ |
||||
type LoadingProgressProps = {}; |
||||
|
||||
export function LoadingProgress(props: LoadingProgressProps) { |
||||
return ( |
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-3"> |
||||
{Array.from({ length: 6 }).map((_, index) => ( |
||||
<div |
||||
key={index} |
||||
className="h-[38px] w-full animate-pulse rounded-md border border-gray-300 bg-gray-100" |
||||
></div> |
||||
))} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,340 @@ |
||||
import { type JSXElementConstructor, useEffect, useState } from 'react'; |
||||
import { httpGet } from '../../lib/http'; |
||||
import type { UserProgress } from '../TeamProgress/TeamProgressPage'; |
||||
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions'; |
||||
import type { PageType } from '../CommandMenu/CommandMenu'; |
||||
import { useToast } from '../../hooks/use-toast'; |
||||
import { getCurrentPeriod } from '../../lib/date'; |
||||
import { ListDashboardCustomProgress } from './ListDashboardCustomProgress'; |
||||
import { RecommendedRoadmaps } from './RecommendedRoadmaps'; |
||||
import { ProgressStack } from './ProgressStack'; |
||||
import { useStore } from '@nanostores/react'; |
||||
import { $accountStreak, type StreakResponse } from '../../stores/streak'; |
||||
import { CheckEmoji } from '../ReactIcons/CheckEmoji.tsx'; |
||||
import { ConstructionEmoji } from '../ReactIcons/ConstructionEmoji.tsx'; |
||||
import { BookEmoji } from '../ReactIcons/BookEmoji.tsx'; |
||||
import { DashboardAiRoadmaps } from './DashboardAiRoadmaps.tsx'; |
||||
|
||||
type UserDashboardResponse = { |
||||
name: string; |
||||
email: string; |
||||
avatar: string; |
||||
headline: string; |
||||
username: string; |
||||
progresses: UserProgress[]; |
||||
projects: ProjectStatusDocument[]; |
||||
aiRoadmaps: { |
||||
id: string; |
||||
title: string; |
||||
slug: string; |
||||
}[]; |
||||
topicDoneToday: number; |
||||
}; |
||||
|
||||
export type BuiltInRoadmap = { |
||||
id: string; |
||||
url: string; |
||||
title: string; |
||||
description: string; |
||||
isFavorite?: boolean; |
||||
relatedRoadmapIds?: string[]; |
||||
}; |
||||
|
||||
type PersonalDashboardProps = { |
||||
builtInRoleRoadmaps?: BuiltInRoadmap[]; |
||||
builtInSkillRoadmaps?: BuiltInRoadmap[]; |
||||
builtInBestPractices?: BuiltInRoadmap[]; |
||||
}; |
||||
|
||||
export function PersonalDashboard(props: PersonalDashboardProps) { |
||||
const { |
||||
builtInRoleRoadmaps = [], |
||||
builtInBestPractices = [], |
||||
builtInSkillRoadmaps = [], |
||||
} = props; |
||||
|
||||
const toast = useToast(); |
||||
const [isLoading, setIsLoading] = useState(true); |
||||
const [personalDashboardDetails, setPersonalDashboardDetails] = |
||||
useState<UserDashboardResponse>(); |
||||
const [projectDetails, setProjectDetails] = useState<PageType[]>([]); |
||||
const accountStreak = useStore($accountStreak); |
||||
|
||||
const loadAccountStreak = async () => { |
||||
if (accountStreak) { |
||||
return; |
||||
} |
||||
|
||||
setIsLoading(true); |
||||
const { response, error } = await httpGet<StreakResponse>( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-streak`, |
||||
); |
||||
|
||||
if (error || !response) { |
||||
toast.error(error?.message || 'Failed to load account streak'); |
||||
return; |
||||
} |
||||
|
||||
$accountStreak.set(response); |
||||
}; |
||||
|
||||
async function loadProgress() { |
||||
const { response: progressList, error } = |
||||
await httpGet<UserDashboardResponse>( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-user-dashboard`, |
||||
); |
||||
|
||||
if (error || !progressList) { |
||||
return; |
||||
} |
||||
|
||||
progressList?.progresses?.forEach((progress) => { |
||||
window.dispatchEvent( |
||||
new CustomEvent('mark-favorite', { |
||||
detail: { |
||||
resourceId: progress.resourceId, |
||||
resourceType: progress.resourceType, |
||||
isFavorite: progress.isFavorite, |
||||
}, |
||||
}), |
||||
); |
||||
}); |
||||
setPersonalDashboardDetails(progressList); |
||||
} |
||||
|
||||
async function loadAllProjectDetails() { |
||||
const { error, response } = await httpGet<PageType[]>(`/pages.json`); |
||||
|
||||
if (error) { |
||||
toast.error(error.message || 'Something went wrong'); |
||||
return; |
||||
} |
||||
|
||||
if (!response) { |
||||
return []; |
||||
} |
||||
|
||||
const allProjects = response.filter((page) => page.group === 'Projects'); |
||||
setProjectDetails(allProjects); |
||||
} |
||||
|
||||
useEffect(() => { |
||||
Promise.allSettled([ |
||||
loadProgress(), |
||||
loadAllProjectDetails(), |
||||
loadAccountStreak(), |
||||
]).finally(() => setIsLoading(false)); |
||||
}, []); |
||||
|
||||
useEffect(() => { |
||||
window.addEventListener('refresh-favorites', loadProgress); |
||||
return () => window.removeEventListener('refresh-favorites', loadProgress); |
||||
}, []); |
||||
|
||||
const learningRoadmapsToShow = (personalDashboardDetails?.progresses || []) |
||||
.filter((progress) => !progress.isCustomResource) |
||||
.sort((a, b) => { |
||||
const updatedAtA = new Date(a.updatedAt); |
||||
const updatedAtB = new Date(b.updatedAt); |
||||
|
||||
if (a.isFavorite && !b.isFavorite) { |
||||
return -1; |
||||
} |
||||
|
||||
if (!a.isFavorite && b.isFavorite) { |
||||
return 1; |
||||
} |
||||
|
||||
return updatedAtB.getTime() - updatedAtA.getTime(); |
||||
}); |
||||
|
||||
const aiGeneratedRoadmaps = personalDashboardDetails?.aiRoadmaps || []; |
||||
const customRoadmaps = (personalDashboardDetails?.progresses || []) |
||||
.filter((progress) => progress.isCustomResource) |
||||
.sort((a, b) => { |
||||
const updatedAtA = new Date(a.updatedAt); |
||||
const updatedAtB = new Date(b.updatedAt); |
||||
return updatedAtB.getTime() - updatedAtA.getTime(); |
||||
}); |
||||
|
||||
const { avatar, name } = personalDashboardDetails || {}; |
||||
const avatarLink = avatar |
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}` |
||||
: '/images/default-avatar.png'; |
||||
|
||||
const allRoadmapsAndBestPractices = [ |
||||
...builtInRoleRoadmaps, |
||||
...builtInSkillRoadmaps, |
||||
...builtInBestPractices, |
||||
]; |
||||
|
||||
const relatedRoadmapIds = allRoadmapsAndBestPractices |
||||
.filter((roadmap) => |
||||
learningRoadmapsToShow?.some( |
||||
(learningRoadmap) => learningRoadmap.resourceId === roadmap.id, |
||||
), |
||||
) |
||||
.flatMap((roadmap) => roadmap.relatedRoadmapIds) |
||||
.filter( |
||||
(roadmapId) => |
||||
!learningRoadmapsToShow.some((lr) => lr.resourceId === roadmapId), |
||||
); |
||||
|
||||
const recommendedRoadmapIds = new Set( |
||||
relatedRoadmapIds.length === 0 |
||||
? [ |
||||
'frontend', |
||||
'backend', |
||||
'devops', |
||||
'ai-data-scientist', |
||||
'full-stack', |
||||
'api-design', |
||||
] |
||||
: relatedRoadmapIds, |
||||
); |
||||
|
||||
const recommendedRoadmaps = allRoadmapsAndBestPractices.filter((roadmap) => |
||||
recommendedRoadmapIds.has(roadmap.id), |
||||
); |
||||
|
||||
const enrichedProjects = personalDashboardDetails?.projects |
||||
.map((project) => { |
||||
const projectDetail = projectDetails.find( |
||||
(page) => page.id === project.projectId, |
||||
); |
||||
|
||||
return { |
||||
...project, |
||||
title: projectDetail?.title || 'N/A', |
||||
}; |
||||
}) |
||||
.sort((a, b) => { |
||||
if (a.repositoryUrl && !b.repositoryUrl) { |
||||
return 1; |
||||
} |
||||
|
||||
if (!a.repositoryUrl && b.repositoryUrl) { |
||||
return -1; |
||||
} |
||||
|
||||
return 0; |
||||
}); |
||||
|
||||
return ( |
||||
<section> |
||||
{isLoading ? ( |
||||
<div className="h-7 w-1/4 animate-pulse rounded-lg bg-gray-200"></div> |
||||
) : ( |
||||
<h2 className="text-lg font-medium"> |
||||
Hi {name}, good {getCurrentPeriod()}! |
||||
</h2> |
||||
)} |
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-4"> |
||||
{isLoading ? ( |
||||
<> |
||||
<DashboardCardSkeleton /> |
||||
<DashboardCardSkeleton /> |
||||
<DashboardCardSkeleton /> |
||||
<DashboardCardSkeleton /> |
||||
</> |
||||
) : ( |
||||
<> |
||||
<DashboardCard |
||||
imgUrl={avatarLink} |
||||
title={name!} |
||||
description="Setup your profile" |
||||
href="/account/update-profile" |
||||
/> |
||||
|
||||
<DashboardCard |
||||
icon={BookEmoji} |
||||
title="Visit Roadmaps" |
||||
description="Learn new skills" |
||||
href="/roadmaps" |
||||
/> |
||||
|
||||
<DashboardCard |
||||
icon={ConstructionEmoji} |
||||
title="Build Projects" |
||||
description="Practice what you learn" |
||||
href="/backend/projects" |
||||
/> |
||||
<DashboardCard |
||||
icon={CheckEmoji} |
||||
title="Best Practices" |
||||
description="Do things right way" |
||||
href="/best-practices" |
||||
/> |
||||
</> |
||||
)} |
||||
</div> |
||||
|
||||
<ProgressStack |
||||
progresses={learningRoadmapsToShow} |
||||
projects={enrichedProjects || []} |
||||
isLoading={isLoading} |
||||
accountStreak={accountStreak} |
||||
topicDoneToday={personalDashboardDetails?.topicDoneToday || 0} |
||||
/> |
||||
|
||||
<ListDashboardCustomProgress |
||||
progresses={customRoadmaps} |
||||
isLoading={isLoading} |
||||
/> |
||||
|
||||
<DashboardAiRoadmaps |
||||
roadmaps={aiGeneratedRoadmaps} |
||||
isLoading={isLoading} |
||||
/> |
||||
|
||||
<RecommendedRoadmaps |
||||
roadmaps={recommendedRoadmaps} |
||||
isLoading={isLoading} |
||||
/> |
||||
</section> |
||||
); |
||||
} |
||||
|
||||
type DashboardCardProps = { |
||||
icon?: JSXElementConstructor<any>; |
||||
imgUrl?: string; |
||||
title: string; |
||||
description: string; |
||||
href: string; |
||||
}; |
||||
|
||||
function DashboardCard(props: DashboardCardProps) { |
||||
const { icon: Icon, imgUrl, title, description, href } = props; |
||||
|
||||
return ( |
||||
<a |
||||
href={href} |
||||
target="_blank" |
||||
className="flex flex-col overflow-hidden rounded-lg border border-gray-300 bg-white hover:border-gray-400 hover:bg-gray-50" |
||||
> |
||||
{Icon && ( |
||||
<div className="px-4 pb-3 pt-4"> |
||||
<Icon className="size-6" /> |
||||
</div> |
||||
)} |
||||
|
||||
{imgUrl && ( |
||||
<div className="px-4 pb-1.5 pt-3.5"> |
||||
<img src={imgUrl} alt={title} className="size-8 rounded-full" /> |
||||
</div> |
||||
)} |
||||
|
||||
<div className="flex grow flex-col justify-center gap-0.5 p-4"> |
||||
<h3 className="truncate font-medium text-black">{title}</h3> |
||||
<p className="text-xs text-black">{description}</p> |
||||
</div> |
||||
</a> |
||||
); |
||||
} |
||||
|
||||
function DashboardCardSkeleton() { |
||||
return ( |
||||
<div className="h-[128px] animate-pulse rounded-lg border border-gray-300 bg-white"></div> |
||||
); |
||||
} |
@ -0,0 +1,328 @@ |
||||
import { |
||||
ArrowUpRight, |
||||
Bookmark, |
||||
FolderKanban, |
||||
type LucideIcon, |
||||
Map, |
||||
} from 'lucide-react'; |
||||
import type { UserProgress } from '../TeamProgress/TeamProgressPage'; |
||||
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions'; |
||||
import { DashboardBookmarkCard } from './DashboardBookmarkCard'; |
||||
import { DashboardProjectCard } from './DashboardProjectCard'; |
||||
import { useState } from 'react'; |
||||
import { cn } from '../../lib/classname'; |
||||
import { DashboardProgressCard } from './DashboardProgressCard'; |
||||
import { useStore } from '@nanostores/react'; |
||||
import { $accountStreak, type StreakResponse } from '../../stores/streak'; |
||||
|
||||
type ProgressStackProps = { |
||||
progresses: UserProgress[]; |
||||
projects: (ProjectStatusDocument & { |
||||
title: string; |
||||
})[]; |
||||
accountStreak?: StreakResponse; |
||||
isLoading: boolean; |
||||
topicDoneToday: number; |
||||
}; |
||||
|
||||
const MAX_PROGRESS_TO_SHOW = 5; |
||||
const MAX_PROJECTS_TO_SHOW = 8; |
||||
const MAX_BOOKMARKS_TO_SHOW = 8; |
||||
|
||||
type ProgressLaneProps = { |
||||
title: string; |
||||
linkText?: string; |
||||
linkHref?: string; |
||||
isLoading?: boolean; |
||||
isEmpty?: boolean; |
||||
loadingSkeletonCount?: number; |
||||
loadingSkeletonClassName?: string; |
||||
children: React.ReactNode; |
||||
emptyMessage?: string; |
||||
emptyIcon?: LucideIcon; |
||||
emptyLinkText?: string; |
||||
emptyLinkHref?: string; |
||||
}; |
||||
|
||||
function ProgressLane(props: ProgressLaneProps) { |
||||
const { |
||||
title, |
||||
linkText, |
||||
linkHref, |
||||
isLoading = false, |
||||
loadingSkeletonCount = 4, |
||||
loadingSkeletonClassName = '', |
||||
children, |
||||
isEmpty = false, |
||||
emptyIcon: EmptyIcon = Map, |
||||
emptyMessage = `No ${title.toLowerCase()} to show`, |
||||
emptyLinkHref = '/roadmaps', |
||||
emptyLinkText = 'Explore', |
||||
} = props; |
||||
|
||||
return ( |
||||
<div className="flex h-full flex-col rounded-md border bg-white px-4 py-3 shadow-sm"> |
||||
{isLoading && ( |
||||
<div className={'flex flex-row justify-between'}> |
||||
<div className="h-[16px] w-[75px] animate-pulse rounded-md bg-gray-100"></div> |
||||
</div> |
||||
)} |
||||
{!isLoading && !isEmpty && ( |
||||
<div className="flex items-center justify-between gap-2"> |
||||
<h3 className="text-xs uppercase text-gray-500">{title}</h3> |
||||
|
||||
{linkText && linkHref && ( |
||||
<a |
||||
href={linkHref} |
||||
className="flex items-center gap-1 text-xs text-gray-500" |
||||
> |
||||
<ArrowUpRight size={12} /> |
||||
{linkText} |
||||
</a> |
||||
)} |
||||
</div> |
||||
)} |
||||
|
||||
<div className="mt-4 flex flex-grow flex-col gap-2"> |
||||
{isLoading && ( |
||||
<> |
||||
{Array.from({ length: loadingSkeletonCount }).map((_, index) => ( |
||||
<CardSkeleton key={index} className={loadingSkeletonClassName} /> |
||||
))} |
||||
</> |
||||
)} |
||||
{!isLoading && children} |
||||
|
||||
{!isLoading && isEmpty && ( |
||||
<div className="flex flex-grow flex-col items-center justify-center text-gray-500"> |
||||
<EmptyIcon |
||||
size={37} |
||||
strokeWidth={1.5} |
||||
className={'mb-3 text-gray-200'} |
||||
/> |
||||
<span className="mb-0.5 text-sm">{emptyMessage}</span> |
||||
<a |
||||
href={emptyLinkHref} |
||||
className="text-xs font-medium text-gray-600 underline-offset-2 hover:underline" |
||||
> |
||||
{emptyLinkText} |
||||
</a> |
||||
</div> |
||||
)} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
export function ProgressStack(props: ProgressStackProps) { |
||||
const { progresses, projects, isLoading, accountStreak, topicDoneToday } = |
||||
props; |
||||
|
||||
const bookmarkedProgresses = progresses.filter( |
||||
(progress) => progress?.isFavorite, |
||||
); |
||||
|
||||
const userProgresses = progresses.filter( |
||||
(progress) => !progress?.isFavorite || progress?.done > 0, |
||||
); |
||||
|
||||
const [showAllProgresses, setShowAllProgresses] = useState(false); |
||||
const userProgressesToShow = showAllProgresses |
||||
? userProgresses |
||||
: userProgresses.slice(0, MAX_PROGRESS_TO_SHOW); |
||||
|
||||
const [showAllProjects, setShowAllProjects] = useState(false); |
||||
const projectsToShow = showAllProjects |
||||
? projects |
||||
: projects.slice(0, MAX_PROJECTS_TO_SHOW); |
||||
|
||||
const [showAllBookmarks, setShowAllBookmarks] = useState(false); |
||||
const bookmarksToShow = showAllBookmarks |
||||
? bookmarkedProgresses |
||||
: bookmarkedProgresses.slice(0, MAX_BOOKMARKS_TO_SHOW); |
||||
|
||||
const totalProjectFinished = projects.filter( |
||||
(project) => project.repositoryUrl, |
||||
).length; |
||||
|
||||
return ( |
||||
<> |
||||
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3"> |
||||
<StatsCard |
||||
title="Current Streak" |
||||
value={accountStreak?.count || 0} |
||||
isLoading={isLoading} |
||||
/> |
||||
<StatsCard |
||||
title="Topics Done Today" |
||||
value={topicDoneToday} |
||||
isLoading={isLoading} |
||||
/> |
||||
<StatsCard |
||||
title="Projects Finished" |
||||
value={totalProjectFinished} |
||||
isLoading={isLoading} |
||||
/> |
||||
</div> |
||||
|
||||
<div className="mt-2 grid min-h-[330px] grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3"> |
||||
<ProgressLane |
||||
title={'Your Progress'} |
||||
isLoading={isLoading} |
||||
loadingSkeletonCount={5} |
||||
isEmpty={userProgressesToShow.length === 0} |
||||
emptyMessage={'Update your Progress'} |
||||
emptyIcon={Map} |
||||
emptyLinkText={'Explore Roadmaps'} |
||||
> |
||||
{userProgressesToShow.length > 0 && ( |
||||
<> |
||||
{userProgressesToShow.map((progress) => { |
||||
return ( |
||||
<DashboardProgressCard |
||||
key={progress.resourceId} |
||||
progress={progress} |
||||
/> |
||||
); |
||||
})} |
||||
</> |
||||
)} |
||||
|
||||
{userProgresses.length > MAX_PROGRESS_TO_SHOW && ( |
||||
<ShowAllButton |
||||
showAll={showAllProgresses} |
||||
setShowAll={setShowAllProgresses} |
||||
count={userProgresses.length} |
||||
maxCount={MAX_PROGRESS_TO_SHOW} |
||||
className="mb-0.5 mt-3" |
||||
/> |
||||
)} |
||||
</ProgressLane> |
||||
|
||||
<ProgressLane |
||||
title={'Projects'} |
||||
isLoading={isLoading} |
||||
loadingSkeletonClassName={'h-5'} |
||||
loadingSkeletonCount={8} |
||||
isEmpty={projectsToShow.length === 0} |
||||
emptyMessage={'No projects started'} |
||||
emptyIcon={FolderKanban} |
||||
emptyLinkText={'Explore Projects'} |
||||
emptyLinkHref={'/backend/projects'} |
||||
> |
||||
{projectsToShow.map((project) => { |
||||
return ( |
||||
<DashboardProjectCard key={project.projectId} project={project} /> |
||||
); |
||||
})} |
||||
|
||||
{projects.length > MAX_PROJECTS_TO_SHOW && ( |
||||
<ShowAllButton |
||||
showAll={showAllProjects} |
||||
setShowAll={setShowAllProjects} |
||||
count={projects.length} |
||||
maxCount={MAX_PROJECTS_TO_SHOW} |
||||
className="mb-0.5 mt-3" |
||||
/> |
||||
)} |
||||
</ProgressLane> |
||||
|
||||
<ProgressLane |
||||
title={'Bookmarks'} |
||||
isLoading={isLoading} |
||||
loadingSkeletonClassName={'h-5'} |
||||
loadingSkeletonCount={8} |
||||
linkHref={'/roadmaps'} |
||||
linkText={'Explore'} |
||||
isEmpty={bookmarksToShow.length === 0} |
||||
emptyIcon={Bookmark} |
||||
emptyMessage={'No bookmarks to show'} |
||||
emptyLinkHref={'/roadmaps'} |
||||
emptyLinkText={'Explore Roadmaps'} |
||||
> |
||||
{bookmarksToShow.map((progress) => { |
||||
return ( |
||||
<DashboardBookmarkCard |
||||
key={progress.resourceId} |
||||
bookmark={progress} |
||||
/> |
||||
); |
||||
})} |
||||
{bookmarkedProgresses.length > MAX_BOOKMARKS_TO_SHOW && ( |
||||
<ShowAllButton |
||||
showAll={showAllBookmarks} |
||||
setShowAll={setShowAllBookmarks} |
||||
count={bookmarkedProgresses.length} |
||||
maxCount={MAX_BOOKMARKS_TO_SHOW} |
||||
className="mb-0.5 mt-3" |
||||
/> |
||||
)} |
||||
</ProgressLane> |
||||
</div> |
||||
</> |
||||
); |
||||
} |
||||
|
||||
type ShowAllButtonProps = { |
||||
showAll: boolean; |
||||
setShowAll: (showAll: boolean) => void; |
||||
count: number; |
||||
maxCount: number; |
||||
className?: string; |
||||
}; |
||||
|
||||
function ShowAllButton(props: ShowAllButtonProps) { |
||||
const { showAll, setShowAll, count, maxCount, className } = props; |
||||
|
||||
return ( |
||||
<span className="flex flex-grow items-end"> |
||||
<button |
||||
className={cn( |
||||
'flex w-full items-center justify-center text-sm text-gray-500 hover:text-gray-700', |
||||
className, |
||||
)} |
||||
onClick={() => setShowAll(!showAll)} |
||||
> |
||||
{!showAll ? <>+ show {count - maxCount} more</> : <>- show less</>} |
||||
</button> |
||||
</span> |
||||
); |
||||
} |
||||
|
||||
type CardSkeletonProps = { |
||||
className?: string; |
||||
}; |
||||
|
||||
function CardSkeleton(props: CardSkeletonProps) { |
||||
const { className } = props; |
||||
|
||||
return ( |
||||
<div |
||||
className={cn( |
||||
'h-10 w-full animate-pulse rounded-md bg-gray-100', |
||||
className, |
||||
)} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
type StatsCardProps = { |
||||
title: string; |
||||
value: number; |
||||
isLoading?: boolean; |
||||
}; |
||||
|
||||
function StatsCard(props: StatsCardProps) { |
||||
const { title, value, isLoading = false } = props; |
||||
|
||||
return ( |
||||
<div className="flex flex-col gap-1 rounded-md border bg-white p-4 shadow-sm"> |
||||
<h3 className="mb-1 text-xs uppercase text-gray-500">{title}</h3> |
||||
{isLoading ? ( |
||||
<CardSkeleton className="h-8" /> |
||||
) : ( |
||||
<span className="text-2xl font-medium text-black">{value}</span> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,73 @@ |
||||
import type { BuiltInRoadmap } from './PersonalDashboard'; |
||||
import { ArrowUpRight } from 'lucide-react'; |
||||
import { MarkFavorite } from '../FeaturedItems/MarkFavorite.tsx'; |
||||
|
||||
type RecommendedRoadmapsProps = { |
||||
roadmaps: BuiltInRoadmap[]; |
||||
isLoading: boolean; |
||||
}; |
||||
|
||||
export function RecommendedRoadmaps(props: RecommendedRoadmapsProps) { |
||||
const { roadmaps: roadmapsToShow, isLoading } = props; |
||||
|
||||
return ( |
||||
<> |
||||
<div className="mb-2 mt-8 flex items-center justify-between gap-2"> |
||||
<h2 className="text-xs uppercase text-gray-400"> |
||||
Recommended Roadmaps |
||||
</h2> |
||||
|
||||
<a |
||||
href="/roadmaps" |
||||
className="flex items-center gap-1 rounded-full bg-gray-500 px-2 py-0.5 text-xs font-medium text-white transition-colors hover:bg-black" |
||||
> |
||||
<ArrowUpRight size={12} strokeWidth={2.5} /> |
||||
All Roadmaps |
||||
</a> |
||||
</div> |
||||
|
||||
{isLoading ? ( |
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 md:grid-cols-3"> |
||||
{Array.from({ length: 9 }).map((_, index) => ( |
||||
<RecommendedCardSkeleton key={index} /> |
||||
))} |
||||
</div> |
||||
) : ( |
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 md:grid-cols-3"> |
||||
{roadmapsToShow.map((roadmap) => ( |
||||
<RecommendedRoadmapCard key={roadmap.id} roadmap={roadmap} /> |
||||
))} |
||||
</div> |
||||
)} |
||||
|
||||
<div className="mt-6 text-sm text-gray-500"> |
||||
Need some help getting started? Check out our{' '}<a href="/get-started" className="text-blue-600 underline">Getting Started Guide</a>. |
||||
</div> |
||||
</> |
||||
); |
||||
} |
||||
|
||||
type RecommendedRoadmapCardProps = { |
||||
roadmap: BuiltInRoadmap; |
||||
}; |
||||
|
||||
export function RecommendedRoadmapCard(props: RecommendedRoadmapCardProps) { |
||||
const { roadmap } = props; |
||||
const { title, url, description } = roadmap; |
||||
|
||||
return ( |
||||
<a |
||||
href={url} |
||||
className="font-regular text-sm sm:text-sm group relative block rounded-lg border border-gray-200 bg-white px-2.5 py-2 text-black no-underline hover:border-gray-400 hover:bg-gray-50" |
||||
> |
||||
<MarkFavorite className={'opacity-30'} resourceType={'roadmap'} resourceId={roadmap.id} /> |
||||
{title} |
||||
</a> |
||||
); |
||||
} |
||||
|
||||
function RecommendedCardSkeleton() { |
||||
return ( |
||||
<div className="h-[42px] w-full animate-pulse rounded-md bg-gray-200" /> |
||||
); |
||||
} |
@ -0,0 +1,165 @@ |
||||
import { useEffect, useState } from 'react'; |
||||
import type { TeamMember } from '../TeamProgress/TeamProgressPage'; |
||||
import { httpGet } from '../../lib/http'; |
||||
import { useToast } from '../../hooks/use-toast'; |
||||
import { getUser } from '../../lib/jwt'; |
||||
import { LoadingProgress } from './LoadingProgress'; |
||||
import { ResourceProgress } from '../Activity/ResourceProgress'; |
||||
import { TeamActivityPage } from '../TeamActivity/TeamActivityPage'; |
||||
import { cn } from '../../lib/classname'; |
||||
import { Tooltip } from '../Tooltip'; |
||||
|
||||
type TeamDashboardProps = { |
||||
teamId: string; |
||||
}; |
||||
|
||||
export function TeamDashboard(props: TeamDashboardProps) { |
||||
const { teamId } = props; |
||||
|
||||
const toast = useToast(); |
||||
const currentUser = getUser(); |
||||
|
||||
const [isLoading, setIsLoading] = useState(true); |
||||
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]); |
||||
|
||||
async function getTeamProgress() { |
||||
const { response, error } = await httpGet<TeamMember[]>( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-progress/${teamId}`, |
||||
); |
||||
if (error || !response) { |
||||
toast.error(error?.message || 'Failed to get team progress'); |
||||
return; |
||||
} |
||||
|
||||
setTeamMembers( |
||||
response.sort((a, b) => { |
||||
if (a.email === currentUser?.email) { |
||||
return -1; |
||||
} |
||||
if (b.email === currentUser?.email) { |
||||
return 1; |
||||
} |
||||
return 0; |
||||
}), |
||||
); |
||||
} |
||||
|
||||
useEffect(() => { |
||||
if (!teamId) { |
||||
return; |
||||
} |
||||
|
||||
setIsLoading(true); |
||||
setTeamMembers([]); |
||||
getTeamProgress().finally(() => setIsLoading(false)); |
||||
}, [teamId]); |
||||
|
||||
if (!currentUser) { |
||||
return null; |
||||
} |
||||
|
||||
const currentMember = teamMembers.find( |
||||
(member) => member.email === currentUser.email, |
||||
); |
||||
const learningRoadmapsToShow = |
||||
currentMember?.progress?.filter( |
||||
(progress) => progress.resourceType === 'roadmap', |
||||
) || []; |
||||
|
||||
const allMembersWithoutCurrentUser = teamMembers.sort((a, b) => { |
||||
if (a.email === currentUser.email) { |
||||
return -1; |
||||
} |
||||
|
||||
if (b.email === currentUser.email) { |
||||
return 1; |
||||
} |
||||
|
||||
return 0; |
||||
}); |
||||
|
||||
return ( |
||||
<section className="mt-8"> |
||||
<h2 className="mb-3 text-xs uppercase text-gray-400">Roadmaps</h2> |
||||
{isLoading && <LoadingProgress />} |
||||
{!isLoading && learningRoadmapsToShow.length > 0 && ( |
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-3"> |
||||
{learningRoadmapsToShow.map((roadmap) => { |
||||
const learningCount = roadmap.learning || 0; |
||||
const doneCount = roadmap.done || 0; |
||||
const totalCount = roadmap.total || 0; |
||||
const skippedCount = roadmap.skipped || 0; |
||||
|
||||
return ( |
||||
<ResourceProgress |
||||
key={roadmap.resourceId} |
||||
isCustomResource={roadmap?.isCustomResource || false} |
||||
doneCount={doneCount > totalCount ? totalCount : doneCount} |
||||
learningCount={ |
||||
learningCount > totalCount ? totalCount : learningCount |
||||
} |
||||
totalCount={totalCount} |
||||
skippedCount={skippedCount} |
||||
resourceId={roadmap.resourceId} |
||||
resourceType="roadmap" |
||||
updatedAt={roadmap.updatedAt} |
||||
title={roadmap.resourceTitle} |
||||
showActions={false} |
||||
roadmapSlug={roadmap.roadmapSlug} |
||||
/> |
||||
); |
||||
})} |
||||
</div> |
||||
)} |
||||
|
||||
<h2 className="mb-3 mt-6 text-xs uppercase text-gray-400"> |
||||
Team Members |
||||
</h2> |
||||
{isLoading && <TeamMemberLoading className="mb-6" />} |
||||
{!isLoading && ( |
||||
<div className="mb-6 flex flex-wrap gap-2"> |
||||
{allMembersWithoutCurrentUser.map((member) => { |
||||
const avatar = member?.avatar |
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${member.avatar}` |
||||
: '/images/default-avatar.png'; |
||||
return ( |
||||
<span className="group relative" key={member.email}> |
||||
<figure className="relative aspect-square size-8 overflow-hidden rounded-md bg-gray-100"> |
||||
<img |
||||
src={avatar} |
||||
alt={member.name || ''} |
||||
className="absolute inset-0 h-full w-full object-cover" |
||||
/> |
||||
</figure> |
||||
<Tooltip position="top-center" additionalClass="text-sm"> |
||||
{member.name} |
||||
</Tooltip> |
||||
</span> |
||||
); |
||||
})} |
||||
</div> |
||||
)} |
||||
|
||||
<TeamActivityPage teamId={teamId} /> |
||||
</section> |
||||
); |
||||
} |
||||
|
||||
type TeamMemberLoadingProps = { |
||||
className?: string; |
||||
}; |
||||
|
||||
function TeamMemberLoading(props: TeamMemberLoadingProps) { |
||||
const { className } = props; |
||||
|
||||
return ( |
||||
<div className={cn('flex flex-wrap gap-2', className)}> |
||||
{Array.from({ length: 15 }).map((_, index) => ( |
||||
<div |
||||
key={index} |
||||
className="size-8 animate-pulse rounded-md bg-gray-200" |
||||
></div> |
||||
))} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,39 @@ |
||||
import type { SVGProps } from 'react'; |
||||
import React from 'react'; |
||||
|
||||
export function BookEmoji(props: SVGProps<SVGSVGElement>) { |
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
width="1em" |
||||
height="1em" |
||||
viewBox="0 0 36 36" |
||||
{...props} |
||||
> |
||||
<path |
||||
fill="#3e721d" |
||||
d="M35 26a4 4 0 0 1-4 4H5a4 4 0 0 1-4-4V6.313C1 4.104 6.791 0 9 0h20.625C32.719 0 35 2.312 35 5.375z" |
||||
></path> |
||||
<path |
||||
fill="#ccd6dd" |
||||
d="M33 30a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V6c0-4.119-.021-4 5-4h21a4 4 0 0 1 4 4z" |
||||
></path> |
||||
<path |
||||
fill="#e1e8ed" |
||||
d="M31 31a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h24a3 3 0 0 1 3 3z" |
||||
></path> |
||||
<path |
||||
fill="#5c913b" |
||||
d="M31 32a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V10a4 4 0 0 1 4-4h21a4 4 0 0 1 4 4z" |
||||
></path> |
||||
<path |
||||
fill="#77b255" |
||||
d="M29 32a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V12a4 4 0 0 1 4-4h19.335C27.544 8 29 9.456 29 11.665z" |
||||
></path> |
||||
<path |
||||
fill="#3e721d" |
||||
d="M6 6C4.312 6 4.269 4.078 5 3.25C5.832 2.309 7.125 2 9.438 2H11V0H8.281C4.312 0 1 2.5 1 5.375V32a4 4 0 0 0 4 4h2V6z" |
||||
></path> |
||||
</svg> |
||||
); |
||||
} |
@ -0,0 +1,36 @@ |
||||
import React from 'react'; |
||||
import type { SVGProps } from 'react'; |
||||
|
||||
export function BuildEmoji(props: SVGProps<SVGSVGElement>) { |
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
width="1em" |
||||
height="1em" |
||||
viewBox="0 0 36 36" |
||||
{...props} |
||||
> |
||||
<path |
||||
fill="#66757f" |
||||
d="M28.25 8.513a.263.263 0 0 0-.263-.263h-.475a.263.263 0 0 0-.263.263v11.475c0 .145.117.263.263.263h.475a.263.263 0 0 0 .263-.263z" |
||||
></path> |
||||
<g fill="#f19020"> |
||||
<circle cx={27.75} cy={19.75} r={1.5}></circle> |
||||
<circle cx={27.75} cy={22.25} r={1}></circle> |
||||
</g> |
||||
<path |
||||
fill="#bd2032" |
||||
d="M33.25 8.25h-4.129L9.946.29L9.944.289h-.001c-.016-.007-.032-.005-.047-.01C9.849.265 9.802.25 9.75.25h-.002a.5.5 0 0 0-.19.038a.5.5 0 0 0-.122.082c-.012.009-.026.014-.037.025a.5.5 0 0 0-.11.164V.56c-.004.009-.003.02-.006.029l-5.541 7.81l-.006.014a.99.99 0 0 0-.486.837v2a1 1 0 0 0 1 1h1.495L2.031 34H.25v2h18.958v-2h-1.74l-3.713-21.75H33.25a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1m-21.769 4L9.75 13.639L8.02 12.25zM9.75 21.3l3.667 2.404l-3.667 2l-3.667-2zm-3.639.71l.474-2.784l1.866 1.223zm4.938-1.561l1.87-1.225l.477 2.789zm-1.299-.866l-2.828-1.885l2.828-2.322l2.828 2.322zm-2.563-3.887l.362-2.127l1.131.928zm3.633-1.198l1.132-.929l.364 2.13zM5.073 8.25L9.25 2.362V6.25h-2a1 1 0 0 0-1 1v1zm.53 16.738l2.73 1.489l-3.29 1.794zM15.443 34H4.067l.686-4.024L9.75 27.25l5.006 2.731zm-1.54-9.015l.562 3.291l-3.298-1.799zM13.25 8.25v-1a1 1 0 0 0-1-1h-2V1.499L26.513 8.25zm2 3h-1.16v-2h1.16zm3 0h-2v-2h2zm3 0h-2v-2h2zm3 0h-2v-2h2zm3 0h-2v-2h2zm3 0h-2v-2h2zm3-.5a.5.5 0 0 1-.5.5h-1.5v-2h1.5a.5.5 0 0 1 .5.5z" |
||||
></path> |
||||
<path |
||||
fill="#4b545d" |
||||
d="M12.25 7.25h-2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h3v-4z" |
||||
></path> |
||||
<path fill="#cdd7df" d="M11.25 7.25h2v4h-2z"></path> |
||||
<path |
||||
fill="#66757f" |
||||
d="M34.844 24v-1H20.656v1h.844v2.469h-.844v1h14.188v-1H34V24z" |
||||
></path> |
||||
</svg> |
||||
); |
||||
} |
@ -0,0 +1,37 @@ |
||||
// twitter bulb emoji
|
||||
import type { SVGProps } from 'react'; |
||||
|
||||
type BulbEmojiProps = SVGProps<SVGSVGElement>; |
||||
|
||||
export function BulbEmoji(props: BulbEmojiProps) { |
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
width="1em" |
||||
height="1em" |
||||
viewBox="0 0 36 36" |
||||
{...props} |
||||
> |
||||
<path |
||||
fill="#FFD983" |
||||
d="M29 11.06c0 6.439-5 7.439-5 13.44c0 3.098-3.123 3.359-5.5 3.359c-2.053 0-6.586-.779-6.586-3.361C11.914 18.5 7 17.5 7 11.06C7 5.029 12.285.14 18.083.14C23.883.14 29 5.029 29 11.06" |
||||
></path> |
||||
<path |
||||
fill="#CCD6DD" |
||||
d="M22.167 32.5c0 .828-2.234 2.5-4.167 2.5s-4.167-1.672-4.167-2.5S16.066 32 18 32s4.167-.328 4.167.5" |
||||
></path> |
||||
<path |
||||
fill="#FFCC4D" |
||||
d="M22.707 10.293a1 1 0 0 0-1.414 0L18 13.586l-3.293-3.293a.999.999 0 1 0-1.414 1.414L17 15.414V26a1 1 0 1 0 2 0V15.414l3.707-3.707a1 1 0 0 0 0-1.414" |
||||
></path> |
||||
<path |
||||
fill="#99AAB5" |
||||
d="M24 31a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2v-6h12z" |
||||
></path> |
||||
<path |
||||
fill="#CCD6DD" |
||||
d="M11.999 32a1 1 0 0 1-.163-1.986l12-2a.994.994 0 0 1 1.15.822a1 1 0 0 1-.822 1.15l-12 2a1 1 0 0 1-.165.014m0-4a1 1 0 0 1-.163-1.986l12-2a.995.995 0 0 1 1.15.822a1 1 0 0 1-.822 1.15l-12 2a1 1 0 0 1-.165.014" |
||||
></path> |
||||
</svg> |
||||
); |
||||
} |
@ -0,0 +1,6 @@ |
||||
import React from 'react'; |
||||
import type { SVGProps } from 'react'; |
||||
|
||||
export function CheckEmoji(props: SVGProps<SVGSVGElement>) { |
||||
return (<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 36 36" {...props}><path fill="#77b255" d="M36 32a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h28a4 4 0 0 1 4 4z"></path><path fill="#fff" d="M29.28 6.362a2.5 2.5 0 0 0-3.458.736L14.936 23.877l-5.029-4.65a2.5 2.5 0 1 0-3.394 3.671l7.209 6.666c.48.445 1.09.665 1.696.665c.673 0 1.534-.282 2.099-1.139c.332-.506 12.5-19.27 12.5-19.27a2.5 2.5 0 0 0-.737-3.458"></path></svg>); |
||||
} |
@ -0,0 +1,24 @@ |
||||
import type { SVGProps } from 'react'; |
||||
import React from 'react'; |
||||
|
||||
export function ConstructionEmoji(props: SVGProps<SVGSVGElement>) { |
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
width="1em" |
||||
height="1em" |
||||
viewBox="0 0 36 36" |
||||
{...props} |
||||
> |
||||
<path |
||||
fill="#ffcc4d" |
||||
d="M36 15a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h28a4 4 0 0 1 4 4z" |
||||
></path> |
||||
<path |
||||
fill="#292f33" |
||||
d="M6 3H4a4 4 0 0 0-4 4v2zm6 0L0 15c0 1.36.682 2.558 1.72 3.28L17 3zM7 19h5L28 3h-5zm16 0L35.892 6.108A4 4 0 0 0 33.64 3.36L18 19zm13-4v-3l-7 7h3a4 4 0 0 0 4-4" |
||||
></path> |
||||
<path fill="#99aab5" d="M4 19h5v14H4zm23 0h5v14h-5z"></path> |
||||
</svg> |
||||
); |
||||
} |
@ -0,0 +1,57 @@ |
||||
import type { ProjectPageType } from '../../api/roadmap'; |
||||
import { ProjectProgress } from '../Activity/ProjectProgress'; |
||||
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions'; |
||||
|
||||
type UserPublicProjectsProps = { |
||||
userId: string; |
||||
projects: ProjectStatusDocument[]; |
||||
projectDetails: ProjectPageType[]; |
||||
}; |
||||
|
||||
export function UserPublicProjects(props: UserPublicProjectsProps) { |
||||
const { projects, projectDetails } = props; |
||||
|
||||
const enrichedProjects = |
||||
projects |
||||
.map((project) => { |
||||
const projectDetail = projectDetails.find( |
||||
(projectDetail) => projectDetail.id === project.projectId, |
||||
); |
||||
|
||||
return { |
||||
...project, |
||||
title: projectDetail?.title || 'N/A', |
||||
}; |
||||
}) |
||||
?.sort((a, b) => { |
||||
const isPendingA = !a.repositoryUrl && !a.submittedAt; |
||||
const isPendingB = !b.repositoryUrl && !b.submittedAt; |
||||
|
||||
if (isPendingA && !isPendingB) { |
||||
return -1; |
||||
} |
||||
|
||||
if (!isPendingA && isPendingB) { |
||||
return 1; |
||||
} |
||||
|
||||
return 0; |
||||
}) || []; |
||||
|
||||
return ( |
||||
<div className="mt-5"> |
||||
<h2 className="mb-2 text-xs uppercase tracking-wide text-gray-400"> |
||||
Projects I have worked on |
||||
</h2> |
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 md:grid-cols-3"> |
||||
{enrichedProjects.map((project) => ( |
||||
<ProjectProgress |
||||
key={project._id} |
||||
projectStatus={project} |
||||
showActions={false} |
||||
/> |
||||
))} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,59 @@ |
||||
--- |
||||
import { DashboardPage } from '../components/Dashboard/DashboardPage'; |
||||
import BaseLayout from '../layouts/BaseLayout.astro'; |
||||
import { getAllBestPractices } from '../lib/best-practice'; |
||||
import { getRoadmapsByTag } from '../lib/roadmap'; |
||||
|
||||
const roleRoadmaps = await getRoadmapsByTag('role-roadmap'); |
||||
const skillRoadmaps = await getRoadmapsByTag('skill-roadmap'); |
||||
const bestPractices = await getAllBestPractices(); |
||||
|
||||
const enrichedRoleRoadmaps = roleRoadmaps |
||||
.filter((roadmapItem) => !roadmapItem.frontmatter.isHidden) |
||||
.map((roadmap) => { |
||||
const { frontmatter } = roadmap; |
||||
|
||||
return { |
||||
id: roadmap.id, |
||||
url: `/${roadmap.id}`, |
||||
title: frontmatter.briefTitle, |
||||
description: frontmatter.briefDescription, |
||||
relatedRoadmapIds: frontmatter.relatedRoadmaps, |
||||
}; |
||||
}); |
||||
const enrichedSkillRoadmaps = skillRoadmaps |
||||
.filter((roadmapItem) => !roadmapItem.frontmatter.isHidden) |
||||
.map((roadmap) => { |
||||
const { frontmatter } = roadmap; |
||||
|
||||
return { |
||||
id: roadmap.id, |
||||
url: `/${roadmap.id}`, |
||||
title: |
||||
frontmatter.briefTitle === 'Go' ? 'Go Roadmap' : frontmatter.briefTitle, |
||||
description: frontmatter.briefDescription, |
||||
relatedRoadmapIds: frontmatter.relatedRoadmaps, |
||||
}; |
||||
}); |
||||
|
||||
const enrichedBestPractices = bestPractices.map((bestPractice) => { |
||||
const { frontmatter } = bestPractice; |
||||
|
||||
return { |
||||
id: bestPractice.id, |
||||
url: `/best-practices/${bestPractice.id}`, |
||||
title: frontmatter.briefTitle, |
||||
description: frontmatter.briefDescription, |
||||
}; |
||||
}); |
||||
--- |
||||
|
||||
<BaseLayout title='Dashboard' noIndex={true}> |
||||
<DashboardPage |
||||
builtInRoleRoadmaps={enrichedRoleRoadmaps} |
||||
builtInSkillRoadmaps={enrichedSkillRoadmaps} |
||||
builtInBestPractices={enrichedBestPractices} |
||||
client:load |
||||
/> |
||||
<div slot='open-source-banner'></div> |
||||
</BaseLayout> |
@ -0,0 +1,11 @@ |
||||
import { atom } from 'nanostores'; |
||||
|
||||
export type StreakResponse = { |
||||
count: number; |
||||
longestCount: number; |
||||
previousCount?: number | null; |
||||
firstVisitAt: Date; |
||||
lastVisitAt: Date; |
||||
}; |
||||
|
||||
export const $accountStreak = atom<StreakResponse | undefined>(); |
Loading…
Reference in new issue