feat: personal dashboard page

feat/dashboard
Arik Chakma 3 months ago
parent 8994d1b3b1
commit a408abbe30
  1. 50
      src/components/Dashboard/DashboardPage.tsx
  2. 14
      src/components/Dashboard/DashboardTab.tsx
  3. 151
      src/components/Dashboard/PersonalDashboard.tsx
  4. 36
      src/components/Dashboard/TeamDashboard.tsx
  5. 30
      src/pages/dashboard.astro

@ -5,12 +5,19 @@ import { useStore } from '@nanostores/react';
import { $teamList } from '../../stores/team';
import type { TeamListResponse } from '../TeamDropdown/TeamDropdown';
import { DashboardTab } from './DashboardTab';
import { PersonalDashboard } from './PersonalDashboard';
import { PersonalDashboard, type BuiltInRoadmap } from './PersonalDashboard';
import { TeamDashboard } from './TeamDashboard';
import { getUser } from '../../lib/jwt';
type DashboardPageProps = {};
type DashboardPageProps = {
builtInRoadmaps?: BuiltInRoadmap[];
builtInBestPractices?: BuiltInRoadmap[];
};
export function DashboardPage(props: DashboardPageProps) {
const { builtInRoadmaps, builtInBestPractices } = props;
const currentUser = getUser();
const toast = useToast();
const teamList = useStore($teamList);
@ -37,13 +44,18 @@ export function DashboardPage(props: DashboardPageProps) {
getAllTeams().finally(() => setIsLoading(false));
}, []);
const userAvatar = currentUser?.avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${currentUser.avatar}`
: '/images/default-avatar.png';
return (
<div className="container pb-20 pt-8">
<div className="container min-h-screen pb-20 pt-8">
<div className="flex flex-wrap items-center gap-1">
<DashboardTab
label="Personal"
isActive={!selectedTeamId}
onClick={() => setSelectedTeamId(undefined)}
avatar={userAvatar}
/>
{isLoading && (
<>
@ -55,14 +67,21 @@ export function DashboardPage(props: DashboardPageProps) {
{!isLoading && (
<>
{teamList.map((team) => (
<DashboardTab
key={team._id}
label={team.name}
isActive={team._id === selectedTeamId}
onClick={() => setSelectedTeamId(team._id)}
/>
))}
{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}
onClick={() => setSelectedTeamId(team._id)}
avatar={avatarUrl}
/>
);
})}
<DashboardTab
label="+ Create Team"
isActive={false}
@ -73,7 +92,12 @@ export function DashboardPage(props: DashboardPageProps) {
)}
</div>
{!selectedTeamId && <PersonalDashboard />}
{!selectedTeamId && (
<PersonalDashboard
builtInRoadmaps={builtInRoadmaps}
builtInBestPractices={builtInBestPractices}
/>
)}
{selectedTeamId && <TeamDashboard teamId={selectedTeamId} />}
</div>
);
@ -81,6 +105,6 @@ export function DashboardPage(props: DashboardPageProps) {
function DashboardTabLoading() {
return (
<div className="h-7 w-20 animate-pulse rounded-md border bg-gray-100"></div>
<div className="h-[30px] w-20 animate-pulse rounded-md border bg-gray-100"></div>
);
}

@ -7,10 +7,12 @@ type DashboardTabProps = {
onClick?: () => void;
className?: string;
href?: string;
avatar?: string;
icon?: ReactNode;
};
export function DashboardTab(props: DashboardTabProps) {
const { isActive, onClick, label, className, href } = props;
const { isActive, onClick, label, className, href, avatar, icon } = props;
const Slot = href ? 'a' : 'button';
@ -18,12 +20,20 @@ export function DashboardTab(props: DashboardTabProps) {
<Slot
onClick={onClick}
className={cn(
'shrink-0 rounded-md border p-1.5 px-2 text-sm leading-none text-gray-600',
'flex shrink-0 items-center h-[30px] gap-1 rounded-md border 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 rounded-full object-cover"
/>
)}
{icon}
{label}
</Slot>
);

@ -7,15 +7,31 @@ import { ProjectProgress } from '../Activity/ProjectProgress';
import type { PageType } from '../CommandMenu/CommandMenu';
import { useToast } from '../../hooks/use-toast';
import { LoadingProgress } from './LoadingProgress';
import { ArrowUpRight, Pencil } from 'lucide-react';
type UserDashboardResponse = {
name: string;
email: string;
avatar: string;
headline: string;
username: string;
progresses: UserProgress[];
projects: ProjectStatusDocument[];
};
type PersonalDashboardProps = {};
export type BuiltInRoadmap = {
id: string;
title: string;
description: string;
};
type PersonalDashboardProps = {
builtInRoadmaps?: BuiltInRoadmap[];
builtInBestPractices?: BuiltInRoadmap[];
};
export function PersonalDashboard(props: PersonalDashboardProps) {
const { builtInRoadmaps = [], builtInBestPractices = [] } = props;
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
@ -79,19 +95,81 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
});
const enrichedProjects =
personalDashboardDetails?.projects?.map((project) => {
const projectDetail = projectDetails.find(
(page) => page.id === project.projectId,
);
personalDashboardDetails?.projects
?.map((project) => {
const projectDetail = projectDetails.find(
(page) => page.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 {
...project,
title: projectDetail?.title || 'N/A',
};
}) || [];
return 0;
}) || [];
const { avatar, name, headline, email, username } =
personalDashboardDetails || {};
const avatarLink = avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
: '/images/default-avatar.png';
return (
<section className="mt-8">
{isLoading && (
<div className="mb-6 h-[91px] animate-pulse rounded-md border bg-gray-100" />
)}
{!isLoading && (
<div className="mb-6 flex items-center justify-between gap-2 overflow-hidden rounded-md border bg-gray-50">
<div className="flex items-center gap-3 pl-4">
<figure className="shrink-0">
<img
src={avatarLink}
alt={name}
className="h-14 w-14 rounded-full object-cover"
/>
</figure>
<div>
<h2 className="text-xl font-bold">{name}</h2>
<p className="text-sm text-gray-500">{headline || email}</p>
</div>
</div>
<div className="flex flex-col justify-start divide-y border-l">
<a
className="flex items-center gap-2 bg-white px-3 py-3 text-sm font-medium text-gray-500 hover:text-black"
href={`/account/update-profile`}
target="_blank"
>
<Pencil className="size-4" />
Edit Profile
</a>
<a
className="flex items-center gap-2 bg-white px-3 py-3 text-sm font-medium text-gray-500 hover:text-black aria-disabled:cursor-not-allowed"
{...(username ? { href: `/u/${username}` } : {})}
target="_blank"
aria-disabled={!username}
>
<ArrowUpRight className="size-4" />
View Profile
</a>
</div>
</div>
)}
<h2 className="mb-3 text-xs uppercase text-gray-400">
Progress and Bookmarks
</h2>
@ -141,6 +219,59 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
})}
</div>
)}
<h2 className="mb-3 mt-6 text-xs uppercase text-gray-400">
All Roadmaps
</h2>
<ListRoadmaps roadmaps={builtInRoadmaps} />
<h2 className="mb-3 mt-6 text-xs uppercase text-gray-400">
Best Practices
</h2>
<ListRoadmaps roadmaps={builtInBestPractices} />
</section>
);
}
type ListRoadmapsProps = {
roadmaps: BuiltInRoadmap[];
};
export function ListRoadmaps(props: ListRoadmapsProps) {
const { roadmaps } = props;
const [showAll, setShowAll] = useState(roadmaps.length <= 12);
const roadmapsToShow = showAll ? roadmaps : roadmaps.slice(0, 12);
return (
<div className="relative">
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 md:grid-cols-3">
{roadmapsToShow.map((roadmap) => (
<a
key={roadmap.id}
className="rounded-md border bg-white px-3 py-2 text-left text-sm shadow-sm transition-all hover:border-gray-300 hover:bg-gray-50"
href={`/${roadmap.id}`}
>
{roadmap.title}
</a>
))}
</div>
{!showAll && (
<div
className="absolute bottom-0 left-0 right-0 -m-1 flex h-full items-end justify-center bg-gradient-to-t from-white to-transparent p-2"
style={{
background:
'linear-gradient(180deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.8) 50%, rgba(255,255,255,1) 100%)',
}}
>
<button
className="text-sm font-medium text-gray-600 hover:text-black focus:outline-none"
onClick={() => setShowAll(true)}
>
+ Show all
</button>
</div>
)}
</div>
);
}

@ -5,7 +5,6 @@ import { useToast } from '../../hooks/use-toast';
import { getUser } from '../../lib/jwt';
import { LoadingProgress } from './LoadingProgress';
import { ResourceProgress } from '../Activity/ResourceProgress';
import { Plus, Minus } from 'lucide-react';
import { TeamActivityPage } from '../TeamActivity/TeamActivityPage';
import { cn } from '../../lib/classname';
@ -21,7 +20,6 @@ export function TeamDashboard(props: TeamDashboardProps) {
const [isLoading, setIsLoading] = useState(true);
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
const [showAllMembers, setShowAllMembers] = useState(false);
async function getTeamProgress() {
const { response, error } = await httpGet<TeamMember[]>(
@ -65,19 +63,17 @@ export function TeamDashboard(props: TeamDashboardProps) {
(progress) => progress.resourceType === 'roadmap',
) || [];
const allMembersWithoutCurrentUser = teamMembers
.sort((a, b) => {
if (a.email === currentUser.email) {
return -1;
}
const allMembersWithoutCurrentUser = teamMembers.sort((a, b) => {
if (a.email === currentUser.email) {
return -1;
}
if (b.email === currentUser.email) {
return 1;
}
if (b.email === currentUser.email) {
return 1;
}
return 0;
})
.slice(0, showAllMembers ? teamMembers.length : 15);
return 0;
});
return (
<section className="mt-8">
@ -137,18 +133,6 @@ export function TeamDashboard(props: TeamDashboardProps) {
</figure>
);
})}
{teamMembers.length > 0 && (
<button
onClick={() => setShowAllMembers((prev) => !prev)}
className="flex aspect-square size-8 items-center justify-center rounded-md bg-gray-200 text-gray-600 hover:text-black"
>
{showAllMembers ? (
<Minus className="size-5" />
) : (
<Plus className="size-5" />
)}
</button>
)}
</div>
)}
@ -166,7 +150,7 @@ function TeamMemberLoading(props: TeamMemberLoadingProps) {
return (
<div className={cn('flex flex-wrap gap-2', className)}>
{Array.from({ length: 8 }).map((_, index) => (
{Array.from({ length: 15 }).map((_, index) => (
<div
key={index}
className="size-8 animate-pulse rounded-md bg-gray-200"

@ -1,9 +1,37 @@
---
import { DashboardPage } from '../components/Dashboard/DashboardPage';
import BaseLayout from '../layouts/BaseLayout.astro';
import { getAllBestPractices } from '../lib/best-practice';
import { getRoadmapsByTag } from '../lib/roadmap';
const roadmaps = await getRoadmapsByTag('roadmap');
const bestPractices = await getAllBestPractices();
const enrichedRoadmaps = roadmaps.map((roadmap) => {
const { frontmatter } = roadmap;
return {
id: roadmap.id,
title: frontmatter.briefTitle,
description: frontmatter.briefDescription,
};
});
const enrichedBestPractices = bestPractices.map((bestPractice) => {
const { frontmatter } = bestPractice;
return {
id: bestPractice.id,
title: frontmatter.briefTitle,
description: frontmatter.briefDescription,
};
});
---
<BaseLayout title='Dashboard' noIndex={true}>
<DashboardPage client:load />
<DashboardPage
builtInRoadmaps={enrichedRoadmaps}
builtInBestPractices={enrichedBestPractices}
client:load
/>
<div slot='open-source-banner'></div>
</BaseLayout>

Loading…
Cancel
Save