mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-13 04:34:13 +02:00
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>
This commit is contained in:
@@ -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)}>
|
||||||
<h2 className="mb-3 text-xs uppercase text-gray-400">
|
{activities.length > 0 && (
|
||||||
Learning Activity
|
<h2 className="mb-3 text-xs uppercase text-gray-400">
|
||||||
</h2>
|
Learning Activity
|
||||||
|
</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
|
Activities will appear here as you start tracking your progress.
|
||||||
<a href="/roadmaps" className="mt-4 text-blue-500 hover:underline">
|
|
||||||
Roadmaps
|
|
||||||
</a>
|
|
||||||
,
|
|
||||||
<a
|
|
||||||
href="/best-practices"
|
|
||||||
className="mt-4 text-blue-500 hover:underline"
|
|
||||||
>
|
|
||||||
Best Practices
|
|
||||||
</a>
|
|
||||||
or
|
|
||||||
<a href="/questions" className="mt-4 text-blue-500 hover:underline">
|
|
||||||
Questions
|
|
||||||
</a>
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
303
src/components/Dashboard/DashboardTeamRoadmaps.tsx
Normal file
303
src/components/Dashboard/DashboardTeamRoadmaps.tsx
Normal file
@@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const canManageCurrentTeam = ['admin', 'manager'].includes(
|
||||||
|
currentMember?.role!,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mt-8">
|
<section className="mt-8">
|
||||||
<h2 className="mb-3 text-xs uppercase text-gray-400">Roadmaps</h2>
|
{isInvitingMember && (
|
||||||
{isLoading && <LoadingProgress />}
|
<InviteMemberPopup
|
||||||
{!isLoading && learningRoadmapsToShow.length > 0 && (
|
onInvited={() => {
|
||||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-3">
|
toast.success('Invite sent');
|
||||||
{learningRoadmapsToShow.map((roadmap) => {
|
getTeamProgress().finally(() => null);
|
||||||
const learningCount = roadmap.learning || 0;
|
setIsInvitingMember(false);
|
||||||
const doneCount = roadmap.done || 0;
|
}}
|
||||||
const totalCount = roadmap.total || 0;
|
onClose={() => {
|
||||||
const skippedCount = roadmap.skipped || 0;
|
setIsInvitingMember(false);
|
||||||
|
}}
|
||||||
return (
|
/>
|
||||||
<ResourceProgress
|
|
||||||
key={roadmap.resourceId}
|
|
||||||
isCustomResource={roadmap?.isCustomResource || false}
|
|
||||||
doneCount={doneCount > totalCount ? totalCount : doneCount}
|
|
||||||
learningCount={
|
|
||||||
learningCount > totalCount ? totalCount : learningCount
|
|
||||||
}
|
|
||||||
totalCount={totalCount}
|
|
||||||
skippedCount={skippedCount}
|
|
||||||
resourceId={roadmap.resourceId}
|
|
||||||
resourceType="roadmap"
|
|
||||||
updatedAt={roadmap.updatedAt}
|
|
||||||
title={roadmap.resourceTitle}
|
|
||||||
showActions={false}
|
|
||||||
roadmapSlug={roadmap.roadmapSlug}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h2 className="mb-3 mt-6 text-xs uppercase text-gray-400">
|
<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}
|
||||||
resource(s)
|
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,13 +189,44 @@ 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 null;
|
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 (
|
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) {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<TeamEmptyStream teamId={teamId} />
|
<>
|
||||||
|
{sectionHeading}
|
||||||
|
<div className="rounded-lg border bg-white p-4">
|
||||||
|
<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) {
|
||||||
@@ -92,7 +93,7 @@ export function InviteMemberPopup(props: InviteMemberPopupProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className=" rounded-md border border-red-300 bg-red-50 p-2 text-sm text-red-700">
|
<p className="rounded-md border border-red-300 bg-red-50 p-2 text-sm text-red-700">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
@@ -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
|
||||||
|
48
src/data/projects/log-analyser.md
Normal file
48
src/data/projects/log-analyser.md
Normal file
@@ -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>
|
||||||
|
Reference in New Issue
Block a user