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
Arik Chakma 3 months ago committed by GitHub
parent 2959ea3fda
commit a913da47a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 28
      src/api/roadmap.ts
  2. 2
      src/api/user.ts
  3. 23
      src/components/AccountStreak/AccountStreak.tsx
  4. 57
      src/components/Activity/ActivityPage.tsx
  5. 57
      src/components/Activity/ProjectProgress.tsx
  6. 68
      src/components/Activity/ProjectProgressActions.tsx
  7. 24
      src/components/Activity/ProjectStatus.tsx
  8. 1
      src/components/Authenticator/authenticator.ts
  9. 78
      src/components/Dashboard/DashboardAiRoadmaps.tsx
  10. 36
      src/components/Dashboard/DashboardBookmarkCard.tsx
  11. 30
      src/components/Dashboard/DashboardCardLink.tsx
  12. 64
      src/components/Dashboard/DashboardCustomProgressCard.tsx
  13. 124
      src/components/Dashboard/DashboardPage.tsx
  14. 54
      src/components/Dashboard/DashboardProgressCard.tsx
  15. 55
      src/components/Dashboard/DashboardProjectCard.tsx
  16. 40
      src/components/Dashboard/DashboardTab.tsx
  17. 112
      src/components/Dashboard/ListDashboardCustomProgress.tsx
  18. 14
      src/components/Dashboard/LoadingProgress.tsx
  19. 340
      src/components/Dashboard/PersonalDashboard.tsx
  20. 328
      src/components/Dashboard/ProgressStack.tsx
  21. 73
      src/components/Dashboard/RecommendedRoadmaps.tsx
  22. 165
      src/components/Dashboard/TeamDashboard.tsx
  23. 26
      src/components/FeaturedItems/MarkFavorite.tsx
  24. 2
      src/components/Projects/ListProjectSolutions.tsx
  25. 39
      src/components/ReactIcons/BookEmoji.tsx
  26. 36
      src/components/ReactIcons/BuildEmoji.tsx
  27. 37
      src/components/ReactIcons/BulbEmoji.tsx
  28. 6
      src/components/ReactIcons/CheckEmoji.tsx
  29. 24
      src/components/ReactIcons/ConstructionEmoji.tsx
  30. 21
      src/components/TeamActivity/TeamActivityPage.tsx
  31. 1
      src/components/TeamProgress/TeamProgressPage.tsx
  32. 28
      src/components/UserPublicProfile/UserPublicProfilePage.tsx
  33. 57
      src/components/UserPublicProfile/UserPublicProjects.tsx
  34. 4
      src/layouts/BaseLayout.astro
  35. 12
      src/lib/date.ts
  36. 59
      src/pages/dashboard.astro
  37. 9
      src/pages/pages.json.ts
  38. 12
      src/pages/u/[username].astro
  39. 11
      src/stores/streak.ts

@ -1,6 +1,7 @@
import { type APIContext } from 'astro'; import { type APIContext } from 'astro';
import { api } from './api.ts'; import { api } from './api.ts';
import type { RoadmapDocument } from '../components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx'; import type { RoadmapDocument } from '../components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
import type { PageType } from '../components/CommandMenu/CommandMenu.tsx';
export type ListShowcaseRoadmapResponse = { export type ListShowcaseRoadmapResponse = {
data: Pick< data: Pick<
@ -37,3 +38,30 @@ export function roadmapApi(context: APIContext) {
}, },
}; };
} }
export type ProjectPageType = {
id: string;
title: string;
url: string;
};
export async function getProjectList() {
const baseUrl = import.meta.env.DEV
? 'http://localhost:3000'
: 'https://roadmap.sh';
const pages = await fetch(`${baseUrl}/pages.json`).catch((err) => {
console.error(err);
return [];
});
const pagesJson = await (pages as any).json();
const projects: ProjectPageType[] = pagesJson
.filter((page: any) => page?.group?.toLowerCase() === 'projects')
.map((page: any) => ({
id: page.id,
title: page.title,
url: page.url,
}));
return projects;
}

@ -1,6 +1,7 @@
import { type APIContext } from 'astro'; import { type APIContext } from 'astro';
import { api } from './api.ts'; import { api } from './api.ts';
import type { ResourceType } from '../lib/resource-progress.ts'; import type { ResourceType } from '../lib/resource-progress.ts';
import type { ProjectStatusDocument } from '../components/Projects/ListProjectSolutions.tsx';
export const allowedRoadmapVisibility = ['all', 'none', 'selected'] as const; export const allowedRoadmapVisibility = ['all', 'none', 'selected'] as const;
export type AllowedRoadmapVisibility = export type AllowedRoadmapVisibility =
@ -99,6 +100,7 @@ export type GetPublicProfileResponse = Omit<
> & { > & {
activity: UserActivityCount; activity: UserActivityCount;
roadmaps: ProgressResponse[]; roadmaps: ProgressResponse[];
projects: ProjectStatusDocument[];
isOwnProfile: boolean; isOwnProfile: boolean;
}; };

@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { isLoggedIn } from '../../lib/jwt'; import { isLoggedIn } from '../../lib/jwt';
import { httpGet } from '../../lib/http'; import { httpGet } from '../../lib/http';
import { useToast } from '../../hooks/use-toast'; import { useToast } from '../../hooks/use-toast';
import {Flame, X, Zap, ZapOff} from 'lucide-react'; import { Flame, X, Zap, ZapOff } from 'lucide-react';
import { useOutsideClick } from '../../hooks/use-outside-click'; import { useOutsideClick } from '../../hooks/use-outside-click';
import { StreakDay } from './StreakDay'; import { StreakDay } from './StreakDay';
import { import {
@ -11,6 +11,7 @@ import {
} from '../../stores/page.ts'; } from '../../stores/page.ts';
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { cn } from '../../lib/classname.ts'; import { cn } from '../../lib/classname.ts';
import { $accountStreak } from '../../stores/streak.ts';
type StreakResponse = { type StreakResponse = {
count: number; count: number;
@ -27,12 +28,7 @@ export function AccountStreak(props: AccountStreakProps) {
const dropdownRef = useRef(null); const dropdownRef = useRef(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [accountStreak, setAccountStreak] = useState<StreakResponse>({ const accountStreak = useStore($accountStreak);
count: 0,
longestCount: 0,
firstVisitAt: new Date(),
lastVisitAt: new Date(),
});
const [showDropdown, setShowDropdown] = useState(false); const [showDropdown, setShowDropdown] = useState(false);
const $roadmapsDropdownOpen = useStore(roadmapsDropdownOpen); const $roadmapsDropdownOpen = useStore(roadmapsDropdownOpen);
@ -49,6 +45,11 @@ export function AccountStreak(props: AccountStreakProps) {
return; return;
} }
if (accountStreak) {
setIsLoading(false);
return;
}
setIsLoading(true); setIsLoading(true);
const { response, error } = await httpGet<StreakResponse>( const { response, error } = await httpGet<StreakResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-streak`, `${import.meta.env.PUBLIC_API_URL}/v1-streak`,
@ -60,7 +61,7 @@ export function AccountStreak(props: AccountStreakProps) {
return; return;
} }
setAccountStreak(response); $accountStreak.set(response);
setIsLoading(false); setIsLoading(false);
}; };
@ -76,7 +77,7 @@ export function AccountStreak(props: AccountStreakProps) {
return null; return null;
} }
let { count: currentCount } = accountStreak; let { count: currentCount = 0 } = accountStreak || {};
const previousCount = const previousCount =
accountStreak?.previousCount || accountStreak?.count || 0; accountStreak?.previousCount || accountStreak?.count || 0;
@ -110,7 +111,7 @@ export function AccountStreak(props: AccountStreakProps) {
ref={dropdownRef} ref={dropdownRef}
className="absolute right-0 top-full z-50 w-[335px] translate-y-1 rounded-lg bg-slate-800 shadow-xl" className="absolute right-0 top-full z-50 w-[335px] translate-y-1 rounded-lg bg-slate-800 shadow-xl"
> >
<div className="pl-4 pr-5 py-3"> <div className="py-3 pl-4 pr-5">
<div className="flex items-center justify-between gap-2 text-sm text-slate-500"> <div className="flex items-center justify-between gap-2 text-sm text-slate-500">
<p> <p>
Current Streak Current Streak
@ -180,7 +181,7 @@ export function AccountStreak(props: AccountStreakProps) {
</div> </div>
</div> </div>
<p className="text-center text-xs text-slate-600 tracking-wide mb-[1.75px] -mt-[0px]"> <p className="-mt-[0px] mb-[1.75px] text-center text-xs tracking-wide text-slate-600">
Visit every day to keep your streak alive! Visit every day to keep your streak alive!
</p> </p>
</div> </div>

@ -5,6 +5,10 @@ import { ResourceProgress } from './ResourceProgress';
import { pageProgressMessage } from '../../stores/page'; import { pageProgressMessage } from '../../stores/page';
import { EmptyActivity } from './EmptyActivity'; import { EmptyActivity } from './EmptyActivity';
import { ActivityStream, type UserStreamActivity } from './ActivityStream'; import { ActivityStream, type UserStreamActivity } from './ActivityStream';
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions';
import type { PageType } from '../CommandMenu/CommandMenu';
import { useToast } from '../../hooks/use-toast';
import { ProjectProgress } from './ProjectProgress';
type ProgressResponse = { type ProgressResponse = {
updatedAt: string; updatedAt: string;
@ -47,11 +51,14 @@ export type ActivityResponse = {
}; };
}[]; }[];
activities: UserStreamActivity[]; activities: UserStreamActivity[];
projects: ProjectStatusDocument[];
}; };
export function ActivityPage() { export function ActivityPage() {
const toast = useToast();
const [activity, setActivity] = useState<ActivityResponse>(); const [activity, setActivity] = useState<ActivityResponse>();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [projectDetails, setProjectDetails] = useState<PageType[]>([]);
async function loadActivity() { async function loadActivity() {
const { error, response } = await httpGet<ActivityResponse>( const { error, response } = await httpGet<ActivityResponse>(
@ -68,11 +75,29 @@ export function ActivityPage() {
setActivity(response); setActivity(response);
} }
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(() => { useEffect(() => {
loadActivity().finally(() => { Promise.allSettled([loadActivity(), loadAllProjectDetails()]).finally(
pageProgressMessage.set(''); () => {
setIsLoading(false); pageProgressMessage.set('');
}); setIsLoading(false);
},
);
}, []); }, []);
const learningRoadmaps = activity?.learning.roadmaps || []; const learningRoadmaps = activity?.learning.roadmaps || [];
@ -106,6 +131,17 @@ export function ActivityPage() {
learningRoadmapsToShow.length !== 0 || learningRoadmapsToShow.length !== 0 ||
learningBestPracticesToShow.length !== 0; learningBestPracticesToShow.length !== 0;
const enrichedProjects = activity?.projects.map((project) => {
const projectDetail = projectDetails.find(
(page) => page.id === project.projectId,
);
return {
...project,
title: projectDetail?.title || 'N/A',
};
});
return ( return (
<> <>
<ActivityCounters <ActivityCounters
@ -201,6 +237,19 @@ export function ActivityPage() {
)} )}
</div> </div>
{enrichedProjects && enrichedProjects?.length > 0 && (
<div className="mx-0 px-0 py-5 pb-0 md:-mx-10 md:px-8 md:py-8 md:pb-0">
<h2 className="mb-3 text-xs uppercase text-gray-400">
Your Projects
</h2>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{enrichedProjects.map((project) => (
<ProjectProgress key={project._id} projectStatus={project} />
))}
</div>
</div>
)}
{hasProgress && ( {hasProgress && (
<ActivityStream activities={activity?.activities || []} /> <ActivityStream activities={activity?.activities || []} />
)} )}

@ -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" />
);
}

@ -48,6 +48,7 @@ function handleGuest() {
'/team/members', '/team/members',
'/team/member', '/team/member',
'/team/settings', '/team/settings',
'/dashboard',
]; ];
showHideAuthElements('hide'); showHideAuthElements('hide');

@ -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>
);
}

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { MouseEvent } from "react"; import type { MouseEvent } from 'react';
import { httpPatch } from '../../lib/http'; import { httpPatch } from '../../lib/http';
import type { ResourceType } from '../../lib/resource-progress'; import type { ResourceType } from '../../lib/resource-progress';
import { isLoggedIn } from '../../lib/jwt'; import { isLoggedIn } from '../../lib/jwt';
@ -7,6 +7,7 @@ import { showLoginPopup } from '../../lib/popup';
import { FavoriteIcon } from './FavoriteIcon'; import { FavoriteIcon } from './FavoriteIcon';
import { Spinner } from '../ReactIcons/Spinner'; import { Spinner } from '../ReactIcons/Spinner';
import { useToast } from '../../hooks/use-toast'; import { useToast } from '../../hooks/use-toast';
import { cn } from '../../lib/classname';
type MarkFavoriteType = { type MarkFavoriteType = {
resourceType: ResourceType; resourceType: ResourceType;
@ -27,7 +28,9 @@ export function MarkFavorite({
const toast = useToast(); const toast = useToast();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isFavorite, setIsFavorite] = useState( const [isFavorite, setIsFavorite] = useState(
isAuthenticated ? (favorite ?? localStorage.getItem(localStorageKey) === '1') : false isAuthenticated
? (favorite ?? localStorage.getItem(localStorageKey) === '1')
: false,
); );
async function toggleFavoriteHandler(e: MouseEvent<HTMLButtonElement>) { async function toggleFavoriteHandler(e: MouseEvent<HTMLButtonElement>) {
@ -48,7 +51,7 @@ export function MarkFavorite({
{ {
resourceType, resourceType,
resourceId, resourceId,
} },
); );
if (error) { if (error) {
@ -68,7 +71,7 @@ export function MarkFavorite({
resourceType, resourceType,
isFavorite: !isFavorite, isFavorite: !isFavorite,
}, },
}) }),
); );
window.dispatchEvent(new CustomEvent('refresh-favorites', {})); window.dispatchEvent(new CustomEvent('refresh-favorites', {}));
@ -99,11 +102,18 @@ export function MarkFavorite({
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'} aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
onClick={toggleFavoriteHandler} onClick={toggleFavoriteHandler}
tabIndex={-1} tabIndex={-1}
className={`${isFavorite ? '' : 'opacity-30 hover:opacity-100'} ${ className={cn(
className || 'absolute right-1.5 top-1.5 z-30 focus:outline-0' 'absolute right-1.5 top-1.5 z-30 focus:outline-0',
}`} isFavorite ? '' : 'opacity-30 hover:opacity-100',
className,
)}
data-is-favorite={isFavorite}
> >
{isLoading ? <Spinner isDualRing={false} /> : <FavoriteIcon isFavorite={isFavorite} />} {isLoading ? (
<Spinner isDualRing={false} />
) : (
<FavoriteIcon isFavorite={isFavorite} />
)}
</button> </button>
); );
} }

@ -33,7 +33,7 @@ export interface ProjectStatusDocument {
isVisible?: boolean; isVisible?: boolean;
updated1t: Date; updatedAt: Date;
} }
const allowedVoteType = ['upvote', 'downvote'] as const; const allowedVoteType = ['upvote', 'downvote'] as const;

@ -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>
);
}

@ -49,8 +49,13 @@ type GetTeamActivityResponse = {
perPage: number; perPage: number;
}; };
export function TeamActivityPage() { type TeamActivityPageProps = {
const { t: teamId } = getUrlParams(); teamId?: string;
};
export function TeamActivityPage(props: TeamActivityPageProps) {
const { teamId: defaultTeamId } = props;
const { t: teamId = defaultTeamId } = getUrlParams();
const toast = useToast(); const toast = useToast();
@ -92,6 +97,18 @@ export function TeamActivityPage() {
return; return;
} }
setIsLoading(true);
setTeamActivities({
data: {
users: [],
activities: [],
},
totalCount: 0,
totalPages: 0,
currPage: 1,
perPage: 21,
});
setCurrPage(1);
getTeamProgress().then(() => { getTeamProgress().then(() => {
pageProgressMessage.set(''); pageProgressMessage.set('');
setIsLoading(false); setIsLoading(false);

@ -24,6 +24,7 @@ export type UserProgress = {
updatedAt: string; updatedAt: string;
isCustomResource?: boolean; isCustomResource?: boolean;
roadmapSlug?: string; roadmapSlug?: string;
aiRoadmapId?: string;
}; };
export type TeamMember = { export type TeamMember = {

@ -1,10 +1,14 @@
import type { ProjectPageType } from '../../api/roadmap';
import type { GetPublicProfileResponse } from '../../api/user'; import type { GetPublicProfileResponse } from '../../api/user';
import { PrivateProfileBanner } from './PrivateProfileBanner'; import { PrivateProfileBanner } from './PrivateProfileBanner';
import { UserActivityHeatmap } from './UserPublicActivityHeatmap'; import { UserActivityHeatmap } from './UserPublicActivityHeatmap';
import { UserPublicProfileHeader } from './UserPublicProfileHeader'; import { UserPublicProfileHeader } from './UserPublicProfileHeader';
import { UserPublicProgresses } from './UserPublicProgresses'; import { UserPublicProgresses } from './UserPublicProgresses';
import { UserPublicProjects } from './UserPublicProjects';
type UserPublicProfilePageProps = GetPublicProfileResponse; type UserPublicProfilePageProps = GetPublicProfileResponse & {
projectDetails: ProjectPageType[];
};
export function UserPublicProfilePage(props: UserPublicProfilePageProps) { export function UserPublicProfilePage(props: UserPublicProfilePageProps) {
const { const {
@ -14,10 +18,11 @@ export function UserPublicProfilePage(props: UserPublicProfilePageProps) {
profileVisibility, profileVisibility,
_id: userId, _id: userId,
createdAt, createdAt,
projectDetails,
} = props; } = props;
return ( return (
<div className="bg-gray-200/40 min-h-full flex-grow pt-10 pb-36"> <div className="min-h-full flex-grow bg-gray-200/40 pb-36 pt-10">
<div className="container flex flex-col gap-8"> <div className="container flex flex-col gap-8">
<PrivateProfileBanner <PrivateProfileBanner
isOwnProfile={isOwnProfile} isOwnProfile={isOwnProfile}
@ -27,12 +32,19 @@ export function UserPublicProfilePage(props: UserPublicProfilePageProps) {
<UserPublicProfileHeader userDetails={props!} /> <UserPublicProfileHeader userDetails={props!} />
<UserActivityHeatmap joinedAt={createdAt} activity={activity!} /> <UserActivityHeatmap joinedAt={createdAt} activity={activity!} />
<UserPublicProgresses <div>
username={username!} <UserPublicProgresses
userId={userId!} username={username!}
roadmaps={props.roadmaps} userId={userId!}
publicConfig={props.publicConfig} roadmaps={props.roadmaps}
/> publicConfig={props.publicConfig}
/>
<UserPublicProjects
userId={userId!}
projects={props.projects}
projectDetails={projectDetails}
/>
</div>
</div> </div>
</div> </div>
); );

@ -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>
);
}

@ -164,7 +164,9 @@ const gaPageIdentifier = Astro.url.pathname
<slot /> <slot />
<slot name='page-footer'> <slot name='page-footer'>
<OpenSourceBanner /> <slot name='open-source-banner'>
<OpenSourceBanner />
</slot>
<Footer /> <Footer />
</slot> </slot>

@ -65,3 +65,15 @@ export function formatActivityDate(date: string): string {
day: 'numeric', day: 'numeric',
}); });
} }
export function getCurrentPeriod() {
const now = new Date();
const hour = now.getHours();
if (hour < 12) {
return 'morning';
} else if (hour < 18) {
return 'afternoon';
} else {
return 'evening';
}
}

@ -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>

@ -3,6 +3,7 @@ import { getAllGuides } from '../lib/guide';
import { getRoadmapsByTag } from '../lib/roadmap'; import { getRoadmapsByTag } from '../lib/roadmap';
import { getAllVideos } from '../lib/video'; import { getAllVideos } from '../lib/video';
import { getAllQuestionGroups } from '../lib/question-group'; import { getAllQuestionGroups } from '../lib/question-group';
import { getAllProjects } from '../lib/project';
export async function GET() { export async function GET() {
const guides = await getAllGuides(); const guides = await getAllGuides();
@ -10,6 +11,7 @@ export async function GET() {
const questionGroups = await getAllQuestionGroups(); const questionGroups = await getAllQuestionGroups();
const roadmaps = await getRoadmapsByTag('roadmap'); const roadmaps = await getRoadmapsByTag('roadmap');
const bestPractices = await getAllBestPractices(); const bestPractices = await getAllBestPractices();
const projects = await getAllProjects();
return new Response( return new Response(
JSON.stringify([ JSON.stringify([
@ -53,6 +55,13 @@ export async function GET() {
title: video.frontmatter.title, title: video.frontmatter.title,
group: 'Videos', group: 'Videos',
})), })),
...projects.map((project) => ({
id: project.id,
url: `/projects/${project.id}`,
title: project.frontmatter.title,
description: project.frontmatter.description,
group: 'Projects',
})),
]), ]),
{ {
status: 200, status: 200,

@ -1,4 +1,5 @@
--- ---
import { getProjectList } from '../../api/roadmap';
import { userApi } from '../../api/user'; import { userApi } from '../../api/user';
import { UserPublicProfilePage } from '../../components/UserPublicProfile/UserPublicProfilePage'; import { UserPublicProfilePage } from '../../components/UserPublicProfile/UserPublicProfilePage';
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
@ -23,6 +24,7 @@ if (error || !userDetails) {
errorMessage = error?.message || 'User not found'; errorMessage = error?.message || 'User not found';
} }
const projectDetails = await getProjectList();
const origin = Astro.url.origin; const origin = Astro.url.origin;
const ogImage = `${origin}/og/user/${username}`; const ogImage = `${origin}/og/user/${username}`;
--- ---
@ -32,7 +34,15 @@ const ogImage = `${origin}/og/user/${username}`;
description='Check out my skill profile at roadmap.sh' description='Check out my skill profile at roadmap.sh'
ogImageUrl={ogImage} ogImageUrl={ogImage}
> >
{!errorMessage && <UserPublicProfilePage {...userDetails!} client:load />} {
!errorMessage && (
<UserPublicProfilePage
{...userDetails!}
projectDetails={projectDetails}
client:load
/>
)
}
{ {
errorMessage && ( errorMessage && (
<div class='container my-24 flex flex-col'> <div class='container my-24 flex flex-col'>

@ -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…
Cancel
Save