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