From a913da47a7cf16f894a1e640eea834e1e5b1983c Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Wed, 11 Sep 2024 21:01:26 +0600 Subject: [PATCH] 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 --- src/api/roadmap.ts | 28 ++ src/api/user.ts | 2 + .../AccountStreak/AccountStreak.tsx | 23 +- src/components/Activity/ActivityPage.tsx | 57 ++- src/components/Activity/ProjectProgress.tsx | 57 +++ .../Activity/ProjectProgressActions.tsx | 68 ++++ src/components/Activity/ProjectStatus.tsx | 24 ++ src/components/Authenticator/authenticator.ts | 1 + .../Dashboard/DashboardAiRoadmaps.tsx | 78 ++++ .../Dashboard/DashboardBookmarkCard.tsx | 36 ++ .../Dashboard/DashboardCardLink.tsx | 30 ++ .../Dashboard/DashboardCustomProgressCard.tsx | 64 ++++ src/components/Dashboard/DashboardPage.tsx | 124 +++++++ .../Dashboard/DashboardProgressCard.tsx | 54 +++ .../Dashboard/DashboardProjectCard.tsx | 55 +++ src/components/Dashboard/DashboardTab.tsx | 40 +++ .../Dashboard/ListDashboardCustomProgress.tsx | 112 ++++++ src/components/Dashboard/LoadingProgress.tsx | 14 + .../Dashboard/PersonalDashboard.tsx | 340 ++++++++++++++++++ src/components/Dashboard/ProgressStack.tsx | 328 +++++++++++++++++ .../Dashboard/RecommendedRoadmaps.tsx | 73 ++++ src/components/Dashboard/TeamDashboard.tsx | 165 +++++++++ src/components/FeaturedItems/MarkFavorite.tsx | 26 +- .../Projects/ListProjectSolutions.tsx | 2 +- src/components/ReactIcons/BookEmoji.tsx | 39 ++ src/components/ReactIcons/BuildEmoji.tsx | 36 ++ src/components/ReactIcons/BulbEmoji.tsx | 37 ++ src/components/ReactIcons/CheckEmoji.tsx | 6 + .../ReactIcons/ConstructionEmoji.tsx | 24 ++ .../TeamActivity/TeamActivityPage.tsx | 21 +- .../TeamProgress/TeamProgressPage.tsx | 1 + .../UserPublicProfilePage.tsx | 28 +- .../UserPublicProfile/UserPublicProjects.tsx | 57 +++ src/layouts/BaseLayout.astro | 4 +- src/lib/date.ts | 12 + src/pages/dashboard.astro | 59 +++ src/pages/pages.json.ts | 9 + src/pages/u/[username].astro | 12 +- src/stores/streak.ts | 11 + 39 files changed, 2121 insertions(+), 36 deletions(-) create mode 100644 src/components/Activity/ProjectProgress.tsx create mode 100644 src/components/Activity/ProjectProgressActions.tsx create mode 100644 src/components/Activity/ProjectStatus.tsx create mode 100644 src/components/Dashboard/DashboardAiRoadmaps.tsx create mode 100644 src/components/Dashboard/DashboardBookmarkCard.tsx create mode 100644 src/components/Dashboard/DashboardCardLink.tsx create mode 100644 src/components/Dashboard/DashboardCustomProgressCard.tsx create mode 100644 src/components/Dashboard/DashboardPage.tsx create mode 100644 src/components/Dashboard/DashboardProgressCard.tsx create mode 100644 src/components/Dashboard/DashboardProjectCard.tsx create mode 100644 src/components/Dashboard/DashboardTab.tsx create mode 100644 src/components/Dashboard/ListDashboardCustomProgress.tsx create mode 100644 src/components/Dashboard/LoadingProgress.tsx create mode 100644 src/components/Dashboard/PersonalDashboard.tsx create mode 100644 src/components/Dashboard/ProgressStack.tsx create mode 100644 src/components/Dashboard/RecommendedRoadmaps.tsx create mode 100644 src/components/Dashboard/TeamDashboard.tsx create mode 100644 src/components/ReactIcons/BookEmoji.tsx create mode 100644 src/components/ReactIcons/BuildEmoji.tsx create mode 100644 src/components/ReactIcons/BulbEmoji.tsx create mode 100644 src/components/ReactIcons/CheckEmoji.tsx create mode 100644 src/components/ReactIcons/ConstructionEmoji.tsx create mode 100644 src/components/UserPublicProfile/UserPublicProjects.tsx create mode 100644 src/pages/dashboard.astro create mode 100644 src/stores/streak.ts diff --git a/src/api/roadmap.ts b/src/api/roadmap.ts index dab4e6fcb..2df832159 100644 --- a/src/api/roadmap.ts +++ b/src/api/roadmap.ts @@ -1,6 +1,7 @@ import { type APIContext } from 'astro'; import { api } from './api.ts'; import type { RoadmapDocument } from '../components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx'; +import type { PageType } from '../components/CommandMenu/CommandMenu.tsx'; export type ListShowcaseRoadmapResponse = { 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; +} diff --git a/src/api/user.ts b/src/api/user.ts index 819c68dbd..4e557e166 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -1,6 +1,7 @@ import { type APIContext } from 'astro'; import { api } from './api.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 type AllowedRoadmapVisibility = @@ -99,6 +100,7 @@ export type GetPublicProfileResponse = Omit< > & { activity: UserActivityCount; roadmaps: ProgressResponse[]; + projects: ProjectStatusDocument[]; isOwnProfile: boolean; }; diff --git a/src/components/AccountStreak/AccountStreak.tsx b/src/components/AccountStreak/AccountStreak.tsx index 932ef40b2..542a28a06 100644 --- a/src/components/AccountStreak/AccountStreak.tsx +++ b/src/components/AccountStreak/AccountStreak.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { isLoggedIn } from '../../lib/jwt'; import { httpGet } from '../../lib/http'; 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 { StreakDay } from './StreakDay'; import { @@ -11,6 +11,7 @@ import { } from '../../stores/page.ts'; import { useStore } from '@nanostores/react'; import { cn } from '../../lib/classname.ts'; +import { $accountStreak } from '../../stores/streak.ts'; type StreakResponse = { count: number; @@ -27,12 +28,7 @@ export function AccountStreak(props: AccountStreakProps) { const dropdownRef = useRef(null); const [isLoading, setIsLoading] = useState(true); - const [accountStreak, setAccountStreak] = useState({ - count: 0, - longestCount: 0, - firstVisitAt: new Date(), - lastVisitAt: new Date(), - }); + const accountStreak = useStore($accountStreak); const [showDropdown, setShowDropdown] = useState(false); const $roadmapsDropdownOpen = useStore(roadmapsDropdownOpen); @@ -49,6 +45,11 @@ export function AccountStreak(props: AccountStreakProps) { return; } + if (accountStreak) { + setIsLoading(false); + return; + } + setIsLoading(true); const { response, error } = await httpGet( `${import.meta.env.PUBLIC_API_URL}/v1-streak`, @@ -60,7 +61,7 @@ export function AccountStreak(props: AccountStreakProps) { return; } - setAccountStreak(response); + $accountStreak.set(response); setIsLoading(false); }; @@ -76,7 +77,7 @@ export function AccountStreak(props: AccountStreakProps) { return null; } - let { count: currentCount } = accountStreak; + let { count: currentCount = 0 } = accountStreak || {}; const previousCount = accountStreak?.previousCount || accountStreak?.count || 0; @@ -110,7 +111,7 @@ export function AccountStreak(props: AccountStreakProps) { ref={dropdownRef} className="absolute right-0 top-full z-50 w-[335px] translate-y-1 rounded-lg bg-slate-800 shadow-xl" > -
+

Current Streak @@ -180,7 +181,7 @@ export function AccountStreak(props: AccountStreakProps) {

-

+

Visit every day to keep your streak alive!

diff --git a/src/components/Activity/ActivityPage.tsx b/src/components/Activity/ActivityPage.tsx index 1ff5b51c0..b6a95e87c 100644 --- a/src/components/Activity/ActivityPage.tsx +++ b/src/components/Activity/ActivityPage.tsx @@ -5,6 +5,10 @@ import { ResourceProgress } from './ResourceProgress'; import { pageProgressMessage } from '../../stores/page'; import { EmptyActivity } from './EmptyActivity'; 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 = { updatedAt: string; @@ -47,11 +51,14 @@ export type ActivityResponse = { }; }[]; activities: UserStreamActivity[]; + projects: ProjectStatusDocument[]; }; export function ActivityPage() { + const toast = useToast(); const [activity, setActivity] = useState(); const [isLoading, setIsLoading] = useState(true); + const [projectDetails, setProjectDetails] = useState([]); async function loadActivity() { const { error, response } = await httpGet( @@ -68,11 +75,29 @@ export function ActivityPage() { setActivity(response); } + async function loadAllProjectDetails() { + const { error, response } = await httpGet(`/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(() => { - loadActivity().finally(() => { - pageProgressMessage.set(''); - setIsLoading(false); - }); + Promise.allSettled([loadActivity(), loadAllProjectDetails()]).finally( + () => { + pageProgressMessage.set(''); + setIsLoading(false); + }, + ); }, []); const learningRoadmaps = activity?.learning.roadmaps || []; @@ -106,6 +131,17 @@ export function ActivityPage() { learningRoadmapsToShow.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 ( <> + {enrichedProjects && enrichedProjects?.length > 0 && ( +
+

+ Your Projects +

+
+ {enrichedProjects.map((project) => ( + + ))} +
+
+ )} + {hasProgress && ( )} diff --git a/src/components/Activity/ProjectProgress.tsx b/src/components/Activity/ProjectProgress.tsx new file mode 100644 index 000000000..6ac20de28 --- /dev/null +++ b/src/components/Activity/ProjectProgress.tsx @@ -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 ( +
+ + + {projectStatus?.title} + + {projectStatus.upvotes} + + + + + {shouldShowActions && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/Activity/ProjectProgressActions.tsx b/src/components/Activity/ProjectProgressActions.tsx new file mode 100644 index 000000000..d463099b1 --- /dev/null +++ b/src/components/Activity/ProjectProgressActions.tsx @@ -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(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 ( +
+ + + {isOpen && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/Activity/ProjectStatus.tsx b/src/components/Activity/ProjectStatus.tsx new file mode 100644 index 000000000..1e19358db --- /dev/null +++ b/src/components/Activity/ProjectStatus.tsx @@ -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 ; + } + + return ( + + ); +} diff --git a/src/components/Authenticator/authenticator.ts b/src/components/Authenticator/authenticator.ts index d4c77f6e5..1e9d57ac3 100644 --- a/src/components/Authenticator/authenticator.ts +++ b/src/components/Authenticator/authenticator.ts @@ -48,6 +48,7 @@ function handleGuest() { '/team/members', '/team/member', '/team/settings', + '/dashboard', ]; showHideAuthElements('hide'); diff --git a/src/components/Dashboard/DashboardAiRoadmaps.tsx b/src/components/Dashboard/DashboardAiRoadmaps.tsx new file mode 100644 index 000000000..5da40545f --- /dev/null +++ b/src/components/Dashboard/DashboardAiRoadmaps.tsx @@ -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 ( + <> +

+ AI Generated Roadmaps +

+ + {!isLoading && roadmaps.length === 0 && ( + + )} + +
+ {isLoading && ( + <> + {Array.from({ length: 9 }).map((_, index) => ( + + ))} + + )} + + {!isLoading && roadmaps.length > 0 && ( + <> + {roadmaps.map((roadmap) => ( + + {roadmap.title} + + ))} + + + + Generate New + + + )} +
+ + ); +} + +type CustomProgressCardSkeletonProps = {}; + +function RoadmapCardSkeleton( + props: CustomProgressCardSkeletonProps, +) { + return ( +
+ ); +} diff --git a/src/components/Dashboard/DashboardBookmarkCard.tsx b/src/components/Dashboard/DashboardBookmarkCard.tsx new file mode 100644 index 000000000..3a7bafbb9 --- /dev/null +++ b/src/components/Dashboard/DashboardBookmarkCard.tsx @@ -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 ( + + +

{resourceTitle}

+
+ ); +} diff --git a/src/components/Dashboard/DashboardCardLink.tsx b/src/components/Dashboard/DashboardCardLink.tsx new file mode 100644 index 000000000..d6182da45 --- /dev/null +++ b/src/components/Dashboard/DashboardCardLink.tsx @@ -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 ( + + +

{title}

+

{description}

+ +
+ ); +} diff --git a/src/components/Dashboard/DashboardCustomProgressCard.tsx b/src/components/Dashboard/DashboardCustomProgressCard.tsx new file mode 100644 index 000000000..9464d5a73 --- /dev/null +++ b/src/components/Dashboard/DashboardCustomProgressCard.tsx @@ -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 ( + +

{resourceTitle}

+ +
+
+
+
+ + {Math.floor(+progressPercentage)}% + +
+ +

+ {isCustomResource ? ( + <>Last updated {getRelativeTimeString(updatedAt)} + ) : ( + <>Last practiced {getRelativeTimeString(updatedAt)} + )} +

+
+ ); +} diff --git a/src/components/Dashboard/DashboardPage.tsx b/src/components/Dashboard/DashboardPage.tsx new file mode 100644 index 000000000..639acfb93 --- /dev/null +++ b/src/components/Dashboard/DashboardPage.tsx @@ -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(); + + async function getAllTeams() { + if (teamList.length > 0) { + return; + } + + const { response, error } = await httpGet( + `${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 ( +
+
+
+ setSelectedTeamId(undefined)} + avatar={userAvatar} + /> + {isLoading && ( + <> + + + + )} + + {!isLoading && ( + <> + {teamList.map((team) => { + const { avatar } = team; + const avatarUrl = avatar + ? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}` + : '/images/default-avatar.png'; + return ( + { + // setSelectedTeamId(team._id); + // }, + })} + avatar={avatarUrl} + /> + ); + })} + + + )} +
+ + {!selectedTeamId && ( + + )} + {selectedTeamId && } +
+
+ ); +} + +function DashboardTabSkeleton() { + return ( +
+ ); +} diff --git a/src/components/Dashboard/DashboardProgressCard.tsx b/src/components/Dashboard/DashboardProgressCard.tsx new file mode 100644 index 000000000..d243051b0 --- /dev/null +++ b/src/components/Dashboard/DashboardProgressCard.tsx @@ -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 ( + + {resourceTitle} + + {parseInt(progressPercentage, 10)}% + + + + + ); +} diff --git a/src/components/Dashboard/DashboardProjectCard.tsx b/src/components/Dashboard/DashboardProjectCard.tsx new file mode 100644 index 000000000..81c6614f7 --- /dev/null +++ b/src/components/Dashboard/DashboardProjectCard.tsx @@ -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 ( + + + {status === 'submitted' && ( + + )} + + {title.replace(/(System)|(Service)/, '')} + + {!!startedAt && + status === 'started' && + getRelativeTimeString(startedAt)} + {!!submittedAt && + status === 'submitted' && + getRelativeTimeString(submittedAt)} + + + ); +} diff --git a/src/components/Dashboard/DashboardTab.tsx b/src/components/Dashboard/DashboardTab.tsx new file mode 100644 index 000000000..c6b345599 --- /dev/null +++ b/src/components/Dashboard/DashboardTab.tsx @@ -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 ( + + {avatar && ( + avatar + )} + {icon} + {label} + + ); +} diff --git a/src/components/Dashboard/ListDashboardCustomProgress.tsx b/src/components/Dashboard/ListDashboardCustomProgress.tsx new file mode 100644 index 000000000..8e552cbb1 --- /dev/null +++ b/src/components/Dashboard/ListDashboardCustomProgress.tsx @@ -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 ? ( + setIsCreateCustomRoadmapModalOpen(false)} + onCreated={(roadmap) => { + window.location.href = `${ + import.meta.env.PUBLIC_EDITOR_APP_URL + }/${roadmap?._id}`; + return; + }} + /> + ) : null; + + return ( + <> + {customRoadmapModal} + +

+ {isAIGeneratedRoadmaps ? 'AI Generated Roadmaps' : 'Custom Roadmaps'} +

+ + {!isLoading && progresses.length === 0 && isAIGeneratedRoadmaps && ( + + )} + + {!isLoading && progresses.length === 0 && !isAIGeneratedRoadmaps && ( + + )} + +
+ {isLoading && ( + <> + {Array.from({ length: 8 }).map((_, index) => ( + + ))} + + )} + + {!isLoading && progresses.length > 0 && ( + <> + {progresses.map((progress) => ( + + ))} + + { + if (!isAIGeneratedRoadmaps) { + e.preventDefault(); + setIsCreateCustomRoadmapModalOpen(true); + } + }} + > + {isAIGeneratedRoadmaps ? '+ Generate New' : '+ Create New'} + + + )} +
+ + ); +} + +type CustomProgressCardSkeletonProps = {}; + +export function CustomProgressCardSkeleton( + props: CustomProgressCardSkeletonProps, +) { + return ( +
+ ); +} diff --git a/src/components/Dashboard/LoadingProgress.tsx b/src/components/Dashboard/LoadingProgress.tsx new file mode 100644 index 000000000..a367efcae --- /dev/null +++ b/src/components/Dashboard/LoadingProgress.tsx @@ -0,0 +1,14 @@ +type LoadingProgressProps = {}; + +export function LoadingProgress(props: LoadingProgressProps) { + return ( +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+ ))} +
+ ); +} diff --git a/src/components/Dashboard/PersonalDashboard.tsx b/src/components/Dashboard/PersonalDashboard.tsx new file mode 100644 index 000000000..c52a48ebd --- /dev/null +++ b/src/components/Dashboard/PersonalDashboard.tsx @@ -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(); + const [projectDetails, setProjectDetails] = useState([]); + const accountStreak = useStore($accountStreak); + + const loadAccountStreak = async () => { + if (accountStreak) { + return; + } + + setIsLoading(true); + const { response, error } = await httpGet( + `${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( + `${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(`/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 ( +
+ {isLoading ? ( +
+ ) : ( +

+ Hi {name}, good {getCurrentPeriod()}! +

+ )} + +
+ {isLoading ? ( + <> + + + + + + ) : ( + <> + + + + + + + + )} +
+ + + + + + + + +
+ ); +} + +type DashboardCardProps = { + icon?: JSXElementConstructor; + imgUrl?: string; + title: string; + description: string; + href: string; +}; + +function DashboardCard(props: DashboardCardProps) { + const { icon: Icon, imgUrl, title, description, href } = props; + + return ( + + {Icon && ( +
+ +
+ )} + + {imgUrl && ( +
+ {title} +
+ )} + +
+

{title}

+

{description}

+
+
+ ); +} + +function DashboardCardSkeleton() { + return ( +
+ ); +} diff --git a/src/components/Dashboard/ProgressStack.tsx b/src/components/Dashboard/ProgressStack.tsx new file mode 100644 index 000000000..bdcb7e6ad --- /dev/null +++ b/src/components/Dashboard/ProgressStack.tsx @@ -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 ( +
+ {isLoading && ( +
+
+
+ )} + {!isLoading && !isEmpty && ( +
+

{title}

+ + {linkText && linkHref && ( + + + {linkText} + + )} +
+ )} + +
+ {isLoading && ( + <> + {Array.from({ length: loadingSkeletonCount }).map((_, index) => ( + + ))} + + )} + {!isLoading && children} + + {!isLoading && isEmpty && ( +
+ + {emptyMessage} + + {emptyLinkText} + +
+ )} +
+
+ ); +} + +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 ( + <> +
+ + + +
+ +
+ + {userProgressesToShow.length > 0 && ( + <> + {userProgressesToShow.map((progress) => { + return ( + + ); + })} + + )} + + {userProgresses.length > MAX_PROGRESS_TO_SHOW && ( + + )} + + + + {projectsToShow.map((project) => { + return ( + + ); + })} + + {projects.length > MAX_PROJECTS_TO_SHOW && ( + + )} + + + + {bookmarksToShow.map((progress) => { + return ( + + ); + })} + {bookmarkedProgresses.length > MAX_BOOKMARKS_TO_SHOW && ( + + )} + +
+ + ); +} + +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 ( + + + + ); +} + +type CardSkeletonProps = { + className?: string; +}; + +function CardSkeleton(props: CardSkeletonProps) { + const { className } = props; + + return ( +
+ ); +} + +type StatsCardProps = { + title: string; + value: number; + isLoading?: boolean; +}; + +function StatsCard(props: StatsCardProps) { + const { title, value, isLoading = false } = props; + + return ( +
+

{title}

+ {isLoading ? ( + + ) : ( + {value} + )} +
+ ); +} diff --git a/src/components/Dashboard/RecommendedRoadmaps.tsx b/src/components/Dashboard/RecommendedRoadmaps.tsx new file mode 100644 index 000000000..734f07d4a --- /dev/null +++ b/src/components/Dashboard/RecommendedRoadmaps.tsx @@ -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 ( + <> +
+

+ Recommended Roadmaps +

+ + + + All Roadmaps + +
+ + {isLoading ? ( +
+ {Array.from({ length: 9 }).map((_, index) => ( + + ))} +
+ ) : ( +
+ {roadmapsToShow.map((roadmap) => ( + + ))} +
+ )} + +
+ Need some help getting started? Check out our{' '}Getting Started Guide. +
+ + ); +} + +type RecommendedRoadmapCardProps = { + roadmap: BuiltInRoadmap; +}; + +export function RecommendedRoadmapCard(props: RecommendedRoadmapCardProps) { + const { roadmap } = props; + const { title, url, description } = roadmap; + + return ( + + + {title} + + ); +} + +function RecommendedCardSkeleton() { + return ( +
+ ); +} diff --git a/src/components/Dashboard/TeamDashboard.tsx b/src/components/Dashboard/TeamDashboard.tsx new file mode 100644 index 000000000..f8aab7c4e --- /dev/null +++ b/src/components/Dashboard/TeamDashboard.tsx @@ -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([]); + + async function getTeamProgress() { + const { response, error } = await httpGet( + `${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 ( +
+

Roadmaps

+ {isLoading && } + {!isLoading && learningRoadmapsToShow.length > 0 && ( +
+ {learningRoadmapsToShow.map((roadmap) => { + const learningCount = roadmap.learning || 0; + const doneCount = roadmap.done || 0; + const totalCount = roadmap.total || 0; + const skippedCount = roadmap.skipped || 0; + + return ( + 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} + /> + ); + })} +
+ )} + +

+ Team Members +

+ {isLoading && } + {!isLoading && ( +
+ {allMembersWithoutCurrentUser.map((member) => { + const avatar = member?.avatar + ? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${member.avatar}` + : '/images/default-avatar.png'; + return ( + +
+ {member.name +
+ + {member.name} + +
+ ); + })} +
+ )} + + +
+ ); +} + +type TeamMemberLoadingProps = { + className?: string; +}; + +function TeamMemberLoading(props: TeamMemberLoadingProps) { + const { className } = props; + + return ( +
+ {Array.from({ length: 15 }).map((_, index) => ( +
+ ))} +
+ ); +} diff --git a/src/components/FeaturedItems/MarkFavorite.tsx b/src/components/FeaturedItems/MarkFavorite.tsx index 3d6191c31..05bc80aa9 100644 --- a/src/components/FeaturedItems/MarkFavorite.tsx +++ b/src/components/FeaturedItems/MarkFavorite.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import type { MouseEvent } from "react"; +import type { MouseEvent } from 'react'; import { httpPatch } from '../../lib/http'; import type { ResourceType } from '../../lib/resource-progress'; import { isLoggedIn } from '../../lib/jwt'; @@ -7,6 +7,7 @@ import { showLoginPopup } from '../../lib/popup'; import { FavoriteIcon } from './FavoriteIcon'; import { Spinner } from '../ReactIcons/Spinner'; import { useToast } from '../../hooks/use-toast'; +import { cn } from '../../lib/classname'; type MarkFavoriteType = { resourceType: ResourceType; @@ -27,7 +28,9 @@ export function MarkFavorite({ const toast = useToast(); const [isLoading, setIsLoading] = useState(false); const [isFavorite, setIsFavorite] = useState( - isAuthenticated ? (favorite ?? localStorage.getItem(localStorageKey) === '1') : false + isAuthenticated + ? (favorite ?? localStorage.getItem(localStorageKey) === '1') + : false, ); async function toggleFavoriteHandler(e: MouseEvent) { @@ -48,7 +51,7 @@ export function MarkFavorite({ { resourceType, resourceId, - } + }, ); if (error) { @@ -68,7 +71,7 @@ export function MarkFavorite({ resourceType, isFavorite: !isFavorite, }, - }) + }), ); window.dispatchEvent(new CustomEvent('refresh-favorites', {})); @@ -99,11 +102,18 @@ export function MarkFavorite({ aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'} onClick={toggleFavoriteHandler} tabIndex={-1} - className={`${isFavorite ? '' : 'opacity-30 hover:opacity-100'} ${ - className || 'absolute right-1.5 top-1.5 z-30 focus:outline-0' - }`} + className={cn( + 'absolute right-1.5 top-1.5 z-30 focus:outline-0', + isFavorite ? '' : 'opacity-30 hover:opacity-100', + className, + )} + data-is-favorite={isFavorite} > - {isLoading ? : } + {isLoading ? ( + + ) : ( + + )} ); } diff --git a/src/components/Projects/ListProjectSolutions.tsx b/src/components/Projects/ListProjectSolutions.tsx index a543d8a39..2d29a4ae2 100644 --- a/src/components/Projects/ListProjectSolutions.tsx +++ b/src/components/Projects/ListProjectSolutions.tsx @@ -33,7 +33,7 @@ export interface ProjectStatusDocument { isVisible?: boolean; - updated1t: Date; + updatedAt: Date; } const allowedVoteType = ['upvote', 'downvote'] as const; diff --git a/src/components/ReactIcons/BookEmoji.tsx b/src/components/ReactIcons/BookEmoji.tsx new file mode 100644 index 000000000..b4565b253 --- /dev/null +++ b/src/components/ReactIcons/BookEmoji.tsx @@ -0,0 +1,39 @@ +import type { SVGProps } from 'react'; +import React from 'react'; + +export function BookEmoji(props: SVGProps) { + return ( + + + + + + + + + ); +} diff --git a/src/components/ReactIcons/BuildEmoji.tsx b/src/components/ReactIcons/BuildEmoji.tsx new file mode 100644 index 000000000..a6d2f8a49 --- /dev/null +++ b/src/components/ReactIcons/BuildEmoji.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import type { SVGProps } from 'react'; + +export function BuildEmoji(props: SVGProps) { + return ( + + + + + + + + + + + + ); +} diff --git a/src/components/ReactIcons/BulbEmoji.tsx b/src/components/ReactIcons/BulbEmoji.tsx new file mode 100644 index 000000000..f5e344f7a --- /dev/null +++ b/src/components/ReactIcons/BulbEmoji.tsx @@ -0,0 +1,37 @@ +// twitter bulb emoji +import type { SVGProps } from 'react'; + +type BulbEmojiProps = SVGProps; + +export function BulbEmoji(props: BulbEmojiProps) { + return ( + + + + + + + + ); +} diff --git a/src/components/ReactIcons/CheckEmoji.tsx b/src/components/ReactIcons/CheckEmoji.tsx new file mode 100644 index 000000000..629237be3 --- /dev/null +++ b/src/components/ReactIcons/CheckEmoji.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import type { SVGProps } from 'react'; + +export function CheckEmoji(props: SVGProps) { + return (); +} \ No newline at end of file diff --git a/src/components/ReactIcons/ConstructionEmoji.tsx b/src/components/ReactIcons/ConstructionEmoji.tsx new file mode 100644 index 000000000..df9d0e9f8 --- /dev/null +++ b/src/components/ReactIcons/ConstructionEmoji.tsx @@ -0,0 +1,24 @@ +import type { SVGProps } from 'react'; +import React from 'react'; + +export function ConstructionEmoji(props: SVGProps) { + return ( + + + + + + ); +} diff --git a/src/components/TeamActivity/TeamActivityPage.tsx b/src/components/TeamActivity/TeamActivityPage.tsx index b8d6b9657..5fe64182d 100644 --- a/src/components/TeamActivity/TeamActivityPage.tsx +++ b/src/components/TeamActivity/TeamActivityPage.tsx @@ -49,8 +49,13 @@ type GetTeamActivityResponse = { perPage: number; }; -export function TeamActivityPage() { - const { t: teamId } = getUrlParams(); +type TeamActivityPageProps = { + teamId?: string; +}; + +export function TeamActivityPage(props: TeamActivityPageProps) { + const { teamId: defaultTeamId } = props; + const { t: teamId = defaultTeamId } = getUrlParams(); const toast = useToast(); @@ -92,6 +97,18 @@ export function TeamActivityPage() { return; } + setIsLoading(true); + setTeamActivities({ + data: { + users: [], + activities: [], + }, + totalCount: 0, + totalPages: 0, + currPage: 1, + perPage: 21, + }); + setCurrPage(1); getTeamProgress().then(() => { pageProgressMessage.set(''); setIsLoading(false); diff --git a/src/components/TeamProgress/TeamProgressPage.tsx b/src/components/TeamProgress/TeamProgressPage.tsx index 747465465..78e8c7585 100644 --- a/src/components/TeamProgress/TeamProgressPage.tsx +++ b/src/components/TeamProgress/TeamProgressPage.tsx @@ -24,6 +24,7 @@ export type UserProgress = { updatedAt: string; isCustomResource?: boolean; roadmapSlug?: string; + aiRoadmapId?: string; }; export type TeamMember = { diff --git a/src/components/UserPublicProfile/UserPublicProfilePage.tsx b/src/components/UserPublicProfile/UserPublicProfilePage.tsx index d7c8342bb..47ecb8005 100644 --- a/src/components/UserPublicProfile/UserPublicProfilePage.tsx +++ b/src/components/UserPublicProfile/UserPublicProfilePage.tsx @@ -1,10 +1,14 @@ +import type { ProjectPageType } from '../../api/roadmap'; import type { GetPublicProfileResponse } from '../../api/user'; import { PrivateProfileBanner } from './PrivateProfileBanner'; import { UserActivityHeatmap } from './UserPublicActivityHeatmap'; import { UserPublicProfileHeader } from './UserPublicProfileHeader'; import { UserPublicProgresses } from './UserPublicProgresses'; +import { UserPublicProjects } from './UserPublicProjects'; -type UserPublicProfilePageProps = GetPublicProfileResponse; +type UserPublicProfilePageProps = GetPublicProfileResponse & { + projectDetails: ProjectPageType[]; +}; export function UserPublicProfilePage(props: UserPublicProfilePageProps) { const { @@ -14,10 +18,11 @@ export function UserPublicProfilePage(props: UserPublicProfilePageProps) { profileVisibility, _id: userId, createdAt, + projectDetails, } = props; return ( -
+
- +
+ + +
); diff --git a/src/components/UserPublicProfile/UserPublicProjects.tsx b/src/components/UserPublicProfile/UserPublicProjects.tsx new file mode 100644 index 000000000..a96a1a3eb --- /dev/null +++ b/src/components/UserPublicProfile/UserPublicProjects.tsx @@ -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 ( +
+

+ Projects I have worked on +

+
+ {enrichedProjects.map((project) => ( + + ))} +
+
+ ); +} diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 6dd31190c..b231f89bd 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -164,7 +164,9 @@ const gaPageIdentifier = Astro.url.pathname - + + +