feat: team dashboard (#7213)

* fix: add team roadmaps

* feat: implement add member

* feat: separate team dashboard page

* UI changes for team dashboard

* Add team activity dashboard

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
pull/7371/head
Arik Chakma 2 weeks ago committed by GitHub
parent 3f7e50907a
commit 8b0c536750
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      .astro/settings.json
  2. 2
      src/components/Activity/ActivityStream.tsx
  3. 2
      src/components/Activity/EmptyActivity.tsx
  4. 21
      src/components/Activity/EmptyStream.tsx
  5. 4
      src/components/CreateTeam/RoadmapSelector.tsx
  6. 22
      src/components/CreateTeam/SelectRoadmapModal.tsx
  7. 42
      src/components/Dashboard/DashboardPage.tsx
  8. 303
      src/components/Dashboard/DashboardTeamRoadmaps.tsx
  9. 13
      src/components/Dashboard/PersonalDashboard.tsx
  10. 114
      src/components/Dashboard/TeamDashboard.tsx
  11. 4
      src/components/TeamActivity/TeamActivityItem.tsx
  12. 57
      src/components/TeamActivity/TeamActivityPage.tsx
  13. 2
      src/components/TeamActivity/TeamEmptyStream.tsx
  14. 11
      src/components/TeamMemberDetails/TeamMemberEmptyPage.tsx
  15. 7
      src/components/TeamMembers/InviteMemberPopup.tsx
  16. 8
      src/components/TeamRoadmapsList/TeamRoadmaps.tsx
  17. 48
      src/data/projects/log-analyser.md
  18. 8
      src/pages/dashboard.astro
  19. 77
      src/pages/team/index.astro

@ -3,6 +3,6 @@
"enabled": false "enabled": false
}, },
"_variables": { "_variables": {
"lastUpdateCheck": 1728161578172 "lastUpdateCheck": 1728296475293
} }
} }

@ -56,9 +56,11 @@ export function ActivityStream(props: ActivityStreamProps) {
return ( return (
<div className={cn('mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8', className)}> <div className={cn('mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8', className)}>
{activities.length > 0 && (
<h2 className="mb-3 text-xs uppercase text-gray-400"> <h2 className="mb-3 text-xs uppercase text-gray-400">
Learning Activity Learning Activity
</h2> </h2>
)}
{selectedActivity && ( {selectedActivity && (
<ActivityTopicsModal <ActivityTopicsModal

@ -4,7 +4,7 @@ export function EmptyActivity() {
return ( return (
<div className="rounded-md"> <div className="rounded-md">
<div className="flex flex-col items-center p-7 text-center"> <div className="flex flex-col items-center p-7 text-center">
<RoadmapIcon className="mb-2 w-[60px] h-[60px] sm:h-[120px] sm:w-[120px] opacity-10" /> <RoadmapIcon className="mb-2 h-14 w-14 opacity-10" />
<h2 className="text-lg sm:text-xl font-bold">No Progress</h2> <h2 className="text-lg sm:text-xl font-bold">No Progress</h2>
<p className="my-1 sm:my-2 max-w-[400px] text-gray-500 text-sm sm:text-base"> <p className="my-1 sm:my-2 max-w-[400px] text-gray-500 text-sm sm:text-base">

@ -4,26 +4,11 @@ export function EmptyStream() {
return ( return (
<div className="rounded-md"> <div className="rounded-md">
<div className="flex flex-col items-center p-7 text-center"> <div className="flex flex-col items-center p-7 text-center">
<List className="mb-4 h-[60px] w-[60px] opacity-10 sm:h-[60px] sm:w-[60px]" /> <List className="mb-4 h-14 w-14 opacity-10" />
<h2 className="text-lg font-bold sm:text-xl">No Activities</h2> <h2 className="text-lg font-bold sm:text-xl">No Activity</h2>
<p className="my-1 max-w-[400px] text-balance text-sm text-gray-500 sm:my-2 sm:text-base"> <p className="my-1 max-w-[400px] text-balance text-sm text-gray-500 sm:my-2 sm:text-base">
Activities will appear here as you start tracking your&nbsp; Activities will appear here as you start tracking your progress.
<a href="/roadmaps" className="mt-4 text-blue-500 hover:underline">
Roadmaps
</a>
,&nbsp;
<a
href="/best-practices"
className="mt-4 text-blue-500 hover:underline"
>
Best Practices
</a>
&nbsp;or&nbsp;
<a href="/questions" className="mt-4 text-blue-500 hover:underline">
Questions
</a>
&nbsp;progress.
</p> </p>
</div> </div>
</div> </div>

@ -166,8 +166,8 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
{showSelectRoadmapModal && ( {showSelectRoadmapModal && (
<SelectRoadmapModal <SelectRoadmapModal
onClose={() => setShowSelectRoadmapModal(false)} onClose={() => setShowSelectRoadmapModal(false)}
teamResourceConfig={teamResources} teamResourceConfig={teamResources.map((r) => r.resourceId)}
allRoadmaps={allRoadmaps.filter(r => r.renderer === 'editor')} allRoadmaps={allRoadmaps.filter((r) => r.renderer === 'editor')}
teamId={teamId} teamId={teamId}
onRoadmapAdd={(roadmapId) => { onRoadmapAdd={(roadmapId) => {
addTeamResource(roadmapId).finally(() => { addTeamResource(roadmapId).finally(() => {

@ -10,7 +10,7 @@ export type SelectRoadmapModalProps = {
teamId: string; teamId: string;
allRoadmaps: PageType[]; allRoadmaps: PageType[];
onClose: () => void; onClose: () => void;
teamResourceConfig: TeamResourceConfig; teamResourceConfig: string[];
onRoadmapAdd: (roadmapId: string) => void; onRoadmapAdd: (roadmapId: string) => void;
onRoadmapRemove: (roadmapId: string) => void; onRoadmapRemove: (roadmapId: string) => void;
}; };
@ -100,9 +100,7 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
{roleBasedRoadmaps.length > 0 && ( {roleBasedRoadmaps.length > 0 && (
<div className="mb-5 flex flex-wrap items-center gap-2"> <div className="mb-5 flex flex-wrap items-center gap-2">
{roleBasedRoadmaps.map((roadmap) => { {roleBasedRoadmaps.map((roadmap) => {
const isSelected = !!teamResourceConfig?.find( const isSelected = teamResourceConfig.includes(roadmap.id);
(r) => r.resourceId === roadmap.id,
);
return ( return (
<SelectRoadmapModalItem <SelectRoadmapModalItem
@ -126,9 +124,7 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
</span> </span>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{skillBasedRoadmaps.map((roadmap) => { {skillBasedRoadmaps.map((roadmap) => {
const isSelected = !!teamResourceConfig.find( const isSelected = teamResourceConfig.includes(roadmap.id);
(r) => r.resourceId === roadmap.id,
);
return ( return (
<SelectRoadmapModalItem <SelectRoadmapModalItem
@ -148,12 +144,14 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
</div> </div>
</div> </div>
<div className="border-t border-t-yellow-300 text-yellow-900 bg-yellow-100 px-4 py-3 text-sm"> <div className="border-t border-t-yellow-300 bg-yellow-100 px-4 py-3 text-sm text-yellow-900">
<h2 className='font-medium text-base text-yellow-900 mb-1'>More Official Roadmaps Coming Soon</h2> <h2 className="mb-1 text-base font-medium text-yellow-900">
More Official Roadmaps Coming Soon
</h2>
<p> <p>
We are currently adding more of our official We are currently adding more of our official roadmaps to this
roadmaps to this list. If you don't see the roadmap you are list. If you don't see the roadmap you are looking for, please
looking for, please check back later. check back later.
</p> </p>
</div> </div>
</div> </div>

@ -8,21 +8,29 @@ import { DashboardTab } from './DashboardTab';
import { PersonalDashboard, type BuiltInRoadmap } from './PersonalDashboard'; import { PersonalDashboard, type BuiltInRoadmap } from './PersonalDashboard';
import { TeamDashboard } from './TeamDashboard'; import { TeamDashboard } from './TeamDashboard';
import { getUser } from '../../lib/jwt'; import { getUser } from '../../lib/jwt';
import { useParams } from '../../hooks/use-params';
type DashboardPageProps = { type DashboardPageProps = {
builtInRoleRoadmaps?: BuiltInRoadmap[]; builtInRoleRoadmaps?: BuiltInRoadmap[];
builtInSkillRoadmaps?: BuiltInRoadmap[]; builtInSkillRoadmaps?: BuiltInRoadmap[];
builtInBestPractices?: BuiltInRoadmap[]; builtInBestPractices?: BuiltInRoadmap[];
isTeamPage?: boolean;
}; };
export function DashboardPage(props: DashboardPageProps) { export function DashboardPage(props: DashboardPageProps) {
const { builtInRoleRoadmaps, builtInBestPractices, builtInSkillRoadmaps } = const {
props; builtInRoleRoadmaps,
builtInBestPractices,
builtInSkillRoadmaps,
isTeamPage = false,
} = props;
const currentUser = getUser(); const currentUser = getUser();
const toast = useToast(); const toast = useToast();
const teamList = useStore($teamList); const teamList = useStore($teamList);
const { t: currTeamId } = useParams();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [selectedTeamId, setSelectedTeamId] = useState<string>(); const [selectedTeamId, setSelectedTeamId] = useState<string>();
@ -43,8 +51,14 @@ export function DashboardPage(props: DashboardPageProps) {
} }
useEffect(() => { useEffect(() => {
getAllTeams().finally(() => setIsLoading(false)); getAllTeams().finally(() => {
}, []); if (currTeamId) {
setSelectedTeamId(currTeamId);
}
setIsLoading(false);
});
}, [currTeamId]);
const userAvatar = const userAvatar =
currentUser?.avatar && !isLoading currentUser?.avatar && !isLoading
@ -57,8 +71,8 @@ export function DashboardPage(props: DashboardPageProps) {
<div className="mb-6 flex flex-wrap items-center gap-1.5 sm:mb-8"> <div className="mb-6 flex flex-wrap items-center gap-1.5 sm:mb-8">
<DashboardTab <DashboardTab
label="Personal" label="Personal"
isActive={!selectedTeamId} isActive={!selectedTeamId && !isTeamPage}
onClick={() => setSelectedTeamId(undefined)} href="/dashboard"
avatar={userAvatar} avatar={userAvatar}
/> />
@ -86,10 +100,7 @@ export function DashboardPage(props: DashboardPageProps) {
href: `/respond-invite?i=${team.memberId}`, href: `/respond-invite?i=${team.memberId}`,
} }
: { : {
href: `/team/activity?t=${team._id}`, href: `/team?t=${team._id}`,
// onClick: () => {
// setSelectedTeamId(team._id);
// },
})} })}
avatar={avatarUrl} avatar={avatarUrl}
/> />
@ -105,14 +116,21 @@ export function DashboardPage(props: DashboardPageProps) {
)} )}
</div> </div>
{!selectedTeamId && ( {!selectedTeamId && !isTeamPage && (
<PersonalDashboard <PersonalDashboard
builtInRoleRoadmaps={builtInRoleRoadmaps} builtInRoleRoadmaps={builtInRoleRoadmaps}
builtInSkillRoadmaps={builtInSkillRoadmaps} builtInSkillRoadmaps={builtInSkillRoadmaps}
builtInBestPractices={builtInBestPractices} builtInBestPractices={builtInBestPractices}
/> />
)} )}
{selectedTeamId && <TeamDashboard teamId={selectedTeamId} />}
{(selectedTeamId || isTeamPage) && (
<TeamDashboard
builtInRoleRoadmaps={builtInRoleRoadmaps!}
builtInSkillRoadmaps={builtInSkillRoadmaps!}
teamId={selectedTeamId!}
/>
)}
</div> </div>
</div> </div>
); );

@ -0,0 +1,303 @@
import { useEffect, useMemo, useState } from 'react';
import { ResourceProgress } from '../Activity/ResourceProgress';
import { RoadmapIcon } from '../ReactIcons/RoadmapIcon';
import type { UserProgress } from '../TeamProgress/TeamProgressPage';
import { LoadingProgress } from './LoadingProgress';
import { PickRoadmapOptionModal } from '../TeamRoadmaps/PickRoadmapOptionModal';
import { SelectRoadmapModal } from '../CreateTeam/SelectRoadmapModal';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
import { ContentConfirmationModal } from '../CreateTeam/ContentConfirmationModal';
import { httpGet, httpPut } from '../../lib/http';
import type { PageType } from '../CommandMenu/CommandMenu';
import { useToast } from '../../hooks/use-toast';
import type { TeamResourceConfig } from '../CreateTeam/RoadmapSelector';
import { pageProgressMessage } from '../../stores/page';
import type { BuiltInRoadmap } from './PersonalDashboard';
import { MapIcon, Users2 } from 'lucide-react';
type DashboardTeamRoadmapsProps = {
isLoading: boolean;
teamId: string;
learningRoadmapsToShow: (UserProgress & {
defaultRoadmapId?: string;
})[];
canManageCurrentTeam: boolean;
onUpdate: () => void;
builtInRoleRoadmaps: BuiltInRoadmap[];
builtInSkillRoadmaps: BuiltInRoadmap[];
};
export function DashboardTeamRoadmaps(props: DashboardTeamRoadmapsProps) {
const {
isLoading,
teamId,
learningRoadmapsToShow,
canManageCurrentTeam,
onUpdate,
builtInRoleRoadmaps,
builtInSkillRoadmaps,
} = props;
const toast = useToast();
const [isPickingOptions, setIsPickingOptions] = useState(false);
const [isAddingRoadmap, setIsAddingRoadmap] = useState(false);
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
const [confirmationContentId, setConfirmationContentId] = useState<string>();
const allRoadmaps = useMemo(
() =>
builtInRoleRoadmaps.concat(builtInSkillRoadmaps).map((r) => {
return {
id: r.id,
title: r.title,
url: r.url,
group: 'Roadmaps',
renderer: r.renderer || 'balsamiq',
metadata: r.metadata,
};
}),
[builtInRoleRoadmaps, builtInSkillRoadmaps],
);
async function onAdd(roadmapId: string, shouldCopyContent = false) {
if (!teamId) {
return;
}
toast.loading('Adding roadmap');
pageProgressMessage.set('Adding roadmap');
const roadmap = allRoadmaps.find((r) => r.id === roadmapId);
const { error, response } = await httpPut<TeamResourceConfig>(
`${
import.meta.env.PUBLIC_API_URL
}/v1-update-team-resource-config/${teamId}`,
{
teamId: teamId,
resourceId: roadmapId,
resourceType: 'roadmap',
removed: [],
renderer: roadmap?.renderer || 'balsamiq',
shouldCopyContent,
},
);
if (error || !response) {
toast.error(error?.message || 'Error adding roadmap');
return;
}
onUpdate();
toast.success('Roadmap added');
if (roadmap?.renderer === 'editor') {
setIsAddingRoadmap(false);
}
}
async function deleteResource(roadmapId: string) {
if (!teamId) {
return;
}
toast.loading('Deleting roadmap');
pageProgressMessage.set(`Deleting roadmap from team`);
const { error, response } = await httpPut<TeamResourceConfig>(
`${import.meta.env.PUBLIC_API_URL}/v1-delete-team-resource-config/${
teamId
}`,
{
resourceId: roadmapId,
resourceType: 'roadmap',
},
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
toast.success('Roadmap removed');
onUpdate();
}
async function onRemove(resourceId: string) {
pageProgressMessage.set('Removing roadmap');
deleteResource(resourceId).finally(() => {
pageProgressMessage.set('');
});
}
const pickRoadmapOptionModal = isPickingOptions && (
<PickRoadmapOptionModal
onClose={() => setIsPickingOptions(false)}
showDefaultRoadmapsModal={() => {
setIsAddingRoadmap(true);
setIsPickingOptions(false);
}}
showCreateCustomRoadmapModal={() => {
setIsCreatingRoadmap(true);
setIsPickingOptions(false);
}}
/>
);
const filteredAllRoadmaps = allRoadmaps.filter(
(r) => !learningRoadmapsToShow.find((c) => c?.defaultRoadmapId === r.id),
);
const addRoadmapModal = isAddingRoadmap && (
<SelectRoadmapModal
onClose={() => setIsAddingRoadmap(false)}
teamResourceConfig={learningRoadmapsToShow.map((r) => r.resourceId)}
allRoadmaps={filteredAllRoadmaps.filter((r) => r.renderer === 'editor')}
teamId={teamId}
onRoadmapAdd={(roadmapId: string) => {
const isEditorRoadmap = allRoadmaps.find(
(r) => r.id === roadmapId && r.renderer === 'editor',
);
if (!isEditorRoadmap) {
onAdd(roadmapId).finally(() => {
pageProgressMessage.set('');
});
return;
}
setIsAddingRoadmap(false);
setConfirmationContentId(roadmapId);
}}
onRoadmapRemove={(roadmapId: string) => {
if (confirm('Are you sure you want to remove this roadmap?')) {
onRemove(roadmapId).finally(() => {});
}
}}
/>
);
const confirmationContentIdModal = confirmationContentId && (
<ContentConfirmationModal
onClose={() => {
setConfirmationContentId('');
}}
onClick={(shouldCopy) => {
onAdd(confirmationContentId, shouldCopy).finally(() => {
pageProgressMessage.set('');
setConfirmationContentId('');
});
}}
/>
);
const createRoadmapModal = isCreatingRoadmap && (
<CreateRoadmapModal
teamId={teamId}
onClose={() => {
setIsCreatingRoadmap(false);
}}
onCreated={() => {
setIsCreatingRoadmap(false);
}}
/>
);
const roadmapHeading = (
<div className="mb-3 flex h-[20px] items-center justify-between gap-2 text-xs">
<h2 className="uppercase text-gray-400">Roadmaps</h2>
<span className="mx-3 h-[1px] flex-grow bg-gray-200" />
{canManageCurrentTeam && (
<a
href={`/team/roadmaps?t=${teamId}`}
className="flex flex-row items-center rounded-full bg-gray-400 px-2.5 py-0.5 text-xs text-white transition-colors hover:bg-black"
>
<MapIcon className="mr-1.5 size-3" strokeWidth={2.5} />
Roadmaps
</a>
)}
</div>
);
if (!isLoading && learningRoadmapsToShow.length === 0) {
return (
<>
{roadmapHeading}
<div className="flex flex-col items-center rounded-md border bg-white p-4 py-10">
{pickRoadmapOptionModal}
{addRoadmapModal}
{createRoadmapModal}
{confirmationContentIdModal}
<RoadmapIcon className="mb-4 h-14 w-14 opacity-10" />
<h2 className="text-lg font-semibold sm:text-lg">No roadmaps</h2>
<p className="my-1 max-w-[400px] text-balance text-sm text-gray-500 sm:my-2 sm:text-base">
{canManageCurrentTeam
? 'Add a roadmap to start tracking your team'
: 'Ask your team admin to add some roadmaps'}
</p>
{canManageCurrentTeam && (
<button
className="mt-1 rounded-lg bg-black px-3 py-1 text-sm font-medium text-white hover:bg-gray-900"
onClick={() => setIsPickingOptions(true)}
>
Add roadmap
</button>
)}
</div>
</>
);
}
return (
<>
{pickRoadmapOptionModal}
{addRoadmapModal}
{createRoadmapModal}
{confirmationContentIdModal}
{roadmapHeading}
{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}
/>
);
})}
{canManageCurrentTeam && (
<button
onClick={() => setIsPickingOptions(true)}
className="group relative flex w-full items-center justify-center overflow-hidden rounded-md border border-dashed border-gray-300 bg-white px-3 py-2 text-center text-sm text-gray-500 transition-all hover:border-gray-400 hover:text-black"
>
+ Add Roadmap
</button>
)}
</div>
)}
</>
);
}

@ -17,6 +17,7 @@ import { DashboardAiRoadmaps } from './DashboardAiRoadmaps.tsx';
import type { AllowedProfileVisibility } from '../../api/user.ts'; import type { AllowedProfileVisibility } from '../../api/user.ts';
import { PencilIcon, type LucideIcon } from 'lucide-react'; import { PencilIcon, type LucideIcon } from 'lucide-react';
import { cn } from '../../lib/classname.ts'; import { cn } from '../../lib/classname.ts';
import type { AllowedRoadmapRenderer } from '../../lib/roadmap.ts';
type UserDashboardResponse = { type UserDashboardResponse = {
name: string; name: string;
@ -42,6 +43,8 @@ export type BuiltInRoadmap = {
description: string; description: string;
isFavorite?: boolean; isFavorite?: boolean;
relatedRoadmapIds?: string[]; relatedRoadmapIds?: string[];
renderer?: AllowedRoadmapRenderer;
metadata?: Record<string, any>;
}; };
type PersonalDashboardProps = { type PersonalDashboardProps = {
@ -350,13 +353,11 @@ function DashboardCard(props: DashboardCardProps) {
} = props; } = props;
return ( return (
<div <div className={cn('relative overflow-hidden', className)}>
className={cn( <a
'relative overflow-hidden', href={href}
className, className="flex flex-col rounded-lg border border-gray-300 bg-white hover:border-gray-400 hover:bg-gray-50"
)}
> >
<a href={href} className="flex flex-col rounded-lg border border-gray-300 bg-white hover:border-gray-400 hover:bg-gray-50">
{Icon && ( {Icon && (
<div className="px-4 pb-3 pt-4"> <div className="px-4 pb-3 pt-4">
<Icon className="size-6" /> <Icon className="size-6" />

@ -3,29 +3,35 @@ import type { TeamMember } from '../TeamProgress/TeamProgressPage';
import { httpGet } from '../../lib/http'; import { httpGet } from '../../lib/http';
import { useToast } from '../../hooks/use-toast'; import { useToast } from '../../hooks/use-toast';
import { getUser } from '../../lib/jwt'; import { getUser } from '../../lib/jwt';
import { LoadingProgress } from './LoadingProgress';
import { ResourceProgress } from '../Activity/ResourceProgress';
import { TeamActivityPage } from '../TeamActivity/TeamActivityPage'; import { TeamActivityPage } from '../TeamActivity/TeamActivityPage';
import { cn } from '../../lib/classname'; import { cn } from '../../lib/classname';
import { Tooltip } from '../Tooltip'; import { Tooltip } from '../Tooltip';
import { DashboardTeamRoadmaps } from './DashboardTeamRoadmaps';
import type { BuiltInRoadmap } from './PersonalDashboard';
import { InviteMemberPopup } from '../TeamMembers/InviteMemberPopup';
import { Users, Users2 } from 'lucide-react';
type TeamDashboardProps = { type TeamDashboardProps = {
builtInRoleRoadmaps: BuiltInRoadmap[];
builtInSkillRoadmaps: BuiltInRoadmap[];
teamId: string; teamId: string;
}; };
export function TeamDashboard(props: TeamDashboardProps) { export function TeamDashboard(props: TeamDashboardProps) {
const { teamId } = props; const { teamId, builtInRoleRoadmaps, builtInSkillRoadmaps } = props;
const toast = useToast(); const toast = useToast();
const currentUser = getUser(); const currentUser = getUser();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]); const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
const [isInvitingMember, setIsInvitingMember] = useState(false);
async function getTeamProgress() { async function getTeamProgress() {
const { response, error } = await httpGet<TeamMember[]>( const { response, error } = await httpGet<TeamMember[]>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-progress/${teamId}`, `${import.meta.env.PUBLIC_API_URL}/v1-get-team-progress/${teamId}`,
); );
if (error || !response) { if (error || !response) {
toast.error(error?.message || 'Failed to get team progress'); toast.error(error?.message || 'Failed to get team progress');
return; return;
@ -54,12 +60,8 @@ export function TeamDashboard(props: TeamDashboardProps) {
getTeamProgress().finally(() => setIsLoading(false)); getTeamProgress().finally(() => setIsLoading(false));
}, [teamId]); }, [teamId]);
if (!currentUser) {
return null;
}
const currentMember = teamMembers.find( const currentMember = teamMembers.find(
(member) => member.email === currentUser.email, (member) => member.email === currentUser?.email,
); );
const learningRoadmapsToShow = const learningRoadmapsToShow =
currentMember?.progress?.filter( currentMember?.progress?.filter(
@ -67,53 +69,58 @@ export function TeamDashboard(props: TeamDashboardProps) {
) || []; ) || [];
const allMembersWithoutCurrentUser = teamMembers.sort((a, b) => { const allMembersWithoutCurrentUser = teamMembers.sort((a, b) => {
if (a.email === currentUser.email) { if (a.email === currentUser?.email) {
return -1; return -1;
} }
if (b.email === currentUser.email) { if (b.email === currentUser?.email) {
return 1; return 1;
} }
return 0; return 0;
}); });
return ( const canManageCurrentTeam = ['admin', 'manager'].includes(
<section className="mt-8"> currentMember?.role!,
<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 ( return (
<ResourceProgress <section className="mt-8">
key={roadmap.resourceId} {isInvitingMember && (
isCustomResource={roadmap?.isCustomResource || false} <InviteMemberPopup
doneCount={doneCount > totalCount ? totalCount : doneCount} onInvited={() => {
learningCount={ toast.success('Invite sent');
learningCount > totalCount ? totalCount : learningCount getTeamProgress().finally(() => null);
} setIsInvitingMember(false);
totalCount={totalCount} }}
skippedCount={skippedCount} onClose={() => {
resourceId={roadmap.resourceId} setIsInvitingMember(false);
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"> <DashboardTeamRoadmaps
isLoading={isLoading}
teamId={teamId}
learningRoadmapsToShow={learningRoadmapsToShow}
canManageCurrentTeam={canManageCurrentTeam}
onUpdate={getTeamProgress}
builtInRoleRoadmaps={builtInRoleRoadmaps}
builtInSkillRoadmaps={builtInSkillRoadmaps}
/>
<h2 className="mb-3 mt-6 flex h-[20px] items-center justify-between text-xs uppercase text-gray-400">
Team Members Team Members
<span className="flex-grow h-[1px] bg-gray-200 mx-3" />
{canManageCurrentTeam && (
<a
href={`/team/members?t=${teamId}`}
className="flex flex-row items-center rounded-full bg-gray-400 px-2.5 py-0.5 text-xs text-white transition-colors hover:bg-black"
>
<Users2 className="mr-1.5 size-3" strokeWidth={2.5} />
Members
</a>
)}
</h2> </h2>
{isLoading && <TeamMemberLoading className="mb-6" />} {isLoading && <TeamMemberLoading className="mb-6" />}
{!isLoading && ( {!isLoading && (
@ -123,7 +130,11 @@ export function TeamDashboard(props: TeamDashboardProps) {
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${member.avatar}` ? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${member.avatar}`
: '/images/default-avatar.png'; : '/images/default-avatar.png';
return ( return (
<span className="group relative" key={member.email}> <a
className="group relative"
key={member.email}
href={`/team/member?t=${teamId}&m=${member._id}`}
>
<figure className="relative aspect-square size-8 overflow-hidden rounded-md bg-gray-100"> <figure className="relative aspect-square size-8 overflow-hidden rounded-md bg-gray-100">
<img <img
src={avatar} src={avatar}
@ -134,13 +145,30 @@ export function TeamDashboard(props: TeamDashboardProps) {
<Tooltip position="top-center" additionalClass="text-sm"> <Tooltip position="top-center" additionalClass="text-sm">
{member.name} {member.name}
</Tooltip> </Tooltip>
</span> </a>
); );
})} })}
{canManageCurrentTeam && (
<button
className="group relative"
onClick={() => setIsInvitingMember(true)}
>
<span className="relative flex aspect-square size-8 items-center justify-center overflow-hidden rounded-md border border-dashed bg-gray-100">
+
</span>
<Tooltip position="top-center" additionalClass="text-sm">
Add Member
</Tooltip>
</button>
)}
</div> </div>
)} )}
<TeamActivityPage teamId={teamId} /> <TeamActivityPage
teamId={teamId}
canManageCurrentTeam={canManageCurrentTeam}
/>
</section> </section>
); );
} }

@ -102,7 +102,7 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
return ( return (
<li <li
key={user._id} key={user._id}
className="flex flex-wrap items-center gap-1 rounded-md border px-2 py-2.5 text-sm" className="flex flex-wrap items-center gap-1 rounded-md border px-2 py-2.5 text-sm bg-white"
> >
{actionType === 'in_progress' && ( {actionType === 'in_progress' && (
<> <>
@ -158,7 +158,7 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
const activityLimit = showAll ? activities.length : 5; const activityLimit = showAll ? activities.length : 5;
return ( return (
<li key={user._id} className="overflow-hidden rounded-md border"> <li key={user._id} className="overflow-hidden bg-white rounded-md border">
<h3 className="flex flex-wrap items-center gap-1 bg-gray-100 px-2 py-2.5 text-sm"> <h3 className="flex flex-wrap items-center gap-1 bg-gray-100 px-2 py-2.5 text-sm">
{username} has {activities.length} updates in {uniqueResourcesCount} {username} has {activities.length} updates in {uniqueResourcesCount}
&nbsp;resource(s) &nbsp;resource(s)

@ -9,6 +9,12 @@ import { TeamActivityItem } from './TeamActivityItem';
import { TeamActivityTopicsModal } from './TeamActivityTopicsModal'; import { TeamActivityTopicsModal } from './TeamActivityTopicsModal';
import { TeamEmptyStream } from './TeamEmptyStream'; import { TeamEmptyStream } from './TeamEmptyStream';
import { Pagination } from '../Pagination/Pagination'; import { Pagination } from '../Pagination/Pagination';
import {
ChartNoAxesGantt,
CircleDashed,
Flag,
LoaderCircle,
} from 'lucide-react';
export type TeamStreamActivity = { export type TeamStreamActivity = {
_id?: string; _id?: string;
@ -51,10 +57,11 @@ type GetTeamActivityResponse = {
type TeamActivityPageProps = { type TeamActivityPageProps = {
teamId?: string; teamId?: string;
canManageCurrentTeam?: boolean;
}; };
export function TeamActivityPage(props: TeamActivityPageProps) { export function TeamActivityPage(props: TeamActivityPageProps) {
const { teamId: defaultTeamId } = props; const { teamId: defaultTeamId, canManageCurrentTeam = false } = props;
const { t: teamId = defaultTeamId } = getUrlParams(); const { t: teamId = defaultTeamId } = getUrlParams();
const toast = useToast(); const toast = useToast();
@ -182,14 +189,45 @@ export function TeamActivityPage(props: TeamActivityPageProps) {
return enrichedUsers; return enrichedUsers;
}, [users, activities]); }, [users, activities]);
if (!teamId) { const sectionHeading = (
window.location.href = '/'; <h3 className="mb-3 flex h-[20px] w-full items-center justify-between text-xs uppercase text-gray-400">
return; Team Activity
} <span className="mx-3 h-[1px] flex-grow bg-gray-200" />
{canManageCurrentTeam && (
<a
href={`/team/progress?t=${teamId}`}
className="flex flex-row items-center rounded-full bg-gray-400 px-2.5 py-0.5 text-xs text-white transition-colors hover:bg-black"
>
<ChartNoAxesGantt className="mr-1.5 size-3" strokeWidth={2.5} />
Progresses
</a>
)}
</h3>
);
if (isLoading) { if (isLoading) {
return (
<>
{sectionHeading}
<div className="flex flex-col gap-2">
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="h-[70px] w-full animate-pulse rounded-lg border bg-gray-100"
/>
))}
</div>
</>
);
}
if (!teamId) {
if (typeof window !== 'undefined') {
window.location.href = '/';
} else {
return null; return null;
} }
}
return ( return (
<> <>
@ -202,9 +240,7 @@ export function TeamActivityPage(props: TeamActivityPageProps) {
{usersWithActivities.length > 0 ? ( {usersWithActivities.length > 0 ? (
<> <>
<h3 className="mb-4 flex w-full items-center justify-between text-xs uppercase text-gray-400"> {sectionHeading}
Team Activity
</h3>
<ul className="mb-4 mt-2 flex flex-col gap-3"> <ul className="mb-4 mt-2 flex flex-col gap-3">
{usersWithActivities.map((user, index) => { {usersWithActivities.map((user, index) => {
return ( return (
@ -233,7 +269,12 @@ export function TeamActivityPage(props: TeamActivityPageProps) {
/> />
</> </>
) : ( ) : (
<>
{sectionHeading}
<div className="rounded-lg border bg-white p-4">
<TeamEmptyStream teamId={teamId} /> <TeamEmptyStream teamId={teamId} />
</div>
</>
)} )}
</> </>
); );

@ -10,7 +10,7 @@ export function TeamEmptyStream(props: TeamActivityItemProps) {
return ( return (
<div className="rounded-md"> <div className="rounded-md">
<div className="flex flex-col items-center p-7 text-center sm:p-14"> <div className="flex flex-col items-center p-7 text-center sm:p-14">
<ListTodo className="mb-4 h-[60px] w-[60px] opacity-10 sm:h-[60px] sm:w-[60px]" /> <ListTodo className="mb-4 h-14 w-14 opacity-10" />
<h2 className="text-lg font-semibold sm:text-lg">No Activity</h2> <h2 className="text-lg font-semibold sm:text-lg">No Activity</h2>
<p className="my-1 max-w-[400px] text-balance text-sm text-gray-500 sm:my-2 sm:text-base"> <p className="my-1 max-w-[400px] text-balance text-sm text-gray-500 sm:my-2 sm:text-base">

@ -10,18 +10,11 @@ export function TeamMemberEmptyPage(props: TeamMemberEmptyPageProps) {
return ( return (
<div className="rounded-md"> <div className="rounded-md">
<div className="flex flex-col items-center p-7 text-center"> <div className="flex flex-col items-center p-7 text-center">
<RoadmapIcon className="mb-2 h-[60px] w-[60px] opacity-10 sm:h-[120px] sm:w-[120px]" /> <RoadmapIcon className="mb-2 h-14 w-14 opacity-10" />
<h2 className="text-lg font-bold sm:text-xl">No Progress</h2> <h2 className="text-lg font-bold sm:text-xl">No Progress</h2>
<p className="my-1 max-w-[400px] text-balance text-sm text-gray-500 sm:my-2 sm:text-base"> <p className="my-1 max-w-[400px] text-balance text-sm text-gray-500 sm:my-2 sm:text-base">
Progress will appear here as they start tracking their{' '} Progress will appear here as they start tracking their roadmaps.
<a
href={`/team/roadmaps?t=${teamId}`}
className="mt-4 text-blue-500 hover:underline"
>
Roadmaps
</a>{' '}
progress.
</p> </p>
</div> </div>
</div> </div>

@ -7,10 +7,11 @@ import { type AllowedRoles, RoleDropdown } from '../CreateTeam/RoleDropdown';
type InviteMemberPopupProps = { type InviteMemberPopupProps = {
onInvited: () => void; onInvited: () => void;
onClose: () => void; onClose: () => void;
teamId?: string;
}; };
export function InviteMemberPopup(props: InviteMemberPopupProps) { export function InviteMemberPopup(props: InviteMemberPopupProps) {
const { onClose, onInvited } = props; const { onClose, onInvited, teamId: defaultTeamId } = props;
const popupBodyRef = useRef<HTMLDivElement>(null); const popupBodyRef = useRef<HTMLDivElement>(null);
const emailRef = useRef<HTMLInputElement>(null); const emailRef = useRef<HTMLInputElement>(null);
@ -18,7 +19,7 @@ export function InviteMemberPopup(props: InviteMemberPopupProps) {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const { teamId } = useTeamId(); const { teamId = defaultTeamId } = useTeamId();
useEffect(() => { useEffect(() => {
emailRef?.current?.focus(); emailRef?.current?.focus();
@ -31,7 +32,7 @@ export function InviteMemberPopup(props: InviteMemberPopupProps) {
const { response, error } = await httpPost( const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-invite-member/${teamId}`, `${import.meta.env.PUBLIC_API_URL}/v1-invite-member/${teamId}`,
{ email, role: selectedRole } { email, role: selectedRole },
); );
if (error || !response) { if (error || !response) {

@ -233,7 +233,7 @@ export function TeamRoadmaps() {
const addRoadmapModal = isAddingRoadmap && ( const addRoadmapModal = isAddingRoadmap && (
<SelectRoadmapModal <SelectRoadmapModal
onClose={() => setIsAddingRoadmap(false)} onClose={() => setIsAddingRoadmap(false)}
teamResourceConfig={teamResources} teamResourceConfig={teamResources.map((c) => c.resourceId)}
allRoadmaps={filteredAllRoadmaps.filter((r) => r.renderer === 'editor')} allRoadmaps={filteredAllRoadmaps.filter((r) => r.renderer === 'editor')}
teamId={teamId} teamId={teamId}
onRoadmapAdd={(roadmapId: string) => { onRoadmapAdd={(roadmapId: string) => {
@ -309,9 +309,9 @@ export function TeamRoadmaps() {
{createRoadmapModal} {createRoadmapModal}
{confirmationContentIdModal} {confirmationContentIdModal}
<RoadmapIcon className="mb-4 h-24 w-24 opacity-10" /> <RoadmapIcon className="mb-3 h-14 w-14 opacity-10" />
<h3 className="mb-1 text-2xl font-bold text-gray-900">No roadmaps</h3> <h3 className="mb-1 text-xl font-bold text-gray-900">No roadmaps</h3>
<p className="text-base text-gray-500"> <p className="text-base text-gray-500">
{canManageCurrentTeam {canManageCurrentTeam
? 'Add a roadmap to start tracking your team' ? 'Add a roadmap to start tracking your team'
@ -320,7 +320,7 @@ export function TeamRoadmaps() {
{canManageCurrentTeam && ( {canManageCurrentTeam && (
<button <button
className="mt-4 rounded-lg bg-black px-4 py-2 font-medium text-white hover:bg-gray-900" className="mt-3 rounded-md bg-black px-3 py-1.5 font-medium text-white hover:bg-gray-900 text-sm"
onClick={() => setIsPickingOptions(true)} onClick={() => setIsPickingOptions(true)}
> >
Add roadmap Add roadmap

@ -0,0 +1,48 @@
---
title: 'Log Analysis Tool'
description: 'Write a simple tool to analyze logs from the command line.'
isNew: true
sort: 3
difficulty: 'beginner'
nature: 'CLI'
skills:
- 'linux'
- 'bash'
- 'shell scripting'
seo:
title: 'Log Analysis Tool'
description: 'Build a simple CLI tool to analyze logs from the command line.'
keywords:
- 'log analysis tool'
- 'devops project idea'
roadmapIds:
- 'devops'
- 'linux'
---
The goal of this project is to help you practice some basic shell scripting skills. You will write a simple tool to analyze logs from the command line.
## Requirements
Download the sample nginx access log file from [here](https://gist.githubusercontent.com/kamranahmedse/e66c3b9ea89a1a030d3b739eeeef22d0/raw/77fb3ac837a73c4f0206e78a236d885590b7ae35/nginx-access.log). The log file contains the following fields:
- IP address
- Date and time
- Request method and path
- Response status code
- Response size
- Referrer
- User agent
You are required to create a shell script that reads the log file and provides the following information:
```text
Top 5 IP addresses with the most requests:
45.76.135.253 - 1000 requests
142.93.143.8 - 600 requests
178.128.94.113 - 50 requests
43.224.43.187 - 30 requests
178.128.94.113 - 20 requests
```

@ -19,6 +19,10 @@ const enrichedRoleRoadmaps = roleRoadmaps
title: frontmatter.briefTitle, title: frontmatter.briefTitle,
description: frontmatter.briefDescription, description: frontmatter.briefDescription,
relatedRoadmapIds: frontmatter.relatedRoadmaps, relatedRoadmapIds: frontmatter.relatedRoadmaps,
renderer: frontmatter.renderer,
metadata: {
tags: frontmatter.tags,
},
}; };
}); });
const enrichedSkillRoadmaps = skillRoadmaps const enrichedSkillRoadmaps = skillRoadmaps
@ -33,6 +37,10 @@ const enrichedSkillRoadmaps = skillRoadmaps
frontmatter.briefTitle === 'Go' ? 'Go Roadmap' : frontmatter.briefTitle, frontmatter.briefTitle === 'Go' ? 'Go Roadmap' : frontmatter.briefTitle,
description: frontmatter.briefDescription, description: frontmatter.briefDescription,
relatedRoadmapIds: frontmatter.relatedRoadmaps, relatedRoadmapIds: frontmatter.relatedRoadmaps,
renderer: frontmatter.renderer,
metadata: {
tags: frontmatter.tags,
},
}; };
}); });

@ -1,15 +1,68 @@
--- ---
import AccountSidebar from '../../components/AccountSidebar.astro'; import { DashboardPage } from '../../components/Dashboard/DashboardPage';
import { TeamsList } from '../../components/TeamsList'; import BaseLayout from '../../layouts/BaseLayout.astro';
import AccountLayout from '../../layouts/AccountLayout.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,
renderer: frontmatter.renderer,
metadata: {
tags: frontmatter.tags,
},
};
});
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,
renderer: frontmatter.renderer,
metadata: {
tags: frontmatter.tags,
},
};
});
const enrichedBestPractices = bestPractices.map((bestPractice) => {
const { frontmatter } = bestPractice;
return {
id: bestPractice.id,
url: `/best-practices/${bestPractice.id}`,
title: frontmatter.briefTitle,
description: frontmatter.briefDescription,
};
});
--- ---
<AccountLayout <BaseLayout title='Dashboard' noIndex={true}>
title='Update Profile' <DashboardPage
noIndex={true} builtInRoleRoadmaps={enrichedRoleRoadmaps}
initialLoadingMessage={'Loading teams'} builtInSkillRoadmaps={enrichedSkillRoadmaps}
> builtInBestPractices={enrichedBestPractices}
<AccountSidebar hasDesktopSidebar={false} activePageId='team' activePageTitle='Teams'> isTeamPage={true}
<TeamsList client:only="react" /> client:load
</AccountSidebar> />
</AccountLayout> <div slot='open-source-banner'></div>
</BaseLayout>

Loading…
Cancel
Save